fix: preserve provider route context metadata (#1741)

* fix: preserve provider route context metadata

Resolve issue #1732 by keeping active provider-profile routes attached during context limit resolution and making gateway-prefixed model IDs resolve against provider-scoped metadata instead of falling through to global aliases.

Preserve composite provider-path suffixes such as accounts/fireworks/models/... and fireworks/models/... before generic last-segment matching, so wrapped gateway IDs resolve Fireworks-specific limits instead of generic descriptors.

Refresh OpenCode Zen and OpenCode Go catalog metadata, including route-specific context/output limits, regenerate integration artifacts, add DeepSeek V4 Pro on NVIDIA NIM, add the Gemini 3.1 Pro router alias, and update user-facing OpenCode model counts.

Scope OpenCode descriptor default model names to OpenCode routes via providerModelMap so unprefixed vendor lookups are not hijacked by gateway descriptors. Add wrapper-path assertions for the user-facing max output token helper.

Add regression coverage for provider-prefixed gateway models, active-profile route preservation, account-qualified composite paths, and OpenRouter-wrapped fireworks/models/... paths. Harden Windows/full-suite validation by normalizing plugin hook display paths and resetting status-redaction HOME/USERPROFILE state.

Validation: bun install; bun run build; bun run smoke; bun run typecheck; bun run typecheck:type-tests; bun run check (4634 pass, 0 fail); bun run test:provider (857 pass, 0 fail); bun run test:provider-recommendation (91 pass, 0 fail); bun run integrations:check; bun run security:pr-scan -- --base upstream/main; git diff --check. Follow-up validation: bun test src/integrations/runtimeMetadata.test.ts --max-concurrency=1; bun test src/utils/context.test.ts src/integrations/runtimeMetadata.test.ts src/integrations/gateways/opencode.test.ts --max-concurrency=1; bun run test:provider; bun run integrations:check; bun run typecheck; git diff --check.

# Conflicts:
#	src/integrations/gateways/opencode-go.ts
#	src/integrations/models/opencode.ts

* fix: align OpenCode context metadata

Refresh OpenCode Zen and Go descriptor context/output limits against the live OpenCode model lists and Models.dev provider metadata.

Add a regression assertion for provider-specific OpenCode limits so route-scoped metadata does not fall back to generic model budgets.

* fix: remove duplicate Gemini model descriptor

Keep the canonical Gemini 3.1 Pro descriptor and rely on provider-prefixed suffix matching for google/gemini-3.1-pro runtime lookups.

Also separate the OpenCode limit regression test from the following assertion for readability.

* fix: preserve OpenCode Go messages auth metadata

* test: cover OpenCode Go review cases
This commit is contained in:
JATMN
2026-06-22 13:35:31 +08:00
committed by GitHub
parent ae66c30df5
commit 5625f4217d
14 changed files with 452 additions and 1108 deletions
+2 -2
View File
@@ -203,8 +203,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 (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 |
| OpenCode Zen | `/provider` or env vars | Pay-as-you-go AI gateway (48 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 (20 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` |
| NEAR AI | `/provider` or env vars | Unified gateway (Claude, GPT, Gemini + TEE open models); uses `NEARAI_API_KEY` at `https://cloud-api.near.ai/v1` |
| Ollama | `/provider` or env vars | Local inference with no API key |
+2 -2
View File
@@ -197,7 +197,7 @@ export OPENAI_MODEL=gpt-5.4
openclaude
```
OpenCode Zen is a pay-as-you-go AI gateway with 43 models (GPT, Claude, Gemini,
OpenCode Zen is a pay-as-you-go AI gateway with 48 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.
@@ -212,7 +212,7 @@ export OPENAI_MODEL=glm-5.1
openclaude
```
OpenCode Go is a $10/mo subscription for 13 open models (GLM, Kimi, DeepSeek,
OpenCode Go is a $10/mo subscription for 20 open models (GLM, Kimi, DeepSeek,
MiMo, MiniMax, Qwen). Uses the same `OPENCODE_API_KEY` as OpenCode Zen.
### Gitlawb Opengateway
+6
View File
@@ -92,6 +92,12 @@ export default defineGateway({
label: 'Llama 3.1 Nemotron 70B',
modelDescriptorId: 'nvidia/llama-3.1-nemotron-70b-instruct',
},
{
id: 'nvidia-deepseek-v4-pro',
apiName: 'deepseek-ai/deepseek-v4-pro',
label: 'DeepSeek V4 Pro',
modelDescriptorId: 'deepseek-v4-pro',
},
],
},
usage: { supported: false },
+51 -18
View File
@@ -1,5 +1,54 @@
import { defineGateway } from '../define.js'
type OpenCodeGoCatalogSpec = {
id: string
apiName: string
label: string
endpointPath?: string
}
function catalogEntry(spec: OpenCodeGoCatalogSpec) {
return {
id: `opencode-go-${spec.id}`,
apiName: spec.apiName,
label: spec.label,
modelDescriptorId: `opencode-go-${spec.id}`,
...(spec.endpointPath
? {
transportOverrides: {
openaiShim: {
endpointPath: spec.endpointPath,
defaultAuthHeader: { name: 'x-api-key', scheme: 'raw' as const },
},
},
}
: {}),
}
}
const goModels: OpenCodeGoCatalogSpec[] = [
{ id: 'minimax-m3', apiName: 'minimax-m3', label: 'MiniMax M3', endpointPath: '/messages' },
{ id: 'minimax-m2.7', apiName: 'minimax-m2.7', label: 'MiniMax M2.7', endpointPath: '/messages' },
{ id: 'minimax-m2.5', apiName: 'minimax-m2.5', label: 'MiniMax M2.5', endpointPath: '/messages' },
{ id: 'kimi-k2.7-code', apiName: 'kimi-k2.7-code', label: 'Kimi K2.7 Code' },
{ id: 'kimi-k2.6', apiName: 'kimi-k2.6', label: 'Kimi K2.6' },
{ id: 'kimi-k2.5', apiName: 'kimi-k2.5', label: 'Kimi K2.5' },
{ id: 'glm-5.2', apiName: 'glm-5.2', label: 'GLM 5.2' },
{ id: 'glm-5.1', apiName: 'glm-5.1', label: 'GLM 5.1' },
{ id: 'glm-5', apiName: 'glm-5', label: 'GLM 5' },
{ id: 'deepseek-v4-pro', apiName: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' },
{ id: 'deepseek-v4-flash', apiName: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' },
{ id: 'qwen3.7-max', apiName: 'qwen3.7-max', label: 'Qwen3.7 Max', endpointPath: '/messages' },
{ id: 'qwen3.7-plus', apiName: 'qwen3.7-plus', label: 'Qwen3.7 Plus', endpointPath: '/messages' },
{ id: 'qwen3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen3.6 Plus', endpointPath: '/messages' },
{ id: 'qwen3.5-plus', apiName: 'qwen3.5-plus', label: 'Qwen3.5 Plus', endpointPath: '/messages' },
{ id: 'mimo-v2-pro', apiName: 'mimo-v2-pro', label: 'MiMo V2 Pro' },
{ id: 'mimo-v2-omni', apiName: 'mimo-v2-omni', label: 'MiMo V2 Omni' },
{ id: 'mimo-v2.5-pro', apiName: 'mimo-v2.5-pro', label: 'MiMo V2.5 Pro' },
{ id: 'mimo-v2.5', apiName: 'mimo-v2.5', label: 'MiMo V2.5' },
{ id: 'hy3-preview', apiName: 'hy3-preview', label: 'HY3 Preview' },
]
export default defineGateway({
id: 'opencode-go',
label: 'OpenCode Go',
@@ -29,29 +78,13 @@ export default defineGateway({
preset: {
id: 'opencode-go',
vendorId: 'openai',
description: 'OpenCode Go $10/mo subscription for open models (13 models)',
description: 'OpenCode Go - $10/mo subscription for open models (20 models)',
apiKeyEnvVars: ['OPENCODE_API_KEY'],
modelEnvVars: ['OPENAI_MODEL'],
},
catalog: {
source: 'static',
models: [
// OpenAI-compatible — /zen/go/v1/chat/completions
{ id: 'opencode-go-glm-5.1', apiName: 'glm-5.1', label: 'GLM 5.1', modelDescriptorId: 'opencode-go-glm-5.1' },
{ id: 'opencode-go-glm-5', apiName: 'glm-5', label: 'GLM 5', modelDescriptorId: 'opencode-go-glm-5' },
{ id: 'opencode-go-kimi-k2.5', apiName: 'kimi-k2.5', label: 'Kimi K2.5', modelDescriptorId: 'opencode-go-kimi-k2.5' },
{ id: 'opencode-go-kimi-k2.6', apiName: 'kimi-k2.6', label: 'Kimi K2.6', modelDescriptorId: 'opencode-go-kimi-k2.6' },
{ id: 'opencode-go-deepseek-v4-pro', apiName: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro', modelDescriptorId: 'opencode-go-deepseek-v4-pro' },
{ id: 'opencode-go-deepseek-v4-flash', apiName: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', modelDescriptorId: 'opencode-go-deepseek-v4-flash' },
{ id: 'opencode-go-mimo-v2.5', apiName: 'mimo-v2.5', label: 'MiMo V2.5', modelDescriptorId: 'opencode-go-mimo-v2.5' },
{ id: 'opencode-go-mimo-v2.5-pro', apiName: 'mimo-v2.5-pro', label: 'MiMo V2.5 Pro', modelDescriptorId: 'opencode-go-mimo-v2.5-pro' },
// Anthropic Messages API — /zen/go/v1/messages with x-api-key auth
{ id: 'opencode-go-minimax-m2.7', apiName: 'minimax-m2.7', label: 'MiniMax M2.7', modelDescriptorId: 'opencode-go-minimax-m2.7', transportOverrides: { openaiShim: { endpointPath: '/messages', defaultAuthHeader: { name: 'x-api-key', scheme: 'raw' } } } },
{ id: 'opencode-go-minimax-m2.5', apiName: 'minimax-m2.5', label: 'MiniMax M2.5', modelDescriptorId: 'opencode-go-minimax-m2.5', transportOverrides: { openaiShim: { endpointPath: '/messages', defaultAuthHeader: { name: 'x-api-key', scheme: 'raw' } } } },
{ id: 'opencode-go-qwen3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen3.6 Plus', modelDescriptorId: 'opencode-go-qwen3.6-plus', transportOverrides: { openaiShim: { endpointPath: '/messages', defaultAuthHeader: { name: 'x-api-key', scheme: 'raw' } } } },
{ id: 'opencode-go-qwen3.5-plus', apiName: 'qwen3.5-plus', label: 'Qwen3.5 Plus', modelDescriptorId: 'opencode-go-qwen3.5-plus', transportOverrides: { openaiShim: { endpointPath: '/messages', defaultAuthHeader: { name: 'x-api-key', scheme: 'raw' } } } },
{ id: 'opencode-go-minimax-m3', apiName: 'minimax-m3', label: 'MiniMax M3', modelDescriptorId: 'opencode-go-minimax-m3', transportOverrides: { openaiShim: { endpointPath: '/messages', defaultAuthHeader: { name: 'x-api-key', scheme: 'raw' } } } },
],
models: goModels.map(catalogEntry),
},
usage: { supported: false },
})
+17 -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(43)
expect(models.length).toBe(48)
})
test('go model count matches expected', () => {
const models = getCatalogEntriesForRoute('opencode-go')
expect(models.length).toBe(13)
expect(models.length).toBe(20)
})
test('all zen gpt models have modelDescriptorId', () => {
@@ -324,6 +324,8 @@ describe('OpenCode model catalog', () => {
'opencode-go-minimax-m3',
'opencode-go-minimax-m2.7',
'opencode-go-minimax-m2.5',
'opencode-go-qwen3.7-max',
'opencode-go-qwen3.7-plus',
'opencode-go-qwen3.6-plus',
'opencode-go-qwen3.5-plus',
]
@@ -466,6 +468,19 @@ describe('OpenCode edge cases', () => {
}
})
test('model descriptors preserve OpenCode provider limit metadata', () => {
const models = new Map(
getAllModels()
.filter(m => m.id.startsWith('opencode-'))
.map(model => [model.id, model]),
)
expect(models.get('opencode-qwen3.6-plus')?.contextWindow).toBe(262_144)
expect(models.get('opencode-deepseek-v4-pro')?.maxOutputTokens).toBe(384_000)
expect(models.get('opencode-go-minimax-m2.5')?.maxOutputTokens).toBe(65_536)
expect(models.get('opencode-go-hy3-preview')?.contextWindow).toBe(256_000)
})
test('zen gateway validation message mentions OPENCODE_API_KEY', () => {
const gateway = getGateway('opencode')
expect(gateway!.validation!.missingCredentialMessage).toContain('OPENCODE_API_KEY')
+71 -51
View File
@@ -1,5 +1,74 @@
import { defineGateway } from '../define.js'
type OpenCodeCatalogSpec = {
id: string
label: string
endpointPath?: string
}
function catalogEntry(spec: OpenCodeCatalogSpec) {
return {
id: spec.id,
apiName: spec.id,
label: spec.label,
modelDescriptorId: `opencode-${spec.id}`,
...(spec.endpointPath
? { transportOverrides: { openaiShim: { endpointPath: spec.endpointPath } } }
: {}),
}
}
const zenModels: OpenCodeCatalogSpec[] = [
{ id: 'claude-fable-5', label: 'Claude Fable 5', endpointPath: '/messages' },
{ id: 'claude-opus-4-8', label: 'Claude Opus 4.8', endpointPath: '/messages' },
{ id: 'claude-opus-4-7', label: 'Claude Opus 4.7', endpointPath: '/messages' },
{ id: 'claude-opus-4-6', label: 'Claude Opus 4.6', endpointPath: '/messages' },
{ id: 'claude-opus-4-5', label: 'Claude Opus 4.5', endpointPath: '/messages' },
{ id: 'claude-opus-4-1', label: 'Claude Opus 4.1', endpointPath: '/messages' },
{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', endpointPath: '/messages' },
{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', endpointPath: '/messages' },
{ id: 'claude-sonnet-4', label: 'Claude Sonnet 4', endpointPath: '/messages' },
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', endpointPath: '/messages' },
{ id: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', endpointPath: '/models/gemini-3.5-flash' },
{ id: 'gemini-3.1-pro', label: 'Gemini 3.1 Pro', endpointPath: '/models/gemini-3.1-pro' },
{ id: 'gemini-3-flash', label: 'Gemini 3 Flash', endpointPath: '/models/gemini-3-flash' },
{ id: 'gpt-5.5', label: 'GPT 5.5', endpointPath: '/responses' },
{ id: 'gpt-5.5-pro', label: 'GPT 5.5 Pro', endpointPath: '/responses' },
{ id: 'gpt-5.4', label: 'GPT 5.4', endpointPath: '/responses' },
{ id: 'gpt-5.4-pro', label: 'GPT 5.4 Pro', endpointPath: '/responses' },
{ id: 'gpt-5.4-mini', label: 'GPT 5.4 Mini', endpointPath: '/responses' },
{ id: 'gpt-5.4-nano', label: 'GPT 5.4 Nano', endpointPath: '/responses' },
{ id: 'gpt-5.3-codex-spark', label: 'GPT 5.3 Codex Spark', endpointPath: '/responses' },
{ id: 'gpt-5.3-codex', label: 'GPT 5.3 Codex', endpointPath: '/responses' },
{ id: 'gpt-5.2', label: 'GPT 5.2', endpointPath: '/responses' },
{ id: 'gpt-5.2-codex', label: 'GPT 5.2 Codex', endpointPath: '/responses' },
{ id: 'gpt-5.1', label: 'GPT 5.1', endpointPath: '/responses' },
{ id: 'gpt-5.1-codex-max', label: 'GPT 5.1 Codex Max', endpointPath: '/responses' },
{ id: 'gpt-5.1-codex', label: 'GPT 5.1 Codex', endpointPath: '/responses' },
{ id: 'gpt-5.1-codex-mini', label: 'GPT 5.1 Codex Mini', endpointPath: '/responses' },
{ id: 'gpt-5', label: 'GPT 5', endpointPath: '/responses' },
{ id: 'gpt-5-codex', label: 'GPT 5 Codex', endpointPath: '/responses' },
{ id: 'gpt-5-nano', label: 'GPT 5 Nano', endpointPath: '/responses' },
{ id: 'grok-build-0.1', label: 'Grok Build 0.1' },
{ id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' },
{ id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' },
{ id: 'glm-5.1', label: 'GLM 5.1' },
{ id: 'glm-5', label: 'GLM 5' },
{ id: 'minimax-m2.7', label: 'MiniMax M2.7' },
{ id: 'minimax-m2.5', label: 'MiniMax M2.5' },
{ id: 'kimi-k2.6', label: 'Kimi K2.6' },
{ id: 'kimi-k2.5', label: 'Kimi K2.5' },
{ id: 'qwen3.6-plus', label: 'Qwen3.6 Plus', endpointPath: '/messages' },
{ id: 'qwen3.5-plus', label: 'Qwen3.5 Plus', endpointPath: '/messages' },
{ id: 'big-pickle', label: 'Big Pickle' },
{ id: 'deepseek-v4-flash-free', label: 'DeepSeek V4 Flash Free' },
{ id: 'mimo-v2.5-free', label: 'MiMo V2.5 Free' },
{ id: 'qwen3.6-plus-free', label: 'Qwen3.6 Plus Free', endpointPath: '/messages' },
{ id: 'minimax-m3-free', label: 'MiniMax M3 Free' },
{ id: 'nemotron-3-ultra-free', label: 'Nemotron 3 Ultra Free' },
{ id: 'north-mini-code-free', label: 'North Mini Code Free' },
]
export default defineGateway({
id: 'opencode',
label: 'OpenCode Zen',
@@ -20,7 +89,7 @@ export default defineGateway({
preset: {
id: 'opencode',
vendorId: 'openai',
description: 'OpenCode Zen pay-as-you-go AI gateway (43 models)',
description: 'OpenCode Zen - pay-as-you-go AI gateway (48 models)',
apiKeyEnvVars: ['OPENCODE_API_KEY'],
modelEnvVars: ['OPENAI_MODEL'],
},
@@ -35,56 +104,7 @@ export default defineGateway({
},
catalog: {
source: 'static',
models: [
// GPT family — /zen/v1/responses
{ id: 'gpt-5.5', apiName: 'gpt-5.5', label: 'GPT 5.5', modelDescriptorId: 'opencode-gpt-5.5', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.5-pro', apiName: 'gpt-5.5-pro', label: 'GPT 5.5 Pro', modelDescriptorId: 'opencode-gpt-5.5-pro', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.4', apiName: 'gpt-5.4', label: 'GPT 5.4', modelDescriptorId: 'opencode-gpt-5.4', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.4-pro', apiName: 'gpt-5.4-pro', label: 'GPT 5.4 Pro', modelDescriptorId: 'opencode-gpt-5.4-pro', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.4-mini', apiName: 'gpt-5.4-mini', label: 'GPT 5.4 Mini', modelDescriptorId: 'opencode-gpt-5.4-mini', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.4-nano', apiName: 'gpt-5.4-nano', label: 'GPT 5.4 Nano', modelDescriptorId: 'opencode-gpt-5.4-nano', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.3-codex', apiName: 'gpt-5.3-codex', label: 'GPT 5.3 Codex', modelDescriptorId: 'opencode-gpt-5.3-codex', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.3-codex-spark', apiName: 'gpt-5.3-codex-spark', label: 'GPT 5.3 Codex Spark', modelDescriptorId: 'opencode-gpt-5.3-codex-spark', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.2', apiName: 'gpt-5.2', label: 'GPT 5.2', modelDescriptorId: 'opencode-gpt-5.2', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.2-codex', apiName: 'gpt-5.2-codex', label: 'GPT 5.2 Codex', modelDescriptorId: 'opencode-gpt-5.2-codex', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.1', apiName: 'gpt-5.1', label: 'GPT 5.1', modelDescriptorId: 'opencode-gpt-5.1', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.1-codex', apiName: 'gpt-5.1-codex', label: 'GPT 5.1 Codex', modelDescriptorId: 'opencode-gpt-5.1-codex', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.1-codex-max', apiName: 'gpt-5.1-codex-max', label: 'GPT 5.1 Codex Max', modelDescriptorId: 'opencode-gpt-5.1-codex-max', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.1-codex-mini', apiName: 'gpt-5.1-codex-mini', label: 'GPT 5.1 Codex Mini', modelDescriptorId: 'opencode-gpt-5.1-codex-mini', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5', apiName: 'gpt-5', label: 'GPT 5', modelDescriptorId: 'opencode-gpt-5', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5-codex', apiName: 'gpt-5-codex', label: 'GPT 5 Codex', modelDescriptorId: 'opencode-gpt-5-codex', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ 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' } } },
{ id: 'claude-sonnet-4-6', apiName: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', modelDescriptorId: 'opencode-claude-sonnet-4-6', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-sonnet-4-5', apiName: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', modelDescriptorId: 'opencode-claude-sonnet-4-5', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-sonnet-4', apiName: 'claude-sonnet-4', label: 'Claude Sonnet 4', modelDescriptorId: 'opencode-claude-sonnet-4', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-haiku-4-5', apiName: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', modelDescriptorId: 'opencode-claude-haiku-4-5', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-3-5-haiku', apiName: 'claude-3-5-haiku', label: 'Claude Haiku 3.5', modelDescriptorId: 'opencode-claude-3-5-haiku', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
// Gemini family — /zen/v1/models/<id>
{ id: 'gemini-3.5-flash', apiName: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', modelDescriptorId: 'opencode-gemini-3.5-flash', transportOverrides: { openaiShim: { endpointPath: '/models/gemini-3.5-flash' } } },
{ id: 'gemini-3.1-pro', apiName: 'gemini-3.1-pro', label: 'Gemini 3.1 Pro', modelDescriptorId: 'opencode-gemini-3.1-pro', transportOverrides: { openaiShim: { endpointPath: '/models/gemini-3.1-pro' } } },
{ id: 'gemini-3-flash', apiName: 'gemini-3-flash', label: 'Gemini 3 Flash', modelDescriptorId: 'opencode-gemini-3-flash', transportOverrides: { openaiShim: { endpointPath: '/models/gemini-3-flash' } } },
// Qwen — /zen/v1/messages
{ id: 'qwen3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen3.6 Plus', modelDescriptorId: 'opencode-qwen3.6-plus', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'qwen3.5-plus', apiName: 'qwen3.5-plus', label: 'Qwen3.5 Plus', modelDescriptorId: 'opencode-qwen3.5-plus', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
// OpenAI-compatible — /zen/v1/chat/completions (default, no override needed)
{ id: 'minimax-m2.7', apiName: 'minimax-m2.7', label: 'MiniMax M2.7', modelDescriptorId: 'opencode-minimax-m2.7' },
{ id: 'minimax-m2.5', apiName: 'minimax-m2.5', label: 'MiniMax M2.5', modelDescriptorId: 'opencode-minimax-m2.5' },
{ id: 'glm-5.1', apiName: 'glm-5.1', label: 'GLM 5.1', modelDescriptorId: 'opencode-glm-5.1' },
{ id: 'glm-5', apiName: 'glm-5', label: 'GLM 5', modelDescriptorId: 'opencode-glm-5' },
{ id: 'kimi-k2.5', apiName: 'kimi-k2.5', label: 'Kimi K2.5', modelDescriptorId: 'opencode-kimi-k2.5' },
{ id: 'kimi-k2.6', apiName: 'kimi-k2.6', label: 'Kimi K2.6', modelDescriptorId: 'opencode-kimi-k2.6' },
{ id: 'grok-build-0.1', apiName: 'grok-build-0.1', label: 'Grok Build 0.1', modelDescriptorId: 'opencode-grok-build-0.1' },
{ 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' },
],
models: zenModels.map(catalogEntry),
},
usage: { supported: false },
})
@@ -357,7 +357,7 @@ export const PROVIDER_PRESET_MANIFEST = [
"routeId": "opencode-go",
"vendorId": "openai",
"gatewayId": "opencode-go",
"description": "OpenCode Go $10/mo subscription for open models (13 models)",
"description": "OpenCode Go - $10/mo subscription for open models (20 models)",
"apiKeyEnvVars": [
"OPENCODE_API_KEY"
],
@@ -371,7 +371,7 @@ export const PROVIDER_PRESET_MANIFEST = [
"routeId": "opencode",
"vendorId": "openai",
"gatewayId": "opencode",
"description": "OpenCode Zen pay-as-you-go AI gateway (43 models)",
"description": "OpenCode Zen - pay-as-you-go AI gateway (48 models)",
"apiKeyEnvVars": [
"OPENCODE_API_KEY"
],
File diff suppressed because it is too large Load Diff
+69
View File
@@ -82,6 +82,52 @@ describe('resolveModelRuntimeLimits', () => {
expect(limits.contextWindow).toBe(1_000_000)
expect(limits.maxOutputTokens).toBe(131_072)
})
it('uses the applied provider profile route before generic custom base URL fallback', () => {
const limits = resolveModelRuntimeLimits({
model: 'kimi-k2.6',
activeProfileProvider: 'opencode',
processEnv: {
CLAUDE_CODE_USE_OPENAI: '1',
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: '1',
OPENAI_BASE_URL: 'https://proxy.example.test/v1',
},
})
expect(limits.contextWindow).toBe(262_144)
expect(limits.maxOutputTokens).toBe(65_536)
})
it('preserves composite provider paths before generic last-segment fallbacks', () => {
for (const model of [
'openrouter/accounts/fireworks/models/deepseek-v4-pro',
'openrouter/fireworks/models/deepseek-v4-pro',
]) {
expect(
resolveModelRuntimeLimits({
model,
processEnv: {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'https://openrouter.ai/api/v1',
},
}).maxOutputTokens,
).toBe(32_768)
}
for (const model of [
'openrouter/accounts/fireworks/models/llama-v3p1-70b-instruct',
'openrouter/fireworks/models/llama-v3p1-70b-instruct',
]) {
expect(
resolveModelRuntimeLimits({
model,
processEnv: {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'https://openrouter.ai/api/v1',
},
}).contextWindow,
).toBe(131_072)
}
})
})
describe('resolveOpenAIShimRuntimeContext - Z.AI GLM-5.2', () => {
@@ -201,4 +247,27 @@ describe('resolveOpenAIShimRuntimeContext - segment-boundary heuristic', () => {
expect(result.openaiShimConfig.preserveReasoningContent).toBeUndefined()
})
})
it('matches provider-prefixed model ids to built-in runtime limits', () => {
expect(
resolveModelRuntimeLimits({
model: 'google/gemini-3.1-pro',
activeProfileProvider: 'custom',
processEnv: {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'https://example-gateway.test/v1',
},
}).contextWindow,
).toBe(1_048_576)
expect(
resolveModelRuntimeLimits({
model: 'moonshotai/kimi-k2.6',
activeProfileProvider: 'nvidia-nim',
processEnv: {
CLAUDE_CODE_USE_OPENAI: '1',
OPENAI_BASE_URL: 'https://integrate.api.nvidia.com/v1',
},
}).contextWindow,
).toBe(262_144)
})
})
+32 -3
View File
@@ -277,6 +277,21 @@ function getModelDescriptorForCatalogEntry(entry: ModelCatalogEntry | null) {
return getModel(entry.modelDescriptorId) ?? null
}
function getProviderScopedModelSegments(modelApiName: string): string[] {
const segments = modelApiName
.split('/')
.map(segment => segment.trim())
.filter(Boolean)
const suffixes = segments
.slice(1)
.map((_, index) => segments.slice(index + 1).join('/'))
const accountQualifiedSuffixes = suffixes
.filter(suffix => /^[^/]+\/models\//.test(suffix))
.map(suffix => `accounts/${suffix}`)
return [...suffixes, ...accountQualifiedSuffixes]
}
function findModelDescriptorForApiName(
routeId: string | null,
modelApiName: string | undefined,
@@ -286,18 +301,25 @@ function findModelDescriptorForApiName(
return null
}
const normalizedModel = trimmedModel.toLowerCase()
const providerScopedSegments = getProviderScopedModelSegments(trimmedModel)
const normalizedProviderScopedSegments = getProviderScopedModelSegments(
normalizedModel,
)
ensureIntegrationsLoaded()
const models = getAllModels()
.map(model => {
const providerModelMap = model.providerModelMap
const routeMappedModel = routeId
? model.providerModelMap?.[routeId]
? providerModelMap?.[routeId]
: undefined
const hasProviderModelMap =
providerModelMap && Object.keys(providerModelMap).length > 0
return {
model,
names: [
model.id,
model.defaultModel,
hasProviderModelMap ? routeMappedModel : model.defaultModel,
routeMappedModel,
].filter((value): value is string => Boolean(value?.trim())),
}
@@ -320,13 +342,20 @@ function findModelDescriptorForApiName(
}
}
for (const candidate of models) {
if (candidate.names.some(name => providerScopedSegments.includes(name.trim()))) {
return candidate.model
}
}
for (const candidate of models) {
if (
candidate.names.some(name => {
const normalizedName = name.trim().toLowerCase()
return (
normalizedModel === normalizedName ||
normalizedModel.startsWith(normalizedName)
normalizedModel.startsWith(normalizedName) ||
normalizedProviderScopedSegments.includes(normalizedName)
)
})
) {
+48
View File
@@ -20,6 +20,10 @@ const originalEnv = {
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_MODEL: process.env.OPENAI_MODEL,
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED:
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED,
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID:
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID,
MINIMAX_API_KEY: process.env.MINIMAX_API_KEY,
XAI_API_KEY: process.env.XAI_API_KEY,
}
@@ -34,6 +38,8 @@ beforeEach(async () => {
delete process.env.OPENAI_API_BASE
delete process.env.OPENAI_API_KEY
delete process.env.OPENAI_MODEL
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
delete process.env.MINIMAX_API_KEY
delete process.env.XAI_API_KEY
})
@@ -83,6 +89,18 @@ afterEach(() => {
} else {
process.env.OPENAI_API_KEY = originalEnv.OPENAI_API_KEY
}
if (originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED === undefined) {
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
} else {
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED =
originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
}
if (originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID === undefined) {
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
} else {
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID =
originalEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
}
if (originalEnv.MINIMAX_API_KEY === undefined) {
delete process.env.MINIMAX_API_KEY
} else {
@@ -480,6 +498,36 @@ test('prefixed OpenGateway Gemini Flash Lite uses integration metadata', () => {
})
expect(getMaxOutputTokensForModel('google/gemini-3.1-flash-lite')).toBe(65_536)
})
test('prefixed Gemini 3.1 Pro router model uses integration metadata', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
delete process.env.OPENAI_MODEL
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
expect(getContextWindowForModel('google/gemini-3.1-pro')).toBe(1_048_576)
expect(getModelMaxOutputTokens('google/gemini-3.1-pro')).toEqual({
default: 65_536,
upperLimit: 65_536,
})
expect(getMaxOutputTokensForModel('google/gemini-3.1-pro')).toBe(65_536)
})
test('NVIDIA NIM DeepSeek V4 Pro uses NIM route catalog metadata', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.OPENAI_BASE_URL = 'https://integrate.api.nvidia.com/v1'
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
delete process.env.OPENAI_MODEL
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
expect(getContextWindowForModel('deepseek-ai/deepseek-v4-pro')).toBe(1_048_576)
expect(getModelMaxOutputTokens('deepseek-ai/deepseek-v4-pro')).toEqual({
default: 65_536,
upperLimit: 65_536,
})
expect(getMaxOutputTokensForModel('deepseek-ai/deepseek-v4-pro')).toBe(65_536)
})
test('OpenAI-compatible custom model limits honor documented env overrides', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
+38 -4
View File
@@ -11,6 +11,7 @@ import {
import { getCanonicalName } from './model/model.js'
import { getModelCapability } from './model/modelCapabilities.js'
import { resolveAntModel } from './model/antModels.js'
import { getActiveProviderProfile } from './providerProfiles.js'
// Model context window size (200k tokens for all models right now)
export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000
@@ -42,6 +43,8 @@ export const CAPPED_DEFAULT_MAX_TOKENS = 8_000
export const ESCALATED_MAX_TOKENS = 64_000
const warnedUnknownIntegrationRuntimeLimitKeys = new Set<string>()
const PROFILE_ENV_APPLIED_FLAG = 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED'
const PROFILE_ENV_APPLIED_ID = 'CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID'
/**
* Check if 1M context is disabled via environment variable.
@@ -71,10 +74,32 @@ export function modelSupports1M(model: string): boolean {
)
}
function getAppliedActiveProfileProvider(
processEnv: NodeJS.ProcessEnv = process.env,
): string | undefined {
if (processEnv[PROFILE_ENV_APPLIED_FLAG] !== '1') {
return undefined
}
const activeProfile = getActiveProviderProfile()
if (!activeProfile) {
return undefined
}
const appliedId = processEnv[PROFILE_ENV_APPLIED_ID]?.trim()
if (appliedId && appliedId !== activeProfile.id) {
return undefined
}
return activeProfile.provider
}
export function shouldUseIntegrationRuntimeLimits(
processEnv: NodeJS.ProcessEnv = process.env,
): boolean {
const routeId = resolveActiveRouteIdFromEnv(processEnv)
const routeId = resolveActiveRouteIdFromEnv(processEnv, {
activeProfileProvider: getAppliedActiveProfileProvider(processEnv),
})
const transportKind = routeId ? getTransportKindForRoute(routeId) : null
return (
@@ -93,7 +118,10 @@ export function shouldUseIntegrationRuntimeLimits(
* the Ink runtime treats console errors as application errors.
*/
function warnUnknownIntegrationRuntimeLimits(model: string): void {
const routeId = resolveActiveRouteIdFromEnv(process.env) ?? 'unknown-route'
const routeId =
resolveActiveRouteIdFromEnv(process.env, {
activeProfileProvider: getAppliedActiveProfileProvider(process.env),
}) ?? 'unknown-route'
const warningKey = `${routeId}:${model}`
if (warnedUnknownIntegrationRuntimeLimitKeys.has(warningKey)) return
@@ -134,7 +162,10 @@ export function getContextWindowForModel(
// but that caused auto-compact to fire on every turn because the effective
// context (8k minus output reservation) became negative (issue #635).
if (shouldUseIntegrationRuntimeLimits()) {
const runtimeLimits = resolveModelRuntimeLimits({ model })
const runtimeLimits = resolveModelRuntimeLimits({
model,
activeProfileProvider: getAppliedActiveProfileProvider(),
})
if (runtimeLimits.contextWindow !== undefined) {
return runtimeLimits.contextWindow
}
@@ -235,7 +266,10 @@ export function getModelMaxOutputTokens(model: string): {
// OpenAI-compatible provider — use known output limits to avoid 400 errors
if (shouldUseIntegrationRuntimeLimits()) {
const runtimeLimits = resolveModelRuntimeLimits({ model })
const runtimeLimits = resolveModelRuntimeLimits({
model,
activeProfileProvider: getAppliedActiveProfileProvider(),
})
if (runtimeLimits.maxOutputTokens !== undefined) {
return {
default: runtimeLimits.maxOutputTokens,
+1 -1
View File
@@ -176,7 +176,7 @@ export function hookSourceDescriptionDisplayString(source: HookSource): string {
}
const pluginHooksPath = getDisplayPath(
join(getClaudeConfigHomeDir(), 'plugins', '*', 'hooks', 'hooks.json'),
)
).replace(/\\/g, '/')
switch (source) {
case 'userSettings':
+2
View File
@@ -96,6 +96,8 @@ describe('redactUrlForStatus', () => {
describe('redactPathForStatus', () => {
beforeEach(async () => {
await acquireSharedMutationLock('utils/statusRedaction.test.ts')
process.env.HOME = REAL_HOMEDIR
restoreEnvValue('USERPROFILE', ORIGINAL_USERPROFILE)
})
afterEach(() => {