zidane/ providers/ openai-compat
A factory for any OpenAI-compatible Chat Completions endpoint, with the message and stream helpers it uses.
import { … } from 'zidane/providers/openai-compat'src/providers/openai-compat.ts
Use openaiCompat() when your model sits behind an OpenAI-style /chat/completions route that has no dedicated provider here, such as a gateway or a self-hosted server. You pass the base URL and key. You can also set the auth header, extra headers and capability flags. promptCacheKey sends a cache-affinity key: 'session' uses the agent's session id, and true uses it when there is one, else a hash of the static system prompt and the first user message. The OpenRouter, Cerebras, xAI, Baseten, Arcee, Neuralwatt, Z.ai, Mistral and local providers wrap it. The entry also exports the converters and stream readers, for custom providers.
import { createAgent } from 'zidane'
import { openaiCompat } from 'zidane/providers/openai-compat'
const provider = openaiCompat({
name: 'my-gateway',
apiKey: process.env.GATEWAY_API_KEY ?? '',
baseURL: 'https://gateway.example.com/v1',
defaultModel: 'my-model',
})
const agent = createAgent({ provider })
await agent.run({ prompt: 'Say hello.' })Functions#
applyOAICacheBreakpointsFunction#
Add cache_control: { type: 'ephemeral' } breakpoints to the system message's
last text part and the last message's final content part.
Mutates messages in place. Converts plain-string content into a single-element
content array so the cache marker can attach — this shape is accepted verbatim by
OpenRouter's Anthropic and Gemini routes and ignored by routes with automatic
caching (OpenAI, DeepSeek, Grok, Groq, Moonshot).
Skip conditions (safe no-ops):
- Empty messages array.
- Assistant messages with no text (tool-call-only) — attaching a cache marker to a
tool_callsblock has no defined semantics, so we skip and let the prior system/tools breakpoints carry caching.
System-prompt boundary handling: when originalSystem (the un-rendered
system text passed to toOAIMessages) contains
SYSTEM_PROMPT_BOUNDARY, the leading system message is rewritten as
a two-part array — cache_control lands on the static prefix only, so
per-turn churn in the dynamic suffix doesn't invalidate the cached
doctrine above. originalSystem is optional: omit it (or pass a
marker-free string) for the single-block behavior.
function applyOAICacheBreakpoints(
messages: OAIMessage[],
originalSystem?: string,
ttl?: CacheTtl,
): voidParameters
| Name | Type | Description |
|---|---|---|
| messages | OAIMessage[] | Wire messages to mutate. The first system message and the last cacheable message get a marker. |
| originalSystem? | string | System prompt before boundary rendering, used to split the static and dynamic parts. |
| ttl? | CacheTtl | Cache lifetime for the markers. Uses the host default when omitted. |
applyOAIToolCacheBreakpointFunction#
Return a copy of tools with cache_control on the last entry.
OpenRouter accepts the marker alongside the standard type + function fields
and forwards it when routing to Anthropic/Gemini. Leaves the non-cached tools
unchanged so the caller's reference is not mutated.
assistantMessageFunction#
Creates a text-only assistant message, for priming.
function assistantMessage(content: string): SessionMessageParameters
| Name | Type | Description |
|---|---|---|
| content | string | Message text. |
Returns
buildAssistantContentFunction#
Builds an assistant message from streamed text, tool calls and reasoning. Falls back to a [no content] text block when everything is empty.
function buildAssistantContent(
text: string,
toolCalls: { id: string; input: Record<string, unknown>; name: string }[],
thinking?: string,
reasoning?: { details: unknown[]; model?: string; producer: 'openrouter' } | { model?: string; producer: 'vllm'; reasoningText: string },
): SessionMessageParameters
| Name | Type | Description |
|---|---|---|
| text | string | Streamed text. |
| toolCalls | { id: string; input: Record<string, unknown>; name: string }[] | Tool calls requested by the model. |
| thinking? | string | Streamed thinking text. |
| reasoning? | { details: unknown[]; model?: string; producer: 'openrouter' } | { model?: string; producer: 'vllm'; reasoningText: string } | Reasoning state to persist, from OpenRouter details or vLLM-style text. |
Returns
classifyOpenAICompatErrorFunction#
Classify an OpenAI-compatible error into ClassifiedError.
Recognizes:
AbortError(from fetch) →aborted.OpenAICompatHttpErrorwith a context-exceeded code or message →context_exceeded.- A rate limit, spent credit or overload (429, 402, 529) →
provider_errorworded bydescribeProviderLimit. - Any other
OpenAICompatHttpError→provider_error.
Returns null for unrecognized error shapes (the loop falls back to AgentProviderError).
function classifyOpenAICompatError(err: unknown, label?: string): ClassifiedError | nullParameters
| Name | Type | Default | Description |
|---|---|---|---|
| err | unknown | Error thrown by | |
| label? | string | 'openai-compat' | Product a limit's message names ( |
Returns
ClassifiedError | nullconsumeSSEFunction#
Reads an SSE chat-completions response to the end, forwarding text and thinking deltas to callbacks. Returns the accumulated text, tool calls, usage, finish reason and, when the server sent them, energy in joules (see readEnergyJoules) and the request's own USD price as costUsd (see readRequestCostUsd). Throws OpenAICompatStreamError on an empty or truncated stream and an AbortError when signal aborts.
function consumeSSE(
response: Response,
callbacks: StreamCallbacks,
signal?: AbortSignal,
): Promise<{ costUsd: number | undefined; energyJoules: number | undefined; finishReason: string; reasoningDetails: Record<string, unknown>[]; text: string; thinking: string; toolCalls: { id: string; input: Record<string, unknown>; malformedArgs?: MalformedToolArguments; name: string }[]; usage: OpenAICompatUsage }>Parameters
| Name | Type | Description |
|---|---|---|
| response | Response | Streaming HTTP response with a body. |
| callbacks | StreamCallbacks | Receives text, thinking and tool-call activity as chunks arrive. |
| signal? | AbortSignal | Aborts reading when signaled. |
Returns
Promise<{ costUsd: number | undefined; energyJoules: number | undefined; finishReason: string; reasoningDetails: Record<string, unknown>[]; text: string; thinking: string; toolCalls: { id: string; input: Record<string, unknown>; malformedArgs?: MalformedToolArguments; name: string }[]; usage: OpenAICompatUsage }>formatToolsFunction#
mapOAIFinishReasonFunction#
Map an OpenAI-compatible finish_reason string to the zidane TurnFinishReason union.
function mapOAIFinishReason(
reason: string | null | undefined,
): TurnFinishReason | undefinedParameters
| Name | Type | Description |
|---|---|---|
| reason | string | null | undefined | Raw |
Returns
TurnFinishReason | undefinedopenaiCompatFunction#
Factory for any OpenAI-compatible HTTP endpoint.
Speaks the standard POST /chat/completions + stream: true + SSE dialect.
Thin wrappers such as openrouter, cerebras and baseten call this with pinned defaults.
function openaiCompat(params: OpenAICompatParams): ProviderParameters
| Name | Type | Description |
|---|---|---|
| params | OpenAICompatParams | Endpoint, API key, model and capability settings for the OpenAI-compatible host. |
Returns
Examples
openaiCompat({
name: 'baseten',
apiKey: process.env.BASETEN_API_KEY!,
baseURL: process.env.BASETEN_PROXY_URL!,
authHeader: { name: 'Authorization', scheme: 'Api-Key' },
})openaiCompat({
name: 'openai',
apiKey: process.env.OPENAI_API_KEY!,
baseURL: 'https://api.openai.com/v1',
capabilities: { vision: true, documents: true },
})openAICompatPromptMessageFunction#
Build a user SessionMessage from multimodal prompt parts.
Passes every kind (text/image/audio/video/document) through as a canonical
block; the actual wire emission, capability gating, and unsupported-media
throwing all happen later in toOAIMessages. Implementing this (rather
than letting the agent fall back to defaultPromptMessage) is what lets
openai-compat routes accept base64 documents/PDFs at all — the default
builder throws on base64 documents.
function openAICompatPromptMessage(parts: PromptPart[]): SessionMessageParameters
| Name | Type | Description |
|---|---|---|
| parts | PromptPart[] | Prompt parts to pack into one user message. |
Returns
toOAIMessagesFunction#
Converts a system prompt and session messages to OpenAI Chat Completions messages. Starts with a system message and applies the capability and reasoning settings in options.
function toOAIMessages(
system: string,
messages: SessionMessage[],
options?: ToOAIOptions,
): OAIMessage[]Parameters
| Name | Type | Default | Description |
|---|---|---|---|
| system | string | System prompt. The prompt-boundary marker is removed before sending. | |
| messages | SessionMessage[] | Session messages to convert. | |
| options? | ToOAIOptions | {} | Capability, reasoning and model settings that shape the wire messages. |
Returns
toolResultsMessageFunction#
Creates a user message carrying tool results back to the model.
function toolResultsMessage(results: ToolResult[]): SessionMessageParameters
| Name | Type | Description |
|---|---|---|
| results | ToolResult[] | Results of the tool calls of the previous turn. |
Returns
userMessageFunction#
Creates a text-only user message.
function userMessage(content: string): SessionMessageParameters
| Name | Type | Description |
|---|---|---|
| content | string | Message text. |
Returns
Classes#
OpenAICompatHttpErrorClass#
HTTP error thrown when an OpenAI-compatible endpoint returns a non-OK response.
The body is best-effort JSON-parsed; error.message / error.code / error.type
are extracted for clean downstream classification.
class OpenAICompatHttpError extends ErrorConstructor
new OpenAICompatHttpError(status: number, bodyText: string)Parses bodyText as JSON to build the error message when possible.
Parameters
| Name | Type | Description |
|---|---|---|
| status | number | HTTP status code. |
| bodyText | string | Raw response body. |
Returns
Properties
| Name | Type | Description |
|---|---|---|
| bodyTextreadonly | string | Raw response body. |
| providerCode?readonly | string | Error |
| statusreadonly | number | HTTP status code of the response. |
OpenAICompatStreamErrorClass#
Error thrown when an OpenAI-compatible stream is empty, truncated or malformed. Classified as retryable.
class OpenAICompatStreamError extends ErrorConstructor
new OpenAICompatStreamError(message: string)Creates the error.
Parameters
| Name | Type | Description |
|---|---|---|
| message | string | Description of the stream failure. |
Returns
Constants#
ASSISTANT_TOOL_CALLS_TAGConstant#
Sentinel tag that marks a message carrying round-tripped assistant tool calls.
const ASSISTANT_TOOL_CALLS_TAG = '__zidane_assistant_tc__'TOOL_RESULTS_TAGConstant#
Sentinel tag that marks a message carrying round-tripped tool results.
const TOOL_RESULTS_TAG = '__zidane_tool_results__'Interfaces#
OAIMessageInterface#
A chat message in the OpenAI Chat Completions wire format.
interface OAIMessageProperties
| Name | Type | Description |
|---|---|---|
| content? | unknown | Message content: a string or an array of content parts. |
| reasoning? | string | Plain reasoning string, used when |
| reasoning_content? | string | Plain reasoning string echoed back on assistant messages for vLLM-style
reasoning models (Arcee Trinity-Thinking, DeepSeek-R1). Arcee's API accepts
|
| reasoning_details? | unknown[] | OpenRouter normalized reasoning envelope. Echoed back unmodified on assistant messages so the gateway can resume an extended-reasoning chain on the upstream route. Ignored by hosts that don't speak the envelope. |
| role | 'system' | 'tool' | 'user' | 'assistant' | Author of the message. |
| tool_call_id? | string | Id of the tool call a |
| tool_calls? | { function: { arguments: string; name: string }; id: string; type: 'function' }[] | Tool calls requested by an assistant message. |
OAIToolInterface#
A function tool in the OpenAI Chat Completions wire format.
interface OAIToolProperties
| Name | Type | Description |
|---|---|---|
| function | { description: string; name: string; parameters: Record<string, unknown> } | Function name, description and JSON Schema parameters. |
| type | 'function' | Tool kind. Always |
OpenAICompatAuthHeaderInterface#
Auth header config. scheme is prepended to the api key with a space, e.g.
{ name: 'Authorization', scheme: 'Bearer' } → Authorization: Bearer <key>.
Omit scheme for raw header values (e.g. { name: 'X-Api-Key' } → X-Api-Key: <key>).
Real-world examples:
- Default OpenAI / OpenRouter / Cerebras:
{ name: 'Authorization', scheme: 'Bearer' }. - Baseten dedicated deployments:
{ name: 'Authorization', scheme: 'Api-Key' }. - Some gateways:
{ name: 'X-Api-Key' }.
interface OpenAICompatAuthHeaderProperties
| Name | Type | Description |
|---|---|---|
| name | string | Header name. |
| scheme? | string | Scheme prepended to the key with a space, such as |
OpenAICompatParamsInterface#
Options for openaiCompat.
interface OpenAICompatParamsProperties
| Name | Type | Description |
|---|---|---|
| apiKey | string | Bearer-style API key. |
| authHeader? | OpenAICompatAuthHeader | Auth header shape. Defaults to |
| baseURL | string | Base URL — |
| cacheBreakpoints? | boolean | Whether this endpoint honors
Default: |
| capabilities? | ProviderCapabilities | Provider-level capability flags. Routed into the message shaper and the agent loop so images in tool results + user messages are handled correctly for the underlying model. Defaults when omitted: |
| defaultModel? | string | Default model id when |
| extraBodyParams? | Record<string, unknown> | Generic pass-through for fields on the Chat Completions request body that zidane does not yet type. Spread into the request before the typed core (model / messages / tools / max_tokens / stream / tool_choice), so explicit options always win on collision. Forward-compat escape hatch for endpoints that ship one-off fields ahead
of zidane (e.g. OpenAI |
| extraHeaders? | Record<string, string> | Extra headers sent with every request (e.g. referer, user-agent). |
| label? | string | Product a rate-limit, quota or overload message names ( |
| name? | string | Provider name exposed as |
| normalizeNullContent? | boolean | Normalize |
| pdfEngine? | 'native' | 'pdf-text' | 'mistral-ocr' | PDF-parsing engine for OpenRouter's Without an explicit engine, OpenRouter's per-model native PDF handling is inconsistent (some models silently fail to receive the parsed document). Pinning the engine makes it deterministic:
When unset, no |
| planReasoningRequest? | (ctx: { model: string; thinking?: 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'adaptive'; thinkingBudget?: number }) => Record<string, unknown> | undefined | Per-request reasoning body planner for hosts whose thinking knob is a
vendor-specific request field rather than OpenRouter's |
| promptCacheKey? | PromptCacheKeyOption | Prompt-cache affinity key — an opt-in routing hint that keeps a session's
requests landing on a replica whose KV cache is already warm. See
Default: unset — the field is absent from requests. |
| promptCacheKeyField? | false | 'user' | (string & {}) | 'prompt_cache_key' | Request-body field carrying
Any other field name is accepted for vendor-specific spellings. |
| promptCacheKeyHeader? | string | Additionally send the affinity key as this HTTP header, for gateways that
route on a header rather than a body field. Defers to any same-named entry
in Default: unset — no header is added. |
| reasoningContentField? | 'reasoning_content' | 'reasoning' | Field name to use when echoing plain reasoning back upstream in
|
| reasoningMode? | 'openrouter' | 'reasoning_content' | Shape used to round-trip reasoning state across turns when
Default: |
| supportsReasoning? | boolean | Whether this endpoint speaks a reasoning round-trip shape selected by
Default: |