Skip to content

Custom endpoints & auth

A model that is an HTTP endpoint is one you configure here. (It need not be — if your model runs in-process, keep the same client and swap the transport; base_url and headers are then just values your own callable receives and can ignore.) Auth is a header template: you write "Bearer ${OPENAI_API_KEY}", and the ${ENV_VAR} is expanded from the process environment at the moment the request is sent — never before. The secret’s value never lives on a client object, in a config, in a repr, or in a log. Only the ${NAME} placeholder is ever held.

${ENV} header auth is the same mechanism in all three ports: the template is held on the client, and the HTTP layer expands it at the request boundary — Python HttpClient, Go models.HTTPClient.ResolveHeaders, JavaScript HttpClient.resolveHeaders. Same embedding endpoint, one tab per language:

from citenexus import OpenAICompatibleEmbedding
embedder = OpenAICompatibleEmbedding(
base_url="https://api.jina.ai/v1",
model="jina-embeddings-v3",
headers={
"Authorization": "Bearer ${JINA_API_KEY}", # expands at call time
"X-Tenant": "legal", # arbitrary provider header
},
)
vectors = embedder.embed_many(["hello"])

The same option carries a bare token under whatever header name a provider wants — {"x-api-key": "${ACME_TOKEN}"} — so a non-Bearer scheme needs no special support.

Chat generation is the same shape in all three:

from citenexus import OpenAICompatibleGenerator
generator = OpenAICompatibleGenerator(
base_url="https://api.openai.com/v1",
model="gpt-4o-mini",
headers={"Authorization": "Bearer ${OPENAI_API_KEY}"},
)
# Python only — vision and reranking clients take the same headers= templates.
from citenexus import OpenAICompatibleVision
vision = OpenAICompatibleVision(
base_url="https://api.internal.acme/v1",
model="acme-vlm",
headers={
"x-api-key": "${ACME_TOKEN}", # bare token, custom auth header
"X-Tenant": "legal", # arbitrary provider header
},
)

Everything from here down belongs to the Python facade: HttpEndpoint, the typed subclasses, and from_config are the config layer, and the ports have no config layer to wire them into. In Go and JavaScript you construct the client directly, exactly as the tabs above show.

For config-driven wiring, the typed HttpEndpoint subclasses carry the same ${ENV} headers (and reusable defaults like the right base URL and auth style):

from citenexus import (
OpenAIHttpEndpoint, GeminiHttpEndpoint, AnthropicHttpEndpoint,
OpenRouterHttpEndpoint, OllamaHttpEndpoint,
)
openai = OpenAIHttpEndpoint(headers={"Authorization": "Bearer ${OPENAI_API_KEY}"})
gemini = GeminiHttpEndpoint(headers={"Authorization": "Bearer ${GEMINI_API_KEY}"})
claude = AnthropicHttpEndpoint(headers={"x-api-key": "${ANTHROPIC_API_KEY}"}) # no Bearer
local = OllamaHttpEndpoint(base_url="http://localhost:11434/v1") # no key

A private gateway is just the general HttpEndpoint — URL, ${ENV} token in a header of your choosing, and arbitrary per-request headers:

from citenexus import HttpEndpoint
endpoint = HttpEndpoint(
base_url="https://llm.internal.acme/v1",
headers={
"Authorization": "Bearer ${ACME_TOKEN}",
"X-Tenant": "legal", "X-Env": "prod",
},
timeout_s=60,
)

Endpoints are applied through from_config — CiteNexus builds each model client with a transport that expands the ${ENV} headers at call time:

from citenexus import CiteNexus, GeminiHttpEndpoint, OpenAIHttpEndpoint
from citenexus.config.schema import (
CiteNexusConfig, EmbeddingConfig, LLMConfig, RerankerConfig, StorageConfig,
)
from citenexus.config.signals import Signal
jina = OpenAIHttpEndpoint(
base_url="https://api.jina.ai/v1",
headers={"Authorization": "Bearer ${JINA_API_KEY}"},
)
config = CiteNexusConfig(
storage=StorageConfig(bucket="./citenexus-data"),
embedding=EmbeddingConfig(endpoint=jina, model="jina-embeddings-v3"),
reranker=RerankerConfig(endpoint=jina, model="jina-reranker-v2-base-multilingual"),
llm=LLMConfig(
endpoint=GeminiHttpEndpoint(headers={"Authorization": "Bearer ${GEMINI_API_KEY}"}),
model="gemini-2.5-flash",
),
signals=(Signal.embedding, Signal.text),
)
rag = CiteNexus.from_config(config) # each client resolves ${ENV} headers at the edge

If you would rather not use a shipped client, the HTTP layer is public on its own — the ${ENV} expansion lives there, not in the clients:

from citenexus.http import HttpClient
client = HttpClient() # the client *is* the transport: __call__
raw = client(
"https://api.jina.ai/v1/embeddings",
b'{"model": "jina-embeddings-v3", "input": ["hello"]}',
{"Authorization": "Bearer ${JINA_API_KEY}"}, # expanded inside the call
)

In every port the merge order is the same — User-Agent < client defaults < per-call headers, auth wins — and the templates stay unexpanded until the one call that sends them (resolve_headers / ResolveHeaders / resolveHeaders).

Transport is (url, body, headers) => string | Promise<string>, so an async transport needs no cast — a consumer awaits either. HttpClient.send is async (Node fetch); resolveHeaders is the synchronous primitive underneath it, so a synchronous transport (the hermetic test fakes) stays valid.

Writing a model that is not an HTTP endpoint at all — in-process, a local daemon, an adapter for a third library, a fixture? Keep these same clients and replace one callable: Bring your own model — swap the transport.