Skip to content

feat(providers): add the four OpenAI-compatible vendors OpenCode ships that we lacked - #784

Merged
ericleepi314 merged 2 commits into
mainfrom
feat/opencode-compat-providers
Aug 2, 2026
Merged

feat(providers): add the four OpenAI-compatible vendors OpenCode ships that we lacked#784
ericleepi314 merged 2 commits into
mainfrom
feat/opencode-compat-providers

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

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:

  • The architecture is already equivalent. ProviderSpec is the same data-driven idea as OpenCode's profiles, with 23 rows to its 9.
  • DeepSeek needs nothing. OpenCode treats it as a plain profile with no special handling; clawcodex already does more (prefix-cache usage re-mapping, reasoning_content both directions).
  • OpenRouter's 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 closed OpenAIChatUsage schema strips cost/cost_details at decode.

So the gap was the four rows. They are plain /chat/completions vendors, 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 a gpt-5.5 config. get_model_config falls back to a prefix match on key.rsplit("-", 1)[0], under which gpt-oss-120b reduces to gpt and matched the gpt-5.x family. Cerebras's default is a bare gpt-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 namespaced openai/gpt-oss-120b that 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_catalog alone 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: under dynamic, Groq's curated ids vanished entirely and whisper/guard/TTS took their place. New catalog_mode field; hosted rows say hybrid, matching what openrouter_provider already passes by hand.

Model ids

Not taken from OpenCode — its profile table has none to give (OpenAICompatibleProfile is {provider, baseURL} and nothing else, for all nine entries). Read from each vendor's own docs and pinned in test_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_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 also isn't needed: OpenCode's own docs serve grok-4.5 over both protocols. test_xai_requests_go_to_chat_completions asserts the URL actually requested.

Verification

  • Full suite 9550 passed, 0 failed.
  • All four base URLs answer /models with 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.
  • Mutation-tested throughout. Three mutants that survived a first pass drove real fixes: the alias-collision test only iterated _SPECS and missed the seven hand-written providers; the xai test asserted not hasattr(_, "_use_responses"), true of 29 of 30 providers; and the mode=spec.catalog_mode wiring 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_tokens is unmapped for every spec provider; OpenRouter's delta.reasoning is dropped; the README provider count has drifted. All predate this change and affect all 23 rows, not these four.

🤖 Generated with Claude Code

…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>
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Test Results

    1 files      1 suites   7m 56s ⏱️
9 553 tests 9 547 ✅ 6 💤 0 ❌
9 853 runs  9 847 ✅ 6 💤 0 ❌

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
ericleepi314 merged commit b20699a into main Aug 2, 2026
3 checks passed
@ericleepi314
ericleepi314 deleted the feat/opencode-compat-providers branch August 2, 2026 05:33
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant