Skip to content

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.

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.

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.

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.

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 HTTP
gen = OpenAICompatibleGenerator(
base_url="http://in-process.invalid", model="qwen2.5-1.5b",
transport=OnnxTransport(model),
)

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.