Bring your own model — swap the transport
CiteNexus bundles no models. The shipped clients — OpenAICompatibleEmbedding,
OpenAICompatibleGenerator, OpenAICompatibleVision, OpenAICompatibleReranker
— already know how to talk to a model. What they do not need to know is
whether the bytes travel over a socket.
So bring your own model by changing that one thing: swap the transport.
gen = OpenAICompatibleGenerator( base_url="http://in-process.invalid", # never dialled — see below model="qwen2.5-1.5b", transport=my_transport, # <- this line, and nothing else)Transport is
Callable[[str, bytes, dict[str, str]], bytes] — (url, json body, headers) -> response bytes
(citenexus/http.py).
It is a plain callable: there is no class to subclass and no citenexus
base to import. A function works. All four clients take it as a keyword-only
transport= argument.
What you write, and what the client keeps doing
Section titled “What you write, and what the client keeps doing”You move bytes. The client keeps doing everything else — and that is the whole argument for this design:
| The client still does | You do not reimplement |
|---|---|
| Builds the OpenAI-shaped request body | message roles, the pinned grounded-answer system prompt, temperature / max_tokens, embedding batching |
| Applies headers | User-Agent, merge order, per-call auth winning over defaults |
Expands ${ENV} secrets at the request boundary |
see Custom endpoints & auth |
| Parses the OpenAI-shaped response | choices[0].message.content, data[*].embedding, error semantics |
Contrast that with writing a provider from scratch, where all four of those columns become yours to get right and keep right.
A generator
Section titled “A generator”The request the client hands you is already OpenAI-shaped, so the last message is the prompt. Return an OpenAI-shaped response and you are done.
import json
from citenexus import OpenAICompatibleGenerator
class OnnxTransport: def __init__(self, model): self.model = model
def __call__(self, url: str, body: bytes, headers: dict) -> bytes: req = json.loads(body) # OpenAI-shaped, built by the client prompt = req["messages"][-1]["content"] return json.dumps({"choices": [{"message": {"content": self.model(prompt)}}]}).encode()
gen = OpenAICompatibleGenerator( base_url="http://in-process.invalid", model="qwen2.5-1.5b", transport=OnnxTransport(model),)
gen.answer( "Can the employee disclose?", "The employee shall not disclose confidential information.",)# 'The employee shall not disclose confidential information.'A class is used here only because this transport carries state (the loaded model). A bare function with the same signature is equally valid.
An embedder
Section titled “An embedder”The embedding response shape is {"data": [{"embedding": [...]}, ...]}, one
entry per input, in input order. input may be a string or a list, so
normalise it:
import json
from citenexus import OpenAICompatibleEmbedding
class OnnxEmbedTransport: def __call__(self, url, body, headers): req = json.loads(body) texts = req["input"] if isinstance(req["input"], list) else [req["input"]] return json.dumps({"data": [{"embedding": embed(t)} for t in texts]}).encode()
emb = OpenAICompatibleEmbedding( base_url="http://in-process.invalid", model="bge-m3", transport=OnnxEmbedTransport(),)
emb.embed_many(["hello", "hello world"])# with a toy embed(t) = [len(t), 1.0, 0.5]:# [[5.0, 1.0, 0.5], [11.0, 1.0, 0.5]]Batching stays the client’s job: embed_many hands your transport the whole
list in one request, so a real corpus does not cost you one round trip per
Evidence Unit.
Then pass the client to CiteNexus(...)
Section titled “Then pass the client to CiteNexus(...)”Nothing downstream changes — a transport-swapped client is still just a client:
from citenexus import CiteNexus
rag = CiteNexus("./citenexus-data", embedder=emb, generator=gen)The keyword-only injection points are embedder=, generator=, reranker=,
vision=, detector= and agentic_decider=. A partial set is valid: a
generator with no embedder falls back to lexical (BM25) retrieval and ask()
still answers.
And the model is still not trusted: whatever your transport returns goes through the per-claim faithfulness gate before it can become an answer.
Go and JavaScript have the same seam
Section titled “Go and JavaScript have the same seam”Both ports carry a Transport seam of the same shape, verified by running the
equivalent of the snippets above. The one honest difference: in Go and
JavaScript transport is a required positional constructor argument — there
is no default — whereas Python defaults it to real HTTP. The payload type
differs too: Go passes []byte and returns ([]byte, error), JavaScript passes
a string and returns string | Promise<string>.
# (url, body: bytes, headers) -> bytes ; keyword-only, defaults to real HTTPgen = OpenAICompatibleGenerator( base_url="http://in-process.invalid", model="qwen2.5-1.5b", transport=OnnxTransport(model),)// models.Transport = func(url string, body []byte, headers map[string]string) ([]byte, error)inProcess := func(url string, body []byte, headers map[string]string) ([]byte, error) { return []byte(`{"choices":[{"message":{"content":"..."}}]}`), nil}
gen := models.NewOpenAIChatGenerator( "http://in-process.invalid", "qwen2.5-1.5b", 0, nil, inProcess, // required argument — models.NewHTTPClient(...).Transport() is the real-HTTP one)out, err := gen.Answer(question, passage, "en")// Transport = (url, body: string, headers) => string | Promise<string>const gen = new OpenAIChatGenerator( { base_url: "http://in-process.invalid", model: "qwen2.5-1.5b" }, (url, body, headers) => { const req = JSON.parse(body); return JSON.stringify({ choices: [{ message: { content: myModel(req.messages.at(-1).content) } }], }); }, // required argument — `new HttpClient().send` is the real-HTTP one);const out = await gen.answer(question, passage, "en");When this is not enough
Section titled “When this is not enough”Transport-swapping assumes your model can be made to look OpenAI-shaped — you parse an OpenAI request and return an OpenAI response, whatever happens in between. That covers in-process models, local daemons, SDK wrappers and fixtures.
If your model cannot be adapted to that shape at all, there is a second, lower level: implement one of the five published Protocols directly. That is the reference path, not the recommended one — see Provider contracts.
- Custom endpoints & auth — when the model is
an HTTP endpoint:
${ENV}header templates, expanded only at the boundary. - Models & endpoints — the clients CiteNexus already ships.
- Provider contracts — the contracts reference, for a model that is not OpenAI-shaped at all.