Skip to content

feat(routing): learn per-model OpenAI endpoints on relays that mix them - #1717

Open
0x0079 wants to merge 2 commits into
mainfrom
claude/opencode-model-endpoint-format
Open

0x0079 wants to merge 2 commits into
mainfrom
claude/opencode-model-endpoint-format

Conversation

@0x0079

@0x0079 0x0079 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

OpenCode Zen decides the wire format per model: 32 of its 35 models answer only on /chat/completions, while gpt-5.6-luna, grok-4.5 and grok-4.6 answer only on /responses. OpenAIEndpointMode is a per-provider fact and cannot say that, so a Codex client asking for luna was downgraded to Chat and got a bare 500.

Key Changes

  • A fourth mode, per_model: declares "both endpoints exist, but each model picks one" — not both, where mirroring the client's protocol is always safe.
  • Reactive learning: guess Chat, and on a format-mismatch rejection retry the same service once on the other endpoint, remembering the answer for 8h in memory.
  • Bounded by the declaration: providers that have not declared per_model cannot spend an extra round-trip, reach the mismatch matcher, or write to the memory — the containment the old AdaptiveProbe lacked.
  • Retry is free of failover semantics: it runs before the failure is charged, so it costs no failover tier and no breaker strike, and it reuses the existing firstChunkGate (nesting a second buffer would break the first-chunk signal).
  • Existing providers are migrated: normalizeOpenCodeEndpointMode sets the mode on every Zen provider, matching on API base so hand-added and old-snapshot providers are covered too.

Notes

  • Tolerating a bare 5xx as a mismatch signal is load-bearing — Zen reports one condition three ways and luna's carries no marker — and is only safe under the mode gate. The 4xx path still requires an explicit marker, so a credential 401 is never retried.
  • Verified live (endpoint_learning_e2e_test.go, skipped without OPENCODE_API_KEY): luna 500/200, kimi-k3 200/401, both rejections recognized by the matcher. Rationale and the Adaptive comparison are in .design/openai-endpoint-routing.md §8.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LS5eJDtN9bfnVXPde2CNwk

@0x0079
0x0079 force-pushed the claude/opencode-model-endpoint-format branch from 85c124b to 4f7b94b Compare September 8, 2026 01:57
@0x0079
0x0079 changed the base branch from claude/opencode-go-issues-vt45zi to main September 8, 2026 01:57
@0x0079
0x0079 force-pushed the claude/opencode-model-endpoint-format branch from c9ab28e to 51b53f2 Compare September 8, 2026 02:18
OpenCode Zen decides the wire format per model, not per provider: 32 of its
35 models answer only on /chat/completions, while gpt-5.6-luna, grok-4.5 and
grok-4.6 answer only on /responses. The provider-level OpenAIEndpointMode
cannot say that, so a Codex client asking for luna was downgraded to Chat and
got a bare 500, and declaring the provider "both" would have been worse —
Anthropic-shaped requests count as Responses incoming, so Claude Code on
kimi/glm would have been routed to /responses and rejected.

Add a fourth mode, per_model, and let it learn. It guesses Chat (the safe
default and the 32/35 case), and on a rejection that looks like a format
mismatch retries the same service once on the other endpoint, remembering the
answer for 8h in memory. Every other mode is untouched and still cannot spend
an extra round-trip.

The declaration is what bounds this. The old AdaptiveProbe (PR #976) failed
because it probed every provider at cold start, burned tokens, blocked 10s
and cached failures; here nothing happens until a provider is known to be
per-model, the retry rides a real request, only verified successes are
stored, and nothing is persisted.

The mode is derived rather than declared, because the declaration cannot
reach these providers: ProviderTemplate.OpenAIEndpointMode is only copied
onto a Provider by the OAuth path, so a provider created through the normal
API keeps the zero value — which is why openai-com's "both" has never taken
effect either. Deriving it from the API base makes existing, new and
hand-added providers work without a migration or a restart. Fixing the
template plumbing is the deeper fix, but it would change openai-com's
behavior and does not belong here.

Tolerating a bare 5xx as a mismatch signal is load-bearing and only safe
under that gate: Zen reports one condition three ways, and luna's rejection
carries no marker at all. A 4xx still needs an explicit marker, and neither
a gateway-side setup failure nor an endpoint the rule pinned is retried.

The retry runs before the failure is charged, so it costs neither a failover
tier nor a breaker strike, and it reuses the existing firstChunkGate rather
than nesting a second buffer — which would break the first-chunk signal. A
failed retry never masks a retryable original, so a sibling service still
gets its turn.

Verified against the live upstream (endpoint_learning_e2e_test.go, skipped
without OPENCODE_API_KEY): luna is 500 on Chat and 200 on Responses, kimi-k3
the mirror image, and both rejections are recognized by the matcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LS5eJDtN9bfnVXPde2CNwk
@0x0079
0x0079 force-pushed the claude/opencode-model-endpoint-format branch from 51b53f2 to 745c27e Compare September 8, 2026 06:52
Review pass over the per_model feature. No behavior change beyond the last
two points.

One decision object instead of four context keys. The loop↔attempt contract
(resolved endpoint, forced endpoint, pinned, setup-failed) is one value with
one lifetime, so it travels as one struct with one reset, and the three
type-asserting accessors go away. It also memoizes EffectiveEndpointMode,
which derives from URL parsing and was recomputed four or five times per
request for every provider, not just OpenCode's.

One map in the store instead of two. "A success ends the cooldown" was an
invariant between two containers kept in step by hand; as one entry holding
both the learned endpoint and the last failed retry, it is structural. The
key is now loadbalance.FormatServiceID, the same service key the breaker and
usage tracking use, and time comes from internal/clock so learned entries
expire with breaker and affinity state under the simulator instead of on the
wall clock. Writes sweep dead entries past a threshold: model names come from
the request, so the map was unbounded.

One predicate, DispatchMayRetry, instead of the same condition spelled two
ways in five places — the dispatch loop deciding whether to buffer, and four
handlers deciding whether to snapshot the request. They must agree: if they
drift, a retry re-transforms a request the first attempt already mutated.

The retry returns the status to judge rather than a bool the caller
reconciles through a second function, and the empty-service-list case goes
back through the fast path instead of widening the loop bound for all
traffic.

Two behavior changes fall out of that predicate. Buffering and snapshotting
now stop once the endpoint is learned — for a Claude-Code-sized request body
the snapshot is a full JSON round-trip, and after the answer is known there
is no second attempt to serve. So that a mapping which later goes stale
cannot pin a model to a dead endpoint for the rest of the TTL,
forgetStaleEndpoint drops it on a 5xx and the next request re-learns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LS5eJDtN9bfnVXPde2CNwk
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