Skip to content

Provider contracts — the reference

CiteNexus bundles no models. That has always been the design — but for a long time “inject your own” meant read our call sites and guess. Embedding alone was injected through five different abstractions in the Python reference, no two agreeing on arity or return type, and the batch path was discovered at runtime by getattr(embedder, "embed_many", None). A capability found by getattr is a capability no type checker can verify and no provider author knows to offer.

There is now one published place to look: citenexus.contracts. Match a shape and you are a provider.

This page is that reference — every contract, its exact signature, and the reasoning behind each. It documents what the shipped clients themselves declare and what python/tests/test_third_party_provider.py exercises. It is the lower level, underneath the transport seam, and most callers never need it.

The contracts are @runtime_checkable Protocols, and that choice is the whole point. An abstract base class would force import citenexus into a third party’s own source and make this library a build-time dependency of anyone who wants to be compatible. A Protocol inverts it: matching the shape is enough.

So an in-process model, a mock, a cached fixture, or an adapter someone else ships for a third library can satisfy every contract here without ever naming CiteNexus — and without opening a socket. Nothing in the contract module mentions a transport: base_url, headers and transport are constructor parameters of the shipped HTTP clients, never contract methods.

That claim is asserted rather than assumed. python/tests/test_third_party_provider.py writes providers the way an outsider would, then proves:

  • their only library import is citenexus.contracts;
  • nothing CiteNexus-owned appears in any provider’s MRO;
  • socket.socket is monkeypatched to explode for the duration of the end-to-end run — these models are in-process, and any network call fails the test;
  • ingest → ask still reaches a cited, gate-approved answer.

Two classes, no CiteNexus base class, no network. This is the shape of the real test, trimmed to the two seams you need for an answer:

import hashlib
import math
import re
from collections.abc import Sequence
from citenexus import CiteNexus, EmbeddingProvider, GeneratorProvider, Vector
_WORD = re.compile(r"\w+", re.UNICODE)
def _tokens(text: str) -> list[str]:
return [m.group(0).lower() for m in _WORD.finditer(text)]
class InProcessEmbedding:
"""A hashing vectorizer that never leaves the process.
Batch is the primitive: a single text is a batch of one, not a second method.
"""
def __init__(self, dim: int = 96) -> None:
self.dim = dim
def embed_many(self, texts: Sequence[str]) -> list[Vector]:
return [self._one(t) for t in texts]
def _one(self, text: str) -> Vector:
vec = [0.0] * self.dim
for tok in _tokens(text):
idx = int(hashlib.blake2s(tok.encode("utf-8")).hexdigest(), 16) % self.dim
vec[idx] += 1.0
norm = math.sqrt(sum(v * v for v in vec)) or 1.0
return [v / norm for v in vec]
class ExtractiveGenerator:
"""A model that quotes the passage, so it cannot hallucinate."""
def answer(self, question: str, passage: str, answer_language: str = "en") -> str:
wanted = set(_tokens(question))
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", passage) if s.strip()]
if not sentences:
return passage
return max(sentences, key=lambda s: len(wanted & set(_tokens(s))))
# Shape alone is enough — this passes, and neither class imports a base class.
assert isinstance(InProcessEmbedding(), EmbeddingProvider)
assert isinstance(ExtractiveGenerator(), GeneratorProvider)

The same two seams exist in Go and JavaScript, spelled for those languages — Where the ports inject has the tabs. What comes next does not:

rag = CiteNexus(
"./citenexus-data",
embedder=InProcessEmbedding(),
generator=ExtractiveGenerator(),
)
rag.ingest(
text=(
"The employee shall not disclose confidential information to any third party. "
"This obligation survives termination of employment for a period of five years."
),
document_id="nda",
)
result = rag.ask("Can the employee disclose confidential information?")
print(result.answer) # The employee shall not disclose confidential information to any third party.
print(result.sources[0].document) # nda

Note what the generator is not asked to do. It does not retrieve and it does not choose evidence — it is handed the passage the library already selected, plus the ISO code the answer must be in. And it is not trusted: whatever it returns goes through the per-claim faithfulness gate before it can become an answer. Which is why an extractive generator — one that quotes — is the best kind of generator, not a toy one.

All five live in citenexus.contracts and are re-exported from the top-level citenexus package. Every one of them returns a value or raises — see Failure must be sayable.

Contract Method Returns
EmbeddingProvider embed_many(texts: Sequence[str]) list[Vector] — one per input text, in input order
GeneratorProvider answer(question, passage, answer_language="en") str
CompletionProvider complete(prompt: str) str
VisionProvider describe(image_region: Any) Mapping[str, Any]
RerankerProvider rerank(query: str, candidates: Sequence[Candidate]) list[Candidate]

Vector is list[float] — a plain dense vector. Sparse term weights are not part of this seam: the lexical signal is BM25 over stored Evidence Unit text and never touches the embedder.

Where each one is used:

  • CompletionProvider is the deep-ask decision seam — one prompt in, one string out. Deliberately not provider tool/function-calling: the library scripts the loop and parses a small JSON decision off a plain completion, so the model never owns control flow.
  • VisionProvider feeds the conditional-vision path. The returned mapping fills a figure Evidence Unit: short_caption (the only required key), detailed_description, objects, relationships, ocr_text, data_values, image_type. Unknown keys are ignored.
  • RerankerProvider may only reorder or drop. It can never introduce a candidate retrieval did not produce, so a reranker cannot bypass the grounding guarantee. See Reranking & retrieval.

Two older shapes are published alongside them, named so they stop being anonymous: SingleTextEmbedder (embed(text) -> Vector) and SequenceEmbedder (embed(texts) -> list[Vector]). Both are deprecated but fully supportedcontracts.embed_texts() is the one place the library decides how to talk to an embedder, and it falls back to the single-text shape. New providers should implement EmbeddingProvider: one request per Evidence Unit does not survive a real corpus.

The shipped clients are just providers too

Section titled “The shipped clients are just providers too”

OpenAICompatibleEmbedding, OpenAICompatibleGenerator, OpenAICompatibleVision and OpenAICompatibleReranker are not privileged. Each one declares the matching contract, so mypy --strict checks the shipped clients against the published shape on every run — the docs cannot drift from the code. All four are now importable from the top-level package, which is what makes their common shape visible:

from citenexus import (
CiteNexus,
OpenAICompatibleEmbedding,
OpenAICompatibleGenerator,
OpenAICompatibleReranker,
OpenAICompatibleVision,
)
rag = CiteNexus(
"./citenexus-data",
embedder=OpenAICompatibleEmbedding(base_url="…/v1", model="bge-m3"),
generator=OpenAICompatibleGenerator(base_url="…/v1", model="qwen2.5"),
)

They share one constructor shape — keyword-only base_url, model, transport, headers, plus role-specific extras. That shared transport is why bringing your own model rarely needs this page at all: keep the client, replace the callable, and the client’s request shaping, headers, ${ENV} expansion and response parsing all still apply — swap the transport. The role-specific extras are batch_size on embedding, temperature / max_tokens on generation and vision, and mime_type on vision. A contract-implementing provider drops into the exact same slot:

rag = CiteNexus(
"./citenexus-data",
embedder=InProcessEmbedding(), # yours
generator=OpenAICompatibleGenerator(base_url="…/v1", model="qwen2.5"), # ours
)

Mix freely. The library cannot tell the difference, and that is the test the contract has to pass. For auth on the shipped clients, see Custom endpoints & auth.

This looks like a naming quibble and is not.

In Python, str is itself a Sequence[str]. A contract spelled embed(texts: Sequence[str]) -> list[Vector] therefore cannot be distinguished from the single-text shape embed(text: str) -> Vector — not by isinstance against a runtime_checkable Protocol, which only checks that a method of that name exists, and not by a type checker either, since passing a str where a Sequence[str] is expected is perfectly legal.

The failure is silent, and it is the worst kind. A single-text embedder handed to a batch contract accepts the call, iterates the string character by character, and returns a list of floats where a list of vectors was promised. Nothing raises at the seam.

embed_many is unambiguous — and it is not invented: it is the exact name ingest was already duck-typing for with getattr. Naming it turned a discovered capability into one a provider can know about.

The ports use the natural name, because the hazard is a property of Python’s Sequence protocol and does not exist there:

  • Gostring and []string are unrelated types. The compiler rejects the wrong one at the assignment, and a x.(EmbeddingProvider) type assertion cannot match the single-text shape. golang/contracts/contracts_test.go proves this rather than asserting it.
  • TypeScriptstring is not assignable to readonly string[] under tsc --strict, and at runtime the two seams are not even the same kind of value: the single-text seam is a function, the batch contract is an object with an embed method, so typeof discriminates them with certainty.

Identical semantics across ports is the requirement. Identical identifiers is not.

Every contract, in every port, returns a value or fails loudly. No sentinels.

Python Go JavaScript
success return value (T, nil) resolved promise (or a plain value)
failure raise (zero, err) rejected promise / throw

A zero vector is not an error value. It is a valid embedding of something, and once written it is indistinguishable from a document that genuinely embeds near the origin — retrieval scores it 0.0000 with no error and no flag. This was a real bug, not a hypothetical: the Go seam used to be Embed(text string) []float64, with no way for a timed-out model to say so, and a reproduction indexed a polarity-flipped “may not disclose” clause as a zero vector while reporting success. An empty string is likewise an answer, not a failure.

Belt as well as braces: in all three implementations the ingest write path now refuses a vector outright — empty, wrong-dimension for the run, non-finite (NaN/±Inf), or all-zeros — and refuses a batch that does not return one vector per input text, because a seam that can report failure still does not stop a provider that returns a degenerate vector while reporting success. The rejection order is itself pinned, by the 48 cases in conformance/cases/vector_validation.json (Python check_vector, Go contracts.CheckVector, JavaScript checkVector). A failed embed is fail-closed and all-or-nothing: the ingest aborts and writes nothing. Skip-and-report was rejected, because a document missing one chunk is, at retrieval time, indistinguishable from a document that never contained that sentence.

And a model failure is not an abstention. A refusal is a finding — “we searched the evidence and it does not support an answer.” A timed-out embedding model is not a finding about the evidence, and dressing it as one is the same class of lie as the zero vector.

Honest scope: five in Python, two in the ports

Section titled “Honest scope: five in Python, two in the ports”

“Bring your own provider” is fully true in Python and partly true in Go and JavaScript. Say it that way until it is true everywhere.

Seam Python Go JavaScript
embedding EmbeddingProvider.embed_many contracts.EmbeddingProvider.Embed EmbeddingProvider.embed
generation GeneratorProvider.answer contracts.GeneratorProvider.Answer GeneratorProvider.answer
completion CompletionProvider.complete not published not published
vision VisionProvider.describe not published not published
reranking RerankerProvider.rerank not published not published

The three withheld seams are deferred, not refused. Each port was checked against one question — what here would consume it? — and the answers were:

  • completion — neither port has a deep-ask decision loop. golang/result/result.go says so outright: “Deep-ask is Python-only today.” There is no loop to feed.
  • vision — neither port has a conditional-vision path, an ImageRef, or a figure Evidence Unit.
  • rerankingrerank has zero hits in either port, tests included. Neither has a Candidate type the contract could even be spelled in terms of without first inventing the retrieval layer it reorders.

A published contract asserts that implementing it makes the library use your provider. Publishing one for a seam with no call site would make that assertion falsely — the mirror image of the getattr problem. Each becomes a one-file addition the day a port grows a caller, and its shape is already settled by Python.

Two further Python-only things, so their absence is not read as an oversight: the authority policy and the answer-language "auto" sentinel both live in the config layer, and neither port has a config layer or an ask() facade over one. The ports fix the answer language at "en" and still pass it through the contract, so a provider sees the same signature it sees in Python.

The injection point differs by port, because what consumes the provider differs. In Python it is the CiteNexus(...) constructor, which owns a store; in Go and JavaScript it is answer.AskWith / askWith over an in-memory corpus. Ask / ask keep their old signature and behaviour on purpose: they are pinned byte-for-byte by conformance/cases/e2e_hermetic.json. Every field of the provider set is optional — an absent provider falls back to the port’s deterministic fake, so a partial set is valid.

from citenexus import CiteNexus
# Shape alone is enough — asserted at RUNTIME by @runtime_checkable Protocols.
rag = CiteNexus(
"./citenexus-data",
embedder=MyEmbedding(), # embed_many(texts) -> list[Vector]
generator=MyGenerator(), # answer(question, passage, answer_language="en") -> str
)
# A model failure raises — never a refusal.
result = rag.ask("Can the employee disclose?")

The Python tab is the odd one out and stays that way: it takes a store URI because Python is the only port that has a store. The two provider seams themselves are the same shape in all three.

Go’s contracts package imports nothing — not even another CiteNexus package — and the JS contracts.ts module imports nothing at all, so implementing a contract costs a provider author one file’s worth of reading and no dependency they did not already have.


Background: ADR-0014 — the model seam is a contract, not an endpoint. Next: Bring your own model — swap the transport (start here) · Models & endpoints for the shipped clients · Custom endpoints & auth for ${ENV} auth.