@kindgi/adapter-model-openai-compat
npm install @kindgi/adapter-model-openai-compat · source
OpenAI-compatible ModelProvider for @kindgi/capabilities. Wraps the official openai SDK client behind the framework's provider-neutral interface and works against OpenAI's own API and any endpoint that speaks the Chat Completions wire format — Ollama, vLLM, llama-server, LM Studio, OpenRouter, Groq, Together, Fireworks, DeepInfra, or a LiteLLM proxy. One adapter, many backends: baseURL selects the endpoint, and api the OpenAI API it speaks.
Purpose
Section titled “Purpose”Translate between the framework's ModelCallInput / ModelCallResult and one non-streaming call, on one of two OpenAI APIs (api):
'responses'— OpenAI's Responses API (responses.create), the default for abaseURLonapi.openai.com(and its data-residency hosts, such aseu.api.openai.com). OpenAI's GPT-6 models call tools only through it.'chat-completions'— Chat Completions (chat.completions.create), the default for every otherbaseURL: the format the OpenAI-compatible servers speak.
Chat Completions
Section titled “Chat Completions”- Messages map role for role. Assistant
toolCallsbecometool_callswith JSON-encoded arguments;toolmessages carrytool_call_id. toolsbecomefunctiontools;structuredOutputbecomesresponse_format: { type: 'json_schema', json_schema: { name, schema, strict: true } };temperatureandmaxOutputTokens(asmax_tokens) are sent only when set.- Chat Completions endpoints reject
.in function names, so tool names are encoded.→__on send and decoded on receive (acme.orders.lookup↔acme__orders__lookup). Tool ids must not contain a literal__. Tool-call arguments that are not valid JSON decode to{}. finish_reasonmapstool_calls/function_call→tool-use,content_filter→content-filter,length→length, anything else →stop.costUsdis computed from the matchingModelInfo.cost(computeCost), on both APIs. The wire format carries no pricing, so rates come from the caller (OpenAICompatCostRates, the names the Gemini and Anthropic adapters price with):- prompt and completion tokens per 1K tokens (
promptUsdPer1kTokens,completionUsdPer1kTokens); - cached prompt tokens at
cachedPromptMultiplierof the prompt rate, and cache-write tokens atpromptCacheCreationMultiplier(each absent: the prompt rate); longContext: { thresholdTokens, promptUsdPer1kTokens, completionUsdPer1kTokens }: past the threshold (cached and cache-write prompt tokens included), the whole call bills at those rates, the cache multipliers applying to the long prompt rate;dataResidencyMultiplier: an uplift on the whole call, only whenbaseURLis a data-residency host (eu.api.openai.com;isDataResidencyHost). A rate that isn't a finite, non-negative number is ignored. Theopenaipreset carries OpenAI's GPT-6 rates. ItslongContextapplies per call, counting all of the call's input tokens (cached and cache-write ones included) past 272,000: that's how we read OpenAI's pricing page, which doesn't spell it out. These costs are estimates from published prices; the provider's invoice is authoritative.
- prompt and completion tokens per 1K tokens (
Responses
Section titled “Responses”- Stateless: every call sends the whole conversation with
store: false, so OpenAI keeps no conversation state for it (noprevious_response_id). - Input items:
systemmessages go asdevelopermessages,userandassistantas messages, assistanttoolCallsasfunction_callitems, andtoolmessages asfunction_call_outputitems. - Tools and typed output:
toolsbecome flatfunctiontools (strict: false, the same.→__name encoding);structuredOutputbecomestext.format: { type: 'json_schema', name, schema, strict: true };temperatureandmaxOutputTokens(asmax_output_tokens) are sent only when set. - The answer: its
output_text(its refusal when it has no text) and itsfunction_callitems.incompletemapsmax_output_tokens→lengthandcontent_filter→content-filter; afailedresponse throws, naming OpenAI's error code and message. - Reasoning between tool calls: a reasoning model's reasoning items (encrypted by OpenAI), with the item ids and
phasethat tie them to the message and calls after them, are carried in the first tool call'sModelToolCall.signature(opaque, versionedoair1.). When the conversation continues with the same model, they go back in their original order; for any other model, or a signature that isn't this adapter's, the turn goes back without them. A final answer'sphaseisn't carried (ModelMessagehas no signature). - Usage:
input_tokens/output_tokens, with the cached, cache-write and reasoning parts reported apart when given.
Retries and errors
Section titled “Retries and errors”The adapter owns the retry policy, built on @kindgi/adapter-model-shared, like the other adapters: the openai SDK client's own retries are off, and a dropped connection, a 408/409/429/5xx, or a vendor's x-should-retry: true is tried again — up to the caller's clientOptions.maxRetries plus one attempts (the SDK default is 2, so 3 attempts) — honouring the vendor's retry-after(-ms), and a vendor asking to wait more than a minute ends the call at once. A connection the runtime refuses the same way every time (an egress or body-size guard, or a redirect the request won't follow) is tried once, its own words on top rather than the SDK's generic "Connection error."
A failed call throws ModelProviderError (kind, status), with the OpenAI SDK's error as its cause, as the other adapters' do; a call stopped by the turn's abort signal ends at once with the turn's reason. (Before this, a failed OpenAI-compatible call rejected with the openai SDK's own APIError, and an abort with APIUserAbortError; code that caught OpenAI.APIError on this path should catch ModelProviderError.)
Exports
Section titled “Exports”createOpenAICompatModelProvider(options)— returns aModelProviderwhosemetadataisoptions.metadataunchanged. The SDK client is created on the firstinvoke()and reused while the key stays the same.invoke()throws wheninput.modelis not one ofmetadata.models[].name, and passes the turn'sabortSignalto the request, so a cancelled turn cancels the call.OpenAICompatProviderOptions:baseURL: string— endpoint root, e.g.BASE_URLS.OLLAMA_LOCAL.apiKey: string | (() => string | Promise<string>)— a static key, or a resolver called on everyinvoke(): a rotated key takes effect on the next call. Local runners that ignore keys still need a non-empty string such as'unused'.metadata: ProviderMetadata— surfaced to the router.models[]must list every model invoked through this connection, with its per-1K-token cost.api?: 'responses' | 'chat-completions'(OPENAI_COMPAT_APIS) — the OpenAI API to speak. Absent:defaultOpenAICompatApi(baseURL),'responses'forapi.openai.comand its subdomains,'chat-completions'for any other. Another value is refused at construction.clientOptions?— otheropenaiclient options (timeout,maxRetries,defaultHeaders,fetch, …);apiKeyandbaseURLare excluded.extraBody?— fields merged into every request body: settings an endpoint takes that the OpenAI format has no field for. A Qwen thinking model served by vLLM, SGLang or llama-server needs{ chat_template_kwargs: { enable_thinking: false } }, or its answer starts with its thinking and a typed (JSON) answer fails. The fields the adapter sets are refused: on Chat CompletionsEXTRA_BODY_RESERVED(model,messages,tools,response_format,temperature,max_tokens,stream), on ResponsesEXTRA_BODY_RESERVED_RESPONSES(model,input,tools,text,temperature,max_output_tokens,stream,store). On Responses,{ reasoning: { effort: 'low' } }sets a reasoning model's effort.
openAICompatAdapterFactory(OPENAI_COMPAT_ADAPTER_ID) — theAdapterFactorya runtime registers. A provider registration names its endpoint inadapter_config.baseURL(an http(s) URL;openAICompatBaseUrlreads and checks it), the OpenAI API inadapter_config.api(openAICompatApireads and checks it, with the base URL's default when absent), any extra request fields asextraBody.*keys (adapter_configis flat, so one key per field and dots nest:"extraBody.chat_template_kwargs.enable_thinking": false;openAICompatExtraBodyexpands and checks them), and, for an endpoint that needs a key, its secret insecret_ref; without one the adapter sends'unused', as local runners expect.openAICompatAdapterEntry— theAdapterFactoryEntrya runtime registers:openAICompatAdapterFactoryplusopenAICompatCheckConfig(input), which reports every problem the factory would throw on (adapter_config.baseURL,adapter_config.api, eachadapter_config.extraBody.*key), with the factory's own messages, without building anything.BASE_URLS— well-known endpoints:OPENAI,OLLAMA_LOCAL,VLLM_LOCAL,LLAMA_SERVER_LOCAL,LM_STUDIO_LOCAL,OPENROUTER,GROQ,TOGETHER,FIREWORKS,DEEPINFRA,LITELLM_LOCAL. Any other URL works too.ModelProvider— type re-export from@kindgi/capabilities.
Example
Section titled “Example”import { createProviderRegistry } from '@kindgi/capabilities';import { BASE_URLS, createOpenAICompatModelProvider } from '@kindgi/adapter-model-openai-compat';
const apiKey = process.env.OPENAI_API_KEY;if (apiKey === undefined) throw new Error('OPENAI_API_KEY is not set');
const openai = createOpenAICompatModelProvider({ baseURL: BASE_URLS.OPENAI, apiKey, metadata: { id: 'openai', region: 'us', models: [ { name: 'gpt-4o-mini', contextWindow: 128_000, features: ['tool-use', 'structured-output'], // Rates from the provider's published pricing. cost: { promptUsdPer1kTokens: 0.00015, completionUsdPer1kTokens: 0.0006 }, }, ], }, clientOptions: { timeout: 30_000 },});
const { registry } = createProviderRegistry();registry.register(tenantId, openai);
const result = await openai.invoke({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'What is the status of order 1042?' }], tools: [ { name: 'acme.orders.lookup', description: 'Fetch an order by id.', inputSchema: { type: 'object', properties: { orderId: { type: 'string' } }, required: ['orderId'] }, }, ],});for (const call of result.message.toolCalls ?? []) { console.log(call.name, call.arguments); // acme.orders.lookup { orderId: '1042' }}console.log(result.finishReason, result.costUsd);A local runner uses the same factory: baseURL: BASE_URLS.OLLAMA_LOCAL, apiKey: 'unused', and zero cost rates in metadata.
Non-goals
Section titled “Non-goals”- Streaming. Requests are sent with
stream: false;invoke()resolves with the complete response. - Server-side conversation state (Responses
previous_response_id,store: true). Every call carries its whole conversation. - Pricing discovery. Cost rates are caller-supplied per model.
Related
Section titled “Related”@kindgi/capabilities—ModelProvider,ProviderMetadata, and the provider registry.@kindgi/adapter-model-anthropic— the Anthropic Messages API adapter, with a lazily resolved API key.@kindgi/adapter-model-in-process— local models inside the Node.js process, no HTTP endpoint.