7.3 KiB
title, module, date, problem_type, component, severity, symptoms, root_cause, resolution_type, related_components, tags
| title | module | date | problem_type | component | severity | symptoms | root_cause | resolution_type | related_components | tags | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| OpenCode converter emits a temperature that Sonnet 5 / Opus 4.8 reject | src/converters/claude-to-opencode.ts | 2026-07-08 | integration_issue | tooling | high |
|
config_error | code_fix |
|
|
OpenCode converter emits a temperature that Sonnet 5 / Opus 4.8 reject
Problem
Bumping the sonnet/opus aliases to a newer Claude generation (Sonnet 5, Opus 4.8) made the OpenCode converter emit an inferred temperature into primary-agent configs for models that reject non-default sampling params, producing configs the target runtime rejects with HTTP 400.
Symptoms
- Converted OpenCode primary-agent configs carried an explicit
temperaturefor agents pinned to Sonnet 5 (and Opus 4.7/4.8). - The generated config triggers an HTTP 400 from Anthropic at runtime because Sonnet 5 / Opus 4.7+ reject non-default
temperature/top_p/top_k. - No test failed — no existing test exercised a primary OpenCode agent on a new-generation model, so the breakage was invisible in the suite.
What Didn't Work
The regression is easy to miss because cause and effect live in different files with no obvious link:
- The triggering change — bumping the
sonnetalias to Sonnet 5 — looks purely mechanical: a single string swap in an alias map (CLAUDE_FAMILY_ALIASESinsrc/utils/model.ts). Nothing at the edit site hints that temperature emission is affected. - The failing behavior lives elsewhere: the converter's
inferTemperatureemission is insrc/converters/claude-to-opencode.ts. Reviewing the alias bump in isolation gives no signal. - No existing test exercised a primary OpenCode agent on a new-generation model, so the suite stayed green.
A naive fix would over- or under-reach:
- Dropping
temperaturefor all agents over-reaches — models that still accept sampling params (Sonnet 4, Haiku) would lose a valid inferred temperature. - String-matching
"sonnet"under-reaches and misclassifies — it would wrongly suppress temperature for older accepting Sonnets (claude-sonnet-4-20250514) while missing rejecting Opus generations (claude-opus-4-7,claude-opus-4-8) entirely.
Solution
Introduce a precise, canonical-ID-based predicate for "this model rejects sampling params," and gate the converter's temperature emission on it.
Added rejectsSamplingParams in src/utils/model.ts, backed by a set of canonical IDs. It resolves bare aliases via resolveClaudeFamilyAlias and strips the anthropic/ provider prefix, so every spelling of the same model matches:
const SAMPLING_PARAM_REJECTING_MODELS: ReadonlySet<string> = new Set([
"claude-sonnet-5",
"claude-opus-4-7",
"claude-opus-4-8",
])
export function rejectsSamplingParams(model: string): boolean {
const canonical = resolveClaudeFamilyAlias(model).replace(/^anthropic\//, "")
return SAMPLING_PARAM_REJECTING_MODELS.has(canonical)
}
This matches sonnet, claude-sonnet-5, and anthropic/claude-sonnet-5; it does not match claude-sonnet-4-20250514 or haiku.
In convertAgent (src/converters/claude-to-opencode.ts), the temperature emission is gated so it is skipped only when a rejecting model was actually written to the config. The converter writes model only for primary agents, so the frontmatter.model !== undefined guard scopes this to primary agents; subagents (no model written — they inherit the parent session's model) keep existing behavior, out of scope because the runtime model is unknown at convert time.
Before:
if (options.inferTemperature) {
const temperature = inferTemperature(agent)
if (temperature !== undefined) {
frontmatter.temperature = temperature
}
}
After:
if (options.inferTemperature) {
const temperature = inferTemperature(agent)
const modelRejectsTemperature =
frontmatter.model !== undefined &&
typeof agent.model === "string" &&
rejectsSamplingParams(agent.model)
if (temperature !== undefined && !modelRejectsTemperature) {
frontmatter.temperature = temperature
}
}
Why This Works
Sonnet 5 and Opus 4.7/4.8 return HTTP 400 for any non-default temperature/top_p/top_k (per Anthropic's Sonnet 5 and Opus 4.8 migration notes). The converter only writes model into the config for primary agents, so tying suppression to "we wrote a rejecting model" (frontmatter.model !== undefined && rejectsSamplingParams(agent.model)) targets exactly the case that would fail at runtime — a primary agent pinned to a rejecting model — without touching subagents or any model that still accepts sampling params.
Prevention
The compounding lesson: a model alias bump is never purely mechanical. When you point an alias at a newer Claude generation, the model ID string is the smallest part of the change — audit every downstream emitter for API constraints that shifted with the generation. Newer generations (Sonnet 5+, Opus 4.7+) reject non-default sampling params, so any code path that emits temperature/top_p/top_k for a resolved Claude model must gate on model compatibility.
Concrete guardrails:
- Keep
SAMPLING_PARAM_REJECTING_MODELSin sync withCLAUDE_FAMILY_ALIASESwhenever a new generation is added — both live insrc/utils/model.tsand are co-located intentionally so the two are edited together. - Test both directions so neither over- nor under-reach regresses: a rejecting model (temperature suppressed) and an accepting model (temperature still inferred). The tests added are
rejectsSamplingParamsunit tests intests/model-utils.test.ts(alias resolution, theanthropic/prefix, and the accepting casesclaude-sonnet-4-20250514/haiku), plus a converter test intests/converter.test.tsasserting temperature is suppressed for a Sonnet 5 primary agent but still inferred (0.1) for a Haiku agent. - Verify bot-sourced API claims against the source of truth. An automated cross-model code-review bot (Codex) caught this before merge, but its factual claim about the HTTP 400 was confirmed against authoritative Anthropic migration docs before building the fix — the claim was true, but bot claims about API behavior must always be verified first.
Related
cross-platform-model-field-normalization.md— the sibling doc covering how bare Claude aliases resolve to provider-prefixed canonical IDs across target platforms. That doc owns the alias-to-provider normalization mechanism (CLAUDE_FAMILY_ALIASES,normalizeModelWithProvider); this doc covers a distinct downstream constraint (sampling-param API limits of newer generations). Both live insrc/utils/model.ts.