Update(zen-go): add claude-opus-4-8, minimax-m3, mimo-v2.5-free models and proper effort level integration for Zen/Go models (#1505)

* feat(provider): add OpenCode Zen/Go subscription support

Add OpenCode as a first-class provider, enabling users to connect their
Zen (pay-as-you-go) and Go ($10/mo) subscriptions via the /provider command.

New integration descriptors:
- vendors/opencode.ts — OpenCode Zen vendor (41 models)
- gateways/opencode-go.ts — OpenCode Go gateway (12 models)
- brands/opencode.ts — brand descriptor
- models/opencode.ts — full model catalog (GPT, Claude, Gemini, Qwen,
  GLM, Kimi, MiniMax, Grok, DeepSeek, MiMo, Nemotron)

Modified files:
- integrationArtifacts.generated.ts — register descriptors and presets
- providerProfile.ts — add OPENCODE_API_KEY env/secret key, 'opencode'
  profile type, and buildLaunchEnv handler
- providerConfig.ts — add DEFAULT_OPENCODE_BASE_URL constants

Auth: OPENCODE_API_KEY env var or interactive key entry in /provider
Transport: openai-compatible (chat_completions)
Base URLs: https://opencode.ai/zen/v1 (Zen), /zen/go/v1 (Go)

* feat(provider): add [Zen]/[Go] tags to OpenCode preset labels

Add visual tags in the /provider preset selection to distinguish
OpenCode Zen (pay-as-you-go) from OpenCode Go (subscription).

* feat(provider): enable dynamic model discovery for OpenCode

Switch OpenCode vendor and Go gateway from static to hybrid model
catalog with openai-compatible discovery. Models are fetched from
/v1/models on startup and cached for 1 hour. Manual refresh is
supported via the /provider UI.

Static model list is preserved as fallback when discovery fails.

* test(provider): add comprehensive OpenCode Zen/Go test suite

97 tests across 2 files covering:

Integration tests (72 tests):
- Vendor descriptor: id, label, classification, base URL, model, auth,
  transport, preset, validation, catalog, discovery, usage metadata
- Gateway descriptor: id, label, vendorId, category, base URL, model,
  auth, transport, preset, catalog, discovery
- Brand descriptor: id, label, canonicalVendorId, capabilities, modelIds
- Model catalog: registration, vendor/gateway associations, required
  fields, valid classifications, reasoning/coding tags, no duplicates,
  model counts (41 Zen, 12 Go), modelDescriptorId consistency
- Cross-reference: brand↔model, vendor↔model, gateway↔model,
  shared OPENCODE_API_KEY
- Registry validation: no errors, no preset conflicts
- Edge cases: unique ids, unique apiNames, non-empty labels, valid
  contextWindow/maxOutputTokens, valid defaultModel format, validation
  message content, discovery config

Profile tests (25 tests):
- Type guard: isProviderProfile('opencode'), rejects invalid values
- buildLaunchEnv: persisted env, defaults, process env precedence,
  OPENCODE_API_KEY mapping, whitespace/null/undefined/empty handling,
  very long keys, special characters, concurrent access, boundary
  values, no credential leakage

* fix(provider): add per-model endpoint routing (P1)

Add endpointPath field to OpenAIShimTransportConfig so catalog entries
can specify which API path to use per model. This addresses the
maintainer's [P1] finding that all models were routed to
/chat/completions regardless of their upstream endpoint.

Changes:
- descriptors.ts: add endpointPath?: string to OpenAIShimTransportConfig
- openaiShim.ts: buildRequestUrl checks shimConfig.endpointPath first
- vendors/opencode.ts: add transportOverrides to 31 catalog entries
  (GPT→/responses, Claude/Qwen→/messages, Gemini→/models/<id>)
  + switch to source: 'static' to prevent free models from live API
- gateways/opencode-go.ts: add transportOverrides to 4 entries
  (MiniMax/Qwen→/messages) + switch to source: 'static'
- opencode.test.ts: update tests for static source, remove discovery tests

* refactor(opencode): model OpenCode Zen/Go as gateways (P2)

* docs(provider): document OpenCode setup and move badge metadata to descriptors

- Add OpenCode Zen/Go rows to README supported providers table
- Add OpenCode Zen/Go examples and OPENCODE_API_KEY to advanced-setup.md
- Add PresetBadge type to descriptor/manifest with badge propagation in
  artifact generator
- Move 4 hard-coded preset badges ([FREE], [Sponsor], [Zen], [Go]) from
  ProviderManager.tsx into descriptor preset metadata
- Add badge field to providerUiMetadata so UI components read from manifest
- Update integration overview docs to recommend preset.badge for future
  gateways

* fix(provider): match request body to endpoint format for OpenCode /messages and /responses (P1)

Extend the openaiShim transport so that endpointPath overrides select
both the URL and the correct body/response format:

- /responses → OpenAI Responses API body (input, max_output_tokens)
- /messages  → Anthropic Messages API body (content blocks, system, max_tokens)

Also fixes: abort listener leak in SSE passthrough, system prompt
content-block flattening, and removes [Zen]/[Go] badge entries (P3).

Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>

* fix(provider): add Google AI SDK body/response format for OpenCode Zen Gemini models (P1)

The three Gemini models in the OpenCode Zen catalog (gemini-3.5-flash,
gemini-3.1-pro, gemini-3-flash) were sending chat-completions body to
the /models/gemini-* endpoint, which expects Google AI SDK format.

- effectiveTransport now detects /models/gemini- endpointPath → 'gemini'
- buildGeminiBody() converts Anthropic messages → Google contents[]
  with role mapping, systemInstruction, generationConfig, functionDeclarations
- geminiSseToAnthropic() parses Google SSE frames → Anthropic stream events
  with text deltas, functionCall tool_use, finishReason mapping
- _convertGeminiToAnthropicResponse() for non-streaming responses
- Streaming/non-streaming routing via URL detection (/models/gemini-)
- serializeBody(), hasToolsPayload, omitGeminiTools all updated

* fix: prevent OpenCode model descriptors from shadowing canonical limits

P1: Prefix all defaultModel values in opencode.ts with 'opencode-'
so the fallback findModelDescriptorForApiName() doesn't match
canonical model names. The OpenCode descriptors are still found
via catalog entry lookup when the OpenCode route is active.

P2: Add 'OpenCode Go' and 'OpenCode Zen' to PRESET_ORDER in
ProviderManager.test.tsx between 'OpenAI' and 'OpenRouter'
so navigateToPreset() sends the correct number of j keypresses.

* fix: align OpenCode Go descriptor metadata with Zen

- category: 'hosted' → 'aggregating' (both are aggregating gateways)
- add validation block with OPENCODE_API_KEY guidance
- update test assertion from 'hosted' to 'aggregating'

* fix: accept OPENAI_API_KEY as fallback in OpenCode validation

When users set up OpenCode Zen/Go via /provider, the key is saved as
OPENAI_API_KEY (via buildCompatibilityProcessEnv). The validation block
only checked OPENCODE_API_KEY, causing a startup warning even though
the runtime auth header had the key it needed.

Add OPENAI_API_KEY to validation.credentialEnvVars for both gateways,
matching the pattern used by Hicap and Gitlawb Opengateway.

* chore: trigger mergeability recheck

* feat(shim): forward effort/thinking to OpenCode Zen/Go endpoints

- buildResponsesBody: add reasoning_effort + reasoning_summary + include
- buildAnthropicMessagesBody: add thinking config (adaptive/enabled/budget)
- buildGeminiBody: add thinkingConfig with thinkingLevel mapping
- modelSupportsEffort: allow OpenCode Claude and Gemini models
- modelSupportsMaxEffort: add opus-4-7
- getAvailableEffortLevels: show standard levels for OpenCode native models
- opencode-go: add missing validation block

* feat: update OpenCode Zen and Go model counts, add new models, and enhance effort level handling

* feat: implement xhigh effort support for specific models and adjust effort level handling

* fix(effort): address reviewer feedback on xhigh + new models

- docs/advanced-setup.md: bump OpenCode Go count 12 → 13
- openaiShim.ts: include opus-4-8 / opus-4.8 in the adaptive thinking
  detection so the new model uses the adaptive + effort path instead
  of falling back to budgetTokens
- effort.ts: modelUsesOpenAIEffort now also rejects models that include
  'claude-' or 'gemini-' — without this, OpenCode Claude/Gemini
  routes (provider=openai) were misclassified as OpenAI-style and
  could leak xhigh past the new gate
- effort.codex.test.ts: lock in the new exclusion with a regression
  test against the openai provider

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(effort): address reviewer feedback on xhigh effort + new models

Closes the three P2 findings from PR #1505 review:

1. Settings schema now accepts 'xhigh' so a persisted xhigh survives
   restart instead of being silently dropped by .catch(undefined).
2. ModelPicker /effort cycle is driven by getAvailableEffortLevels(model)
   instead of a boolean includeMax, so models supporting xhigh
   (opus-4-7/4-8, OpenAI/Codex) can actually select it from the picker.
   displayEffort clamp now uses the available levels list, so stale
   xhigh also clamps to high when the focused model doesn't support it.
3. SDK/control metadata uses getAvailableEffortLevels(model) instead of
   the EFFORT_LEVELS fallback that advertised xhigh to every max-capable
   model. SDK schema + generated types extended to include 'xhigh'.

Also fixes a latent generator bug: the array case in generate-sdk-types
now parenthesizes union/intersection elements so the trailing [] binds
the whole type, e.g. ("a"|"b")[] rather than "a"|"b[]. Without this,
the regenerated xhigh levels ended up typed as the single-literal
"xhigh"[] and broke the modelInfo assignability check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(effort): order xhigh before max in EFFORT_LEVELS

EFFORT_LEVELS now matches getAvailableEffortLevels() output order
(['low', 'medium', 'high', 'xhigh', 'max']), and the order asserted by
the existing effort.codex.test.ts tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(effort): order xhigh before max in settings + SDK schemas

Matches the EFFORT_LEVELS / getAvailableEffortLevels order from the
previous commit. The Zod enum order doesn't affect runtime validation,
but keeps the source consistent and avoids confusion if anyone reads
the enum literal to infer display order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(effort): clamp ModelPicker selection and mark xhigh as current

- ModelPicker.handleSelect: clamp the emitted/persisted effort to the
  focused model's available levels so a toggled-but-unsupported level
  (e.g. 'xhigh' on a model that doesn't support it) is never written
  to settings.json or handed to the consumer. Add focusedAvailableLevels
  + focusedDefaultEffort to the memo guard so the function regenerates
  when the focused model changes.
- EffortPicker: compare the xhigh option against the persisted 'xhigh'
  level directly. The 'max' alias path is kept only for legacy
  settings.json values that still hold 'max' from before xhigh was
  introduced.

* docs(effort): fix stale EffortPicker comment about xhigh normalization

openAIEffortToStandard is a type cast that passes 'xhigh' through as a
first-class EffortLevel — the shim only converts to 'max' at the
Anthropic request boundary, not here. Update the comment to match.

* docs(effort): update /effort help to match xhigh support matrix

The /effort --help output still described max as "Opus 4.6 only" and
xhigh as an "alias for max", but this PR promotes xhigh to a first-class
EffortLevel and allows it for OpenCode Claude Opus 4.7/4.8 (with max
also allowed for those Opus variants). Update the help so it matches
the picker/runtime behavior:
- max: "(Opus 4.6+)"
- xhigh: "(OpenAI/Codex and Opus 4.7+)"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(sdk): address reviewer P2 — sync xhigh across override union, schemas, CLI

- Add 'xhigh_effort' to ModelCapabilityOverride union so the new
  call at effort.ts:93 typechecks (P2 finding 1).
- Add 'xhigh' to AgentDefinition.effort enum (coreSchemas.ts) and
  control.applied.effort enum (controlSchemas.ts), then regenerate
  coreTypes.generated.ts so the SDK public contract matches the
  first-class effort level (P2 finding 2).
- Add 'xhigh' to the --effort CLI flag allowed list and help text
  (main.tsx:945-951) so users can actually pass --effort xhigh
  instead of hitting "It must be one of: low, medium, high, max".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(effort): narrow allowlist to shim-serialized models; sync max description

Address reviewer findings on PR #1505:

P2: The broad `m.includes('opus-4') || m.includes('sonnet-4')` branch
made older variants (claude-opus-4-1, claude-sonnet-4-5) advertise
effort support, but the Anthropic /messages shim only serializes
low/medium as anthropicBody.effort for the isAdaptive || isOpus45
set (opus-4-5/4-6/4-7/4-8, sonnet-4-6). For other models the shim
only emits thinking for high/max, so low/medium on those models
was silently dropped on the wire. Collapse the two 4-model branches
into one that matches the shim's serialization set; the substring
match still covers prefix variations (claude-, opencode-claude-).

P3: getEffortLevelDescription('max') said "Opus 4.6 only" but
modelSupportsMaxEffort now allows opus-4-6, opus-4-7, opus-4-8.
Update the shared description to "Opus 4.6+" so the picker and
/effort confirmation agree with the new support matrix (matching
the /effort --help text from 3cf5de2).

Add effort.codex.test.ts coverage: assert that opus-4-5/4-6/4-7/4-8
and sonnet-4-6 support effort, while opus-4-1, opus-4-2, and
sonnet-4-5 do not (the latter three were previously true via the
broad substring match and are now correctly excluded).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: trigger CodeRabbit re-review

* fix(effort): gate modelSupportsXHighEffort on modelSupportsEffort

---------

Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
Co-authored-by: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gravirei
2026-06-10 08:43:20 +08:00
committed by GitHub
co-authored by Gravirei OpenClaude Claude Opus 4.6
parent 491985a618
commit 286d403093
21 changed files with 337 additions and 77 deletions
+2 -2
View File
@@ -168,8 +168,8 @@ Advanced and source-build guides:
| Codex OAuth | `/provider` | Opens ChatGPT sign-in in your browser and stores Codex credentials securely |
| Codex | `/provider` | Uses existing Codex CLI auth, OpenClaude secure storage, or env credentials |
| Gitlawb Opengateway | Startup default, `/provider`, or env vars | Smart gateway at `https://opengateway.gitlawb.com/v1`; requires an API key from https://gitlawb.com/opengateway/keys and routes Xiaomi MiMo and GMI Cloud partner models by `OPENAI_MODEL` |
| OpenCode Zen | `/provider` or env vars | Pay-as-you-go AI gateway (41 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/v1`; shared key with OpenCode Go |
| OpenCode Go | `/provider` or env vars | $10/mo subscription for open models (12 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/go/v1`; shared key with OpenCode Zen |
| OpenCode Zen | `/provider` or env vars | Pay-as-you-go AI gateway (43 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/v1`; shared key with OpenCode Go |
| OpenCode Go | `/provider` or env vars | $10/mo subscription for open models (13 models); uses `OPENCODE_API_KEY` via `https://opencode.ai/zen/go/v1`; shared key with OpenCode Zen |
| Xiaomi MiMo | `/provider` or env vars | OpenAI-compatible API at `https://mimo.mi.com`; uses `MIMO_API_KEY` and defaults to `mimo-v2.5-pro` |
| Ollama | `/provider` or env vars | Local inference with no API key |
| Atomic Chat | `/provider`, env vars, or `bun run dev:atomic-chat` | Local Model Provider; auto-detects loaded models |
+2 -2
View File
@@ -194,7 +194,7 @@ export OPENAI_MODEL=gpt-5.4
openclaude
```
OpenCode Zen is a pay-as-you-go AI gateway with 41 models (GPT, Claude, Gemini,
OpenCode Zen is a pay-as-you-go AI gateway with 43 models (GPT, Claude, Gemini,
Qwen, MiniMax, GLM, Kimi, Grok, Big Pickle, DeepSeek, Nemotron). Uses the same
`OPENCODE_API_KEY` as OpenCode Go. Get your key from https://opencode.ai.
@@ -209,7 +209,7 @@ export OPENAI_MODEL=glm-5.1
openclaude
```
OpenCode Go is a $10/mo subscription for 12 open models (GLM, Kimi, DeepSeek,
OpenCode Go is a $10/mo subscription for 13 open models (GLM, Kimi, DeepSeek,
MiMo, MiniMax, Qwen). Uses the same `OPENCODE_API_KEY` as OpenCode Zen.
### Gitlawb Opengateway
+3 -6
View File
@@ -272,8 +272,7 @@ import {
import { getModelOptions } from 'src/utils/model/modelOptions.js'
import {
modelSupportsEffort,
modelSupportsMaxEffort,
EFFORT_LEVELS,
getAvailableEffortLevels,
resolveAppliedEffort,
} from 'src/utils/effort.js'
import { modelSupportsAdaptiveThinking } from 'src/utils/thinking.js'
@@ -1194,7 +1193,7 @@ function runHeadlessStreaming(
}
const modelOptions = getModelOptions()
const modelInfos = modelOptions.map(option => {
const modelInfos: ModelInfo[] = modelOptions.map((option): ModelInfo => {
const modelId = option.value === null ? 'default' : option.value
const resolvedModel =
modelId === 'default'
@@ -1210,9 +1209,7 @@ function runHeadlessStreaming(
description: option.description,
...(hasEffort && {
supportsEffort: true,
supportedEffortLevels: modelSupportsMaxEffort(resolvedModel)
? [...EFFORT_LEVELS]
: EFFORT_LEVELS.filter(l => l !== 'max'),
supportedEffortLevels: getAvailableEffortLevels(resolvedModel),
}),
...(hasAdaptiveThinking && { supportsAdaptiveThinking: true }),
...(hasFastMode && { supportsFastMode: true }),
+1 -1
View File
@@ -176,7 +176,7 @@ function ApplyEffortAndClose(t0) {
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
args = args?.trim() || '';
if (COMMON_HELP_ARGS.includes(args)) {
onDone('Usage: /effort [low|medium|high|max|xhigh|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6 only)\n- xhigh: Extra-high reasoning for OpenAI/Codex models (alias for max)\n- auto: Use the default effort level for your model');
onDone('Usage: /effort [low|medium|high|max|xhigh|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6+)\n- xhigh: Extra-high reasoning (OpenAI/Codex and Opus 4.7+)\n- auto: Use the default effort level for your model');
return;
}
if (args === 'current' || args === 'status') {
+7 -7
View File
@@ -51,10 +51,10 @@ export function EffortPicker({ onSelect, onCancel }: Props) {
isAvailable: true,
},
...availableLevels.map(level => {
const displayLevel = usesOpenAIEffort
? (level === 'xhigh' ? 'max' : level)
: level
const isCurrent = currentDisplayedLevel === displayLevel
// xhigh is now the persisted level for OpenAI/Codex, so compare against
// it directly. The 'max' alias path is kept only for legacy settings
// that still hold a persisted 'max' from before xhigh was introduced.
const isCurrent = currentDisplayedLevel === level || (usesOpenAIEffort && level === 'xhigh' && currentDisplayedLevel === 'max')
return {
label: (
<EffortOptionLabel
@@ -78,9 +78,9 @@ export function EffortPicker({ onSelect, onCancel }: Props) {
}))
onSelect(undefined)
} else {
// Normalize OpenAI-shaped 'xhigh' to the standard EffortLevel ('max')
// so AppState + settings.json always hold a persistable value. The shim
// converts back to 'xhigh' at the request boundary.
// Normalize OpenAI-shaped effort to a standard EffortLevel for AppState
// and settings.json persistence. 'xhigh' passes through as-is; the shim
// converts it to 'max' at the Anthropic request boundary if needed.
const effortLevel = isOpenAIEffortLevel(value)
? openAIEffortToStandard(value)
: (value as EffortLevel)
+23 -13
View File
@@ -8,7 +8,7 @@ import { FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeCooldown, isFas
import { Box, Text } from '../ink.js';
import { useKeybindings } from '../keybindings/useKeybinding.js';
import { useAppState, useSetAppState } from '../state/AppState.js';
import { convertEffortValueToLevel, type EffortLevel, getDefaultEffortForModel, modelSupportsEffort, modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort } from '../utils/effort.js';
import { convertEffortValueToLevel, type EffortLevel, getAvailableEffortLevels, getDefaultEffortForModel, modelSupportsEffort, modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort } from '../utils/effort.js';
import { isModelAllowed } from '../utils/model/modelAllowlist.js';
import { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel } from '../utils/model/model.js';
import { getModelOptions, type ModelOption } from '../utils/model/modelOptions.js';
@@ -182,6 +182,10 @@ export function ModelPicker(t0) {
t8 = $[22];
}
const focusedSupportsMax = t8;
const focusedAvailableLevels: EffortLevel[] = (() => {
const focusedModel = resolveOptionModel(focusedValue);
return focusedModel ? getAvailableEffortLevels(focusedModel) : [];
})();
let t9;
if ($[23] !== focusedValue) {
t9 = getDefaultEffortLevelForOption(focusedValue);
@@ -191,7 +195,7 @@ export function ModelPicker(t0) {
t9 = $[24];
}
const focusedDefaultEffort = t9;
const displayEffort = effort === "max" && !focusedSupportsMax ? "high" : effort;
const displayEffort = focusedAvailableLevels.includes(effort) ? effort : "high";
let t10;
if ($[25] !== effortValue || $[26] !== hasToggledEffort) {
t10 = value => {
@@ -208,20 +212,21 @@ export function ModelPicker(t0) {
}
const handleFocus = t10;
let t11;
if ($[28] !== focusedDefaultEffort || $[29] !== focusedSupportsEffort || $[30] !== focusedSupportsMax) {
if ($[28] !== focusedDefaultEffort || $[29] !== focusedSupportsEffort || $[30] !== focusedSupportsMax || $[31] !== focusedAvailableLevels) {
t11 = direction => {
if (!focusedSupportsEffort) {
return;
}
setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax));
setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedAvailableLevels));
setHasToggledEffort(true);
};
$[28] = focusedDefaultEffort;
$[29] = focusedSupportsEffort;
$[30] = focusedSupportsMax;
$[31] = t11;
$[31] = focusedAvailableLevels;
$[32] = t11;
} else {
t11 = $[31];
t11 = $[32];
}
const handleCycleEffort = t11;
const t12 = {
@@ -242,18 +247,22 @@ export function ModelPicker(t0) {
}
useKeybindings(t12, t13);
let t14;
if ($[35] !== effort || $[36] !== hasToggledEffort || $[37] !== onSelect || $[38] !== setAppState || $[39] !== skipSettingsWrite) {
if ($[35] !== effort || $[36] !== hasToggledEffort || $[37] !== onSelect || $[38] !== setAppState || $[39] !== skipSettingsWrite || $[46] !== focusedAvailableLevels || $[47] !== focusedDefaultEffort) {
t14 = function handleSelect(value_0) {
const selectedModel = resolveOptionModel(value_0);
if (value_0 !== NO_PREFERENCE && selectedModel && !isModelAllowed(selectedModel)) {
onSelect(value_0 === NO_PREFERENCE ? null : value_0, undefined);
return;
}
// Clamp effort to a value in the focused model's available levels so
// emitted/persisted values are always valid for the picked model
// (e.g. toggled 'xhigh' then picked a model that doesn't support it).
const clampedEffort = focusedAvailableLevels.includes(effort) ? effort : focusedDefaultEffort;
logEvent("tengu_model_command_menu_effort", {
effort: effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
effort: clampedEffort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
if (!skipSettingsWrite) {
const effortLevel = resolvePickerEffortPersistence(effort, getDefaultEffortLevelForOption(value_0), getSettingsForSource("userSettings")?.effortLevel, hasToggledEffort);
const effortLevel = resolvePickerEffortPersistence(clampedEffort, getDefaultEffortLevelForOption(value_0), getSettingsForSource("userSettings")?.effortLevel, hasToggledEffort);
const persistable = toPersistableEffort(effortLevel);
if (persistable !== undefined) {
updateSettingsForSource("userSettings", {
@@ -265,7 +274,7 @@ export function ModelPicker(t0) {
effortValue: effortLevel
}));
}
const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? effort : undefined;
const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? clampedEffort : undefined;
if (value_0 === NO_PREFERENCE) {
onSelect(null, selectedEffort);
return;
@@ -277,6 +286,8 @@ export function ModelPicker(t0) {
$[37] = onSelect;
$[38] = setAppState;
$[39] = skipSettingsWrite;
$[46] = focusedAvailableLevels;
$[47] = focusedDefaultEffort;
$[40] = t14;
} else {
t14 = $[40];
@@ -447,10 +458,9 @@ function EffortLevelIndicator(t0) {
}
return t4;
}
function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel {
const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high'];
function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', levels: EffortLevel[]): EffortLevel {
// If the current level isn't in the cycle (e.g. 'max' after switching to a
// non-Opus model), clamp to 'high'.
// non-max model), clamp to 'high'.
const idx = levels.indexOf(current);
const currentIndex = idx !== -1 ? idx : levels.indexOf('high');
if (direction === 'right') {
+1 -1
View File
@@ -507,7 +507,7 @@ export const SDKControlGetSettingsResponseSchema = lazySchema(() =>
model: z.string(),
// String levels only — numeric effort is internal-only and the
// Zod→proto generator can't emit enumnumber unions.
effort: z.enum(['low', 'medium', 'high', 'max']).nullable(),
effort: z.enum(['low', 'medium', 'high', 'xhigh', 'max']).nullable(),
})
.optional()
.describe(
+2 -2
View File
@@ -1081,7 +1081,7 @@ export const ModelInfoSchema = lazySchema(() =>
.optional()
.describe('Whether this model supports effort levels'),
supportedEffortLevels: z
.array(z.enum(['low', 'medium', 'high', 'max']))
.array(z.enum(['low', 'medium', 'high', 'xhigh', 'max']))
.optional()
.describe('Available effort levels for this model'),
supportsAdaptiveThinking: z
@@ -1190,7 +1190,7 @@ export const AgentDefinitionSchema = lazySchema(() =>
"Scope for auto-loading agent memory files. 'user' - ~/.claude/agent-memory/<agentType>/, 'project' - .claude/agent-memory/<agentType>/, 'local' - .claude/agent-memory-local/<agentType>/",
),
effort: z
.union([z.enum(['low', 'medium', 'high', 'max']), z.number().int()])
.union([z.enum(['low', 'medium', 'high', 'xhigh', 'max']), z.number().int()])
.optional()
.describe(
'Reasoning effort level for this agent. Either a named level or an integer',
+2 -2
View File
@@ -1470,7 +1470,7 @@ export type ModelInfo = {
displayName: string
description: string
supportsEffort?: boolean
supportedEffortLevels?: ("low" | "medium" | "high" | "max")[]
supportedEffortLevels?: ("low" | "medium" | "high" | "xhigh" | "max")[]
supportsAdaptiveThinking?: boolean
supportsFastMode?: boolean
supportsAutoMode?: boolean
@@ -1534,7 +1534,7 @@ export type AgentDefinition = {
maxTurns?: number
background?: boolean
memory?: "user" | "project" | "local"
effort?: "low" | "medium" | "high" | "max" | number
effort?: "low" | "medium" | "high" | "xhigh" | "max" | number
permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "fullAccess" | "plan" | "dontAsk"
}
+1 -1
View File
@@ -1 +1 @@
export type EffortLevel = 'low' | 'medium' | 'high' | 'max'
export type EffortLevel = 'low' | 'medium' | 'high' | 'max' | 'xhigh'
+2 -1
View File
@@ -29,7 +29,7 @@ export default defineGateway({
preset: {
id: 'opencode-go',
vendorId: 'openai',
description: 'OpenCode Go — $10/mo subscription for open models (12 models)',
description: 'OpenCode Go — $10/mo subscription for open models (13 models)',
apiKeyEnvVars: ['OPENCODE_API_KEY'],
modelEnvVars: ['OPENAI_MODEL'],
},
@@ -50,6 +50,7 @@ export default defineGateway({
{ id: 'opencode-go-minimax-m2.5', apiName: 'minimax-m2.5', label: 'MiniMax M2.5', modelDescriptorId: 'opencode-go-minimax-m2.5', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'opencode-go-qwen3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen3.6 Plus', modelDescriptorId: 'opencode-go-qwen3.6-plus', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'opencode-go-qwen3.5-plus', apiName: 'qwen3.5-plus', label: 'Qwen3.5 Plus', modelDescriptorId: 'opencode-go-qwen3.5-plus', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'opencode-go-minimax-m3', apiName: 'minimax-m3', label: 'MiniMax M3', modelDescriptorId: 'opencode-go-minimax-m3', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
],
},
usage: { supported: false },
+2 -2
View File
@@ -284,12 +284,12 @@ describe('OpenCode model catalog', () => {
test('zen model count matches expected', () => {
const models = getCatalogEntriesForRoute('opencode')
expect(models.length).toBe(41)
expect(models.length).toBe(43)
})
test('go model count matches expected', () => {
const models = getCatalogEntriesForRoute('opencode-go')
expect(models.length).toBe(12)
expect(models.length).toBe(13)
})
test('all zen gpt models have modelDescriptorId', () => {
+3 -1
View File
@@ -20,7 +20,7 @@ export default defineGateway({
preset: {
id: 'opencode',
vendorId: 'openai',
description: 'OpenCode Zen — pay-as-you-go AI gateway (41 models)',
description: 'OpenCode Zen — pay-as-you-go AI gateway (43 models)',
apiKeyEnvVars: ['OPENCODE_API_KEY'],
modelEnvVars: ['OPENAI_MODEL'],
},
@@ -56,6 +56,7 @@ export default defineGateway({
{ id: 'gpt-5-nano', apiName: 'gpt-5-nano', label: 'GPT 5 Nano', modelDescriptorId: 'opencode-gpt-5-nano', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
// Claude family — /zen/v1/messages
{ id: 'claude-opus-4-7', apiName: 'claude-opus-4-7', label: 'Claude Opus 4.7', modelDescriptorId: 'opencode-claude-opus-4-7', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-opus-4-8', apiName: 'claude-opus-4-8', label: 'Claude Opus 4.8', modelDescriptorId: 'opencode-claude-opus-4-8', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-opus-4-6', apiName: 'claude-opus-4-6', label: 'Claude Opus 4.6', modelDescriptorId: 'opencode-claude-opus-4-6', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-opus-4-5', apiName: 'claude-opus-4-5', label: 'Claude Opus 4.5', modelDescriptorId: 'opencode-claude-opus-4-5', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-opus-4-1', apiName: 'claude-opus-4-1', label: 'Claude Opus 4.1', modelDescriptorId: 'opencode-claude-opus-4-1', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
@@ -82,6 +83,7 @@ export default defineGateway({
{ id: 'big-pickle', apiName: 'big-pickle', label: 'Big Pickle', modelDescriptorId: 'opencode-big-pickle' },
{ id: 'deepseek-v4-flash-free', apiName: 'deepseek-v4-flash-free', label: 'DeepSeek V4 Flash Free', modelDescriptorId: 'opencode-deepseek-v4-flash-free' },
{ id: 'nemotron-3-super-free', apiName: 'nemotron-3-super-free', label: 'Nemotron 3 Super Free', modelDescriptorId: 'opencode-nemotron-3-super-free' },
{ id: 'mimo-v2.5-free', apiName: 'mimo-v2.5-free', label: 'MiMo V2.5 Free', modelDescriptorId: 'opencode-mimo-v2.5-free' },
],
},
usage: { supported: false },
@@ -309,7 +309,7 @@ export const PROVIDER_PRESET_MANIFEST = [
"routeId": "opencode-go",
"vendorId": "openai",
"gatewayId": "opencode-go",
"description": "OpenCode Go — $10/mo subscription for open models (12 models)",
"description": "OpenCode Go — $10/mo subscription for open models (13 models)",
"apiKeyEnvVars": [
"OPENCODE_API_KEY"
],
@@ -323,7 +323,7 @@ export const PROVIDER_PRESET_MANIFEST = [
"routeId": "opencode",
"vendorId": "openai",
"gatewayId": "opencode",
"description": "OpenCode Zen — pay-as-you-go AI gateway (41 models)",
"description": "OpenCode Zen — pay-as-you-go AI gateway (43 models)",
"apiKeyEnvVars": [
"OPENCODE_API_KEY"
],
+54
View File
@@ -332,6 +332,24 @@ export default [
contextWindow: 200_000,
maxOutputTokens: 65_536,
}),
defineModel({
id: 'opencode-claude-opus-4-8',
label: 'Claude Opus 4.8',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-claude-opus-4-8',
capabilities: {
supportsVision: true,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
contextWindow: 200_000,
maxOutputTokens: 65_536,
}),
defineModel({
id: 'opencode-claude-opus-4-6',
label: 'Claude Opus 4.6',
@@ -752,6 +770,24 @@ export default [
contextWindow: 131_072,
maxOutputTokens: 32_768,
}),
defineModel({
id: 'opencode-mimo-v2.5-free',
label: 'MiMo V2.5 Free',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-mimo-v2.5-free',
capabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
supportsPreciseTokenCount: false,
},
contextWindow: 131_072,
maxOutputTokens: 32_768,
}),
// ============================================================
// GO MODELS — https://opencode.ai/zen/go/v1
@@ -976,4 +1012,22 @@ export default [
contextWindow: 131_072,
maxOutputTokens: 32_768,
}),
defineModel({
id: 'opencode-go-minimax-m3',
label: 'MiniMax M3',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'minimax-m3',
capabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: false,
supportsPreciseTokenCount: false,
},
contextWindow: 131_072,
maxOutputTokens: 32_768,
}),
]
+2 -2
View File
@@ -952,9 +952,9 @@ async function run(): Promise<CommanderCommand> {
return Number.isFinite(n) ? n : undefined;
}).hideHelp()).option('--from-pr [value]', 'Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term', value => value || true).option('--no-session-persistence', 'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)').addOption(new Option('--resume-session-at <message id>', 'When resuming, only messages up to and including the assistant message with <message.id> (use with --resume in print mode)').argParser(String).hideHelp()).addOption(new Option('--rewind-files <user-message-id>', 'Restore files to state at the specified user message and exit (requires --resume)').hideHelp())
// @[MODEL LAUNCH]: Update the example model ID in the --model help text.
.option('--model <model>', `Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`).option('--provider <provider>', `AI provider to use (anthropic, openai, gemini, github, bedrock, vertex, ollama). Reads API keys from environment variables.`).addOption(new Option('--effort <level>', `Effort level for the current session (low, medium, high, max)`).argParser((rawValue: string) => {
.option('--model <model>', `Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`).option('--provider <provider>', `AI provider to use (anthropic, openai, gemini, github, bedrock, vertex, ollama). Reads API keys from environment variables.`).addOption(new Option('--effort <level>', `Effort level for the current session (low, medium, high, xhigh, max)`).argParser((rawValue: string) => {
const value = rawValue.toLowerCase();
const allowed = ['low', 'medium', 'high', 'max'];
const allowed = ['low', 'medium', 'high', 'xhigh', 'max'];
if (!allowed.includes(value)) {
throw new InvalidArgumentError(`It must be one of: ${allowed.join(', ')}`);
}
+34
View File
@@ -2235,6 +2235,11 @@ class OpenAIShimMessages {
if (params.temperature !== undefined) responsesBody.temperature = params.temperature
if (params.top_p !== undefined) responsesBody.top_p = params.top_p
if (request.reasoning?.effort) {
responsesBody.reasoning_effort = request.reasoning.effort
responsesBody.reasoning_summary = 'auto'
responsesBody.include = ['reasoning.encrypted_content']
}
if (!omitResponsesTools && params.tools && params.tools.length > 0) {
const convertedTools = convertToolsToResponsesTools(
@@ -2285,6 +2290,31 @@ class OpenAIShimMessages {
anthropicBody.tool_choice = params.tool_choice
}
if (request.reasoning?.effort) {
// Shim receives OpenAI effort levels (xhigh) from client.ts, but
// Anthropic API expects 'max' not 'xhigh'. Convert for the effort field.
const effort = request.reasoning.effort === 'xhigh' ? 'max' : request.reasoning.effort
const modelLower = request.resolvedModel.toLowerCase()
const isAdaptive = modelLower.includes('opus-4-7') || modelLower.includes('opus-4-6') ||
modelLower.includes('opus-4-8') ||
modelLower.includes('opus-4.6') || modelLower.includes('opus-4.7') ||
modelLower.includes('opus-4.8') ||
modelLower.includes('sonnet-4-6') || modelLower.includes('sonnet-4.6')
const isOpus45 = modelLower.includes('opus-4-5') || modelLower.includes('opus-4.5')
if (isAdaptive) {
anthropicBody.thinking = { type: 'adaptive' }
anthropicBody.effort = effort
} else if (isOpus45) {
anthropicBody.effort = effort
} else if (effort === 'high' || effort === 'max') {
anthropicBody.thinking = {
type: 'enabled',
budgetTokens: effort === 'max' ? 31_999 : 16_000,
}
}
}
return anthropicBody
}
@@ -2378,6 +2408,10 @@ class OpenAIShimMessages {
}
if (params.temperature !== undefined) genConfig.temperature = params.temperature
if (params.top_p !== undefined) genConfig.topP = params.top_p
if (request.reasoning?.effort) {
const level = request.reasoning.effort === 'xhigh' ? 'high' : request.reasoning.effort
genConfig.thinkingConfig = { includeThoughts: true, thinkingLevel: level }
}
if (Object.keys(genConfig).length > 0) {
geminiBody.generationConfig = genConfig
}
+112 -11
View File
@@ -106,13 +106,13 @@ test('gpt-5.3-codex-spark stays without effort controls', async () => {
expect(getAvailableEffortLevels('gpt-5.3-codex-spark')).toEqual([])
})
test('toPersistableEffort normalizes xhigh to max so it survives settings write', async () => {
test('toPersistableEffort passes xhigh through as a first-class level', async () => {
const { toPersistableEffort } = await importFreshEffortModule({
provider: 'openai',
supportsCodexReasoningEffort: true,
})
expect(toPersistableEffort('xhigh')).toBe('max')
expect(toPersistableEffort('xhigh')).toBe('xhigh')
expect(toPersistableEffort('max')).toBe('max')
expect(toPersistableEffort('high')).toBe('high')
expect(toPersistableEffort('medium')).toBe('medium')
@@ -128,12 +128,13 @@ test('standardEffortToOpenAI maps max to xhigh for shim payload', async () => {
})
expect(standardEffortToOpenAI('max')).toBe('xhigh')
expect(standardEffortToOpenAI('xhigh')).toBe('xhigh')
expect(standardEffortToOpenAI('high')).toBe('high')
expect(openAIEffortToStandard('xhigh')).toBe('max')
expect(openAIEffortToStandard('xhigh')).toBe('xhigh')
expect(openAIEffortToStandard('high')).toBe('high')
})
test('e2e: xhigh → persisted max → resolveAppliedEffort → wire xhigh on OpenAI/Codex (no high clamp)', async () => {
test('e2e: xhigh → persisted xhigh → resolveAppliedEffort → wire xhigh on OpenAI/Codex (no high clamp)', async () => {
const {
toPersistableEffort,
resolveAppliedEffort,
@@ -143,18 +144,16 @@ test('e2e: xhigh → persisted max → resolveAppliedEffort → wire xhigh on Op
supportsCodexReasoningEffort: true,
})
// Picker writes the OpenAI-shaped value; toPersistableEffort normalizes.
// Picker writes 'xhigh'; toPersistableEffort passes it through.
const persisted = toPersistableEffort('xhigh')
expect(persisted).toBe('max')
expect(persisted).toBe('xhigh')
// App state holds 'max'. Non-Opus 'max' must NOT be downgraded to 'high'
// when the model uses the OpenAI effort scheme — the shim converts back
// to 'xhigh' on the wire.
// App state holds 'xhigh'. The OpenAI-shaped 'xhigh' is sent to the API as-is.
const applied = resolveAppliedEffort('gpt-5.4', persisted)
expect(applied).toBe('max')
expect(applied).toBe('xhigh')
// Final wire value the client shim emits.
expect(standardEffortToOpenAI(applied as 'max')).toBe('xhigh')
expect(standardEffortToOpenAI(applied as 'xhigh')).toBe('xhigh')
})
test('e2e: max on non-Opus Anthropic model still clamps to high', async () => {
@@ -165,3 +164,105 @@ test('e2e: max on non-Opus Anthropic model still clamps to high', async () => {
expect(resolveAppliedEffort('claude-sonnet-4-6', 'max')).toBe('high')
})
test('modelSupportsXHighEffort: opus-4-7 and opus-4-8 are allowed; other Claude models are not', async () => {
const { modelSupportsXHighEffort } = await importFreshEffortModule({
provider: 'firstParty' as unknown as 'openai',
supportsCodexReasoningEffort: false,
})
expect(modelSupportsXHighEffort('claude-opus-4-7')).toBe(true)
expect(modelSupportsXHighEffort('claude-opus-4-8')).toBe(true)
expect(modelSupportsXHighEffort('opencode-claude-opus-4-8')).toBe(true)
expect(modelSupportsXHighEffort('claude-opus-4-6')).toBe(false)
expect(modelSupportsXHighEffort('claude-sonnet-4-6')).toBe(false)
expect(modelSupportsXHighEffort('claude-sonnet-4-5')).toBe(false)
expect(modelSupportsXHighEffort('claude-haiku-4-5')).toBe(false)
expect(modelSupportsXHighEffort('claude-3-5-haiku')).toBe(false)
})
test('xhigh does not appear in available levels for non-supporting models', async () => {
const { getAvailableEffortLevels } = await importFreshEffortModule({
provider: 'firstParty' as unknown as 'openai',
supportsCodexReasoningEffort: false,
})
// No xhigh, no max
expect(getAvailableEffortLevels('claude-sonnet-4-6')).toEqual([
'low',
'medium',
'high',
])
expect(getAvailableEffortLevels('claude-haiku-4-5')).toEqual([])
// Has xhigh AND max (opus-4-8)
const opusLevels = getAvailableEffortLevels('claude-opus-4-8')
expect(opusLevels).toEqual(['low', 'medium', 'high', 'xhigh', 'max'])
})
test('effort allowlist is narrowed to the shim isAdaptive||isOpus45 set', async () => {
// The Anthropic /messages shim only serializes low/medium as
// anthropicBody.effort for opus-4-5/4-6/4-7/4-8 and sonnet-4-6. For
// older variants it only emits thinking for high/max — advertising
// effort for them would silently drop low/medium on the wire.
const { modelSupportsEffort, getAvailableEffortLevels } =
await importFreshEffortModule({
provider: 'firstParty' as unknown as 'openai',
supportsCodexReasoningEffort: false,
})
// Inside the shim set → supported
for (const model of [
'claude-opus-4-5',
'claude-opus-4-6',
'claude-opus-4-7',
'claude-opus-4-8',
'claude-sonnet-4-6',
'opencode-claude-opus-4-7',
]) {
expect(modelSupportsEffort(model)).toBe(true)
}
// Outside the shim set → not supported (was previously true via the
// broad `claude-opus-4*` / `claude-sonnet-4*` substring match)
for (const model of [
'claude-opus-4-1',
'claude-opus-4-2',
'claude-sonnet-4-5',
]) {
expect(modelSupportsEffort(model)).toBe(false)
expect(getAvailableEffortLevels(model)).toEqual([])
}
})
test('xhigh clamps to high on non-supporting models so stale settings.json values do not produce API errors', async () => {
const { resolveAppliedEffort } = await importFreshEffortModule({
provider: 'firstParty' as unknown as 'openai',
supportsCodexReasoningEffort: false,
})
// sonnet-4-6 supports effort but not xhigh — clamp
expect(resolveAppliedEffort('claude-sonnet-4-6', 'xhigh')).toBe('high')
// opus-4-8 supports xhigh — pass through
expect(resolveAppliedEffort('claude-opus-4-8', 'xhigh')).toBe('xhigh')
})
test('modelUsesOpenAIEffort: Claude/Gemini are excluded even on the openai provider (OpenCode native route)', async () => {
const { modelUsesOpenAIEffort, getAvailableEffortLevels } =
await importFreshEffortModule({
provider: 'openai',
supportsCodexReasoningEffort: true,
})
// Native Claude/Gemini on OpenCode use Anthropic/Google format, not OpenAI
expect(modelUsesOpenAIEffort('claude-opus-4-8')).toBe(false)
expect(modelUsesOpenAIEffort('claude-sonnet-4-6')).toBe(false)
expect(modelUsesOpenAIEffort('gemini-3-flash')).toBe(false)
// Real OpenAI-shaped models still classify as OpenAI
expect(modelUsesOpenAIEffort('gpt-5.4')).toBe(true)
// And the picker excludes xhigh for OpenCode Claude on openai provider
const opusLevels = getAvailableEffortLevels('claude-opus-4-8')
// Standard branch: no OPENAI_EFFORT_LEVELS, just the supported standard levels
expect(opusLevels).toEqual(['low', 'medium', 'high', 'xhigh', 'max'])
})
+80 -20
View File
@@ -15,6 +15,7 @@ export const EFFORT_LEVELS = [
'low',
'medium',
'high',
'xhigh',
'max',
] as const satisfies readonly EffortLevel[]
@@ -41,8 +42,20 @@ export function modelSupportsEffort(model: string): boolean {
if (modelUsesOpenAIEffort(model) && supportsCodexReasoningEffort(model)) {
return true
}
// Supported by a subset of Claude 4 models
if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) {
// Claude 4 models that support effort. Mirrors the Anthropic /messages
// shim's isAdaptive || isOpus45 set (openaiShim.ts:2292-2297) — only
// these models serialize low/medium as anthropicBody.effort. Older
// variants (opus-4-1, sonnet-4-5, haiku) only emit thinking for
// high/max, so advertising effort for them would silently drop
// low/medium on the wire. The substring match also covers prefix
// variations (e.g. `claude-opus-4-7`, `opencode-claude-opus-4-8`).
if (m.includes('opus-4-5') || m.includes('opus-4-6') ||
m.includes('opus-4-7') || m.includes('opus-4-8') ||
m.includes('sonnet-4-6')) {
return true
}
// OpenCode Gemini models that support thinking via /models/gemini-* endpoint
if (m.includes('gemini-3')) {
return true
}
// Exclude any other known legacy models (haiku, older opus/sonnet variants)
@@ -67,7 +80,7 @@ export function modelSupportsMaxEffort(model: string): boolean {
if (supported3P !== undefined) {
return supported3P
}
if (model.toLowerCase().includes('opus-4-6')) {
if (model.toLowerCase().includes('opus-4-6') || model.toLowerCase().includes('opus-4-7') || model.toLowerCase().includes('opus-4-8')) {
return true
}
if (process.env.USER_TYPE === 'ant' && resolveAntModel(model)) {
@@ -76,6 +89,26 @@ export function modelSupportsMaxEffort(model: string): boolean {
return false
}
// @[MODEL LAUNCH]: Add the new model to the allowlist if it supports 'xhigh' effort.
// xhigh is reserved for OpenAI/Codex models and OpenCode Claude opus 4-7 / 4-8.
// All other effort-supporting models reject xhigh at the API.
export function modelSupportsXHighEffort(model: string): boolean {
if (!modelSupportsEffort(model)) {
return false
}
const supported3P = get3PModelCapabilityOverride(model, 'xhigh_effort')
if (supported3P !== undefined) {
return supported3P
}
if (modelUsesOpenAIEffort(model)) {
return true
}
if (model.toLowerCase().includes('opus-4-7') || model.toLowerCase().includes('opus-4-8')) {
return true
}
return false
}
export function isEffortLevel(value: string): value is EffortLevel {
return (EFFORT_LEVELS as readonly string[]).includes(value)
}
@@ -86,17 +119,39 @@ export function isOpenAIEffortLevel(value: string): value is OpenAIEffortLevel {
export function modelUsesOpenAIEffort(model: string): boolean {
const provider = getAPIProvider()
return provider === 'openai' || provider === 'codex'
if (provider !== 'openai' && provider !== 'codex') {
return false
}
// Native Claude/Gemini models on OpenCode use Anthropic/Google format
// even though the OpenCode shim is provider=openai. They should not be
// classified as OpenAI-style for effort routing.
const m = model.toLowerCase()
if (m.includes('claude-') || m.includes('gemini-')) {
return false
}
return true
}
export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIEffortLevel[] {
export function getAvailableEffortLevels(model: string): EffortLevel[] {
if (!modelSupportsEffort(model)) {
return []
}
if (modelUsesOpenAIEffort(model)) {
return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[]
// OpenCode Claude and Gemini models use /messages or /models/gemini-*
// (Anthropic/Google format) even though getAPIProvider() returns 'openai'.
// Show standard levels (max) not OpenAI levels (xhigh).
const m = model.toLowerCase()
const isOpenCodeNativeFormat = (
m.includes('claude-opus-4') || m.includes('claude-sonnet-4') ||
m.includes('opus-4') || m.includes('sonnet-4') ||
m.includes('gemini-3')
) && getAPIProvider() === 'openai'
if (modelUsesOpenAIEffort(model) && !isOpenCodeNativeFormat) {
return [...OPENAI_EFFORT_LEVELS] as EffortLevel[]
}
const levels: EffortLevel[] = ['low', 'medium', 'high']
if (modelSupportsXHighEffort(model)) {
levels.push('xhigh')
}
if (modelSupportsMaxEffort(model)) {
levels.push('max')
}
@@ -110,8 +165,7 @@ export function getEffortLevelLabel(level: EffortLevel | OpenAIEffortLevel): str
}
export function openAIEffortToStandard(level: OpenAIEffortLevel): EffortLevel {
if (level === 'xhigh') return 'max'
return level
return level as EffortLevel
}
export function standardEffortToOpenAI(level: EffortLevel): OpenAIEffortLevel {
@@ -144,23 +198,23 @@ export function parseEffortValue(value: unknown): EffortValue | undefined {
/**
* Numeric values are model-default only and not persisted.
* 'max' can now be persisted by all users.
* OpenAI-shaped 'xhigh' is normalized to its EffortLevel equivalent ('max')
* so any code path that leaks the OpenAI label still persists correctly.
* 'xhigh' is a first-class EffortLevel (supported by OpenCode Claude 4.7+)
* and is persisted as 'xhigh' no normalization needed.
* Write sites call this before saving to settings so the Zod schema
* (which only accepts string levels) never rejects a write.
*/
export function toPersistableEffort(
value: EffortValue | undefined,
): EffortLevel | undefined {
if (value === 'low' || value === 'medium' || value === 'high') {
if (
value === 'low' ||
value === 'medium' ||
value === 'high' ||
value === 'max' ||
value === 'xhigh'
) {
return value
}
if (value === 'max') {
return value
}
if (value === 'xhigh') {
return 'max'
}
return undefined
}
@@ -229,6 +283,12 @@ export function resolveAppliedEffort(
) {
return 'high'
}
// xhigh is reserved for OpenAI/Codex models and OpenCode opus-4-7/4-8.
// For all other models, downgrade to 'high' so a stale persisted setting
// doesn't surface as an API error.
if (resolved === 'xhigh' && !modelSupportsXHighEffort(model)) {
return 'high'
}
return resolved
}
@@ -296,9 +356,9 @@ export function getEffortLevelDescription(level: EffortLevel | OpenAIEffortLevel
case 'high':
return 'Comprehensive implementation with extensive testing and documentation'
case 'max':
return 'Maximum capability with deepest reasoning (Opus 4.6 only)'
return 'Maximum capability with deepest reasoning (Opus 4.6+)'
case 'xhigh':
return 'Extra high reasoning effort for complex tasks (OpenAI/Codex)'
return 'Extra high reasoning effort for complex tasks'
}
}
+1
View File
@@ -4,6 +4,7 @@ import { getAPIProvider } from './providers.js'
export type ModelCapabilityOverride =
| 'effort'
| 'max_effort'
| 'xhigh_effort'
| 'thinking'
| 'adaptive_thinking'
| 'interleaved_thinking'
+1 -1
View File
@@ -738,7 +738,7 @@ export const SettingsSchema = lazySchema(() =>
'enabled automatically for supported models.',
),
effortLevel: z
.enum(['low', 'medium', 'high', 'max'])
.enum(['low', 'medium', 'high', 'xhigh', 'max'])
.optional()
.catch(undefined)
.describe('Persisted effort level for supported models.'),