feat(providers): add the four OpenAI-compatible vendors OpenCode ships that we lacked - #784
Merged
Merged
Conversation
…s that we lacked
OpenCode enumerates its OpenAI-compatible providers in
`packages/llm/src/providers/openai-compatible-profile.ts`: baseten,
cerebras, deepinfra, deepseek, fireworks, groq, openrouter, togetherai,
xai. Four of them — groq, cerebras, baseten, xai — had no row here.
They are plain `/chat/completions` vendors, so this is metadata only: no
production code, four `ProviderSpec` rows. The registry already
synthesizes a working provider class from a row, which is why the port is
this small.
Model ids could not come from OpenCode, because its profile table has none
to give: `OpenAICompatibleProfile` is `{provider, baseURL}` and nothing
else, for all nine entries, and `providers/xai.ts` carries no ids either.
Each id here was read from the vendor's own docs (2026-08-02) and pinned
in `test_provider_registry.VENDOR_DEFAULTS`, the existing drift guard.
Two rows needed model configs, not just registry entries:
* `gpt-oss-120b` and `gpt-oss-20b` had no `MODEL_CONFIGS` row, so
`get_model_config`'s prefix fallback — `key.rsplit("-", 1)[0]` —
reduced them to `gpt` and matched the gpt-5.x family. Cerebras's
default model is a bare `gpt-oss-120b`, so it silently inherited
gpt-5.5's 272k context window, 128k output cap and $3/$15 pricing.
The window is the damaging one: it sizes auto-compaction, so a session
would run past the real 131k limit and die on a context-length 400
rather than compacting.
* The namespaced `openai/gpt-oss-120b` that groq and baseten serve
dodged that prefix but fell to the generic 200k default — also larger
than the truth, so the same failure arriving later. Explicit rows for
the namespaced forms are the remedy `get_model_config`'s own docstring
documents.
Adding rows to that table risks poisoning unrelated ids through the
same prefix fallback, so every id's resolution was snapshotted before
and after: only the two intended ones moved.
`catalog_mode` is new on `ProviderSpec`. `dynamic_catalog` alone meant
"discovered REPLACES static", which is right for the three rows that had
it — sglang, vllm and ollama are local servers whose static ids are
placeholder stubs — and backwards for a hosted vendor, whose curated list
is deliberate and whose raw `/models` also lists speech, moderation and
embedding models. Under the inherited default, Groq's curated ids vanished
from the picker and ASR/TTS models took their place. The hosted rows now
say `hybrid`, matching what `openrouter_provider` already passes by hand.
Verified, since a table of URLs is the easy thing to get quietly wrong:
every base URL answers `/models` with 401/403 rather than 404, and the
real CLI run against each with a bogus key returns a distinct
vendor-shaped auth error — so the URL and auth wiring reach the actual
vendor, not merely a registry entry.
All four declare `dynamic_catalog="openai-compatible"`, so the curated
`available_models` list can be refreshed from the vendor at runtime rather
than stranding users on ids that will age the same way OpenCode's did.
xai takes the Chat Completions route, which is a deliberate departure:
OpenCode defaults its xai facade to the Responses protocol
(`providers/xai.ts`, `model: responses`), the same shape #783 gave
first-party OpenAI. The reason is structural rather than a coin flip —
`openai_responses` is imported only by `openai_provider`, and
`_use_responses` sits behind `_is_first_party_base_url()`, which #783
scoped to api.openai.com, so Responses is not something a registry row can
select at all. It would need a hand-written class and a carve-out in that
gate. Chat Completions is also known-good for this model rather than
assumed: OpenCode's own docs serve grok-4.5 over both protocols, routing it
to `/chat/completions` in `web/src/content/docs/go.mdx` and to `/responses`
in `zen.mdx`. `test_xai_requests_go_to_chat_completions` asserts the URL
actually requested, so the decision is revisited deliberately, not by
drift.
Examined and deliberately NOT ported:
* OpenRouter's `usage: {include: true}` (openrouter.ts:57-62). Probed
live against the real API: the response is byte-identical with and
without it — `cost` and `cost_details` come back either way. Sending it
would be cargo cult.
* DeepSeek. OpenCode treats it as a plain profile with no special
handling; clawcodex already does more (prompt-prefix-cache usage
re-mapping, and `reasoning_content` in both directions).
* The profile-table architecture itself, which `ProviderSpec` already
matches and exceeds — 23 rows to OpenCode's 9.
While pinning the new aliases, the collision test turned out to only
iterate `_SPECS`, so it could not see a new alias shadowing one of the
seven hand-written providers (anthropic, deepseek, gemini, minimax,
openai, openrouter, zai) — a mutant adding `deepseek` as an xai alias
survived it. It is now seeded from provider ids AND `PROVIDER_ALIASES`,
since names like `glm` and `z.ai` are aliases rather than ids and were
shadowable through the same hole.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test Results 1 files 1 suites 7m 56s ⏱️ Results for commit fc806e4. ♻️ This comment has been updated with latest results. |
The test ran `git log` with `timeout=0.001` and asserted
`rc != 0 or stdout == ""`. That is a race against the runner: when git
finishes inside the millisecond the call succeeds with real output and the
assertion fails. It failed, passed, then failed again across three CI runs
of an unrelated change.
Passing was no better than failing — a timeout that never fires exercises
none of the branch under test, so the green runs asserted nothing.
Now forces `subprocess.TimeoutExpired` and asserts what `_run_git`
actually returns for it: `("", "Command timed out", -1)`. Mutating the
return code or the message fails it; previously neither did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericleepi314
added a commit
that referenced
this pull request
Aug 2, 2026
…OpenRouter reasoning (#785) Four cross-provider follow-ups deferred from #783 and #784. All were silent: nothing errored, the numbers and the transcript were just wrong. ## Cached prompt tokens were billed at the full input rate `prompt_tokens` on the OpenAI-compatible wire INCLUDES tokens served from the prompt cache, with the cached count reported separately under `prompt_tokens_details.cached_tokens`. The base usage builder read only the total, so every cached token was priced as fresh input and the cache hit-rate was invisible. Both builders now split onto the convention `compute_cost` already understands — `input_tokens` = miss, `cache_read_input_tokens` = hit, `cache_creation_input_tokens` = 0 (this wire has no cache-write charge). Every pricing tier already carried a `cache_read` rate, and two of them (`_TIER_MUSE_SPARK`, `_TIER_GPT_56_LUNA`) said in comments that the rate was inert "for when that lands". This is that; those comments now say it is live. Measured against DeepSeek: a 2613-token prompt came back as 53 miss / 2560 hit. Pricing that turn without the split over-reports it 7.5x for `openai/gpt-5.6-luna` and 29x for `deepseek-v4-pro`. Three interactions had to be fixed with it: * `DeepSeekProvider._build_usage_dict` calls `super()` and then does its own `prompt_tokens - hit`. With the base splitting first, that subtraction ran twice and drove billable input to ZERO — under- reporting cost on the provider whose prefix cache fires nearly every turn. It now recovers the original total from the parts. * `openai_responses.build_usage_dict` recorded the hit alongside an untouched `input_tokens`, so the two double-counted: consumers sum input + cache_creation + cache_read, and that turn reported 5173 for a 2613-token prompt. It billed the cached portion at BOTH rates and inflated the prompt size `get_pricing` uses to select a tier, which for gpt-5.6-luna can cross the 272K boundary. Since #783 routes by model, one `OpenAIProvider` can take either wire, so the two agreeing is now a tested invariant rather than a coincidence. * `agent_server._usage_token_total` summed input+output only, ignoring the cache fields already present in the snapshot it reads — an under-count of 98% of the prompt on a warm cache. Both builders reject a non-numeric `cached_tokens` rather than coercing it. `int()` accepts anything with `__int__`, so a `MagicMock` usage stub yielded 1 and invented a one-token cache hit; `bool` is excluded because it is an `int` subclass and `True` would read as a cached token. They also catch `OverflowError`, which descends from `ArithmeticError` and so slipped past a `ValueError`-only guard — stdlib `json.loads` accepts a bare `Infinity`, so a vendor emitting one ended the turn. ## OpenRouter reasoning was discarded entirely Verified live: a streamed `openai/gpt-5.6-luna` turn over OpenRouter carries `delta.reasoning` and `delta.reasoning_details`, and no `reasoning_content` key at all. Both extraction sites read only `reasoning_content`, so every reasoning token from the provider this repo's benchmarks run on was dropped. Reading either name recovers it — 336 characters across 71 thinking chunks on the same prompt. `reasoning_details` is deliberately not read: it repeats the same text in a shape nothing downstream consumes, and arrives as `reasoning: None` when the trace is encrypted. ## Gemini strips ChatGPT replay blocks explicitly `GeminiProvider` was the one converter of four not calling `strip_responses_item_blocks`. This is defence in depth, NOT a bug fix — the converter's `if parts:` guard already drops a message whose blocks all fell through its if/elif chain, so the observable result is identical. The point is that the correctness stops depending on two implicit fallthroughs: adding an `else` branch or a placeholder part, the natural way to support some future block type, would otherwise start forwarding ChatGPT replay items to Gemini silently. ## README provider list Listed 25 against an actual 30, omitting meta, groq, cerebras, baseten and xai. A test now asserts the list equals `PROVIDER_INFO` so it cannot drift again. ## Deferred, and why `agent_loop_compat.py`'s cumulative `result.usage` aggregator drops the cache keys, and `compute_session_cost`'s advisor branch passes none. Both are PRE-EXISTING and documented as such in the code — they were already wrong for Anthropic and DeepSeek, whose wires are natively split. This change widens their blast radius to OpenAI-compatible providers rather than creating the defect, and fixing the aggregator is a contract change to stream-json. `logging.py`'s `NonNullableUsage.total_tokens` and `status_line_command`'s context bar have the same gap with no production caller today. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ericleepi314
added a commit
that referenced
this pull request
Aug 2, 2026
Headline: fusion models (#771) — pair a text-only reasoning model with a vision-capable one so it can read screenshots, diagrams and code images. `deepseek-v4-pro` rejects an image content block outright, so a pasted screenshot used to end the turn; a fusion model describes the image with the second model first and hands the base model text. Verified end to end on Terminal-Bench 2.1's `code-from-image` — transcribe handwritten pseudocode from a PNG and reproduce its output — with `deepseek-v4-flash` + `openai:gpt-5.6-luna` (#787). The base model alone returns a 400 on the same image, so the pass is attributable to the fusion path rather than the base coping. Also in 1.4.0: GPT-5.6 Sol/Terra/Luna (#773); groq, cerebras, baseten and xai take the provider registry to 30 (#784); `/mode` becomes `/permissions` with a three-level picker (#768); `AskUserQuestion` renders a real picker instead of returning JSON to the model (#774); the OpenAI provider picks its wire protocol from the model rather than the auth mode (#783); cached prompt tokens bill at the cache rate (#785, #786); headless runs stop reporting a cut-short run as success (#777–#782). Version bumped in all five spots (pyproject, install.sh INSTALLER_VERSION, gatewayClient CLAWCODEX_VERSION, src/__init__.py fallback, uv.lock). CHANGELOG `[Unreleased]` covered only through #773 and was backfilled with #774–#787; PR citations added to the pre-existing entries so coverage is checkable. #766 is docs-only and deliberately uncited. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows #783. OpenCode enumerates its OpenAI-compatible providers in
packages/llm/src/providers/openai-compatible-profile.ts: baseten, cerebras, deepinfra, deepseek, fireworks, groq, openrouter, togetherai, xai. Four of them — groq, cerebras, baseten, xai — had no row here.What examining OpenCode actually turned up
Most of it was already ported, or was the wrong thing to port:
ProviderSpecis the same data-driven idea as OpenCode'sprofiles, with 23 rows to its 9.reasoning_contentboth directions).usage: {include: true}was probed live rather than ported on faith — the response is byte-identical with and without it. It is also inert in OpenCode itself, whose closedOpenAIChatUsageschema stripscost/cost_detailsat decode.So the gap was the four rows. They are plain
/chat/completionsvendors, and the registry synthesizes a working provider class from a row, which is why the port is this small.Two bugs found while verifying, both fixed here
gpt-oss-*inherited agpt-5.5config.get_model_configfalls back to a prefix match onkey.rsplit("-", 1)[0], under whichgpt-oss-120breduces togptand matched the gpt-5.x family. Cerebras's default is a baregpt-oss-120b, so it silently took gpt-5.5's 272k context window, 128k output cap and $3/$15 pricing. The window sizes auto-compaction, so a session would run past the real 131k limit and die on a context-length 400 rather than compacting. The namespacedopenai/gpt-oss-120bthat groq and baseten serve dodged the prefix but fell to the generic 200k default — also larger than the truth, so the same failure arriving later. Explicit rows for both forms; every model id's resolution was snapshotted before and after to confirm no collateral prefix poisoning.The rows inherited local-server catalog semantics.
dynamic_catalogalone means "discovered replaces static", which is right for sglang/vllm/ollama — local servers whose static ids are placeholder stubs — and backwards for a hosted vendor with a deliberate curated list. Measured: underdynamic, Groq's curated ids vanished entirely and whisper/guard/TTS took their place. Newcatalog_modefield; hosted rows sayhybrid, matching whatopenrouter_provideralready passes by hand.Model ids
Not taken from OpenCode — its profile table has none to give (
OpenAICompatibleProfileis{provider, baseURL}and nothing else, for all nine entries). Read from each vendor's own docs and pinned intest_provider_registry.VENDOR_DEFAULTS.xai takes Chat Completions, deliberately
OpenCode defaults its xai facade to Responses (
model: responses), the shape #783 gave first-party OpenAI. The reason not to follow is structural:openai_responsesis imported only byopenai_provider, and_use_responsessits behind_is_first_party_base_url(), which #783 scoped toapi.openai.com— so Responses is not something a registry row can select at all. It also isn't needed: OpenCode's own docs serve grok-4.5 over both protocols.test_xai_requests_go_to_chat_completionsasserts the URL actually requested.Verification
/modelswith 401/403 rather than 404, and the real CLI against each with a bogus key returns a distinct vendor-shaped auth error — the wiring reaches the vendor, not just the registry._SPECSand missed the seven hand-written providers; the xai test assertednot hasattr(_, "_use_responses"), true of 29 of 30 providers; and themode=spec.catalog_modewiring was untested at both ends, so deleting the one line that implements the catalog fix left the suite green.Not fixed here (pre-existing, separable)
prompt_tokens_details.cached_tokensis unmapped for every spec provider; OpenRouter'sdelta.reasoningis dropped; the README provider count has drifted. All predate this change and affect all 23 rows, not these four.🤖 Generated with Claude Code