provider-openai
v1.2.25OpenAI Responses provider worker with Chat Completions compatibility; implements provider::openai::stream and provider::openai::refresh_models behind llm-router.
- macOS: arm64 · x64
- Linux: arm64 · armv7 · x64
- Windows: arm64 · x64
exact versions are immutable; binary and bundle artifacts are digest-pinned.
readme
open as markdownprovider-openai
OpenAI Responses provider worker behind llm-router, with a
Chat Completions compatibility path for custom gateways.
Implements the provider protocol from
tech-specs/2026-06-agentic/llm-router.md: provider::openai::stream
(SSE chunks → AssistantMessageEvent frames into a router-owned channel),
provider::openai::refresh_models (live GET /v1/models filtered to
chat/reasoning families ∪ curated capability snapshot →
router::models::reconcile), provider::openai::embed (batch text
embeddings behind router::embed; the endpoint derives from the configured
api_url, so OpenAI-compatible local servers — llama.cpp --embeddings,
Ollama, vLLM, LM Studio — and gateways work through the same surface), and
provider::openai::count_tokens (local prompt token estimation with the
tiktoken tokenizers behind router::count_tokens; never runs the model,
costs nothing, and needs no network).
Behavior
- Registration: self-declares via
router::provider::registerwith backoff until acked, and re-declares on therouter::readytrigger type. The model slice is populated from live discovery and the declaration carriescredential_env_var: OPENAI_API_KEY. - Transport: the default
https://api.openai.com/v1/responsesendpoint uses Responses items and typed streaming events. An explicitly configured endpoint that does not end in/responseskeeps the Chat Completions wire format for compatible gateways that have not migrated. - Identity binding: the router returns a
registration_tokenon first registration; it is persisted in state (scopeprovider-openai, keyregistration_token) and presented on every laterregister/resolve/reconcile. If that state is lost the router rejects re-registration — the operator must clear the binding on the router side. - Credentials: resolved per request via
router::provider::resolve(config slice →OPENAI_API_KEYenv on the router → none). Bothapi_keyandoauthcredential shapes are sent asAuthorization: Bearer; v1 performs no OAuth refresh. - Liveness:
pingat least every 30s of upstream silence; a failed channel write (caller gone /router::abort) drops the SSE receiver and aborts the in-flight HTTP request. - Errors: 401/403 →
auth_expired, 429 →rate_limited(exceptinsufficient_quota, a billing wall →permanent),context_length_exceeded→context_overflow, 5xx/network →transient, other 4xx →permanent. No transport retries here — the router owns retry policy. - Structured output: native. A
response_formatwith a schema maps to strictjson_schemamode; without one,json_objectmode (the caller must mention "JSON" in the prompt per OpenAI's rules). Every curated record declaressupports_structured_output: true. - Reasoning:
thinking_levelmaps to the selected transport's reasoning field per model family (src/reasoning.rs— the ladders encode real 400s: o1 and chat-tuned variants take no param, pro is high-only, xhigh is gpt-5.2+). Responses reasoning summaries are relayed as thinking blocks. Chat Completions still exposes onlycompletion_tokens_details.reasoning_tokensasusage.reasoning. Luna requests with function tools on the legacy path forcereasoning_effort: none, matching that endpoint's compatibility rule. - Prompt caching: automatic on OpenAI's side — no request markers.
prompt_cache_keyroutes requests that share a prefix to one cache shard: a caller'sprovider_options.openai.prompt_cache_keywins, else a key derived from the router'scache_intent.surface_digest(the frozen agent-profile prefix, so independent sessions on one profile share a shard), else a key derived from the session id. On GPT-5.6 and later (official endpoint only) the router'ssystem_sectionsgo out as one developer message each, withprompt_cache_breakpoint: {"mode":"explicit"}on the block flaggedcache_boundary: those models look up the cache only at message-level boundaries — explicit breakpoints, the implicit breakpoint on the latest eligible message, up to 20 earlier eligible message endings, and the end of the initial developer block — so a single system message ending with per-session text never matched across sessions (every new session paid a 1.25× cache write and read nothing). Implicit caching stays on for the conversation history; the key is accounting-only there. On the models that document extended retention (gpt-5.5, gpt-5.4, gpt-5.2, gpt-5.1*, gpt-5, gpt-5-codex, gpt-4.1 and their dated snapshots, official endpoint only) every request sendsprompt_cache_retention: "24h"so an entry lives up to a day instead of 5-10 minutes, at no extra write charge;PROVIDER_OPENAI_CACHE_RETENTION(in_memory|off) overrides it. GPT-5.6 and later takeprompt_cache_options.ttl, whose only value (30m) is already the default, so nothing is sent.prompt_tokens_details.cached_tokenslands onusage.cache_read. - Curated snapshot:
src/curated.rscarries windows / output ceilings / capability flags / pricing (USD per MTok). Update it against the official model specifications when OpenAI ships new models — discovery only supplies bare ids. GPT-5.4 / Pro, GPT-5.5 / Pro, GPT-5.6 / Sol / Terra / Luna, GPT-6 Astra / Sol / Luna and GPT-6.1 Sol have 1,050,000-token context windows. GPT-5.3 Codex and GPT-5.4 Mini / Nano have 400,000-token context windows. All of these models have 128,000-token output ceilings. Context-tiered pricing is intentionally omitted from the flat-rate catalog fields.
Image generation
provider::openai::image::generate and provider::openai::image::read are
the agent-callable functions of this worker (images have no router front
door, and the calls touch none of the router's accounting). generate posts
to the Images API sibling of the configured api_url
(…/v1/images/generations) with the router-resolved credential and writes
the picture, at full resolution, to
data/provider-openai/images/ (relative to the
Compose project directory, III_COMPOSE_DIR; the process cwd outside
Compose). The result names that file and carries no image bytes: base64
in a function result only inflates the conversation context.
// request
{ "model": "gpt-image-2.5-sunburst", // or gpt-image-2.5-flare (anything else is rejected)
"prompt": "a lighthouse at dusk, oil painting",
"size": "1536x1024", // 1024x1024 | 1536x1024 | 1024x1536 | auto
"quality": "high", // low | medium | high | auto
"output_format": "png", // png (default) | jpeg | webp
"background": "opaque", // transparent | opaque | auto
"file_name": "lighthouse" } // optional stem for the saved file
// response — the same envelope as browser::screenshot, minus the picture
{ "content": [
{ "type": "text", "text": "Image generated by gpt-image-2.5-sunburst (1536x1024, 1834021 bytes, image/png) saved to /…/data/provider-openai/images/lighthouse-1790171000000-3f9a2c1e.png; view with provider::openai::image::read { path }" } ],
"details": { "model", "path", "mime", "bytes", "size", "quality", "width", "height", "inline": "none", "revised_prompt?", "usage?" } }Preview in the chat. This worker ships an injected console renderer
(ui/, served through src/ui.rs): the chat card for a generate call
fetches a downscaled JPEG from the worker itself through image::read and
shows it beside the file path (copyable), dimensions, size and model — the
bytes travel worker → console, never through the model.
Looking at the picture from an agent. image::read { path, variant? }
takes the details.path a generate call returned (or the bare file name)
and returns viewable content blocks: variant: "preview" (default; a JPEG
≤512px / ~80 KB, which fits the harness's 256 KiB result cap — base64 is
counted twice there, content + details) or variant: "full". Anything
outside data/provider-openai/images/ is refused, symlinks included. For
one-shot use, generate also accepts inline: "preview" | "full" to embed
the picture in its own result.
Tests
cargo test # unit (pure modules + TCP stubs)
III_ENGINE_BIN=$(which iii) cargo test --test integration -- --test-threads=1The integration suite spawns a real engine, the real router (path dep), this provider, and a local stub upstream — no external API calls anywhere.
Running
The binary takes the standard worker CLI flags: --url (engine WebSocket,
default ws://127.0.0.1:49134, falls back to the III_WS_URL environment
variable), --manifest (print the registry manifest and exit), and
--config (accepted but ignored with a warning — provider config comes
from the llm-router configuration entry).