121 lines
7.3 KiB
Markdown
121 lines
7.3 KiB
Markdown
---
|
|
title: OpenCode converter emits a temperature that Sonnet 5 / Opus 4.8 reject
|
|
module: src/converters/claude-to-opencode.ts
|
|
date: 2026-07-08
|
|
problem_type: integration_issue
|
|
component: tooling
|
|
severity: high
|
|
symptoms:
|
|
- Converted OpenCode primary agent fails at runtime with HTTP 400 from Anthropic
|
|
- Breakage appears only after bumping the Claude family alias map to claude-sonnet-5 / claude-opus-4-8
|
|
- inferTemperature (CLI default) emits a temperature (0.1-0.6) on every converted agent
|
|
- Primary agents pinned to claude-sonnet-5 or claude-opus-4-8 reject non-default temperature/top_p/top_k
|
|
- The same conversion worked previously under claude-sonnet-4-6, which accepts temperature
|
|
root_cause: config_error
|
|
resolution_type: code_fix
|
|
related_components:
|
|
- src/utils/model.ts
|
|
- tests/model-utils.test.ts
|
|
- tests/converter.test.ts
|
|
tags:
|
|
- opencode
|
|
- converter
|
|
- temperature
|
|
- sampling-params
|
|
- claude-sonnet-5
|
|
- claude-opus-4-8
|
|
- model-alias
|
|
---
|
|
|
|
# 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 `temperature` for 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 `sonnet` alias to Sonnet 5 — looks purely mechanical: a single string swap in an alias map (`CLAUDE_FAMILY_ALIASES` in `src/utils/model.ts`). Nothing at the edit site hints that temperature emission is affected.
|
|
- The failing behavior lives elsewhere: the converter's `inferTemperature` emission is in `src/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 `temperature` for **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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
if (options.inferTemperature) {
|
|
const temperature = inferTemperature(agent)
|
|
if (temperature !== undefined) {
|
|
frontmatter.temperature = temperature
|
|
}
|
|
}
|
|
```
|
|
|
|
After:
|
|
|
|
```ts
|
|
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_MODELS` in sync with `CLAUDE_FAMILY_ALIASES` whenever a new generation is added — both live in `src/utils/model.ts` and 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 `rejectsSamplingParams` unit tests in `tests/model-utils.test.ts` (alias resolution, the `anthropic/` prefix, and the accepting cases `claude-sonnet-4-20250514` / `haiku`), plus a converter test in `tests/converter.test.ts` asserting 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`](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 in `src/utils/model.ts`.
|