From 455a9967eb8de2c20ad9024429fe932fd8e10468 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sun, 24 May 2026 23:37:06 +0600 Subject: [PATCH 01/27] feat(provider): add OpenCode Zen/Go subscription support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/integrations/brands/opencode.ts | 76 ++ src/integrations/gateways/opencode-go.ts | 48 + .../integrationArtifacts.generated.ts | 41 +- src/integrations/models/opencode.ts | 979 ++++++++++++++++++ src/integrations/vendors/opencode.ts | 88 ++ src/services/api/providerConfig.ts | 2 + src/utils/providerProfile.ts | 30 +- 7 files changed, 1259 insertions(+), 5 deletions(-) create mode 100644 src/integrations/brands/opencode.ts create mode 100644 src/integrations/gateways/opencode-go.ts create mode 100644 src/integrations/models/opencode.ts create mode 100644 src/integrations/vendors/opencode.ts diff --git a/src/integrations/brands/opencode.ts b/src/integrations/brands/opencode.ts new file mode 100644 index 0000000000..c6ac2750e9 --- /dev/null +++ b/src/integrations/brands/opencode.ts @@ -0,0 +1,76 @@ +import { defineBrand } from '../define.js' + +export default defineBrand({ + id: 'opencode', + label: 'OpenCode', + canonicalVendorId: 'opencode', + defaultCapabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + modelIds: [ + // Zen GPT + 'opencode-gpt-5.5', + 'opencode-gpt-5.5-pro', + 'opencode-gpt-5.4', + 'opencode-gpt-5.4-pro', + 'opencode-gpt-5.4-mini', + 'opencode-gpt-5.4-nano', + 'opencode-gpt-5.3-codex', + 'opencode-gpt-5.3-codex-spark', + 'opencode-gpt-5.2', + 'opencode-gpt-5.2-codex', + 'opencode-gpt-5.1', + 'opencode-gpt-5.1-codex', + 'opencode-gpt-5.1-codex-max', + 'opencode-gpt-5.1-codex-mini', + 'opencode-gpt-5', + 'opencode-gpt-5-codex', + 'opencode-gpt-5-nano', + // Zen Claude + 'opencode-claude-opus-4-7', + 'opencode-claude-opus-4-6', + 'opencode-claude-opus-4-5', + 'opencode-claude-opus-4-1', + 'opencode-claude-sonnet-4-6', + 'opencode-claude-sonnet-4-5', + 'opencode-claude-sonnet-4', + 'opencode-claude-haiku-4-5', + 'opencode-claude-3-5-haiku', + // Zen Gemini + 'opencode-gemini-3.5-flash', + 'opencode-gemini-3.1-pro', + 'opencode-gemini-3-flash', + // Zen Qwen + 'opencode-qwen3.6-plus', + 'opencode-qwen3.5-plus', + // Zen others + 'opencode-minimax-m2.7', + 'opencode-minimax-m2.5', + 'opencode-glm-5.1', + 'opencode-glm-5', + 'opencode-kimi-k2.5', + 'opencode-kimi-k2.6', + 'opencode-grok-build-0.1', + 'opencode-big-pickle', + 'opencode-deepseek-v4-flash-free', + 'opencode-nemotron-3-super-free', + // Go + 'opencode-go-glm-5.1', + 'opencode-go-glm-5', + 'opencode-go-kimi-k2.5', + 'opencode-go-kimi-k2.6', + 'opencode-go-deepseek-v4-pro', + 'opencode-go-deepseek-v4-flash', + 'opencode-go-mimo-v2.5', + 'opencode-go-mimo-v2.5-pro', + 'opencode-go-minimax-m2.7', + 'opencode-go-minimax-m2.5', + 'opencode-go-qwen3.6-plus', + 'opencode-go-qwen3.5-plus', + ], +}) diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts new file mode 100644 index 0000000000..0ffc1e6301 --- /dev/null +++ b/src/integrations/gateways/opencode-go.ts @@ -0,0 +1,48 @@ +import { defineGateway } from '../define.js' + +export default defineGateway({ + id: 'opencode-go', + label: 'OpenCode Go', + vendorId: 'opencode', + category: 'hosted', + defaultBaseUrl: 'https://opencode.ai/zen/go/v1', + defaultModel: 'glm-5.1', + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['OPENCODE_API_KEY'], + }, + transportConfig: { + kind: 'openai-compatible', + openaiShim: { + supportsAuthHeaders: true, + }, + }, + preset: { + id: 'opencode-go', + vendorId: 'opencode', + description: 'OpenCode Go — $10/mo subscription for open models (12 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 — /zen/go/v1/messages + { id: 'opencode-go-minimax-m2.7', apiName: 'minimax-m2.7', label: 'MiniMax M2.7', modelDescriptorId: 'opencode-go-minimax-m2.7' }, + { id: 'opencode-go-minimax-m2.5', apiName: 'minimax-m2.5', label: 'MiniMax M2.5', modelDescriptorId: 'opencode-go-minimax-m2.5' }, + { id: 'opencode-go-qwen3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen3.6 Plus', modelDescriptorId: 'opencode-go-qwen3.6-plus' }, + { id: 'opencode-go-qwen3.5-plus', apiName: 'qwen3.5-plus', label: 'Qwen3.5 Plus', modelDescriptorId: 'opencode-go-qwen3.5-plus' }, + ], + }, + usage: { supported: false }, +}) diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 4f064c210d..fe31a1bdb5 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -9,6 +9,7 @@ import vendorGemini from '../vendors/gemini.js' import vendorMinimax from '../vendors/minimax.js' import vendorMoonshot from '../vendors/moonshot.js' import vendorOpenai from '../vendors/openai.js' +import vendorOpencode from '../vendors/opencode.js' import vendorVenice from '../vendors/venice.js' import vendorXai from '../vendors/xai.js' import vendorXiaomiMimo from '../vendors/xiaomi-mimo.js' @@ -28,6 +29,7 @@ import gatewayLmstudio from '../gateways/lmstudio.js' import gatewayMistral from '../gateways/mistral.js' import gatewayNvidiaNim from '../gateways/nvidia-nim.js' import gatewayOllama from '../gateways/ollama.js' +import gatewayOpencodeGo from '../gateways/opencode-go.js' import gatewayOpenrouter from '../gateways/openrouter.js' import gatewayTogether from '../gateways/together.js' import gatewayVertex from '../gateways/vertex.js' @@ -42,6 +44,7 @@ import brandMinimax from '../brands/minimax.js' import brandMistral from '../brands/mistral.js' import brandNemotron from '../brands/nemotron.js' import brandOpenaiCompatibleAlias from '../brands/openai-compatible-alias.js' +import brandOpencode from '../brands/opencode.js' import brandQwen from '../brands/qwen.js' import brandXai from '../brands/xai.js' import brandXiaomiMimo from '../brands/xiaomi-mimo.js' @@ -56,15 +59,16 @@ import modelMinimax from '../models/minimax.js' import modelMistral from '../models/mistral.js' import modelNemotron from '../models/nemotron.js' import modelOpenaiCompatibleAlias from '../models/openai-compatible-alias.js' +import modelOpencode from '../models/opencode.js' import modelQwen from '../models/qwen.js' import modelXai from '../models/xai.js' import modelXiaomiMimo from '../models/xiaomi-mimo.js' -export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorGemini, vendorMinimax, vendorMoonshot, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[] -export const GATEWAY_DESCRIPTORS = [gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpenrouter, gatewayTogether, gatewayVertex] as const satisfies readonly GatewayDescriptor[] +export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorGemini, vendorMinimax, vendorMoonshot, vendorOpenai, vendorOpencode, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[] +export const GATEWAY_DESCRIPTORS = [gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpenrouter, gatewayTogether, gatewayVertex] as const satisfies readonly GatewayDescriptor[] export const ANTHROPIC_PROXY_DESCRIPTORS = [] as const satisfies readonly AnthropicProxyDescriptor[] -export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandGemini, brandGlm, brandGpt, brandKimi, brandLlama, brandMinimax, brandMistral, brandNemotron, brandOpenaiCompatibleAlias, brandQwen, brandXai, brandXiaomiMimo] as const satisfies readonly BrandDescriptor[] -export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelGemini, modelGlm, modelGpt, modelKimi, modelLlama, modelMinimax, modelMistral, modelNemotron, modelOpenaiCompatibleAlias, modelQwen, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[] +export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandGemini, brandGlm, brandGpt, brandKimi, brandLlama, brandMinimax, brandMistral, brandNemotron, brandOpenaiCompatibleAlias, brandOpencode, brandQwen, brandXai, brandXiaomiMimo] as const satisfies readonly BrandDescriptor[] +export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelGemini, modelGlm, modelGpt, modelKimi, modelLlama, modelMinimax, modelMistral, modelNemotron, modelOpenaiCompatibleAlias, modelOpencode, modelQwen, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[] export const MODEL_DESCRIPTORS = MODEL_DESCRIPTOR_GROUPS.flat() satisfies readonly ModelDescriptor[] export const PROVIDER_PRESET_MANIFEST = [ @@ -293,6 +297,33 @@ export const PROVIDER_PRESET_MANIFEST = [ "OPENAI_API_KEY" ] }, + { + "preset": "opencode-go", + "routeKind": "gateway", + "routeId": "opencode-go", + "vendorId": "opencode", + "gatewayId": "opencode-go", + "description": "OpenCode Go — $10/mo subscription for open models (12 models)", + "apiKeyEnvVars": [ + "OPENCODE_API_KEY" + ], + "modelEnvVars": [ + "OPENAI_MODEL" + ] + }, + { + "preset": "opencode", + "routeKind": "vendor", + "routeId": "opencode", + "vendorId": "opencode", + "description": "OpenCode Zen — pay-as-you-go AI gateway (41 models)", + "apiKeyEnvVars": [ + "OPENCODE_API_KEY" + ], + "modelEnvVars": [ + "OPENAI_MODEL" + ] + }, { "preset": "openrouter", "routeKind": "gateway", @@ -414,6 +445,8 @@ export const ORDERED_PROVIDER_PRESETS = [ "kimi-code", "nvidia-nim", "openai", + "opencode-go", + "opencode", "openrouter", "together", "venice", diff --git a/src/integrations/models/opencode.ts b/src/integrations/models/opencode.ts new file mode 100644 index 0000000000..f914bc8318 --- /dev/null +++ b/src/integrations/models/opencode.ts @@ -0,0 +1,979 @@ +import { defineModel } from '../define.js' + +export default [ + // ============================================================ + // ZEN MODELS — https://opencode.ai/zen/v1 + // ============================================================ + + // --- GPT family (responses endpoint) --- + defineModel({ + id: 'opencode-gpt-5.5', + label: 'GPT 5.5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'gpt-5.5', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.5-pro', + label: 'GPT 5.5 Pro', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'gpt-5.5-pro', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.4', + label: 'GPT 5.4', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'gpt-5.4', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.4-pro', + label: 'GPT 5.4 Pro', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'gpt-5.4-pro', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.4-mini', + label: 'GPT 5.4 Mini', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'gpt-5.4-mini', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.4-nano', + label: 'GPT 5.4 Nano', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'gpt-5.4-nano', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 128_000, + maxOutputTokens: 16_384, + }), + defineModel({ + id: 'opencode-gpt-5.3-codex', + label: 'GPT 5.3 Codex', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'coding'], + defaultModel: 'gpt-5.3-codex', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.3-codex-spark', + label: 'GPT 5.3 Codex Spark', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'coding'], + defaultModel: 'gpt-5.3-codex-spark', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 256_000, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-gpt-5.2', + label: 'GPT 5.2', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'gpt-5.2', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.2-codex', + label: 'GPT 5.2 Codex', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'coding'], + defaultModel: 'gpt-5.2-codex', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.1', + label: 'GPT 5.1', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'gpt-5.1', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.1-codex', + label: 'GPT 5.1 Codex', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'coding'], + defaultModel: 'gpt-5.1-codex', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.1-codex-max', + label: 'GPT 5.1 Codex Max', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'coding'], + defaultModel: 'gpt-5.1-codex-max', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5.1-codex-mini', + label: 'GPT 5.1 Codex Mini', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'coding'], + defaultModel: 'gpt-5.1-codex-mini', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 256_000, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-gpt-5', + label: 'GPT 5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'gpt-5', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5-codex', + label: 'GPT 5 Codex', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'coding'], + defaultModel: 'gpt-5-codex', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gpt-5-nano', + label: 'GPT 5 Nano', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'gpt-5-nano', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 128_000, + maxOutputTokens: 16_384, + }), + + // --- Claude family (messages endpoint) --- + defineModel({ + id: 'opencode-claude-opus-4-7', + label: 'Claude Opus 4.7', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'claude-opus-4-7', + 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', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'claude-opus-4-6', + 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-5', + label: 'Claude Opus 4.5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'claude-opus-4-5', + 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-1', + label: 'Claude Opus 4.1', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'claude-opus-4-1', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 200_000, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-claude-sonnet-4-6', + label: 'Claude Sonnet 4.6', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'claude-sonnet-4-6', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 200_000, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-claude-sonnet-4-5', + label: 'Claude Sonnet 4.5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'claude-sonnet-4-5', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 200_000, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-claude-sonnet-4', + label: 'Claude Sonnet 4', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'claude-sonnet-4', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 200_000, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-claude-haiku-4-5', + label: 'Claude Haiku 4.5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'claude-haiku-4-5', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 200_000, + maxOutputTokens: 8_192, + }), + defineModel({ + id: 'opencode-claude-3-5-haiku', + label: 'Claude Haiku 3.5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'claude-3-5-haiku', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 200_000, + maxOutputTokens: 8_192, + }), + + // --- Gemini family (Google AI native endpoint) --- + defineModel({ + id: 'opencode-gemini-3.5-flash', + label: 'Gemini 3.5 Flash', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'gemini-3.5-flash', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gemini-3.1-pro', + label: 'Gemini 3.1 Pro', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'gemini-3.1-pro', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-gemini-3-flash', + label: 'Gemini 3 Flash', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'gemini-3-flash', + capabilities: { + supportsVision: true, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + + // --- Qwen (messages endpoint) --- + defineModel({ + id: 'opencode-qwen3.6-plus', + label: 'Qwen3.6 Plus', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'qwen3.6-plus', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-qwen3.5-plus', + label: 'Qwen3.5 Plus', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'qwen3.5-plus', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + + // --- OpenAI-compatible (chat/completions endpoint) --- + defineModel({ + id: 'opencode-minimax-m2.7', + label: 'MiniMax M2.7', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'minimax-m2.7', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-minimax-m2.5', + label: 'MiniMax M2.5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'minimax-m2.5', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-glm-5.1', + label: 'GLM 5.1', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'glm-5.1', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-glm-5', + label: 'GLM 5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'glm-5', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-kimi-k2.5', + label: 'Kimi K2.5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'kimi-k2.5', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-kimi-k2.6', + label: 'Kimi K2.6', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'kimi-k2.6', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-grok-build-0.1', + label: 'Grok Build 0.1', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'grok-build-0.1', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-big-pickle', + label: 'Big Pickle', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'big-pickle', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-deepseek-v4-flash-free', + label: 'DeepSeek V4 Flash Free', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'deepseek-v4-flash-free', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-nemotron-3-super-free', + label: 'Nemotron 3 Super Free', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'nemotron-3-super-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 + // ============================================================ + + // --- OpenAI-compatible (chat/completions endpoint) --- + defineModel({ + id: 'opencode-go-glm-5.1', + label: 'GLM 5.1', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'glm-5.1', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-go-glm-5', + label: 'GLM 5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'glm-5', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-go-kimi-k2.5', + label: 'Kimi K2.5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'kimi-k2.5', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-go-kimi-k2.6', + label: 'Kimi K2.6', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'kimi-k2.6', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-go-deepseek-v4-pro', + label: 'DeepSeek V4 Pro', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'deepseek-v4-pro', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-go-deepseek-v4-flash', + label: 'DeepSeek V4 Flash', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'deepseek-v4-flash', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }), + defineModel({ + id: 'opencode-go-mimo-v2.5', + label: 'MiMo V2.5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'coding'], + defaultModel: 'mimo-v2.5', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-go-mimo-v2.5-pro', + label: 'MiMo V2.5 Pro', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'coding'], + defaultModel: 'mimo-v2.5-pro', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + + // --- Anthropic messages endpoint --- + defineModel({ + id: 'opencode-go-minimax-m2.7', + label: 'MiniMax M2.7', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'minimax-m2.7', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-go-minimax-m2.5', + label: 'MiniMax M2.5', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat'], + defaultModel: 'minimax-m2.5', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: false, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-go-qwen3.6-plus', + label: 'Qwen3.6 Plus', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'qwen3.6-plus', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), + defineModel({ + id: 'opencode-go-qwen3.5-plus', + label: 'Qwen3.5 Plus', + brandId: 'opencode', + vendorId: 'opencode', + classification: ['chat', 'reasoning'], + defaultModel: 'qwen3.5-plus', + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + supportsPreciseTokenCount: false, + }, + contextWindow: 131_072, + maxOutputTokens: 32_768, + }), +] diff --git a/src/integrations/vendors/opencode.ts b/src/integrations/vendors/opencode.ts new file mode 100644 index 0000000000..d2380bfe27 --- /dev/null +++ b/src/integrations/vendors/opencode.ts @@ -0,0 +1,88 @@ +import { defineVendor } from '../define.js' + +export default defineVendor({ + id: 'opencode', + label: 'OpenCode', + classification: 'openai-compatible', + defaultBaseUrl: 'https://opencode.ai/zen/v1', + defaultModel: 'gpt-5.4', + requiredEnvVars: ['OPENCODE_API_KEY'], + setup: { + requiresAuth: true, + authMode: 'api-key', + credentialEnvVars: ['OPENCODE_API_KEY'], + }, + transportConfig: { + kind: 'openai-compatible', + openaiShim: { + supportsAuthHeaders: true, + }, + }, + preset: { + id: 'opencode', + description: 'OpenCode Zen — pay-as-you-go AI gateway (41 models)', + apiKeyEnvVars: ['OPENCODE_API_KEY'], + modelEnvVars: ['OPENAI_MODEL'], + }, + validation: { + kind: 'credential-env', + routing: { + matchDefaultBaseUrl: true, + }, + credentialEnvVars: ['OPENCODE_API_KEY'], + missingCredentialMessage: + 'OPENCODE_API_KEY is required. Get your API key from https://opencode.ai', + }, + 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' }, + { id: 'gpt-5.5-pro', apiName: 'gpt-5.5-pro', label: 'GPT 5.5 Pro', modelDescriptorId: 'opencode-gpt-5.5-pro' }, + { id: 'gpt-5.4', apiName: 'gpt-5.4', label: 'GPT 5.4', modelDescriptorId: 'opencode-gpt-5.4' }, + { id: 'gpt-5.4-pro', apiName: 'gpt-5.4-pro', label: 'GPT 5.4 Pro', modelDescriptorId: 'opencode-gpt-5.4-pro' }, + { id: 'gpt-5.4-mini', apiName: 'gpt-5.4-mini', label: 'GPT 5.4 Mini', modelDescriptorId: 'opencode-gpt-5.4-mini' }, + { id: 'gpt-5.4-nano', apiName: 'gpt-5.4-nano', label: 'GPT 5.4 Nano', modelDescriptorId: 'opencode-gpt-5.4-nano' }, + { id: 'gpt-5.3-codex', apiName: 'gpt-5.3-codex', label: 'GPT 5.3 Codex', modelDescriptorId: 'opencode-gpt-5.3-codex' }, + { 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' }, + { id: 'gpt-5.2', apiName: 'gpt-5.2', label: 'GPT 5.2', modelDescriptorId: 'opencode-gpt-5.2' }, + { id: 'gpt-5.2-codex', apiName: 'gpt-5.2-codex', label: 'GPT 5.2 Codex', modelDescriptorId: 'opencode-gpt-5.2-codex' }, + { id: 'gpt-5.1', apiName: 'gpt-5.1', label: 'GPT 5.1', modelDescriptorId: 'opencode-gpt-5.1' }, + { id: 'gpt-5.1-codex', apiName: 'gpt-5.1-codex', label: 'GPT 5.1 Codex', modelDescriptorId: 'opencode-gpt-5.1-codex' }, + { 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' }, + { 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' }, + { id: 'gpt-5', apiName: 'gpt-5', label: 'GPT 5', modelDescriptorId: 'opencode-gpt-5' }, + { id: 'gpt-5-codex', apiName: 'gpt-5-codex', label: 'GPT 5 Codex', modelDescriptorId: 'opencode-gpt-5-codex' }, + { id: 'gpt-5-nano', apiName: 'gpt-5-nano', label: 'GPT 5 Nano', modelDescriptorId: 'opencode-gpt-5-nano' }, + // 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' }, + { id: 'claude-opus-4-6', apiName: 'claude-opus-4-6', label: 'Claude Opus 4.6', modelDescriptorId: 'opencode-claude-opus-4-6' }, + { id: 'claude-opus-4-5', apiName: 'claude-opus-4-5', label: 'Claude Opus 4.5', modelDescriptorId: 'opencode-claude-opus-4-5' }, + { id: 'claude-opus-4-1', apiName: 'claude-opus-4-1', label: 'Claude Opus 4.1', modelDescriptorId: 'opencode-claude-opus-4-1' }, + { id: 'claude-sonnet-4-6', apiName: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', modelDescriptorId: 'opencode-claude-sonnet-4-6' }, + { id: 'claude-sonnet-4-5', apiName: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', modelDescriptorId: 'opencode-claude-sonnet-4-5' }, + { id: 'claude-sonnet-4', apiName: 'claude-sonnet-4', label: 'Claude Sonnet 4', modelDescriptorId: 'opencode-claude-sonnet-4' }, + { id: 'claude-haiku-4-5', apiName: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', modelDescriptorId: 'opencode-claude-haiku-4-5' }, + { id: 'claude-3-5-haiku', apiName: 'claude-3-5-haiku', label: 'Claude Haiku 3.5', modelDescriptorId: 'opencode-claude-3-5-haiku' }, + // Gemini family — /zen/v1/models/ + { id: 'gemini-3.5-flash', apiName: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', modelDescriptorId: 'opencode-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' }, + { id: 'gemini-3-flash', apiName: 'gemini-3-flash', label: 'Gemini 3 Flash', modelDescriptorId: 'opencode-gemini-3-flash' }, + // Qwen — /zen/v1/messages + { id: 'qwen3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen3.6 Plus', modelDescriptorId: 'opencode-qwen3.6-plus' }, + { id: 'qwen3.5-plus', apiName: 'qwen3.5-plus', label: 'Qwen3.5 Plus', modelDescriptorId: 'opencode-qwen3.5-plus' }, + // OpenAI-compatible — /zen/v1/chat/completions + { 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' }, + ], + }, + usage: { supported: false }, +}) diff --git a/src/services/api/providerConfig.ts b/src/services/api/providerConfig.ts index e8420613a3..646ce25626 100644 --- a/src/services/api/providerConfig.ts +++ b/src/services/api/providerConfig.ts @@ -27,6 +27,8 @@ import { export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex' export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1' +export const DEFAULT_OPENCODE_BASE_URL = 'https://opencode.ai/zen/v1' +export const DEFAULT_OPENCODE_GO_BASE_URL = 'https://opencode.ai/zen/go/v1' /** Default GitHub Copilot API model when user selects copilot / github:copilot */ export const DEFAULT_GITHUB_MODELS_API_MODEL = 'gpt-4o' const warnedUndefinedEnvNames = new Set() diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 43cf6f28bc..b2e66dc523 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'node:path' import { DEFAULT_CODEX_BASE_URL, DEFAULT_OPENAI_BASE_URL, + DEFAULT_OPENCODE_BASE_URL, isCodexBaseUrl, parseOpenAICompatibleApiFormat, resolveCodexApiCredentials, @@ -94,6 +95,7 @@ const PROFILE_ENV_KEYS = [ 'XAI_CREDENTIAL_SOURCE', 'VENICE_API_KEY', 'MIMO_API_KEY', + 'OPENCODE_API_KEY', ] as const export type CompatibilityProfileMode = @@ -118,6 +120,7 @@ const SECRET_ENV_KEYS = [ 'XAI_API_KEY', 'VENICE_API_KEY', 'MIMO_API_KEY', + 'OPENCODE_API_KEY', ] as const export type ProviderProfile = @@ -134,6 +137,7 @@ export type ProviderProfile = | 'bedrock' | 'vertex' | 'xai' + | 'opencode' export type ProfileEnv = { ANTHROPIC_BASE_URL?: string @@ -175,6 +179,7 @@ export type ProfileEnv = { XAI_CREDENTIAL_SOURCE?: 'oauth' VENICE_API_KEY?: string MIMO_API_KEY?: string + OPENCODE_API_KEY?: string } export type ProfileFile = { @@ -311,7 +316,8 @@ export function isProviderProfile(value: unknown): value is ProviderProfile { value === 'github' || value === 'bedrock' || value === 'vertex' || - value === 'xai' + value === 'xai' || + value === 'opencode' ) } @@ -1398,6 +1404,28 @@ export async function buildLaunchEnv(options: { return result } + if (options.profile === 'opencode') { + const opencodeKey = + sanitizeApiKey(processEnv.OPENCODE_API_KEY) || + sanitizeApiKey(persistedEnv.OPENCODE_API_KEY) + const opencodeBaseUrl = + sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL) || + sanitizeProviderConfigValue(persistedEnv.OPENAI_BASE_URL) || + DEFAULT_OPENCODE_BASE_URL + const opencodeModel = + shellOpenAIModel || persistedOpenAIModel || 'gpt-5.4' + + return buildCompatibilityProcessEnv({ + processEnv, + compatibilityMode: 'openai', + profileEnv: { + OPENAI_BASE_URL: opencodeBaseUrl, + OPENAI_MODEL: opencodeModel, + ...(opencodeKey ? { OPENAI_API_KEY: opencodeKey } : {}), + }, + }) + } + if (options.profile === 'ollama') { const getOllamaBaseUrl = options.getOllamaChatBaseUrl ?? (() => 'http://localhost:11434/v1') From f190744bbd7f9d19b051ae75bcb3165c8ab1ec85 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sun, 24 May 2026 23:45:13 +0600 Subject: [PATCH 02/27] 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). --- src/components/ProviderManager.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index 8f00333ade..dd38c55351 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -248,6 +248,24 @@ function getPresetLabel(preset: ProviderPreset, label: string): React.ReactNode ) } + if (preset === 'opencode') { + return ( + + {label} + [Zen] + + ) + } + + if (preset === 'opencode-go') { + return ( + + {label} + [Go] + + ) + } + return label } From 1b31f2787f1378fde5a7878804f63fa1c205951e Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sun, 24 May 2026 23:55:13 +0600 Subject: [PATCH 03/27] 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. --- src/integrations/gateways/opencode-go.ts | 6 +++++- src/integrations/vendors/opencode.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts index 0ffc1e6301..9ebe8e4420 100644 --- a/src/integrations/gateways/opencode-go.ts +++ b/src/integrations/gateways/opencode-go.ts @@ -26,7 +26,11 @@ export default defineGateway({ modelEnvVars: ['OPENAI_MODEL'], }, catalog: { - source: 'static', + source: 'hybrid', + discovery: { kind: 'openai-compatible' }, + discoveryCacheTtl: '1h', + discoveryRefreshMode: 'startup', + allowManualRefresh: true, 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' }, diff --git a/src/integrations/vendors/opencode.ts b/src/integrations/vendors/opencode.ts index d2380bfe27..0e754ef8a1 100644 --- a/src/integrations/vendors/opencode.ts +++ b/src/integrations/vendors/opencode.ts @@ -34,7 +34,11 @@ export default defineVendor({ 'OPENCODE_API_KEY is required. Get your API key from https://opencode.ai', }, catalog: { - source: 'static', + source: 'hybrid', + discovery: { kind: 'openai-compatible' }, + discoveryCacheTtl: '1h', + discoveryRefreshMode: 'startup', + allowManualRefresh: true, models: [ // GPT family — /zen/v1/responses { id: 'gpt-5.5', apiName: 'gpt-5.5', label: 'GPT 5.5', modelDescriptorId: 'opencode-gpt-5.5' }, From 33ea3516a1e9f5bd3cd1cdf93e938e70cee21e29 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 25 May 2026 00:44:37 +0600 Subject: [PATCH 04/27] test(provider): add comprehensive OpenCode Zen/Go test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/integrations/vendors/opencode.test.ts | 558 ++++++++++++++++++++++ src/utils/opencodeProfile.test.ts | 398 +++++++++++++++ 2 files changed, 956 insertions(+) create mode 100644 src/integrations/vendors/opencode.test.ts create mode 100644 src/utils/opencodeProfile.test.ts diff --git a/src/integrations/vendors/opencode.test.ts b/src/integrations/vendors/opencode.test.ts new file mode 100644 index 0000000000..7e05d8c09d --- /dev/null +++ b/src/integrations/vendors/opencode.test.ts @@ -0,0 +1,558 @@ +// src/integrations/vendors/opencode.test.ts + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { ensureIntegrationsLoaded } from '../index.js' +import { + _clearRegistryForTesting, + getVendor, + getGateway, + getBrand, + getModelsForBrand, + getModelsForGateway, + getCatalogEntriesForRoute, + validateIntegrationRegistry, +} from '../registry.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' + +beforeEach(async () => { + await acquireSharedMutationLock('integrations/vendors/opencode.test.ts') + _clearRegistryForTesting() + ensureIntegrationsLoaded() +}) + +afterEach(() => { + try { + _clearRegistryForTesting() + ensureIntegrationsLoaded() + } finally { + releaseSharedMutationLock() + } +}) + +// --------------------------------------------------------------------------- +// Vendor Descriptor Tests +// --------------------------------------------------------------------------- + +describe('OpenCode vendor descriptor', () => { + test('is registered with correct id', () => { + const vendor = getVendor('opencode') + expect(vendor).not.toBeNull() + expect(vendor!.id).toBe('opencode') + }) + + test('has correct label', () => { + const vendor = getVendor('opencode') + expect(vendor!.label).toBe('OpenCode') + }) + + test('has openai-compatible classification', () => { + const vendor = getVendor('opencode') + expect(vendor!.classification).toBe('openai-compatible') + }) + + test('has correct default base URL', () => { + const vendor = getVendor('opencode') + expect(vendor!.defaultBaseUrl).toBe('https://opencode.ai/zen/v1') + }) + + test('has correct default model', () => { + const vendor = getVendor('opencode') + expect(vendor!.defaultModel).toBe('gpt-5.4') + }) + + test('requires auth', () => { + const vendor = getVendor('opencode') + expect(vendor!.setup.requiresAuth).toBe(true) + }) + + test('uses api-key auth mode', () => { + const vendor = getVendor('opencode') + expect(vendor!.setup.authMode).toBe('api-key') + }) + + test('has OPENCODE_API_KEY in credential env vars', () => { + const vendor = getVendor('opencode') + expect(vendor!.setup.credentialEnvVars).toContain('OPENCODE_API_KEY') + }) + + test('has openai-compatible transport kind', () => { + const vendor = getVendor('opencode') + expect(vendor!.transportConfig.kind).toBe('openai-compatible') + }) + + test('has preset metadata', () => { + const vendor = getVendor('opencode') + expect(vendor!.preset).toBeDefined() + expect(vendor!.preset!.id).toBe('opencode') + expect(vendor!.preset!.apiKeyEnvVars).toContain('OPENCODE_API_KEY') + }) + + test('has validation metadata', () => { + const vendor = getVendor('opencode') + expect(vendor!.validation).toBeDefined() + expect(vendor!.validation!.kind).toBe('credential-env') + }) + + test('has catalog with static + hybrid source', () => { + const vendor = getVendor('opencode') + expect(vendor!.catalog).toBeDefined() + expect(vendor!.catalog!.source).toBe('hybrid') + }) + + test('has discovery config', () => { + const vendor = getVendor('opencode') + expect(vendor!.catalog!.discovery).toBeDefined() + expect(vendor!.catalog!.discovery!.kind).toBe('openai-compatible') + }) + + test('has static models in catalog', () => { + const vendor = getVendor('opencode') + expect(vendor!.catalog!.models).toBeDefined() + expect(vendor!.catalog!.models!.length).toBeGreaterThan(0) + }) + + test('has usage metadata', () => { + const vendor = getVendor('opencode') + expect(vendor!.usage).toBeDefined() + expect(vendor!.usage!.supported).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Gateway Descriptor Tests +// --------------------------------------------------------------------------- + +describe('OpenCode Go gateway descriptor', () => { + test('is registered with correct id', () => { + const gateway = getGateway('opencode-go') + expect(gateway).not.toBeNull() + expect(gateway!.id).toBe('opencode-go') + }) + + test('has correct label', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.label).toBe('OpenCode Go') + }) + + test('has correct vendor id', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.vendorId).toBe('opencode') + }) + + test('has hosted category', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.category).toBe('hosted') + }) + + test('has correct default base URL', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.defaultBaseUrl).toBe('https://opencode.ai/zen/go/v1') + }) + + test('has correct default model', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.defaultModel).toBe('glm-5.1') + }) + + test('requires auth', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.setup.requiresAuth).toBe(true) + }) + + test('uses api-key auth mode', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.setup.authMode).toBe('api-key') + }) + + test('has OPENCODE_API_KEY in credential env vars', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.setup.credentialEnvVars).toContain('OPENCODE_API_KEY') + }) + + test('has openai-compatible transport kind', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.transportConfig.kind).toBe('openai-compatible') + }) + + test('has preset metadata with vendor id', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.preset).toBeDefined() + expect(gateway!.preset!.id).toBe('opencode-go') + expect(gateway!.preset!.vendorId).toBe('opencode') + expect(gateway!.preset!.apiKeyEnvVars).toContain('OPENCODE_API_KEY') + }) + + test('has catalog with hybrid source', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.catalog).toBeDefined() + expect(gateway!.catalog!.source).toBe('hybrid') + }) + + test('has discovery config', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.catalog!.discovery).toBeDefined() + expect(gateway!.catalog!.discovery!.kind).toBe('openai-compatible') + }) + + test('has static models in catalog', () => { + const gateway = getGateway('opencode-go') + expect(gateway!.catalog!.models).toBeDefined() + expect(gateway!.catalog!.models!.length).toBeGreaterThan(0) + }) +}) + +// --------------------------------------------------------------------------- +// Brand Descriptor Tests +// --------------------------------------------------------------------------- + +describe('OpenCode brand descriptor', () => { + test('is registered with correct id', () => { + const brand = getBrand('opencode') + expect(brand).not.toBeNull() + expect(brand!.id).toBe('opencode') + }) + + test('has correct label', () => { + const brand = getBrand('opencode') + expect(brand!.label).toBe('OpenCode') + }) + + test('has correct canonical vendor id', () => { + const brand = getBrand('opencode') + expect(brand!.canonicalVendorId).toBe('opencode') + }) + + test('has default capabilities', () => { + const brand = getBrand('opencode') + expect(brand!.defaultCapabilities).toBeDefined() + expect(brand!.defaultCapabilities.supportsVision).toBe(true) + expect(brand!.defaultCapabilities.supportsStreaming).toBe(true) + expect(brand!.defaultCapabilities.supportsFunctionCalling).toBe(true) + expect(brand!.defaultCapabilities.supportsJsonMode).toBe(true) + }) + + test('has model ids list', () => { + const brand = getBrand('opencode') + expect(brand!.modelIds).toBeDefined() + expect(brand!.modelIds!.length).toBeGreaterThan(0) + }) + + test('model ids list contains both zen and go models', () => { + const brand = getBrand('opencode') + const hasZenModels = brand!.modelIds!.some(id => id.startsWith('opencode-gpt')) + const hasGoModels = brand!.modelIds!.some(id => id.startsWith('opencode-go-')) + expect(hasZenModels).toBe(true) + expect(hasGoModels).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// Model Catalog Tests +// --------------------------------------------------------------------------- + +describe('OpenCode model catalog', () => { + test('brand has models registered', () => { + const models = getModelsForBrand('opencode') + expect(models.length).toBeGreaterThan(0) + }) + + test('gateway has models registered', () => { + const models = getModelsForGateway('opencode-go') + expect(models.length).toBeGreaterThan(0) + }) + + test('zen models have correct vendor id', () => { + const models = getModelsForBrand('opencode') + const zenModels = models.filter(m => m.id.startsWith('opencode-gpt') || m.id.startsWith('opencode-claude') || m.id.startsWith('opencode-gemini')) + for (const model of zenModels) { + expect(model.vendorId).toBe('opencode') + } + }) + + test('go models have correct vendor id', () => { + const models = getModelsForGateway('opencode-go') + for (const model of models) { + expect(model.vendorId).toBe('opencode') + } + }) + + test('all models have required fields', () => { + const models = getModelsForBrand('opencode') + for (const model of models) { + expect(model.id).toBeDefined() + expect(model.label).toBeDefined() + expect(model.vendorId).toBeDefined() + expect(model.classification).toBeDefined() + expect(model.defaultModel).toBeDefined() + expect(model.capabilities).toBeDefined() + } + }) + + test('all models have valid classification', () => { + const models = getModelsForBrand('opencode') + const validClassifications = ['chat', 'reasoning', 'vision', 'coding'] + for (const model of models) { + expect(model.classification.length).toBeGreaterThan(0) + for (const c of model.classification) { + expect(validClassifications).toContain(c) + } + } + }) + + test('zen gpt models have correct classification', () => { + const models = getModelsForBrand('opencode') + const gptModels = models.filter(m => m.id.startsWith('opencode-gpt-')) + for (const model of gptModels) { + expect(model.classification).toContain('chat') + } + }) + + test('zen claude models have correct classification', () => { + const models = getModelsForBrand('opencode') + const claudeModels = models.filter(m => m.id.startsWith('opencode-claude-')) + for (const model of claudeModels) { + expect(model.classification).toContain('chat') + } + }) + + test('codex models have coding classification', () => { + const models = getModelsForBrand('opencode') + const codexModels = models.filter(m => m.defaultModel.includes('codex')) + for (const model of codexModels) { + expect(model.classification).toContain('coding') + } + }) + + test('reasoning models have reasoning classification', () => { + const models = getModelsForBrand('opencode') + // Only check models that are explicitly reasoning models + const reasoningModels = models.filter(m => + m.defaultModel.includes('opus') || + m.defaultModel === 'gpt-5.5-pro' || + m.defaultModel === 'gpt-5.4-pro' || + m.defaultModel === 'deepseek-v4-pro' || + m.defaultModel === 'gemini-3.1-pro' + ) + for (const model of reasoningModels) { + expect(model.classification).toContain('reasoning') + } + }) + + test('no duplicate model ids', () => { + const models = getModelsForBrand('opencode') + const ids = models.map(m => m.id) + const uniqueIds = new Set(ids) + expect(ids.length).toBe(uniqueIds.size) + }) + + test('no duplicate model ids across zen and go', () => { + const zenModels = getCatalogEntriesForRoute('opencode') + const goModels = getCatalogEntriesForRoute('opencode-go') + const zenIds = new Set(zenModels.map(m => m.id)) + const goIds = new Set(goModels.map(m => m.id)) + for (const id of goIds) { + expect(zenIds.has(id)).toBe(false) + } + }) + + test('zen model count matches expected', () => { + const models = getCatalogEntriesForRoute('opencode') + // 17 GPT + 9 Claude + 3 Gemini + 2 Qwen + 10 others = 41 + expect(models.length).toBe(41) + }) + + test('go model count matches expected', () => { + const models = getCatalogEntriesForRoute('opencode-go') + // 8 chat/completions + 4 messages = 12 + expect(models.length).toBe(12) + }) + + test('all zen gpt models have modelDescriptorId', () => { + const models = getCatalogEntriesForRoute('opencode') + const gptModels = models.filter(m => m.apiName.startsWith('gpt-')) + for (const model of gptModels) { + expect(model.modelDescriptorId).toBeDefined() + expect(model.modelDescriptorId).toMatch(/^opencode-gpt-/) + } + }) + + test('all zen claude models have modelDescriptorId', () => { + const models = getCatalogEntriesForRoute('opencode') + const claudeModels = models.filter(m => m.apiName.startsWith('claude-')) + for (const model of claudeModels) { + expect(model.modelDescriptorId).toBeDefined() + expect(model.modelDescriptorId).toMatch(/^opencode-claude-/) + } + }) + + test('all go models have modelDescriptorId', () => { + const models = getCatalogEntriesForRoute('opencode-go') + for (const model of models) { + expect(model.modelDescriptorId).toBeDefined() + expect(model.modelDescriptorId).toMatch(/^opencode-go-/) + } + }) +}) + +// --------------------------------------------------------------------------- +// Cross-Reference Tests +// --------------------------------------------------------------------------- + +describe('OpenCode cross-reference consistency', () => { + test('brand model ids match actual model descriptors', () => { + const brand = getBrand('opencode') + const models = getModelsForBrand('opencode') + const modelIds = new Set(models.map(m => m.id)) + for (const brandModelId of brand!.modelIds!) { + expect(modelIds.has(brandModelId)).toBe(true) + } + }) + + test('vendor catalog modelDescriptorIds match actual model descriptors', () => { + const vendor = getVendor('opencode') + const models = getModelsForBrand('opencode') + const modelIds = new Set(models.map(m => m.id)) + for (const entry of vendor!.catalog!.models!) { + if (entry.modelDescriptorId) { + expect(modelIds.has(entry.modelDescriptorId)).toBe(true) + } + } + }) + + test('gateway catalog modelDescriptorIds match actual model descriptors', () => { + const gateway = getGateway('opencode-go') + const models = getModelsForGateway('opencode-go') + const modelIds = new Set(models.map(m => m.id)) + for (const entry of gateway!.catalog!.models!) { + if (entry.modelDescriptorId) { + expect(modelIds.has(entry.modelDescriptorId)).toBe(true) + } + } + }) + + test('vendor and gateway share the same OPENCODE_API_KEY', () => { + const vendor = getVendor('opencode') + const gateway = getGateway('opencode-go') + expect(vendor!.setup.credentialEnvVars).toEqual(gateway!.setup.credentialEnvVars) + }) +}) + +// --------------------------------------------------------------------------- +// Validation Registry Tests +// --------------------------------------------------------------------------- + +describe('OpenCode integration validation', () => { + test('registry validation passes with opencode descriptors', () => { + const result = validateIntegrationRegistry() + const opencodeErrors = result.errors.filter(e => e.includes('opencode')) + expect(opencodeErrors).toHaveLength(0) + }) + + test('no preset id conflicts', () => { + const result = validateIntegrationRegistry() + const presetErrors = result.errors.filter(e => e.includes('preset')) + expect(presetErrors).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// Edge Cases +// --------------------------------------------------------------------------- + +describe('OpenCode edge cases', () => { + test('vendor catalog entries have unique ids', () => { + const vendor = getVendor('opencode') + const ids = vendor!.catalog!.models!.map(m => m.id) + const uniqueIds = new Set(ids) + expect(ids.length).toBe(uniqueIds.size) + }) + + test('gateway catalog entries have unique ids', () => { + const gateway = getGateway('opencode-go') + const ids = gateway!.catalog!.models!.map(m => m.id) + const uniqueIds = new Set(ids) + expect(ids.length).toBe(uniqueIds.size) + }) + + test('vendor catalog entries have unique apiNames', () => { + const vendor = getVendor('opencode') + const apiNames = vendor!.catalog!.models!.map(m => m.apiName) + const uniqueApiNames = new Set(apiNames) + expect(apiNames.length).toBe(uniqueApiNames.size) + }) + + test('gateway catalog entries have unique apiNames', () => { + const gateway = getGateway('opencode-go') + const apiNames = gateway!.catalog!.models!.map(m => m.apiName) + const uniqueApiNames = new Set(apiNames) + expect(apiNames.length).toBe(uniqueApiNames.size) + }) + + test('vendor catalog entries have non-empty labels', () => { + const vendor = getVendor('opencode') + for (const entry of vendor!.catalog!.models!) { + expect(entry.label.length).toBeGreaterThan(0) + } + }) + + test('gateway catalog entries have non-empty labels', () => { + const gateway = getGateway('opencode-go') + for (const entry of gateway!.catalog!.models!) { + expect(entry.label.length).toBeGreaterThan(0) + } + }) + + test('model descriptors have non-empty contextWindow', () => { + const models = getModelsForBrand('opencode') + for (const model of models) { + if (model.contextWindow !== undefined) { + expect(model.contextWindow).toBeGreaterThan(0) + } + } + }) + + test('model descriptors have non-empty maxOutputTokens', () => { + const models = getModelsForBrand('opencode') + for (const model of models) { + if (model.maxOutputTokens !== undefined) { + expect(model.maxOutputTokens).toBeGreaterThan(0) + } + } + }) + + test('model descriptors have valid defaultModel format', () => { + const models = getModelsForBrand('opencode') + for (const model of models) { + // defaultModel should not contain spaces or special chars + expect(model.defaultModel).toMatch(/^[a-z0-9\-\.]+$/) + } + }) + + test('vendor validation message mentions OPENCODE_API_KEY', () => { + const vendor = getVendor('opencode') + expect(vendor!.validation!.missingCredentialMessage).toContain('OPENCODE_API_KEY') + }) + + test('vendor validation message mentions opencode.ai', () => { + const vendor = getVendor('opencode') + expect(vendor!.validation!.missingCredentialMessage).toContain('opencode.ai') + }) + + test('discovery cache ttl is set', () => { + const vendor = getVendor('opencode') + expect(vendor!.catalog!.discoveryCacheTtl).toBeDefined() + }) + + test('discovery refresh mode is set', () => { + const vendor = getVendor('opencode') + expect(vendor!.catalog!.discoveryRefreshMode).toBeDefined() + }) + + test('manual refresh is allowed', () => { + const vendor = getVendor('opencode') + expect(vendor!.catalog!.allowManualRefresh).toBe(true) + }) +}) diff --git a/src/utils/opencodeProfile.test.ts b/src/utils/opencodeProfile.test.ts new file mode 100644 index 0000000000..cb3e688a74 --- /dev/null +++ b/src/utils/opencodeProfile.test.ts @@ -0,0 +1,398 @@ +// src/utils/opencodeProfile.test.ts + +import assert from 'node:assert/strict' +import test, { afterEach, beforeEach } from 'node:test' + +import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js' +import { DEFAULT_OPENCODE_BASE_URL, DEFAULT_OPENCODE_GO_BASE_URL } from '../services/api/providerConfig.js' +import { + buildLaunchEnv, + isProviderProfile, + type ProfileFile, +} from './providerProfile.js' + +function profile(profile: ProfileFile['profile'], env: ProfileFile['env']): ProfileFile { + return { + profile, + env, + createdAt: '2026-05-25T00:00:00.000Z', + } +} + +beforeEach(async () => { + await acquireEnvMutex() +}) + +afterEach(() => { + releaseEnvMutex() +}) + +// --------------------------------------------------------------------------- +// Type Guard Tests +// --------------------------------------------------------------------------- + +test('isProviderProfile recognizes opencode', () => { + assert.equal(isProviderProfile('opencode'), true) +}) + +test('isProviderProfile rejects invalid values', () => { + assert.equal(isProviderProfile('invalid'), false) + assert.equal(isProviderProfile(''), false) + assert.equal(isProviderProfile('OPENCODE'), false) +}) + +// --------------------------------------------------------------------------- +// buildLaunchEnv Tests +// --------------------------------------------------------------------------- + +test('opencode profile sets OPENAI_BASE_URL from persisted env', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_BASE_URL: 'https://opencode.ai/zen/v1', + OPENAI_MODEL: 'gpt-5.4', + OPENCODE_API_KEY: 'sk-test-key', + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_BASE_URL, 'https://opencode.ai/zen/v1') + assert.equal(env.OPENAI_MODEL, 'gpt-5.4') + assert.equal(env.OPENAI_API_KEY, 'sk-test-key') +}) + +test('opencode profile uses default base URL when not persisted', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', {}), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_BASE_URL, DEFAULT_OPENCODE_BASE_URL) +}) + +test('opencode profile uses default model when not persisted', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', {}), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_MODEL, 'gpt-5.4') +}) + +test('opencode profile prefers process env over persisted env', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_BASE_URL: 'https://old-url.com/v1', + OPENAI_MODEL: 'old-model', + OPENCODE_API_KEY: 'sk-old-key', + }), + goal: 'balanced', + processEnv: { + OPENAI_BASE_URL: 'https://new-url.com/v1', + OPENAI_MODEL: 'new-model', + OPENCODE_API_KEY: 'sk-new-key', + }, + }) + + assert.equal(env.OPENAI_BASE_URL, 'https://new-url.com/v1') + assert.equal(env.OPENAI_MODEL, 'new-model') + assert.equal(env.OPENAI_API_KEY, 'sk-new-key') +}) + +test('opencode profile uses OPENCODE_API_KEY when OPENAI_API_KEY not set', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENCODE_API_KEY: 'sk-opencode-key', + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_API_KEY, 'sk-opencode-key') +}) + +test('opencode profile prefers OPENCODE_API_KEY from process env over persisted', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENCODE_API_KEY: 'sk-persisted-key', + }), + goal: 'balanced', + processEnv: { + OPENCODE_API_KEY: 'sk-process-key', + }, + }) + + assert.equal(env.OPENAI_API_KEY, 'sk-process-key') +}) + +test('opencode profile handles empty api key gracefully', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', {}), + goal: 'balanced', + processEnv: {}, + }) + + // Should not crash, OPENAI_API_KEY may be undefined + assert.ok(env.OPENAI_BASE_URL) + assert.ok(env.OPENAI_MODEL) +}) + +test('opencode profile handles whitespace-only api key', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENCODE_API_KEY: ' ', + }), + goal: 'balanced', + processEnv: {}, + }) + + // Whitespace-only key should be treated as empty + assert.ok(env.OPENAI_BASE_URL) +}) + +test('opencode profile handles undefined values in persisted env', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_BASE_URL: undefined, + OPENAI_MODEL: undefined, + OPENCODE_API_KEY: undefined, + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_BASE_URL, DEFAULT_OPENCODE_BASE_URL) + assert.equal(env.OPENAI_MODEL, 'gpt-5.4') +}) + +test('opencode profile handles null values in persisted env', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_BASE_URL: null as unknown as string, + OPENAI_MODEL: null as unknown as string, + OPENCODE_API_KEY: null as unknown as string, + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_BASE_URL, DEFAULT_OPENCODE_BASE_URL) + assert.equal(env.OPENAI_MODEL, 'gpt-5.4') +}) + +// --------------------------------------------------------------------------- +// Edge Cases +// --------------------------------------------------------------------------- + +test('opencode profile handles very long api key', async () => { + const longKey = 'sk-' + 'a'.repeat(1000) + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENCODE_API_KEY: longKey, + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_API_KEY, longKey) +}) + +test('opencode profile handles special characters in api key', async () => { + const specialKey = 'sk-test-key_with.special-chars@123' + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENCODE_API_KEY: specialKey, + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_API_KEY, specialKey) +}) + +test('opencode profile handles unicode in model name', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_MODEL: 'gpt-5.4', + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_MODEL, 'gpt-5.4') +}) + +test('opencode profile handles concurrent access', async () => { + // Simulate concurrent profile builds + const promises = Array.from({ length: 10 }, (_, i) => + buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENCODE_API_KEY: `sk-key-${i}`, + }), + goal: 'balanced', + processEnv: {}, + }) + ) + + const results = await Promise.all(promises) + for (let i = 0; i < results.length; i++) { + assert.equal(results[i].OPENAI_API_KEY, `sk-key-${i}`) + } +}) + +test('opencode profile handles empty string values', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_BASE_URL: '', + OPENAI_MODEL: '', + OPENCODE_API_KEY: '', + }), + goal: 'balanced', + processEnv: {}, + }) + + // Empty strings should fall back to defaults + assert.equal(env.OPENAI_BASE_URL, DEFAULT_OPENCODE_BASE_URL) + assert.equal(env.OPENAI_MODEL, 'gpt-5.4') +}) + +test('opencode profile handles process env with empty strings', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', {}), + goal: 'balanced', + processEnv: { + OPENAI_BASE_URL: '', + OPENAI_MODEL: '', + OPENAI_API_KEY: '', + }, + }) + + // Empty strings in process env should fall back to defaults + assert.equal(env.OPENAI_BASE_URL, DEFAULT_OPENCODE_BASE_URL) + assert.equal(env.OPENAI_MODEL, 'gpt-5.4') +}) + +// --------------------------------------------------------------------------- +// Boundary Tests +// --------------------------------------------------------------------------- + +test('opencode profile handles minimum valid api key', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENCODE_API_KEY: 'sk-', + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_API_KEY, 'sk-') +}) + +test('opencode profile handles maximum length model name', async () => { + const longModel = 'a'.repeat(256) + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_MODEL: longModel, + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_MODEL, longModel) +}) + +test('opencode profile handles maximum length base url', async () => { + const longUrl = 'https://example.com/' + 'a'.repeat(256) + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_BASE_URL: longUrl, + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.OPENAI_BASE_URL, longUrl) +}) + +// --------------------------------------------------------------------------- +// Negative Tests +// --------------------------------------------------------------------------- + +test('opencode profile does not set OPENCODE_API_KEY in output', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENCODE_API_KEY: 'sk-opencode-key', + }), + goal: 'balanced', + processEnv: {}, + }) + + // OPENCODE_API_KEY should be mapped to OPENAI_API_KEY, not kept as-is + assert.equal(env.OPENCODE_API_KEY, undefined) +}) + +test('opencode profile does not leak credentials to other profiles', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_API_KEY: 'sk-opencode-key', + }), + goal: 'balanced', + processEnv: {}, + }) + + // Should not contain credentials from other providers + assert.equal(env.ANTHROPIC_API_KEY, undefined) + assert.equal(env.GEMINI_API_KEY, undefined) + assert.equal(env.MISTRAL_API_KEY, undefined) +}) + +test('opencode profile handles invalid base url gracefully', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_BASE_URL: 'not-a-url', + }), + goal: 'balanced', + processEnv: {}, + }) + + // Should not crash, just use the value as-is + assert.equal(env.OPENAI_BASE_URL, 'not-a-url') +}) + +test('opencode profile handles invalid model name gracefully', async () => { + const env = await buildLaunchEnv({ + profile: 'opencode', + persisted: profile('opencode', { + OPENAI_MODEL: 'not-a-real-model', + }), + goal: 'balanced', + processEnv: {}, + }) + + // Should not crash, just use the value as-is + assert.equal(env.OPENAI_MODEL, 'not-a-real-model') +}) From 8603ef589b6aab8cf3a7b85e23562c4709f4afde Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 25 May 2026 12:01:47 +0600 Subject: [PATCH 05/27] fix(provider): add per-model endpoint routing (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/) + 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 --- src/integrations/descriptors.ts | 2 + src/integrations/gateways/opencode-go.ts | 14 ++--- src/integrations/vendors/opencode.test.ts | 35 ++---------- src/integrations/vendors/opencode.ts | 70 +++++++++++------------ src/services/api/openaiShim.ts | 8 ++- 5 files changed, 50 insertions(+), 79 deletions(-) diff --git a/src/integrations/descriptors.ts b/src/integrations/descriptors.ts index 1f371b9fbd..0cc73f50b7 100644 --- a/src/integrations/descriptors.ts +++ b/src/integrations/descriptors.ts @@ -40,6 +40,8 @@ export interface OpenAIShimTransportConfig { thinkingRequestFormat?: 'none' | 'deepseek-compatible' maxTokensField?: OpenAIShimTokenField removeBodyFields?: string[] + /** Override the endpoint path for this model (e.g., '/responses', '/messages'). */ + endpointPath?: string } export interface CapabilityFlags { diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts index 9ebe8e4420..86d7ff33d8 100644 --- a/src/integrations/gateways/opencode-go.ts +++ b/src/integrations/gateways/opencode-go.ts @@ -26,11 +26,7 @@ export default defineGateway({ modelEnvVars: ['OPENAI_MODEL'], }, catalog: { - source: 'hybrid', - discovery: { kind: 'openai-compatible' }, - discoveryCacheTtl: '1h', - discoveryRefreshMode: 'startup', - allowManualRefresh: true, + 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' }, @@ -42,10 +38,10 @@ export default defineGateway({ { 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 — /zen/go/v1/messages - { id: 'opencode-go-minimax-m2.7', apiName: 'minimax-m2.7', label: 'MiniMax M2.7', modelDescriptorId: 'opencode-go-minimax-m2.7' }, - { id: 'opencode-go-minimax-m2.5', apiName: 'minimax-m2.5', label: 'MiniMax M2.5', modelDescriptorId: 'opencode-go-minimax-m2.5' }, - { id: 'opencode-go-qwen3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen3.6 Plus', modelDescriptorId: 'opencode-go-qwen3.6-plus' }, - { id: 'opencode-go-qwen3.5-plus', apiName: 'qwen3.5-plus', label: 'Qwen3.5 Plus', modelDescriptorId: 'opencode-go-qwen3.5-plus' }, + { id: 'opencode-go-minimax-m2.7', apiName: 'minimax-m2.7', label: 'MiniMax M2.7', modelDescriptorId: 'opencode-go-minimax-m2.7', transportOverrides: { openaiShim: { endpointPath: '/messages' } } }, + { 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' } } }, ], }, usage: { supported: false }, diff --git a/src/integrations/vendors/opencode.test.ts b/src/integrations/vendors/opencode.test.ts index 7e05d8c09d..a95f5e365d 100644 --- a/src/integrations/vendors/opencode.test.ts +++ b/src/integrations/vendors/opencode.test.ts @@ -96,16 +96,10 @@ describe('OpenCode vendor descriptor', () => { expect(vendor!.validation!.kind).toBe('credential-env') }) - test('has catalog with static + hybrid source', () => { + test('has catalog with static source', () => { const vendor = getVendor('opencode') expect(vendor!.catalog).toBeDefined() - expect(vendor!.catalog!.source).toBe('hybrid') - }) - - test('has discovery config', () => { - const vendor = getVendor('opencode') - expect(vendor!.catalog!.discovery).toBeDefined() - expect(vendor!.catalog!.discovery!.kind).toBe('openai-compatible') + expect(vendor!.catalog!.source).toBe('static') }) test('has static models in catalog', () => { @@ -185,16 +179,10 @@ describe('OpenCode Go gateway descriptor', () => { expect(gateway!.preset!.apiKeyEnvVars).toContain('OPENCODE_API_KEY') }) - test('has catalog with hybrid source', () => { + test('has catalog with static source', () => { const gateway = getGateway('opencode-go') expect(gateway!.catalog).toBeDefined() - expect(gateway!.catalog!.source).toBe('hybrid') - }) - - test('has discovery config', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.catalog!.discovery).toBeDefined() - expect(gateway!.catalog!.discovery!.kind).toBe('openai-compatible') + expect(gateway!.catalog!.source).toBe('static') }) test('has static models in catalog', () => { @@ -540,19 +528,4 @@ describe('OpenCode edge cases', () => { const vendor = getVendor('opencode') expect(vendor!.validation!.missingCredentialMessage).toContain('opencode.ai') }) - - test('discovery cache ttl is set', () => { - const vendor = getVendor('opencode') - expect(vendor!.catalog!.discoveryCacheTtl).toBeDefined() - }) - - test('discovery refresh mode is set', () => { - const vendor = getVendor('opencode') - expect(vendor!.catalog!.discoveryRefreshMode).toBeDefined() - }) - - test('manual refresh is allowed', () => { - const vendor = getVendor('opencode') - expect(vendor!.catalog!.allowManualRefresh).toBe(true) - }) }) diff --git a/src/integrations/vendors/opencode.ts b/src/integrations/vendors/opencode.ts index 0e754ef8a1..d56c607044 100644 --- a/src/integrations/vendors/opencode.ts +++ b/src/integrations/vendors/opencode.ts @@ -34,48 +34,44 @@ export default defineVendor({ 'OPENCODE_API_KEY is required. Get your API key from https://opencode.ai', }, catalog: { - source: 'hybrid', - discovery: { kind: 'openai-compatible' }, - discoveryCacheTtl: '1h', - discoveryRefreshMode: 'startup', - allowManualRefresh: true, + 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' }, - { id: 'gpt-5.5-pro', apiName: 'gpt-5.5-pro', label: 'GPT 5.5 Pro', modelDescriptorId: 'opencode-gpt-5.5-pro' }, - { id: 'gpt-5.4', apiName: 'gpt-5.4', label: 'GPT 5.4', modelDescriptorId: 'opencode-gpt-5.4' }, - { id: 'gpt-5.4-pro', apiName: 'gpt-5.4-pro', label: 'GPT 5.4 Pro', modelDescriptorId: 'opencode-gpt-5.4-pro' }, - { id: 'gpt-5.4-mini', apiName: 'gpt-5.4-mini', label: 'GPT 5.4 Mini', modelDescriptorId: 'opencode-gpt-5.4-mini' }, - { id: 'gpt-5.4-nano', apiName: 'gpt-5.4-nano', label: 'GPT 5.4 Nano', modelDescriptorId: 'opencode-gpt-5.4-nano' }, - { id: 'gpt-5.3-codex', apiName: 'gpt-5.3-codex', label: 'GPT 5.3 Codex', modelDescriptorId: 'opencode-gpt-5.3-codex' }, - { 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' }, - { id: 'gpt-5.2', apiName: 'gpt-5.2', label: 'GPT 5.2', modelDescriptorId: 'opencode-gpt-5.2' }, - { id: 'gpt-5.2-codex', apiName: 'gpt-5.2-codex', label: 'GPT 5.2 Codex', modelDescriptorId: 'opencode-gpt-5.2-codex' }, - { id: 'gpt-5.1', apiName: 'gpt-5.1', label: 'GPT 5.1', modelDescriptorId: 'opencode-gpt-5.1' }, - { id: 'gpt-5.1-codex', apiName: 'gpt-5.1-codex', label: 'GPT 5.1 Codex', modelDescriptorId: 'opencode-gpt-5.1-codex' }, - { 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' }, - { 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' }, - { id: 'gpt-5', apiName: 'gpt-5', label: 'GPT 5', modelDescriptorId: 'opencode-gpt-5' }, - { id: 'gpt-5-codex', apiName: 'gpt-5-codex', label: 'GPT 5 Codex', modelDescriptorId: 'opencode-gpt-5-codex' }, - { id: 'gpt-5-nano', apiName: 'gpt-5-nano', label: 'GPT 5 Nano', modelDescriptorId: 'opencode-gpt-5-nano' }, + { 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' }, - { id: 'claude-opus-4-6', apiName: 'claude-opus-4-6', label: 'Claude Opus 4.6', modelDescriptorId: 'opencode-claude-opus-4-6' }, - { id: 'claude-opus-4-5', apiName: 'claude-opus-4-5', label: 'Claude Opus 4.5', modelDescriptorId: 'opencode-claude-opus-4-5' }, - { id: 'claude-opus-4-1', apiName: 'claude-opus-4-1', label: 'Claude Opus 4.1', modelDescriptorId: 'opencode-claude-opus-4-1' }, - { id: 'claude-sonnet-4-6', apiName: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', modelDescriptorId: 'opencode-claude-sonnet-4-6' }, - { id: 'claude-sonnet-4-5', apiName: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', modelDescriptorId: 'opencode-claude-sonnet-4-5' }, - { id: 'claude-sonnet-4', apiName: 'claude-sonnet-4', label: 'Claude Sonnet 4', modelDescriptorId: 'opencode-claude-sonnet-4' }, - { id: 'claude-haiku-4-5', apiName: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', modelDescriptorId: 'opencode-claude-haiku-4-5' }, - { id: 'claude-3-5-haiku', apiName: 'claude-3-5-haiku', label: 'Claude Haiku 3.5', modelDescriptorId: 'opencode-claude-3-5-haiku' }, + { 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-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: 'gemini-3.5-flash', apiName: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', modelDescriptorId: 'opencode-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' }, - { id: 'gemini-3-flash', apiName: 'gemini-3-flash', label: 'Gemini 3 Flash', modelDescriptorId: 'opencode-gemini-3-flash' }, + { 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' }, - { id: 'qwen3.5-plus', apiName: 'qwen3.5-plus', label: 'Qwen3.5 Plus', modelDescriptorId: 'opencode-qwen3.5-plus' }, - // OpenAI-compatible — /zen/v1/chat/completions + { 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' }, diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 13009c1e5d..f65a82e079 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -2104,10 +2104,14 @@ class OpenAIShimMessages { ? getLocalProviderRetryBaseUrls(request.baseUrl) : [] - const buildRequestUrl = (baseUrl: string): string => - request.transport === 'responses' + const buildRequestUrl = (baseUrl: string): string => { + if (shimConfig.endpointPath) { + return `${baseUrl}${shimConfig.endpointPath}` + } + return request.transport === 'responses' ? `${baseUrl}/responses` : buildChatCompletionsUrl(baseUrl) + } let activeBaseUrl = request.baseUrl let requestUrl = buildRequestUrl(activeBaseUrl) From 0e6cff7a2fa20e9ee2efa504d50196451e8d49c4 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 25 May 2026 15:16:49 +0600 Subject: [PATCH 06/27] refactor(opencode): model OpenCode Zen/Go as gateways (P2) --- .gitignore | 1 + src/integrations/brands/opencode.ts | 76 ----- src/integrations/compatibility.test.ts | 2 + src/integrations/gateways/opencode-go.ts | 3 +- .../{vendors => gateways}/opencode.test.ts | 268 +++++++----------- .../{vendors => gateways}/opencode.ts | 10 +- .../integrationArtifacts.generated.ts | 16 +- src/integrations/models/opencode.ts | 212 +++++++------- 8 files changed, 219 insertions(+), 369 deletions(-) delete mode 100644 src/integrations/brands/opencode.ts rename src/integrations/{vendors => gateways}/opencode.test.ts (58%) rename src/integrations/{vendors => gateways}/opencode.ts (97%) diff --git a/.gitignore b/.gitignore index c1e72d19e5..e3c95ef488 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ package-lock.json coverage/ agent.log plan/ +temp_reference/ \ No newline at end of file diff --git a/src/integrations/brands/opencode.ts b/src/integrations/brands/opencode.ts deleted file mode 100644 index c6ac2750e9..0000000000 --- a/src/integrations/brands/opencode.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { defineBrand } from '../define.js' - -export default defineBrand({ - id: 'opencode', - label: 'OpenCode', - canonicalVendorId: 'opencode', - defaultCapabilities: { - supportsVision: true, - supportsStreaming: true, - supportsFunctionCalling: true, - supportsJsonMode: true, - supportsReasoning: false, - supportsPreciseTokenCount: false, - }, - modelIds: [ - // Zen GPT - 'opencode-gpt-5.5', - 'opencode-gpt-5.5-pro', - 'opencode-gpt-5.4', - 'opencode-gpt-5.4-pro', - 'opencode-gpt-5.4-mini', - 'opencode-gpt-5.4-nano', - 'opencode-gpt-5.3-codex', - 'opencode-gpt-5.3-codex-spark', - 'opencode-gpt-5.2', - 'opencode-gpt-5.2-codex', - 'opencode-gpt-5.1', - 'opencode-gpt-5.1-codex', - 'opencode-gpt-5.1-codex-max', - 'opencode-gpt-5.1-codex-mini', - 'opencode-gpt-5', - 'opencode-gpt-5-codex', - 'opencode-gpt-5-nano', - // Zen Claude - 'opencode-claude-opus-4-7', - 'opencode-claude-opus-4-6', - 'opencode-claude-opus-4-5', - 'opencode-claude-opus-4-1', - 'opencode-claude-sonnet-4-6', - 'opencode-claude-sonnet-4-5', - 'opencode-claude-sonnet-4', - 'opencode-claude-haiku-4-5', - 'opencode-claude-3-5-haiku', - // Zen Gemini - 'opencode-gemini-3.5-flash', - 'opencode-gemini-3.1-pro', - 'opencode-gemini-3-flash', - // Zen Qwen - 'opencode-qwen3.6-plus', - 'opencode-qwen3.5-plus', - // Zen others - 'opencode-minimax-m2.7', - 'opencode-minimax-m2.5', - 'opencode-glm-5.1', - 'opencode-glm-5', - 'opencode-kimi-k2.5', - 'opencode-kimi-k2.6', - 'opencode-grok-build-0.1', - 'opencode-big-pickle', - 'opencode-deepseek-v4-flash-free', - 'opencode-nemotron-3-super-free', - // Go - 'opencode-go-glm-5.1', - 'opencode-go-glm-5', - 'opencode-go-kimi-k2.5', - 'opencode-go-kimi-k2.6', - 'opencode-go-deepseek-v4-pro', - 'opencode-go-deepseek-v4-flash', - 'opencode-go-mimo-v2.5', - 'opencode-go-mimo-v2.5-pro', - 'opencode-go-minimax-m2.7', - 'opencode-go-minimax-m2.5', - 'opencode-go-qwen3.6-plus', - 'opencode-go-qwen3.5-plus', - ], -}) diff --git a/src/integrations/compatibility.test.ts b/src/integrations/compatibility.test.ts index 70965a864c..73c1529b3c 100644 --- a/src/integrations/compatibility.test.ts +++ b/src/integrations/compatibility.test.ts @@ -41,6 +41,8 @@ const EXPECTED_PRESETS = [ 'bankr', 'atomic-chat', 'gitlawb-opengateway', + 'opencode', + 'opencode-go', ] as const satisfies readonly ProviderPreset[] describe('compatibility mappings', () => { diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts index 86d7ff33d8..ecb48f1795 100644 --- a/src/integrations/gateways/opencode-go.ts +++ b/src/integrations/gateways/opencode-go.ts @@ -3,7 +3,6 @@ import { defineGateway } from '../define.js' export default defineGateway({ id: 'opencode-go', label: 'OpenCode Go', - vendorId: 'opencode', category: 'hosted', defaultBaseUrl: 'https://opencode.ai/zen/go/v1', defaultModel: 'glm-5.1', @@ -20,7 +19,7 @@ export default defineGateway({ }, preset: { id: 'opencode-go', - vendorId: 'opencode', + vendorId: 'openai', description: 'OpenCode Go — $10/mo subscription for open models (12 models)', apiKeyEnvVars: ['OPENCODE_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], diff --git a/src/integrations/vendors/opencode.test.ts b/src/integrations/gateways/opencode.test.ts similarity index 58% rename from src/integrations/vendors/opencode.test.ts rename to src/integrations/gateways/opencode.test.ts index a95f5e365d..cf87a37d79 100644 --- a/src/integrations/vendors/opencode.test.ts +++ b/src/integrations/gateways/opencode.test.ts @@ -1,15 +1,11 @@ -// src/integrations/vendors/opencode.test.ts - import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { ensureIntegrationsLoaded } from '../index.js' import { _clearRegistryForTesting, - getVendor, getGateway, - getBrand, - getModelsForBrand, getModelsForGateway, getCatalogEntriesForRoute, + getAllModels, validateIntegrationRegistry, } from '../registry.js' import { @@ -18,7 +14,7 @@ import { } from '../../test/sharedMutationLock.js' beforeEach(async () => { - await acquireSharedMutationLock('integrations/vendors/opencode.test.ts') + await acquireSharedMutationLock('integrations/gateways/opencode.test.ts') _clearRegistryForTesting() ensureIntegrationsLoaded() }) @@ -33,90 +29,91 @@ afterEach(() => { }) // --------------------------------------------------------------------------- -// Vendor Descriptor Tests +// Zen Gateway Descriptor Tests // --------------------------------------------------------------------------- -describe('OpenCode vendor descriptor', () => { +describe('OpenCode Zen gateway descriptor', () => { test('is registered with correct id', () => { - const vendor = getVendor('opencode') - expect(vendor).not.toBeNull() - expect(vendor!.id).toBe('opencode') + const gateway = getGateway('opencode') + expect(gateway).not.toBeNull() + expect(gateway!.id).toBe('opencode') }) test('has correct label', () => { - const vendor = getVendor('opencode') - expect(vendor!.label).toBe('OpenCode') + const gateway = getGateway('opencode') + expect(gateway!.label).toBe('OpenCode Zen') }) - test('has openai-compatible classification', () => { - const vendor = getVendor('opencode') - expect(vendor!.classification).toBe('openai-compatible') + test('has aggregating category', () => { + const gateway = getGateway('opencode') + expect(gateway!.category).toBe('aggregating') }) test('has correct default base URL', () => { - const vendor = getVendor('opencode') - expect(vendor!.defaultBaseUrl).toBe('https://opencode.ai/zen/v1') + const gateway = getGateway('opencode') + expect(gateway!.defaultBaseUrl).toBe('https://opencode.ai/zen/v1') }) test('has correct default model', () => { - const vendor = getVendor('opencode') - expect(vendor!.defaultModel).toBe('gpt-5.4') + const gateway = getGateway('opencode') + expect(gateway!.defaultModel).toBe('gpt-5.4') }) test('requires auth', () => { - const vendor = getVendor('opencode') - expect(vendor!.setup.requiresAuth).toBe(true) + const gateway = getGateway('opencode') + expect(gateway!.setup.requiresAuth).toBe(true) }) test('uses api-key auth mode', () => { - const vendor = getVendor('opencode') - expect(vendor!.setup.authMode).toBe('api-key') + const gateway = getGateway('opencode') + expect(gateway!.setup.authMode).toBe('api-key') }) test('has OPENCODE_API_KEY in credential env vars', () => { - const vendor = getVendor('opencode') - expect(vendor!.setup.credentialEnvVars).toContain('OPENCODE_API_KEY') + const gateway = getGateway('opencode') + expect(gateway!.setup.credentialEnvVars).toContain('OPENCODE_API_KEY') }) test('has openai-compatible transport kind', () => { - const vendor = getVendor('opencode') - expect(vendor!.transportConfig.kind).toBe('openai-compatible') + const gateway = getGateway('opencode') + expect(gateway!.transportConfig.kind).toBe('openai-compatible') }) test('has preset metadata', () => { - const vendor = getVendor('opencode') - expect(vendor!.preset).toBeDefined() - expect(vendor!.preset!.id).toBe('opencode') - expect(vendor!.preset!.apiKeyEnvVars).toContain('OPENCODE_API_KEY') + const gateway = getGateway('opencode') + expect(gateway!.preset).toBeDefined() + expect(gateway!.preset!.id).toBe('opencode') + expect(gateway!.preset!.vendorId).toBe('openai') + expect(gateway!.preset!.apiKeyEnvVars).toContain('OPENCODE_API_KEY') }) test('has validation metadata', () => { - const vendor = getVendor('opencode') - expect(vendor!.validation).toBeDefined() - expect(vendor!.validation!.kind).toBe('credential-env') + const gateway = getGateway('opencode') + expect(gateway!.validation).toBeDefined() + expect(gateway!.validation!.kind).toBe('credential-env') }) test('has catalog with static source', () => { - const vendor = getVendor('opencode') - expect(vendor!.catalog).toBeDefined() - expect(vendor!.catalog!.source).toBe('static') + const gateway = getGateway('opencode') + expect(gateway!.catalog).toBeDefined() + expect(gateway!.catalog!.source).toBe('static') }) test('has static models in catalog', () => { - const vendor = getVendor('opencode') - expect(vendor!.catalog!.models).toBeDefined() - expect(vendor!.catalog!.models!.length).toBeGreaterThan(0) + const gateway = getGateway('opencode') + expect(gateway!.catalog!.models).toBeDefined() + expect(gateway!.catalog!.models!.length).toBeGreaterThan(0) }) test('has usage metadata', () => { - const vendor = getVendor('opencode') - expect(vendor!.usage).toBeDefined() - expect(vendor!.usage!.supported).toBe(false) + const gateway = getGateway('opencode') + expect(gateway!.usage).toBeDefined() + expect(gateway!.usage!.supported).toBe(false) }) }) // --------------------------------------------------------------------------- -// Gateway Descriptor Tests +// Go Gateway Descriptor Tests // --------------------------------------------------------------------------- describe('OpenCode Go gateway descriptor', () => { @@ -131,11 +128,6 @@ describe('OpenCode Go gateway descriptor', () => { expect(gateway!.label).toBe('OpenCode Go') }) - test('has correct vendor id', () => { - const gateway = getGateway('opencode-go') - expect(gateway!.vendorId).toBe('opencode') - }) - test('has hosted category', () => { const gateway = getGateway('opencode-go') expect(gateway!.category).toBe('hosted') @@ -171,11 +163,11 @@ describe('OpenCode Go gateway descriptor', () => { expect(gateway!.transportConfig.kind).toBe('openai-compatible') }) - test('has preset metadata with vendor id', () => { + test('has preset metadata with vendorId openai', () => { const gateway = getGateway('opencode-go') expect(gateway!.preset).toBeDefined() expect(gateway!.preset!.id).toBe('opencode-go') - expect(gateway!.preset!.vendorId).toBe('opencode') + expect(gateway!.preset!.vendorId).toBe('openai') expect(gateway!.preset!.apiKeyEnvVars).toContain('OPENCODE_API_KEY') }) @@ -192,83 +184,30 @@ describe('OpenCode Go gateway descriptor', () => { }) }) -// --------------------------------------------------------------------------- -// Brand Descriptor Tests -// --------------------------------------------------------------------------- - -describe('OpenCode brand descriptor', () => { - test('is registered with correct id', () => { - const brand = getBrand('opencode') - expect(brand).not.toBeNull() - expect(brand!.id).toBe('opencode') - }) - - test('has correct label', () => { - const brand = getBrand('opencode') - expect(brand!.label).toBe('OpenCode') - }) - - test('has correct canonical vendor id', () => { - const brand = getBrand('opencode') - expect(brand!.canonicalVendorId).toBe('opencode') - }) - - test('has default capabilities', () => { - const brand = getBrand('opencode') - expect(brand!.defaultCapabilities).toBeDefined() - expect(brand!.defaultCapabilities.supportsVision).toBe(true) - expect(brand!.defaultCapabilities.supportsStreaming).toBe(true) - expect(brand!.defaultCapabilities.supportsFunctionCalling).toBe(true) - expect(brand!.defaultCapabilities.supportsJsonMode).toBe(true) - }) - - test('has model ids list', () => { - const brand = getBrand('opencode') - expect(brand!.modelIds).toBeDefined() - expect(brand!.modelIds!.length).toBeGreaterThan(0) - }) - - test('model ids list contains both zen and go models', () => { - const brand = getBrand('opencode') - const hasZenModels = brand!.modelIds!.some(id => id.startsWith('opencode-gpt')) - const hasGoModels = brand!.modelIds!.some(id => id.startsWith('opencode-go-')) - expect(hasZenModels).toBe(true) - expect(hasGoModels).toBe(true) - }) -}) - // --------------------------------------------------------------------------- // Model Catalog Tests // --------------------------------------------------------------------------- describe('OpenCode model catalog', () => { - test('brand has models registered', () => { - const models = getModelsForBrand('opencode') + test('zen gateway has models registered', () => { + const models = getModelsForGateway('opencode') expect(models.length).toBeGreaterThan(0) }) - test('gateway has models registered', () => { + test('go gateway has models registered', () => { const models = getModelsForGateway('opencode-go') expect(models.length).toBeGreaterThan(0) }) - test('zen models have correct vendor id', () => { - const models = getModelsForBrand('opencode') - const zenModels = models.filter(m => m.id.startsWith('opencode-gpt') || m.id.startsWith('opencode-claude') || m.id.startsWith('opencode-gemini')) - for (const model of zenModels) { - expect(model.vendorId).toBe('opencode') - } - }) - - test('go models have correct vendor id', () => { - const models = getModelsForGateway('opencode-go') + test('models have vendorId openai', () => { + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) for (const model of models) { - expect(model.vendorId).toBe('opencode') + expect(model.vendorId).toBe('openai') } }) test('all models have required fields', () => { - const models = getModelsForBrand('opencode') + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) for (const model of models) { expect(model.id).toBeDefined() expect(model.label).toBeDefined() @@ -280,7 +219,7 @@ describe('OpenCode model catalog', () => { }) test('all models have valid classification', () => { - const models = getModelsForBrand('opencode') + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) const validClassifications = ['chat', 'reasoning', 'vision', 'coding'] for (const model of models) { expect(model.classification.length).toBeGreaterThan(0) @@ -291,23 +230,21 @@ describe('OpenCode model catalog', () => { }) test('zen gpt models have correct classification', () => { - const models = getModelsForBrand('opencode') - const gptModels = models.filter(m => m.id.startsWith('opencode-gpt-')) - for (const model of gptModels) { + const models = getAllModels().filter(m => m.id.startsWith('opencode-gpt-')) + for (const model of models) { expect(model.classification).toContain('chat') } }) test('zen claude models have correct classification', () => { - const models = getModelsForBrand('opencode') - const claudeModels = models.filter(m => m.id.startsWith('opencode-claude-')) - for (const model of claudeModels) { + const models = getAllModels().filter(m => m.id.startsWith('opencode-claude-')) + for (const model of models) { expect(model.classification).toContain('chat') } }) test('codex models have coding classification', () => { - const models = getModelsForBrand('opencode') + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) const codexModels = models.filter(m => m.defaultModel.includes('codex')) for (const model of codexModels) { expect(model.classification).toContain('coding') @@ -315,8 +252,7 @@ describe('OpenCode model catalog', () => { }) test('reasoning models have reasoning classification', () => { - const models = getModelsForBrand('opencode') - // Only check models that are explicitly reasoning models + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) const reasoningModels = models.filter(m => m.defaultModel.includes('opus') || m.defaultModel === 'gpt-5.5-pro' || @@ -330,7 +266,7 @@ describe('OpenCode model catalog', () => { }) test('no duplicate model ids', () => { - const models = getModelsForBrand('opencode') + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) const ids = models.map(m => m.id) const uniqueIds = new Set(ids) expect(ids.length).toBe(uniqueIds.size) @@ -348,13 +284,11 @@ describe('OpenCode model catalog', () => { test('zen model count matches expected', () => { const models = getCatalogEntriesForRoute('opencode') - // 17 GPT + 9 Claude + 3 Gemini + 2 Qwen + 10 others = 41 expect(models.length).toBe(41) }) test('go model count matches expected', () => { const models = getCatalogEntriesForRoute('opencode-go') - // 8 chat/completions + 4 messages = 12 expect(models.length).toBe(12) }) @@ -390,41 +324,32 @@ describe('OpenCode model catalog', () => { // --------------------------------------------------------------------------- describe('OpenCode cross-reference consistency', () => { - test('brand model ids match actual model descriptors', () => { - const brand = getBrand('opencode') - const models = getModelsForBrand('opencode') - const modelIds = new Set(models.map(m => m.id)) - for (const brandModelId of brand!.modelIds!) { - expect(modelIds.has(brandModelId)).toBe(true) - } - }) - - test('vendor catalog modelDescriptorIds match actual model descriptors', () => { - const vendor = getVendor('opencode') - const models = getModelsForBrand('opencode') + test('gateway catalog modelDescriptorIds match actual model descriptors', () => { + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) const modelIds = new Set(models.map(m => m.id)) - for (const entry of vendor!.catalog!.models!) { - if (entry.modelDescriptorId) { - expect(modelIds.has(entry.modelDescriptorId)).toBe(true) + const entry = getGateway('opencode') + for (const catalogEntry of entry!.catalog!.models!) { + if (catalogEntry.modelDescriptorId) { + expect(modelIds.has(catalogEntry.modelDescriptorId)).toBe(true) } } }) - test('gateway catalog modelDescriptorIds match actual model descriptors', () => { - const gateway = getGateway('opencode-go') - const models = getModelsForGateway('opencode-go') + test('go gateway catalog modelDescriptorIds match actual model descriptors', () => { + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) const modelIds = new Set(models.map(m => m.id)) - for (const entry of gateway!.catalog!.models!) { - if (entry.modelDescriptorId) { - expect(modelIds.has(entry.modelDescriptorId)).toBe(true) + const gateway = getGateway('opencode-go') + for (const catalogEntry of gateway!.catalog!.models!) { + if (catalogEntry.modelDescriptorId) { + expect(modelIds.has(catalogEntry.modelDescriptorId)).toBe(true) } } }) - test('vendor and gateway share the same OPENCODE_API_KEY', () => { - const vendor = getVendor('opencode') - const gateway = getGateway('opencode-go') - expect(vendor!.setup.credentialEnvVars).toEqual(gateway!.setup.credentialEnvVars) + test('zen and go gateways share the same OPENCODE_API_KEY', () => { + const zen = getGateway('opencode') + const go = getGateway('opencode-go') + expect(zen!.setup.credentialEnvVars).toEqual(go!.setup.credentialEnvVars) }) }) @@ -451,42 +376,42 @@ describe('OpenCode integration validation', () => { // --------------------------------------------------------------------------- describe('OpenCode edge cases', () => { - test('vendor catalog entries have unique ids', () => { - const vendor = getVendor('opencode') - const ids = vendor!.catalog!.models!.map(m => m.id) + test('zen catalog entries have unique ids', () => { + const gateway = getGateway('opencode') + const ids = gateway!.catalog!.models!.map(m => m.id) const uniqueIds = new Set(ids) expect(ids.length).toBe(uniqueIds.size) }) - test('gateway catalog entries have unique ids', () => { + test('go catalog entries have unique ids', () => { const gateway = getGateway('opencode-go') const ids = gateway!.catalog!.models!.map(m => m.id) const uniqueIds = new Set(ids) expect(ids.length).toBe(uniqueIds.size) }) - test('vendor catalog entries have unique apiNames', () => { - const vendor = getVendor('opencode') - const apiNames = vendor!.catalog!.models!.map(m => m.apiName) + test('zen catalog entries have unique apiNames', () => { + const gateway = getGateway('opencode') + const apiNames = gateway!.catalog!.models!.map(m => m.apiName) const uniqueApiNames = new Set(apiNames) expect(apiNames.length).toBe(uniqueApiNames.size) }) - test('gateway catalog entries have unique apiNames', () => { + test('go catalog entries have unique apiNames', () => { const gateway = getGateway('opencode-go') const apiNames = gateway!.catalog!.models!.map(m => m.apiName) const uniqueApiNames = new Set(apiNames) expect(apiNames.length).toBe(uniqueApiNames.size) }) - test('vendor catalog entries have non-empty labels', () => { - const vendor = getVendor('opencode') - for (const entry of vendor!.catalog!.models!) { + test('zen catalog entries have non-empty labels', () => { + const gateway = getGateway('opencode') + for (const entry of gateway!.catalog!.models!) { expect(entry.label.length).toBeGreaterThan(0) } }) - test('gateway catalog entries have non-empty labels', () => { + test('go catalog entries have non-empty labels', () => { const gateway = getGateway('opencode-go') for (const entry of gateway!.catalog!.models!) { expect(entry.label.length).toBeGreaterThan(0) @@ -494,7 +419,7 @@ describe('OpenCode edge cases', () => { }) test('model descriptors have non-empty contextWindow', () => { - const models = getModelsForBrand('opencode') + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) for (const model of models) { if (model.contextWindow !== undefined) { expect(model.contextWindow).toBeGreaterThan(0) @@ -503,7 +428,7 @@ describe('OpenCode edge cases', () => { }) test('model descriptors have non-empty maxOutputTokens', () => { - const models = getModelsForBrand('opencode') + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) for (const model of models) { if (model.maxOutputTokens !== undefined) { expect(model.maxOutputTokens).toBeGreaterThan(0) @@ -512,20 +437,19 @@ describe('OpenCode edge cases', () => { }) test('model descriptors have valid defaultModel format', () => { - const models = getModelsForBrand('opencode') + const models = getAllModels().filter(m => m.id.startsWith('opencode-')) for (const model of models) { - // defaultModel should not contain spaces or special chars expect(model.defaultModel).toMatch(/^[a-z0-9\-\.]+$/) } }) - test('vendor validation message mentions OPENCODE_API_KEY', () => { - const vendor = getVendor('opencode') - expect(vendor!.validation!.missingCredentialMessage).toContain('OPENCODE_API_KEY') + test('zen gateway validation message mentions OPENCODE_API_KEY', () => { + const gateway = getGateway('opencode') + expect(gateway!.validation!.missingCredentialMessage).toContain('OPENCODE_API_KEY') }) - test('vendor validation message mentions opencode.ai', () => { - const vendor = getVendor('opencode') - expect(vendor!.validation!.missingCredentialMessage).toContain('opencode.ai') + test('zen gateway validation message mentions opencode.ai', () => { + const gateway = getGateway('opencode') + expect(gateway!.validation!.missingCredentialMessage).toContain('opencode.ai') }) }) diff --git a/src/integrations/vendors/opencode.ts b/src/integrations/gateways/opencode.ts similarity index 97% rename from src/integrations/vendors/opencode.ts rename to src/integrations/gateways/opencode.ts index d56c607044..979ba36461 100644 --- a/src/integrations/vendors/opencode.ts +++ b/src/integrations/gateways/opencode.ts @@ -1,12 +1,11 @@ -import { defineVendor } from '../define.js' +import { defineGateway } from '../define.js' -export default defineVendor({ +export default defineGateway({ id: 'opencode', - label: 'OpenCode', - classification: 'openai-compatible', + label: 'OpenCode Zen', + category: 'aggregating', defaultBaseUrl: 'https://opencode.ai/zen/v1', defaultModel: 'gpt-5.4', - requiredEnvVars: ['OPENCODE_API_KEY'], setup: { requiresAuth: true, authMode: 'api-key', @@ -20,6 +19,7 @@ export default defineVendor({ }, preset: { id: 'opencode', + vendorId: 'openai', description: 'OpenCode Zen — pay-as-you-go AI gateway (41 models)', apiKeyEnvVars: ['OPENCODE_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index fe31a1bdb5..f1022daa90 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -9,7 +9,6 @@ import vendorGemini from '../vendors/gemini.js' import vendorMinimax from '../vendors/minimax.js' import vendorMoonshot from '../vendors/moonshot.js' import vendorOpenai from '../vendors/openai.js' -import vendorOpencode from '../vendors/opencode.js' import vendorVenice from '../vendors/venice.js' import vendorXai from '../vendors/xai.js' import vendorXiaomiMimo from '../vendors/xiaomi-mimo.js' @@ -30,6 +29,7 @@ import gatewayMistral from '../gateways/mistral.js' import gatewayNvidiaNim from '../gateways/nvidia-nim.js' import gatewayOllama from '../gateways/ollama.js' import gatewayOpencodeGo from '../gateways/opencode-go.js' +import gatewayOpencode from '../gateways/opencode.js' import gatewayOpenrouter from '../gateways/openrouter.js' import gatewayTogether from '../gateways/together.js' import gatewayVertex from '../gateways/vertex.js' @@ -44,7 +44,6 @@ import brandMinimax from '../brands/minimax.js' import brandMistral from '../brands/mistral.js' import brandNemotron from '../brands/nemotron.js' import brandOpenaiCompatibleAlias from '../brands/openai-compatible-alias.js' -import brandOpencode from '../brands/opencode.js' import brandQwen from '../brands/qwen.js' import brandXai from '../brands/xai.js' import brandXiaomiMimo from '../brands/xiaomi-mimo.js' @@ -64,10 +63,10 @@ import modelQwen from '../models/qwen.js' import modelXai from '../models/xai.js' import modelXiaomiMimo from '../models/xiaomi-mimo.js' -export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorGemini, vendorMinimax, vendorMoonshot, vendorOpenai, vendorOpencode, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[] -export const GATEWAY_DESCRIPTORS = [gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpenrouter, gatewayTogether, gatewayVertex] as const satisfies readonly GatewayDescriptor[] +export const VENDOR_DESCRIPTORS = [vendorAnthropic, vendorBankr, vendorDeepseek, vendorGemini, vendorMinimax, vendorMoonshot, vendorOpenai, vendorVenice, vendorXai, vendorXiaomiMimo, vendorZai] as const satisfies readonly VendorDescriptor[] +export const GATEWAY_DESCRIPTORS = [gatewayAtomicChat, gatewayAzureOpenai, gatewayBedrock, gatewayCustom, gatewayDashscopeCn, gatewayDashscopeIntl, gatewayGithub, gatewayGitlawbOpengateway, gatewayGroq, gatewayHicap, gatewayKimiCode, gatewayLmstudio, gatewayMistral, gatewayNvidiaNim, gatewayOllama, gatewayOpencodeGo, gatewayOpencode, gatewayOpenrouter, gatewayTogether, gatewayVertex] as const satisfies readonly GatewayDescriptor[] export const ANTHROPIC_PROXY_DESCRIPTORS = [] as const satisfies readonly AnthropicProxyDescriptor[] -export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandGemini, brandGlm, brandGpt, brandKimi, brandLlama, brandMinimax, brandMistral, brandNemotron, brandOpenaiCompatibleAlias, brandOpencode, brandQwen, brandXai, brandXiaomiMimo] as const satisfies readonly BrandDescriptor[] +export const BRAND_DESCRIPTORS = [brandClaude, brandDeepseek, brandGemini, brandGlm, brandGpt, brandKimi, brandLlama, brandMinimax, brandMistral, brandNemotron, brandOpenaiCompatibleAlias, brandQwen, brandXai, brandXiaomiMimo] as const satisfies readonly BrandDescriptor[] export const MODEL_DESCRIPTOR_GROUPS = [modelClaude, modelDeepseek, modelGemini, modelGlm, modelGpt, modelKimi, modelLlama, modelMinimax, modelMistral, modelNemotron, modelOpenaiCompatibleAlias, modelOpencode, modelQwen, modelXai, modelXiaomiMimo] as const satisfies readonly (readonly ModelDescriptor[])[] export const MODEL_DESCRIPTORS = MODEL_DESCRIPTOR_GROUPS.flat() satisfies readonly ModelDescriptor[] @@ -301,7 +300,7 @@ export const PROVIDER_PRESET_MANIFEST = [ "preset": "opencode-go", "routeKind": "gateway", "routeId": "opencode-go", - "vendorId": "opencode", + "vendorId": "openai", "gatewayId": "opencode-go", "description": "OpenCode Go — $10/mo subscription for open models (12 models)", "apiKeyEnvVars": [ @@ -313,9 +312,10 @@ export const PROVIDER_PRESET_MANIFEST = [ }, { "preset": "opencode", - "routeKind": "vendor", + "routeKind": "gateway", "routeId": "opencode", - "vendorId": "opencode", + "vendorId": "openai", + "gatewayId": "opencode", "description": "OpenCode Zen — pay-as-you-go AI gateway (41 models)", "apiKeyEnvVars": [ "OPENCODE_API_KEY" diff --git a/src/integrations/models/opencode.ts b/src/integrations/models/opencode.ts index f914bc8318..8695358b3c 100644 --- a/src/integrations/models/opencode.ts +++ b/src/integrations/models/opencode.ts @@ -9,8 +9,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.5', label: 'GPT 5.5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'gpt-5.5', capabilities: { @@ -27,8 +27,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.5-pro', label: 'GPT 5.5 Pro', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'gpt-5.5-pro', capabilities: { @@ -45,8 +45,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.4', label: 'GPT 5.4', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'gpt-5.4', capabilities: { @@ -63,8 +63,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.4-pro', label: 'GPT 5.4 Pro', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'gpt-5.4-pro', capabilities: { @@ -81,8 +81,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.4-mini', label: 'GPT 5.4 Mini', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'gpt-5.4-mini', capabilities: { @@ -99,8 +99,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.4-nano', label: 'GPT 5.4 Nano', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'gpt-5.4-nano', capabilities: { @@ -117,8 +117,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.3-codex', label: 'GPT 5.3 Codex', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'coding'], defaultModel: 'gpt-5.3-codex', capabilities: { @@ -135,8 +135,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.3-codex-spark', label: 'GPT 5.3 Codex Spark', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'coding'], defaultModel: 'gpt-5.3-codex-spark', capabilities: { @@ -153,8 +153,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.2', label: 'GPT 5.2', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'gpt-5.2', capabilities: { @@ -171,8 +171,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.2-codex', label: 'GPT 5.2 Codex', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'coding'], defaultModel: 'gpt-5.2-codex', capabilities: { @@ -189,8 +189,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.1', label: 'GPT 5.1', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'gpt-5.1', capabilities: { @@ -207,8 +207,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.1-codex', label: 'GPT 5.1 Codex', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'coding'], defaultModel: 'gpt-5.1-codex', capabilities: { @@ -225,8 +225,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.1-codex-max', label: 'GPT 5.1 Codex Max', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'coding'], defaultModel: 'gpt-5.1-codex-max', capabilities: { @@ -243,8 +243,8 @@ export default [ defineModel({ id: 'opencode-gpt-5.1-codex-mini', label: 'GPT 5.1 Codex Mini', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'coding'], defaultModel: 'gpt-5.1-codex-mini', capabilities: { @@ -261,8 +261,8 @@ export default [ defineModel({ id: 'opencode-gpt-5', label: 'GPT 5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'gpt-5', capabilities: { @@ -279,8 +279,8 @@ export default [ defineModel({ id: 'opencode-gpt-5-codex', label: 'GPT 5 Codex', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'coding'], defaultModel: 'gpt-5-codex', capabilities: { @@ -297,8 +297,8 @@ export default [ defineModel({ id: 'opencode-gpt-5-nano', label: 'GPT 5 Nano', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'gpt-5-nano', capabilities: { @@ -317,8 +317,8 @@ export default [ defineModel({ id: 'opencode-claude-opus-4-7', label: 'Claude Opus 4.7', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'claude-opus-4-7', capabilities: { @@ -335,8 +335,8 @@ export default [ defineModel({ id: 'opencode-claude-opus-4-6', label: 'Claude Opus 4.6', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'claude-opus-4-6', capabilities: { @@ -353,8 +353,8 @@ export default [ defineModel({ id: 'opencode-claude-opus-4-5', label: 'Claude Opus 4.5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'claude-opus-4-5', capabilities: { @@ -371,8 +371,8 @@ export default [ defineModel({ id: 'opencode-claude-opus-4-1', label: 'Claude Opus 4.1', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'claude-opus-4-1', capabilities: { @@ -389,8 +389,8 @@ export default [ defineModel({ id: 'opencode-claude-sonnet-4-6', label: 'Claude Sonnet 4.6', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'claude-sonnet-4-6', capabilities: { @@ -407,8 +407,8 @@ export default [ defineModel({ id: 'opencode-claude-sonnet-4-5', label: 'Claude Sonnet 4.5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'claude-sonnet-4-5', capabilities: { @@ -425,8 +425,8 @@ export default [ defineModel({ id: 'opencode-claude-sonnet-4', label: 'Claude Sonnet 4', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'claude-sonnet-4', capabilities: { @@ -443,8 +443,8 @@ export default [ defineModel({ id: 'opencode-claude-haiku-4-5', label: 'Claude Haiku 4.5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'claude-haiku-4-5', capabilities: { @@ -461,8 +461,8 @@ export default [ defineModel({ id: 'opencode-claude-3-5-haiku', label: 'Claude Haiku 3.5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'claude-3-5-haiku', capabilities: { @@ -481,8 +481,8 @@ export default [ defineModel({ id: 'opencode-gemini-3.5-flash', label: 'Gemini 3.5 Flash', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'gemini-3.5-flash', capabilities: { @@ -499,8 +499,8 @@ export default [ defineModel({ id: 'opencode-gemini-3.1-pro', label: 'Gemini 3.1 Pro', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'gemini-3.1-pro', capabilities: { @@ -517,8 +517,8 @@ export default [ defineModel({ id: 'opencode-gemini-3-flash', label: 'Gemini 3 Flash', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'gemini-3-flash', capabilities: { @@ -537,8 +537,8 @@ export default [ defineModel({ id: 'opencode-qwen3.6-plus', label: 'Qwen3.6 Plus', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'qwen3.6-plus', capabilities: { @@ -555,8 +555,8 @@ export default [ defineModel({ id: 'opencode-qwen3.5-plus', label: 'Qwen3.5 Plus', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'qwen3.5-plus', capabilities: { @@ -575,8 +575,8 @@ export default [ defineModel({ id: 'opencode-minimax-m2.7', label: 'MiniMax M2.7', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'minimax-m2.7', capabilities: { @@ -593,8 +593,8 @@ export default [ defineModel({ id: 'opencode-minimax-m2.5', label: 'MiniMax M2.5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'minimax-m2.5', capabilities: { @@ -611,8 +611,8 @@ export default [ defineModel({ id: 'opencode-glm-5.1', label: 'GLM 5.1', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'glm-5.1', capabilities: { @@ -629,8 +629,8 @@ export default [ defineModel({ id: 'opencode-glm-5', label: 'GLM 5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'glm-5', capabilities: { @@ -647,8 +647,8 @@ export default [ defineModel({ id: 'opencode-kimi-k2.5', label: 'Kimi K2.5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'kimi-k2.5', capabilities: { @@ -665,8 +665,8 @@ export default [ defineModel({ id: 'opencode-kimi-k2.6', label: 'Kimi K2.6', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'kimi-k2.6', capabilities: { @@ -683,8 +683,8 @@ export default [ defineModel({ id: 'opencode-grok-build-0.1', label: 'Grok Build 0.1', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'grok-build-0.1', capabilities: { @@ -701,8 +701,8 @@ export default [ defineModel({ id: 'opencode-big-pickle', label: 'Big Pickle', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'big-pickle', capabilities: { @@ -719,8 +719,8 @@ export default [ defineModel({ id: 'opencode-deepseek-v4-flash-free', label: 'DeepSeek V4 Flash Free', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'deepseek-v4-flash-free', capabilities: { @@ -737,8 +737,8 @@ export default [ defineModel({ id: 'opencode-nemotron-3-super-free', label: 'Nemotron 3 Super Free', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'nemotron-3-super-free', capabilities: { @@ -761,8 +761,8 @@ export default [ defineModel({ id: 'opencode-go-glm-5.1', label: 'GLM 5.1', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'glm-5.1', capabilities: { @@ -779,8 +779,8 @@ export default [ defineModel({ id: 'opencode-go-glm-5', label: 'GLM 5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'glm-5', capabilities: { @@ -797,8 +797,8 @@ export default [ defineModel({ id: 'opencode-go-kimi-k2.5', label: 'Kimi K2.5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'kimi-k2.5', capabilities: { @@ -815,8 +815,8 @@ export default [ defineModel({ id: 'opencode-go-kimi-k2.6', label: 'Kimi K2.6', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'kimi-k2.6', capabilities: { @@ -833,8 +833,8 @@ export default [ defineModel({ id: 'opencode-go-deepseek-v4-pro', label: 'DeepSeek V4 Pro', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'deepseek-v4-pro', capabilities: { @@ -851,8 +851,8 @@ export default [ defineModel({ id: 'opencode-go-deepseek-v4-flash', label: 'DeepSeek V4 Flash', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'deepseek-v4-flash', capabilities: { @@ -869,8 +869,8 @@ export default [ defineModel({ id: 'opencode-go-mimo-v2.5', label: 'MiMo V2.5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'coding'], defaultModel: 'mimo-v2.5', capabilities: { @@ -887,8 +887,8 @@ export default [ defineModel({ id: 'opencode-go-mimo-v2.5-pro', label: 'MiMo V2.5 Pro', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'coding'], defaultModel: 'mimo-v2.5-pro', capabilities: { @@ -907,8 +907,8 @@ export default [ defineModel({ id: 'opencode-go-minimax-m2.7', label: 'MiniMax M2.7', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'minimax-m2.7', capabilities: { @@ -925,8 +925,8 @@ export default [ defineModel({ id: 'opencode-go-minimax-m2.5', label: 'MiniMax M2.5', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat'], defaultModel: 'minimax-m2.5', capabilities: { @@ -943,8 +943,8 @@ export default [ defineModel({ id: 'opencode-go-qwen3.6-plus', label: 'Qwen3.6 Plus', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'qwen3.6-plus', capabilities: { @@ -961,8 +961,8 @@ export default [ defineModel({ id: 'opencode-go-qwen3.5-plus', label: 'Qwen3.5 Plus', - brandId: 'opencode', - vendorId: 'opencode', + + vendorId: 'openai', classification: ['chat', 'reasoning'], defaultModel: 'qwen3.5-plus', capabilities: { From aad0fb3569b74b82ecad195363bbc4909c0cdb67 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 26 May 2026 09:34:46 +0600 Subject: [PATCH 07/27] 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 --- README.md | 2 ++ docs/advanced-setup.md | 30 ++++++++++++++++ docs/integrations/overview.md | 7 ++-- src/components/ProviderManager.tsx | 36 +++---------------- src/integrations/artifactGenerator.ts | 1 + src/integrations/descriptors.ts | 7 ++++ .../gateways/gitlawb-opengateway.ts | 1 + src/integrations/gateways/opencode-go.ts | 1 + src/integrations/gateways/opencode.ts | 1 + .../integrationArtifacts.generated.ts | 24 ++++++++++--- src/integrations/providerUiMetadata.ts | 4 ++- src/integrations/vendors/xiaomi-mimo.ts | 1 + 12 files changed, 76 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 865ba21141..11f934c2cc 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,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 | `/provider` or zero-config fallback | Free smart gateway at `https://opengateway.gitlawb.com/v1`; 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 | | Xiaomi MiMo | `/provider` or env vars | OpenAI-compatible API at `https://api.xiaomimimo.com/v1`; 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 | diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index a7a388a0f0..9be9a5b6d3 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -153,6 +153,35 @@ export OPENAI_MODEL=llama-3.3-70b-versatile `GROQ_API_KEY` matches the built-in Groq gateway preset. `OPENAI_API_KEY` also works as a fallback on the generic OpenAI-compatible path, but `GROQ_API_KEY` is the preferred variable for Groq-specific setup. +### OpenCode Zen (pay-as-you-go) + +```bash +export CLAUDE_CODE_USE_OPENAI=1 +export OPENCODE_API_KEY=... +export OPENAI_BASE_URL=https://opencode.ai/zen/v1 +export OPENAI_MODEL=gpt-5.4 + +openclaude +``` + +OpenCode Zen is a pay-as-you-go AI gateway with 41 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. + +### OpenCode Go (subscription) + +```bash +export CLAUDE_CODE_USE_OPENAI=1 +export OPENCODE_API_KEY=... +export OPENAI_BASE_URL=https://opencode.ai/zen/go/v1 +export OPENAI_MODEL=glm-5.1 + +openclaude +``` + +OpenCode Go is a $10/mo subscription for 12 open models (GLM, Kimi, DeepSeek, +MiMo, MiniMax, Qwen). Uses the same `OPENCODE_API_KEY` as OpenCode Zen. + ### Gitlawb Opengateway ```bash @@ -205,6 +234,7 @@ export OPENAI_MODEL=gpt-4o | `OPENAI_MODEL` | OpenAI-compatible only | Model name such as `gpt-4o`, `deepseek-v4-flash`, or `llama3.3:70b` | | `OPENAI_BASE_URL` | No | API endpoint, defaulting to `https://api.openai.com/v1` | | `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` | +| `OPENCODE_API_KEY` | OpenCode Zen / Go | Shared API key for OpenCode Zen (pay-as-you-go) and OpenCode Go (subscription); get yours from https://opencode.ai | | `MIMO_API_KEY` | Xiaomi MiMo route | Xiaomi MiMo API key for `https://api.xiaomimimo.com/v1`; mirrored into the OpenAI-compatible auth env when the MiMo route is active | | `CLAUDE_CODE_USE_GEMINI` | Gemini only | Set to `1` to enable the direct Gemini provider path | | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Gemini API-key auth | Gemini API key for direct Gemini setup | diff --git a/docs/integrations/overview.md b/docs/integrations/overview.md index 917578fca6..61bf2e38fe 100644 --- a/docs/integrations/overview.md +++ b/docs/integrations/overview.md @@ -161,8 +161,11 @@ Normal contributor flow for new preset-participating routes is: 1. add or edit the descriptor file; 2. add `preset` metadata only when the route should be user-facing; -3. run `bun run integrations:generate`; -4. let the generated manifest feed the loader, compatibility mapping, preset +3. add `preset.badge` metadata if the route should show a display tag (e.g. + `[FREE]`, `[Sponsor]`, `[Zen]`, `[Go]`) in the preset picker — this avoids + hard-coded badge logic in `src/components/ProviderManager.tsx`; +4. run `bun run integrations:generate`; +5. let the generated manifest feed the loader, compatibility mapping, preset typing, and provider UI metadata. ## Compatibility Layer diff --git a/src/components/ProviderManager.tsx b/src/components/ProviderManager.tsx index dd38c55351..7c191eb789 100644 --- a/src/components/ProviderManager.tsx +++ b/src/components/ProviderManager.tsx @@ -229,43 +229,15 @@ function toDraft(profile: ProviderProfile): ProviderDraft { } } -function getPresetLabel(preset: ProviderPreset, label: string): React.ReactNode { - if (preset === 'gitlawb-opengateway') { +function getPresetLabel(preset: ProviderPreset, label: string, metadata?: { badge?: { text: string; color?: string } }): React.ReactNode { + if (metadata?.badge) { return ( {label} - [FREE] + [{metadata.badge.text}] ) } - - if (preset === 'xiaomi-mimo') { - return ( - - {label} - [Sponsor] - - ) - } - - if (preset === 'opencode') { - return ( - - {label} - [Zen] - - ) - } - - if (preset === 'opencode-go') { - return ( - - {label} - [Go] - - ) - } - return label } @@ -1837,7 +1809,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode { const metadata = getProviderPresetUiMetadata(preset) return { value: preset, - label: getPresetLabel(preset, metadata.label), + label: getPresetLabel(preset, metadata.label, { badge: metadata.badge }), description: metadata.description, } }) diff --git a/src/integrations/artifactGenerator.ts b/src/integrations/artifactGenerator.ts index 97835254de..3932cb5154 100644 --- a/src/integrations/artifactGenerator.ts +++ b/src/integrations/artifactGenerator.ts @@ -218,6 +218,7 @@ function buildPresetManifestEntry( modelEnvVars: preset.modelEnvVars, fallbackBaseUrl: preset.fallbackBaseUrl, fallbackModel: preset.fallbackModel, + badge: preset.badge, } } diff --git a/src/integrations/descriptors.ts b/src/integrations/descriptors.ts index 0cc73f50b7..f2b4b072a7 100644 --- a/src/integrations/descriptors.ts +++ b/src/integrations/descriptors.ts @@ -149,6 +149,11 @@ export interface ValidationRoutingMetadata { skipWhenUseOpenAI?: boolean } +export interface PresetBadge { + text: string + color?: string +} + export interface ProviderPresetMetadata { id: string description: string @@ -160,6 +165,7 @@ export interface ProviderPresetMetadata { modelEnvVars?: string[] fallbackBaseUrl?: string fallbackModel?: string + badge?: PresetBadge } export type ProviderPresetRouteKind = @@ -181,6 +187,7 @@ export interface ProviderPresetManifestEntry { modelEnvVars?: readonly string[] fallbackBaseUrl?: string fallbackModel?: string + badge?: PresetBadge } export type ValidationMetadata = diff --git a/src/integrations/gateways/gitlawb-opengateway.ts b/src/integrations/gateways/gitlawb-opengateway.ts index b97a59c8dc..554c4271ea 100644 --- a/src/integrations/gateways/gitlawb-opengateway.ts +++ b/src/integrations/gateways/gitlawb-opengateway.ts @@ -51,6 +51,7 @@ export default defineGateway({ baseUrlEnvVars: ['OPENGATEWAY_BASE_URL', 'OPENAI_BASE_URL'], fallbackBaseUrl: 'https://opengateway.gitlawb.com/v1', fallbackModel: 'mimo-v2.5-pro', + badge: { text: 'FREE', color: 'success' }, }, catalog: { source: 'static', diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts index ecb48f1795..94dc6818cf 100644 --- a/src/integrations/gateways/opencode-go.ts +++ b/src/integrations/gateways/opencode-go.ts @@ -23,6 +23,7 @@ export default defineGateway({ description: 'OpenCode Go — $10/mo subscription for open models (12 models)', apiKeyEnvVars: ['OPENCODE_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], + badge: { text: 'Go', color: 'green' }, }, catalog: { source: 'static', diff --git a/src/integrations/gateways/opencode.ts b/src/integrations/gateways/opencode.ts index 979ba36461..ad49904e2e 100644 --- a/src/integrations/gateways/opencode.ts +++ b/src/integrations/gateways/opencode.ts @@ -23,6 +23,7 @@ export default defineGateway({ description: 'OpenCode Zen — pay-as-you-go AI gateway (41 models)', apiKeyEnvVars: ['OPENCODE_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], + badge: { text: 'Zen', color: 'cyan' }, }, validation: { kind: 'credential-env', diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index f1022daa90..adec1ccbec 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -88,7 +88,11 @@ export const PROVIDER_PRESET_MANIFEST = [ "OPENAI_MODEL" ], "fallbackBaseUrl": "https://opengateway.gitlawb.com/v1", - "fallbackModel": "mimo-v2.5-pro" + "fallbackModel": "mimo-v2.5-pro", + "badge": { + "text": "FREE", + "color": "success" + } }, { "preset": "anthropic", @@ -308,7 +312,11 @@ export const PROVIDER_PRESET_MANIFEST = [ ], "modelEnvVars": [ "OPENAI_MODEL" - ] + ], + "badge": { + "text": "Go", + "color": "green" + } }, { "preset": "opencode", @@ -322,7 +330,11 @@ export const PROVIDER_PRESET_MANIFEST = [ ], "modelEnvVars": [ "OPENAI_MODEL" - ] + ], + "badge": { + "text": "Zen", + "color": "cyan" + } }, { "preset": "openrouter", @@ -385,7 +397,11 @@ export const PROVIDER_PRESET_MANIFEST = [ ], "modelEnvVars": [ "OPENAI_MODEL" - ] + ], + "badge": { + "text": "Sponsor", + "color": "success" + } }, { "preset": "zai", diff --git a/src/integrations/providerUiMetadata.ts b/src/integrations/providerUiMetadata.ts index a0bdf94c80..428b44a9f9 100644 --- a/src/integrations/providerUiMetadata.ts +++ b/src/integrations/providerUiMetadata.ts @@ -1,4 +1,4 @@ -import type { AuthMode } from './descriptors.js' +import type { AuthMode, PresetBadge } from './descriptors.js' import type { ProviderPresetManifestEntry } from './descriptors.js' import { routeForPreset } from './compatibility.js' import { @@ -38,6 +38,7 @@ function readFirstEnvValue( export type ProviderPresetUiMetadata = { apiKey: string authMode: AuthMode + badge?: PresetBadge baseUrl: string credentialEnvVars: string[] description: string @@ -83,6 +84,7 @@ export function getProviderPresetUiMetadata( return { apiKey: readFirstEnvValue(processEnv, credentialEnvVars), authMode: descriptor?.setup.authMode ?? 'api-key', + badge: presetMetadata.badge, baseUrl, credentialEnvVars, description: presetMetadata.description, diff --git a/src/integrations/vendors/xiaomi-mimo.ts b/src/integrations/vendors/xiaomi-mimo.ts index 9e1fa396b2..e991287a26 100644 --- a/src/integrations/vendors/xiaomi-mimo.ts +++ b/src/integrations/vendors/xiaomi-mimo.ts @@ -70,6 +70,7 @@ export default defineVendor({ name: 'Xiaomi MiMo', apiKeyEnvVars: ['MIMO_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], + badge: { text: 'Sponsor', color: 'success' }, }, validation: { kind: 'credential-env', From 193a09cb9f389ee1e7a5afbf977b013f4ba407ba Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 26 May 2026 14:53:20 +0600 Subject: [PATCH 08/27] fix(provider): match request body to endpoint format for OpenCode /messages and /responses (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/integrations/overview.md | 2 +- src/integrations/gateways/opencode-go.ts | 1 - src/integrations/gateways/opencode.ts | 1 - .../integrationArtifacts.generated.ts | 12 +- src/services/api/openaiShim.ts | 129 +++++++++++++++++- 5 files changed, 129 insertions(+), 16 deletions(-) diff --git a/docs/integrations/overview.md b/docs/integrations/overview.md index 61bf2e38fe..8894bc8cb6 100644 --- a/docs/integrations/overview.md +++ b/docs/integrations/overview.md @@ -162,7 +162,7 @@ Normal contributor flow for new preset-participating routes is: 1. add or edit the descriptor file; 2. add `preset` metadata only when the route should be user-facing; 3. add `preset.badge` metadata if the route should show a display tag (e.g. - `[FREE]`, `[Sponsor]`, `[Zen]`, `[Go]`) in the preset picker — this avoids + `[FREE]`, `[Sponsor]`) in the preset picker — this avoids hard-coded badge logic in `src/components/ProviderManager.tsx`; 4. run `bun run integrations:generate`; 5. let the generated manifest feed the loader, compatibility mapping, preset diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts index 94dc6818cf..ecb48f1795 100644 --- a/src/integrations/gateways/opencode-go.ts +++ b/src/integrations/gateways/opencode-go.ts @@ -23,7 +23,6 @@ export default defineGateway({ description: 'OpenCode Go — $10/mo subscription for open models (12 models)', apiKeyEnvVars: ['OPENCODE_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], - badge: { text: 'Go', color: 'green' }, }, catalog: { source: 'static', diff --git a/src/integrations/gateways/opencode.ts b/src/integrations/gateways/opencode.ts index ad49904e2e..979ba36461 100644 --- a/src/integrations/gateways/opencode.ts +++ b/src/integrations/gateways/opencode.ts @@ -23,7 +23,6 @@ export default defineGateway({ description: 'OpenCode Zen — pay-as-you-go AI gateway (41 models)', apiKeyEnvVars: ['OPENCODE_API_KEY'], modelEnvVars: ['OPENAI_MODEL'], - badge: { text: 'Zen', color: 'cyan' }, }, validation: { kind: 'credential-env', diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index adec1ccbec..7aa0a631e6 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -312,11 +312,7 @@ export const PROVIDER_PRESET_MANIFEST = [ ], "modelEnvVars": [ "OPENAI_MODEL" - ], - "badge": { - "text": "Go", - "color": "green" - } + ] }, { "preset": "opencode", @@ -330,11 +326,7 @@ export const PROVIDER_PRESET_MANIFEST = [ ], "modelEnvVars": [ "OPENAI_MODEL" - ], - "badge": { - "text": "Zen", - "color": "cyan" - } + ] }, { "preset": "openrouter", diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index f65a82e079..b8377371b2 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1010,6 +1010,68 @@ function repairPossiblyTruncatedObjectJson(raw: string): string | null { * Async generator that transforms an OpenAI SSE stream into * Anthropic-format BetaRawMessageStreamEvent objects. */ +/** + * Passthrough for Anthropic Messages API SSE streams. + * The response events are already in AnthropicStreamEvent format — + * we just parse the SSE frames and yield them directly. + */ +async function* anthropicSsePassthrough( + response: Response, + _model: string, + signal?: AbortSignal, +): AsyncGenerator { + const reader = response.body?.getReader() + if (!reader) return + const decoder = new TextDecoder() + let buffer = '' + + // Read helper that properly cleans up abort listeners (mirrors codexShim.ts pattern). + function readWithAbort(): Promise> { + if (!signal) return reader.read() + return new Promise((resolve, reject) => { + const onAbort = () => reject(new DOMException('Aborted', 'AbortError')) + signal.addEventListener('abort', onAbort, { once: true }) + reader.read().then( + result => { signal.removeEventListener('abort', onAbort); resolve(result) }, + err => { signal.removeEventListener('abort', onAbort); reject(err) }, + ) + }) + } + + try { + while (true) { + const { done, value } = await readWithAbort() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const chunks = buffer.split('\n\n') + buffer = chunks.pop() ?? '' + + for (const chunk of chunks) { + const lines = chunk.split('\n').map(l => l.trim()).filter(Boolean) + if (lines.length === 0) continue + + const dataLines = lines.filter(l => l.startsWith('data: ')) + if (dataLines.length === 0) continue + + const rawData = dataLines.map(l => l.slice(6)).join('\n') + if (rawData === '[DONE]') return + + try { + const parsed = JSON.parse(rawData) as AnthropicStreamEvent + if (parsed && typeof parsed === 'object' && 'type' in parsed) { + yield parsed + } + } catch { + // skip malformed frames + } + } + } + } finally { + reader.releaseLock() + } +} + async function* openaiStreamToAnthropic( response: Response, model: string, @@ -1606,6 +1668,7 @@ class OpenAIShimMessages { if (params.stream) { const isResponsesStream = response.url?.includes('/responses') + const isMessagesStream = response.url?.includes('/messages') return new OpenAIShimStream( ( request.transport === 'codex_responses' || @@ -1613,7 +1676,9 @@ class OpenAIShimMessages { isResponsesStream ) ? codexStreamToAnthropic(response, request.resolvedModel, options?.signal) - : openaiStreamToAnthropic(response, request.resolvedModel, options?.signal), + : isMessagesStream + ? anthropicSsePassthrough(response, request.resolvedModel, options?.signal) + : openaiStreamToAnthropic(response, request.resolvedModel, options?.signal), ) } @@ -1626,6 +1691,7 @@ class OpenAIShimMessages { } const isResponsesNonStream = response.url?.includes('/responses') + const isMessagesNonStream = response.url?.includes('/messages') if ( request.transport === 'responses' || isResponsesNonStream || @@ -1648,6 +1714,15 @@ class OpenAIShimMessages { } } + // Anthropic Messages API response — already in Anthropic format, + // pass through directly without conversion. + if (isMessagesNonStream) { + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) { + return await response.json() as Record + } + } + const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('application/json')) { const data = await response.json() @@ -1785,6 +1860,15 @@ class OpenAIShimMessages { treatAsLocal: isLocalProviderUrl(request.baseUrl), }) const shimConfig = runtimeShimContext.openaiShimConfig + // When endpointPath is overridden, the body format must match the target + // API contract rather than request.transport from providerConfig. + // - /responses → OpenAI Responses API (input, max_output_tokens, instructions) + // - /messages → Anthropic Messages API (system, max_tokens, content blocks) + const effectiveTransport = shimConfig.endpointPath === '/responses' + ? 'responses' + : shimConfig.endpointPath === '/messages' + ? 'anthropic_messages' + : request.transport const openaiMessages = convertMessages(compressedMessages, params.system, { preserveReasoningContent: shimConfig.preserveReasoningContent, reasoningContentFallback: shimConfig.reasoningContentFallback, @@ -1968,6 +2052,42 @@ class OpenAIShimMessages { return responsesBody } + // Anthropic Messages API body — used when endpointPath is /messages. + // params.messages, params.tools, etc. are already in Anthropic format + // (they originate from the Anthropic SDK). We pass them through directly, + // only adding the top-level system (as string or content-block array) + // and max_tokens. + let omitAnthropicTools = false + const buildAnthropicMessagesBody = (): Record => { + const anthropicBody: Record = { + model: request.resolvedModel, + messages: params.messages, + max_tokens: params.max_tokens, + stream: params.stream ?? false, + } + + // Pass system through in native format. The Anthropic Messages API + // accepts either a string or an array of content blocks (with optional + // cache_control markers). Only filter the billing header block. + if (Array.isArray(params.system)) { + const filtered = (params.system as Array<{ type?: string; text?: string }>) + .filter(block => !(block.type === 'text' && (block.text ?? '').startsWith('x-anthropic-billing-header'))) + if (filtered.length > 0) anthropicBody.system = filtered + } else if (params.system) { + const text = typeof params.system === 'string' ? params.system : String(params.system) + if (text && !text.startsWith('x-anthropic-billing-header')) anthropicBody.system = text + } + + if (!omitAnthropicTools && params.tools && params.tools.length > 0) { + anthropicBody.tools = params.tools + } + if (params.tool_choice) { + anthropicBody.tool_choice = params.tool_choice + } + + return anthropicBody + } + const headers: Record = { 'Content-Type': 'application/json', ...filterAnthropicHeaders(shimConfig.headers), @@ -2153,7 +2273,9 @@ class OpenAIShimMessages { // `JSON.stringify` fast path when the fast-path config opts out. const serializeBody = (): string => { const payload = - request.transport === 'responses' ? buildResponsesBody() : body + effectiveTransport === 'responses' ? buildResponsesBody() + : effectiveTransport === 'anthropic_messages' ? buildAnthropicMessagesBody() + : body return fastPath.skipStableStringify ? JSON.stringify(payload) : stableStringifyJson(payload) @@ -2384,7 +2506,7 @@ class OpenAIShimMessages { } const hasToolsPayload = - request.transport === 'responses' + effectiveTransport === 'responses' || effectiveTransport === 'anthropic_messages' ? Array.isArray(params.tools) && params.tools.length > 0 : Array.isArray(body.tools) && body.tools.length > 0 @@ -2400,6 +2522,7 @@ class OpenAIShimMessages { delete body.tools delete body.tool_choice omitResponsesTools = true + omitAnthropicTools = true refreshSerializedBody() logForDebugging( From 592864348a6bcaf2b5e7a3b45076733be2de5fc6 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 26 May 2026 22:26:22 +0600 Subject: [PATCH 09/27] fix(provider): add Google AI SDK body/response format for OpenCode Zen Gemini models (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/services/api/openaiShim.ts | 373 ++++++++++++++++++++++++++++++++- 1 file changed, 368 insertions(+), 5 deletions(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index b8377371b2..a645e3df23 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1072,6 +1072,184 @@ async function* anthropicSsePassthrough( } } +/** + * Transforms Google AI SDK SSE stream into Anthropic-format stream events. + * Google AI SDK yields frames with { candidates: [{ content: { role, parts } }] }. + */ +async function* geminiSseToAnthropic( + response: Response, + model: string, + signal?: AbortSignal, +): AsyncGenerator { + const reader = response.body?.getReader() + if (!reader) return + const decoder = new TextDecoder() + let buffer = '' + const messageId = makeMessageId() + let contentBlockIndex = 0 + let hasEmittedStart = false + let hasEmittedTextStart = false + let hasEmittedCurrentTool = false + let usage: Partial | undefined + let finishReason: string | undefined + + function readWithAbort(): Promise> { + if (!signal) return reader.read() + return new Promise((resolve, reject) => { + const onAbort = () => reject(new DOMException('Aborted', 'AbortError')) + signal.addEventListener('abort', onAbort, { once: true }) + reader.read().then( + result => { signal.removeEventListener('abort', onAbort); resolve(result) }, + err => { signal.removeEventListener('abort', onAbort); reject(err) }, + ) + }) + } + + function mapFinishReason(reason: string | undefined, hasToolUse: boolean): string { + if (hasToolUse) return 'tool_use' + if (reason === 'MAX_TOKENS') return 'max_tokens' + return 'end_turn' + } + + try { + while (true) { + const { done, value } = await readWithAbort() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const chunks = buffer.split('\n\n') + buffer = chunks.pop() ?? '' + + for (const chunk of chunks) { + const lines = chunk.split('\n').map(l => l.trim()).filter(Boolean) + const dataLines = lines.filter(l => l.startsWith('data: ')) + if (dataLines.length === 0) continue + + const rawData = dataLines.map(l => l.slice(6)).join('\n') + if (rawData === '[DONE]') { + if (hasEmittedTextStart || hasEmittedCurrentTool) { + yield { type: 'content_block_stop', index: contentBlockIndex } + } + yield { + type: 'message_delta', + delta: { stop_reason: mapFinishReason(finishReason, hasEmittedCurrentTool) }, + usage: usage ?? {}, + } + yield { type: 'message_stop' } + return + } + + let parsed: Record + try { + parsed = JSON.parse(rawData) as Record + } catch { + continue + } + + if (!hasEmittedStart) { + yield { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + content: [], + model, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + } + hasEmittedStart = true + } + + if (parsed.usageMetadata && typeof parsed.usageMetadata === 'object') { + const um = parsed.usageMetadata as Record + usage = buildAnthropicUsageFromRawUsage({ + input_tokens: um.promptTokenCount ?? 0, + output_tokens: (um.candidatesTokenCount ?? 0) + (um.thoughtsTokenCount ?? 0), + }) + } + + const candidates = parsed.candidates as Array> | undefined + if (!candidates || candidates.length === 0) continue + const candidate = candidates[0] + + if (typeof candidate.finishReason === 'string') { + finishReason = candidate.finishReason + } + + const content = candidate.content as { role?: string; parts?: Array> } | undefined + if (!content || !content.parts) continue + + for (const part of content.parts) { + const text = part.text as string | undefined + const fc = part.functionCall as { name?: string; args?: unknown } | undefined + + if (text) { + if (hasEmittedCurrentTool) { + yield { type: 'content_block_stop', index: contentBlockIndex } + contentBlockIndex++ + hasEmittedCurrentTool = false + } + if (!hasEmittedTextStart) { + yield { + type: 'content_block_start', + index: contentBlockIndex, + content_block: { type: 'text', text: '' }, + } + hasEmittedTextStart = true + } + yield { + type: 'content_block_delta', + index: contentBlockIndex, + delta: { type: 'text_delta', text }, + } + } else if (fc?.name) { + if (hasEmittedTextStart) { + yield { type: 'content_block_stop', index: contentBlockIndex } + contentBlockIndex++ + hasEmittedTextStart = false + } + const toolId = `toolu_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}` + yield { + type: 'content_block_start', + index: contentBlockIndex, + content_block: { + type: 'tool_use', + id: toolId, + name: fc.name, + input: {}, + }, + } + hasEmittedCurrentTool = true + yield { + type: 'content_block_delta', + index: contentBlockIndex, + delta: { + type: 'input_json_delta', + partial_json: typeof fc.args === 'string' ? fc.args : JSON.stringify(fc.args ?? {}), + }, + } + } + } + } + } + + if (hasEmittedTextStart || hasEmittedCurrentTool) { + yield { type: 'content_block_stop', index: contentBlockIndex } + } + yield { + type: 'message_delta', + delta: { stop_reason: mapFinishReason(finishReason, hasEmittedCurrentTool) }, + usage: usage ?? {}, + } + yield { type: 'message_stop' } + } finally { + reader.releaseLock() + } +} + async function* openaiStreamToAnthropic( response: Response, model: string, @@ -1669,6 +1847,7 @@ class OpenAIShimMessages { if (params.stream) { const isResponsesStream = response.url?.includes('/responses') const isMessagesStream = response.url?.includes('/messages') + const isGeminiStream = response.url?.includes('/models/gemini-') return new OpenAIShimStream( ( request.transport === 'codex_responses' || @@ -1678,7 +1857,9 @@ class OpenAIShimMessages { ? codexStreamToAnthropic(response, request.resolvedModel, options?.signal) : isMessagesStream ? anthropicSsePassthrough(response, request.resolvedModel, options?.signal) - : openaiStreamToAnthropic(response, request.resolvedModel, options?.signal), + : isGeminiStream + ? geminiSseToAnthropic(response, request.resolvedModel, options?.signal) + : openaiStreamToAnthropic(response, request.resolvedModel, options?.signal), ) } @@ -1692,6 +1873,7 @@ class OpenAIShimMessages { const isResponsesNonStream = response.url?.includes('/responses') const isMessagesNonStream = response.url?.includes('/messages') + const isGeminiNonStream = response.url?.includes('/models/gemini-') if ( request.transport === 'responses' || isResponsesNonStream || @@ -1723,6 +1905,15 @@ class OpenAIShimMessages { } } + // Google AI SDK response — convert to Anthropic format + if (isGeminiNonStream) { + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) { + const parsed = await response.json() as Record + return self._convertGeminiToAnthropicResponse(parsed, request.resolvedModel) + } + } + const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('application/json')) { const data = await response.json() @@ -1862,13 +2053,16 @@ class OpenAIShimMessages { const shimConfig = runtimeShimContext.openaiShimConfig // When endpointPath is overridden, the body format must match the target // API contract rather than request.transport from providerConfig. - // - /responses → OpenAI Responses API (input, max_output_tokens, instructions) - // - /messages → Anthropic Messages API (system, max_tokens, content blocks) + // - /responses → OpenAI Responses API (input, max_output_tokens, instructions) + // - /messages → Anthropic Messages API (system, max_tokens, content blocks) + // - /models/gemini-* → Google AI SDK (contents, systemInstruction, generationConfig) const effectiveTransport = shimConfig.endpointPath === '/responses' ? 'responses' : shimConfig.endpointPath === '/messages' ? 'anthropic_messages' - : request.transport + : shimConfig.endpointPath?.startsWith('/models/gemini-') + ? 'gemini' + : request.transport const openaiMessages = convertMessages(compressedMessages, params.system, { preserveReasoningContent: shimConfig.preserveReasoningContent, reasoningContentFallback: shimConfig.reasoningContentFallback, @@ -2088,6 +2282,119 @@ class OpenAIShimMessages { return anthropicBody } + // Google AI SDK body — used when endpointPath is /models/gemini-*. + // Converts Anthropic-format params to Google AI SDK format. + let omitGeminiTools = false + const buildGeminiBody = (): Record => { + const contents: Array<{ role: string; parts: Array> }> = [] + + // Build a lookup from tool_use_id → function name so tool_result + // blocks can emit the correct functionResponse.name (Gemini requires + // the function name, not the Anthropic tool_use_id). + const toolUseIdToName = new Map() + const messages = params.messages as Array<{ + role?: string + content?: unknown + }> + for (const msg of messages) { + if (!Array.isArray(msg.content)) continue + for (const block of msg.content as Array<{ type?: string; id?: string; name?: string }>) { + if (block.type === 'tool_use' && block.id && block.name) { + toolUseIdToName.set(block.id, block.name) + } + } + } + + for (const msg of messages) { + const role = msg.role === 'assistant' ? 'model' : 'user' + const parts: Array> = [] + + if (typeof msg.content === 'string') { + parts.push({ text: msg.content }) + } else if (Array.isArray(msg.content)) { + for (const block of msg.content as Array<{ type?: string; text?: string; id?: string; name?: string; input?: unknown; tool_use_id?: string; content?: unknown; is_error?: boolean }>) { + if (block.type === 'text' && block.text) { + parts.push({ text: block.text }) + } else if (block.type === 'tool_use' && block.id && block.name) { + parts.push({ + functionCall: { + name: block.name, + args: block.input ?? {}, + }, + }) + } else if (block.type === 'tool_result' && block.tool_use_id) { + const funcName = toolUseIdToName.get(block.tool_use_id) ?? block.tool_use_id + let resultContent = typeof block.content === 'string' + ? block.content + : Array.isArray(block.content) + ? (block.content as Array<{ type?: string; text?: string }>) + .filter(b => b.type === 'text') + .map(b => b.text ?? '') + .join('\n') + : '' + if (block.is_error) { + resultContent = `Error: ${resultContent}` + } + parts.push({ + functionResponse: { + name: funcName, + response: { + name: funcName, + content: resultContent, + }, + }, + }) + } + } + } + + if (parts.length > 0) { + contents.push({ role, parts }) + } + } + + const geminiBody: Record = { contents } + + // System instruction + const systemText = convertSystemPrompt(params.system) + if (systemText) { + geminiBody.systemInstruction = { parts: [{ text: systemText }] } + } + + // Generation config + const genConfig: Record = {} + if (params.max_tokens !== undefined) { + genConfig.maxOutputTokens = params.max_tokens + } else if (maxTokensValue !== undefined) { + genConfig.maxOutputTokens = maxTokensValue + } else if (maxCompletionTokensValue !== undefined) { + genConfig.maxOutputTokens = maxCompletionTokensValue + } + if (params.temperature !== undefined) genConfig.temperature = params.temperature + if (params.top_p !== undefined) genConfig.topP = params.top_p + if (Object.keys(genConfig).length > 0) { + geminiBody.generationConfig = genConfig + } + + // Tools — convert Anthropic tool format to Google functionDeclarations + if (!omitGeminiTools && params.tools && params.tools.length > 0) { + const functionDeclarations = (params.tools as Array<{ + name?: string + description?: string + input_schema?: Record + }>).map(tool => ({ + name: tool.name ?? '', + description: tool.description ?? '', + ...(tool.input_schema ? { parameters: tool.input_schema } : {}), + })) + if (functionDeclarations.length > 0) { + geminiBody.tools = [{ functionDeclarations }] + } + } + + return geminiBody + } + const headers: Record = { 'Content-Type': 'application/json', ...filterAnthropicHeaders(shimConfig.headers), @@ -2275,6 +2582,7 @@ class OpenAIShimMessages { const payload = effectiveTransport === 'responses' ? buildResponsesBody() : effectiveTransport === 'anthropic_messages' ? buildAnthropicMessagesBody() + : effectiveTransport === 'gemini' ? buildGeminiBody() : body return fastPath.skipStableStringify ? JSON.stringify(payload) @@ -2506,7 +2814,7 @@ class OpenAIShimMessages { } const hasToolsPayload = - effectiveTransport === 'responses' || effectiveTransport === 'anthropic_messages' + effectiveTransport === 'responses' || effectiveTransport === 'anthropic_messages' || effectiveTransport === 'gemini' ? Array.isArray(params.tools) && params.tools.length > 0 : Array.isArray(body.tools) && body.tools.length > 0 @@ -2523,6 +2831,7 @@ class OpenAIShimMessages { delete body.tool_choice omitResponsesTools = true omitAnthropicTools = true + omitGeminiTools = true refreshSerializedBody() logForDebugging( @@ -2705,6 +3014,60 @@ class OpenAIShimMessages { ), } } + + private _convertGeminiToAnthropicResponse( + data: Record, + model: string, + ) { + const content: Array> = [] + let hasToolUse = false + const candidates = data.candidates as Array> | undefined + const candidate = candidates?.[0] + const candidateContent = candidate?.content as { parts?: Array> } | undefined + + if (candidateContent?.parts) { + for (const part of candidateContent.parts) { + const text = part.text as string | undefined + if (text) { + content.push({ type: 'text', text }) + } + const fc = part.functionCall as { name?: string; args?: unknown } | undefined + if (fc?.name) { + hasToolUse = true + content.push({ + type: 'tool_use', + id: `toolu_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`, + name: fc.name, + input: fc.args ?? {}, + }) + } + } + } + + const stopReason = + hasToolUse + ? 'tool_use' + : candidate?.finishReason === 'MAX_TOKENS' + ? 'max_tokens' + : 'end_turn' + + const usageMetadata = data.usageMetadata as Record | undefined + const usage = buildAnthropicUsageFromRawUsage({ + input_tokens: usageMetadata?.promptTokenCount ?? 0, + output_tokens: (usageMetadata?.candidatesTokenCount ?? 0) + (usageMetadata?.thoughtsTokenCount ?? 0), + } as unknown as Record) + + return { + id: makeMessageId(), + type: 'message', + role: 'assistant', + content, + model, + stop_reason: stopReason, + stop_sequence: null, + usage, + } + } } class OpenAIShimBeta { From f796b4772baf344c2651df4e3c9afba09b75862f Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 28 May 2026 11:04:05 +0600 Subject: [PATCH 10/27] 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. --- src/components/ProviderManager.test.tsx | 2 + src/integrations/models/opencode.ts | 106 ++++++++++++------------ 2 files changed, 55 insertions(+), 53 deletions(-) diff --git a/src/components/ProviderManager.test.tsx b/src/components/ProviderManager.test.tsx index 3db67c3e3e..ef52cb7748 100644 --- a/src/components/ProviderManager.test.tsx +++ b/src/components/ProviderManager.test.tsx @@ -130,6 +130,8 @@ const PRESET_ORDER = [ 'Moonshot AI - Kimi Code', 'NVIDIA NIM', 'OpenAI', + 'OpenCode Go', + 'OpenCode Zen', 'OpenRouter', 'Together AI', 'Venice', diff --git a/src/integrations/models/opencode.ts b/src/integrations/models/opencode.ts index 8695358b3c..e4e3042013 100644 --- a/src/integrations/models/opencode.ts +++ b/src/integrations/models/opencode.ts @@ -12,7 +12,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'gpt-5.5', + defaultModel: 'opencode-gpt-5.5', capabilities: { supportsVision: true, supportsStreaming: true, @@ -30,7 +30,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'gpt-5.5-pro', + defaultModel: 'opencode-gpt-5.5-pro', capabilities: { supportsVision: true, supportsStreaming: true, @@ -48,7 +48,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'gpt-5.4', + defaultModel: 'opencode-gpt-5.4', capabilities: { supportsVision: true, supportsStreaming: true, @@ -66,7 +66,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'gpt-5.4-pro', + defaultModel: 'opencode-gpt-5.4-pro', capabilities: { supportsVision: true, supportsStreaming: true, @@ -84,7 +84,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'gpt-5.4-mini', + defaultModel: 'opencode-gpt-5.4-mini', capabilities: { supportsVision: true, supportsStreaming: true, @@ -102,7 +102,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'gpt-5.4-nano', + defaultModel: 'opencode-gpt-5.4-nano', capabilities: { supportsVision: false, supportsStreaming: true, @@ -120,7 +120,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'coding'], - defaultModel: 'gpt-5.3-codex', + defaultModel: 'opencode-gpt-5.3-codex', capabilities: { supportsVision: true, supportsStreaming: true, @@ -138,7 +138,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'coding'], - defaultModel: 'gpt-5.3-codex-spark', + defaultModel: 'opencode-gpt-5.3-codex-spark', capabilities: { supportsVision: false, supportsStreaming: true, @@ -156,7 +156,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'gpt-5.2', + defaultModel: 'opencode-gpt-5.2', capabilities: { supportsVision: true, supportsStreaming: true, @@ -174,7 +174,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'coding'], - defaultModel: 'gpt-5.2-codex', + defaultModel: 'opencode-gpt-5.2-codex', capabilities: { supportsVision: false, supportsStreaming: true, @@ -192,7 +192,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'gpt-5.1', + defaultModel: 'opencode-gpt-5.1', capabilities: { supportsVision: true, supportsStreaming: true, @@ -210,7 +210,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'coding'], - defaultModel: 'gpt-5.1-codex', + defaultModel: 'opencode-gpt-5.1-codex', capabilities: { supportsVision: false, supportsStreaming: true, @@ -228,7 +228,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'coding'], - defaultModel: 'gpt-5.1-codex-max', + defaultModel: 'opencode-gpt-5.1-codex-max', capabilities: { supportsVision: false, supportsStreaming: true, @@ -246,7 +246,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'coding'], - defaultModel: 'gpt-5.1-codex-mini', + defaultModel: 'opencode-gpt-5.1-codex-mini', capabilities: { supportsVision: false, supportsStreaming: true, @@ -264,7 +264,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'gpt-5', + defaultModel: 'opencode-gpt-5', capabilities: { supportsVision: true, supportsStreaming: true, @@ -282,7 +282,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'coding'], - defaultModel: 'gpt-5-codex', + defaultModel: 'opencode-gpt-5-codex', capabilities: { supportsVision: false, supportsStreaming: true, @@ -300,7 +300,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'gpt-5-nano', + defaultModel: 'opencode-gpt-5-nano', capabilities: { supportsVision: false, supportsStreaming: true, @@ -320,7 +320,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'claude-opus-4-7', + defaultModel: 'opencode-claude-opus-4-7', capabilities: { supportsVision: true, supportsStreaming: true, @@ -338,7 +338,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'claude-opus-4-6', + defaultModel: 'opencode-claude-opus-4-6', capabilities: { supportsVision: true, supportsStreaming: true, @@ -356,7 +356,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'claude-opus-4-5', + defaultModel: 'opencode-claude-opus-4-5', capabilities: { supportsVision: true, supportsStreaming: true, @@ -374,7 +374,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'claude-opus-4-1', + defaultModel: 'opencode-claude-opus-4-1', capabilities: { supportsVision: true, supportsStreaming: true, @@ -392,7 +392,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'claude-sonnet-4-6', + defaultModel: 'opencode-claude-sonnet-4-6', capabilities: { supportsVision: true, supportsStreaming: true, @@ -410,7 +410,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'claude-sonnet-4-5', + defaultModel: 'opencode-claude-sonnet-4-5', capabilities: { supportsVision: true, supportsStreaming: true, @@ -428,7 +428,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'claude-sonnet-4', + defaultModel: 'opencode-claude-sonnet-4', capabilities: { supportsVision: true, supportsStreaming: true, @@ -446,7 +446,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'claude-haiku-4-5', + defaultModel: 'opencode-claude-haiku-4-5', capabilities: { supportsVision: true, supportsStreaming: true, @@ -464,7 +464,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'claude-3-5-haiku', + defaultModel: 'opencode-claude-3-5-haiku', capabilities: { supportsVision: true, supportsStreaming: true, @@ -484,7 +484,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'gemini-3.5-flash', + defaultModel: 'opencode-gemini-3.5-flash', capabilities: { supportsVision: true, supportsStreaming: true, @@ -502,7 +502,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'gemini-3.1-pro', + defaultModel: 'opencode-gemini-3.1-pro', capabilities: { supportsVision: true, supportsStreaming: true, @@ -520,7 +520,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'gemini-3-flash', + defaultModel: 'opencode-gemini-3-flash', capabilities: { supportsVision: true, supportsStreaming: true, @@ -540,7 +540,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'qwen3.6-plus', + defaultModel: 'opencode-qwen3.6-plus', capabilities: { supportsVision: false, supportsStreaming: true, @@ -558,7 +558,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'qwen3.5-plus', + defaultModel: 'opencode-qwen3.5-plus', capabilities: { supportsVision: false, supportsStreaming: true, @@ -578,7 +578,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'minimax-m2.7', + defaultModel: 'opencode-minimax-m2.7', capabilities: { supportsVision: false, supportsStreaming: true, @@ -596,7 +596,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'minimax-m2.5', + defaultModel: 'opencode-minimax-m2.5', capabilities: { supportsVision: false, supportsStreaming: true, @@ -614,7 +614,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'glm-5.1', + defaultModel: 'opencode-glm-5.1', capabilities: { supportsVision: false, supportsStreaming: true, @@ -632,7 +632,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'glm-5', + defaultModel: 'opencode-glm-5', capabilities: { supportsVision: false, supportsStreaming: true, @@ -650,7 +650,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'kimi-k2.5', + defaultModel: 'opencode-kimi-k2.5', capabilities: { supportsVision: false, supportsStreaming: true, @@ -668,7 +668,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'kimi-k2.6', + defaultModel: 'opencode-kimi-k2.6', capabilities: { supportsVision: false, supportsStreaming: true, @@ -686,7 +686,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'grok-build-0.1', + defaultModel: 'opencode-grok-build-0.1', capabilities: { supportsVision: false, supportsStreaming: true, @@ -704,7 +704,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'big-pickle', + defaultModel: 'opencode-big-pickle', capabilities: { supportsVision: false, supportsStreaming: true, @@ -722,7 +722,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'deepseek-v4-flash-free', + defaultModel: 'opencode-deepseek-v4-flash-free', capabilities: { supportsVision: false, supportsStreaming: true, @@ -740,7 +740,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'nemotron-3-super-free', + defaultModel: 'opencode-nemotron-3-super-free', capabilities: { supportsVision: false, supportsStreaming: true, @@ -764,7 +764,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'glm-5.1', + defaultModel: 'opencode-glm-5.1', capabilities: { supportsVision: false, supportsStreaming: true, @@ -782,7 +782,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'glm-5', + defaultModel: 'opencode-glm-5', capabilities: { supportsVision: false, supportsStreaming: true, @@ -800,7 +800,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'kimi-k2.5', + defaultModel: 'opencode-kimi-k2.5', capabilities: { supportsVision: false, supportsStreaming: true, @@ -818,7 +818,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'kimi-k2.6', + defaultModel: 'opencode-kimi-k2.6', capabilities: { supportsVision: false, supportsStreaming: true, @@ -836,7 +836,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'deepseek-v4-pro', + defaultModel: 'opencode-deepseek-v4-pro', capabilities: { supportsVision: false, supportsStreaming: true, @@ -854,7 +854,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'deepseek-v4-flash', + defaultModel: 'opencode-deepseek-v4-flash', capabilities: { supportsVision: false, supportsStreaming: true, @@ -872,7 +872,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'coding'], - defaultModel: 'mimo-v2.5', + defaultModel: 'opencode-mimo-v2.5', capabilities: { supportsVision: false, supportsStreaming: true, @@ -890,7 +890,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'coding'], - defaultModel: 'mimo-v2.5-pro', + defaultModel: 'opencode-mimo-v2.5-pro', capabilities: { supportsVision: false, supportsStreaming: true, @@ -910,7 +910,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'minimax-m2.7', + defaultModel: 'opencode-minimax-m2.7', capabilities: { supportsVision: false, supportsStreaming: true, @@ -928,7 +928,7 @@ export default [ vendorId: 'openai', classification: ['chat'], - defaultModel: 'minimax-m2.5', + defaultModel: 'opencode-minimax-m2.5', capabilities: { supportsVision: false, supportsStreaming: true, @@ -946,7 +946,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'qwen3.6-plus', + defaultModel: 'opencode-qwen3.6-plus', capabilities: { supportsVision: false, supportsStreaming: true, @@ -964,7 +964,7 @@ export default [ vendorId: 'openai', classification: ['chat', 'reasoning'], - defaultModel: 'qwen3.5-plus', + defaultModel: 'opencode-qwen3.5-plus', capabilities: { supportsVision: false, supportsStreaming: true, From 53cb00dbf10ead9986933156b5cb3723a92cc690 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 28 May 2026 12:49:19 +0600 Subject: [PATCH 11/27] fix: align OpenCode Go descriptor metadata with Zen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - category: 'hosted' → 'aggregating' (both are aggregating gateways) - add validation block with OPENCODE_API_KEY guidance - update test assertion from 'hosted' to 'aggregating' --- src/integrations/gateways/opencode-go.ts | 11 ++++++++++- src/integrations/gateways/opencode.test.ts | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts index ecb48f1795..2b9a6aa1c0 100644 --- a/src/integrations/gateways/opencode-go.ts +++ b/src/integrations/gateways/opencode-go.ts @@ -3,7 +3,7 @@ import { defineGateway } from '../define.js' export default defineGateway({ id: 'opencode-go', label: 'OpenCode Go', - category: 'hosted', + category: 'aggregating', defaultBaseUrl: 'https://opencode.ai/zen/go/v1', defaultModel: 'glm-5.1', setup: { @@ -11,6 +11,15 @@ export default defineGateway({ authMode: 'api-key', credentialEnvVars: ['OPENCODE_API_KEY'], }, + validation: { + kind: 'credential-env', + routing: { + matchDefaultBaseUrl: true, + }, + credentialEnvVars: ['OPENCODE_API_KEY'], + missingCredentialMessage: + 'OPENCODE_API_KEY is required. Get your API key from https://opencode.ai', + }, transportConfig: { kind: 'openai-compatible', openaiShim: { diff --git a/src/integrations/gateways/opencode.test.ts b/src/integrations/gateways/opencode.test.ts index cf87a37d79..f4199bf027 100644 --- a/src/integrations/gateways/opencode.test.ts +++ b/src/integrations/gateways/opencode.test.ts @@ -128,9 +128,9 @@ describe('OpenCode Go gateway descriptor', () => { expect(gateway!.label).toBe('OpenCode Go') }) - test('has hosted category', () => { + test('has aggregating category', () => { const gateway = getGateway('opencode-go') - expect(gateway!.category).toBe('hosted') + expect(gateway!.category).toBe('aggregating') }) test('has correct default base URL', () => { From 2e58071679fe07460ea58bf5097f4decd0c18aa4 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 29 May 2026 11:09:50 +0600 Subject: [PATCH 12/27] 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. --- src/integrations/gateways/opencode-go.ts | 2 +- src/integrations/gateways/opencode.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts index 2b9a6aa1c0..855314e438 100644 --- a/src/integrations/gateways/opencode-go.ts +++ b/src/integrations/gateways/opencode-go.ts @@ -16,7 +16,7 @@ export default defineGateway({ routing: { matchDefaultBaseUrl: true, }, - credentialEnvVars: ['OPENCODE_API_KEY'], + credentialEnvVars: ['OPENCODE_API_KEY', 'OPENAI_API_KEY'], missingCredentialMessage: 'OPENCODE_API_KEY is required. Get your API key from https://opencode.ai', }, diff --git a/src/integrations/gateways/opencode.ts b/src/integrations/gateways/opencode.ts index 979ba36461..f58914cd6f 100644 --- a/src/integrations/gateways/opencode.ts +++ b/src/integrations/gateways/opencode.ts @@ -29,7 +29,7 @@ export default defineGateway({ routing: { matchDefaultBaseUrl: true, }, - credentialEnvVars: ['OPENCODE_API_KEY'], + credentialEnvVars: ['OPENCODE_API_KEY', 'OPENAI_API_KEY'], missingCredentialMessage: 'OPENCODE_API_KEY is required. Get your API key from https://opencode.ai', }, From 2993a3d1e286f6f392c7dd54edd4becbd330c0d4 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Sat, 30 May 2026 00:25:06 +0600 Subject: [PATCH 13/27] chore: trigger mergeability recheck From 70dc3d93261ef362453b045055b42beb5339fd1d Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 26 May 2026 23:40:12 +0600 Subject: [PATCH 14/27] 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 --- src/services/api/openaiShim.ts | 32 ++++++++++++++++++++++++++++++++ src/utils/effort.ts | 22 ++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index a645e3df23..9f6e26fe4a 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -2229,6 +2229,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( @@ -2279,6 +2284,29 @@ 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.6') || modelLower.includes('opus-4.7') || + 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 } @@ -2372,6 +2400,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 } diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 3ed58281dd..30990cd6f7 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -45,6 +45,15 @@ export function modelSupportsEffort(model: string): boolean { if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) { return true } + // OpenCode Claude models that support thinking via /messages endpoint + if (m.includes('claude-opus-4') || m.includes('claude-sonnet-4') || + m.includes('opus-4') || m.includes('sonnet-4')) { + 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) if (m.includes('haiku') || m.includes('sonnet') || m.includes('opus')) { return false @@ -67,7 +76,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')) { return true } if (process.env.USER_TYPE === 'ant' && resolveAntModel(model)) { @@ -93,7 +102,16 @@ export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIE if (!modelSupportsEffort(model)) { return [] } - if (modelUsesOpenAIEffort(model)) { + // 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 OpenAIEffortLevel[] } const levels: EffortLevel[] = ['low', 'medium', 'high'] From 1498b163b9c3eb20136b274b164cfa9a348f08a4 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 3 Jun 2026 20:34:15 +0600 Subject: [PATCH 15/27] feat: update OpenCode Zen and Go model counts, add new models, and enhance effort level handling --- README.md | 4 +- docs/advanced-setup.md | 2 +- src/entrypoints/sdk/runtimeTypes.ts | 2 +- src/integrations/gateways/opencode-go.ts | 3 +- src/integrations/gateways/opencode.test.ts | 4 +- src/integrations/gateways/opencode.ts | 4 +- .../integrationArtifacts.generated.ts | 4 +- src/integrations/models/opencode.ts | 54 +++++++++++++++++++ src/utils/effort.codex.test.ts | 21 ++++---- src/utils/effort.ts | 29 +++++----- 10 files changed, 92 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 9e318b5a88..505ea4e405 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,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 | diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index af8c1ddaff..50d3a5ebaa 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -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. diff --git a/src/entrypoints/sdk/runtimeTypes.ts b/src/entrypoints/sdk/runtimeTypes.ts index a31be73ec9..b012b2cbd9 100644 --- a/src/entrypoints/sdk/runtimeTypes.ts +++ b/src/entrypoints/sdk/runtimeTypes.ts @@ -1 +1 @@ -export type EffortLevel = 'low' | 'medium' | 'high' | 'max' +export type EffortLevel = 'low' | 'medium' | 'high' | 'max' | 'xhigh' diff --git a/src/integrations/gateways/opencode-go.ts b/src/integrations/gateways/opencode-go.ts index 855314e438..31c7cec83a 100644 --- a/src/integrations/gateways/opencode-go.ts +++ b/src/integrations/gateways/opencode-go.ts @@ -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 }, diff --git a/src/integrations/gateways/opencode.test.ts b/src/integrations/gateways/opencode.test.ts index f4199bf027..bbe6afd3ad 100644 --- a/src/integrations/gateways/opencode.test.ts +++ b/src/integrations/gateways/opencode.test.ts @@ -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', () => { diff --git a/src/integrations/gateways/opencode.ts b/src/integrations/gateways/opencode.ts index f58914cd6f..3848549236 100644 --- a/src/integrations/gateways/opencode.ts +++ b/src/integrations/gateways/opencode.ts @@ -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 }, diff --git a/src/integrations/generated/integrationArtifacts.generated.ts b/src/integrations/generated/integrationArtifacts.generated.ts index 9ad1a20b46..7a6e20c969 100644 --- a/src/integrations/generated/integrationArtifacts.generated.ts +++ b/src/integrations/generated/integrationArtifacts.generated.ts @@ -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" ], diff --git a/src/integrations/models/opencode.ts b/src/integrations/models/opencode.ts index e4e3042013..5d58c70422 100644 --- a/src/integrations/models/opencode.ts +++ b/src/integrations/models/opencode.ts @@ -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, + }), ] diff --git a/src/utils/effort.codex.test.ts b/src/utils/effort.codex.test.ts index dca3ce2bef..c1fa1191ce 100644 --- a/src/utils/effort.codex.test.ts +++ b/src/utils/effort.codex.test.ts @@ -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 () => { diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 30990cd6f7..4234dbe040 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -16,6 +16,7 @@ export const EFFORT_LEVELS = [ 'medium', 'high', 'max', + 'xhigh', ] as const satisfies readonly EffortLevel[] export const OPENAI_EFFORT_LEVELS = [ @@ -42,7 +43,7 @@ export function modelSupportsEffort(model: string): boolean { return true } // Supported by a subset of Claude 4 models - if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) { + if (m.includes('opus-4-6') || m.includes('opus-4-8') || m.includes('sonnet-4-6')) { return true } // OpenCode Claude models that support thinking via /messages endpoint @@ -76,7 +77,7 @@ export function modelSupportsMaxEffort(model: string): boolean { if (supported3P !== undefined) { return supported3P } - if (model.toLowerCase().includes('opus-4-6') || model.toLowerCase().includes('opus-4-7')) { + 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)) { @@ -115,6 +116,7 @@ export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIE return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[] } const levels: EffortLevel[] = ['low', 'medium', 'high'] + levels.push('xhigh') if (modelSupportsMaxEffort(model)) { levels.push('max') } @@ -128,8 +130,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 { @@ -162,23 +163,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') { - return value - } - if (value === 'max') { + if ( + value === 'low' || + value === 'medium' || + value === 'high' || + value === 'max' || + value === 'xhigh' + ) { return value } - if (value === 'xhigh') { - return 'max' - } return undefined } @@ -316,7 +317,7 @@ export function getEffortLevelDescription(level: EffortLevel | OpenAIEffortLevel case 'max': return 'Maximum capability with deepest reasoning (Opus 4.6 only)' case 'xhigh': - return 'Extra high reasoning effort for complex tasks (OpenAI/Codex)' + return 'Extra high reasoning effort for complex tasks' } } From 4bb35528ed683b9f3d805ec73629ae6128f7cf88 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 3 Jun 2026 21:23:08 +0600 Subject: [PATCH 16/27] feat: implement xhigh effort support for specific models and adjust effort level handling --- src/utils/effort.codex.test.ts | 47 ++++++++++++++++++++++++++++++++++ src/utils/effort.ts | 27 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/utils/effort.codex.test.ts b/src/utils/effort.codex.test.ts index c1fa1191ce..53326c9694 100644 --- a/src/utils/effort.codex.test.ts +++ b/src/utils/effort.codex.test.ts @@ -164,3 +164,50 @@ 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('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') +}) diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 4234dbe040..12ec9d48e6 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -86,6 +86,23 @@ 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 { + 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) } @@ -116,7 +133,9 @@ export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIE return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[] } const levels: EffortLevel[] = ['low', 'medium', 'high'] - levels.push('xhigh') + if (modelSupportsXHighEffort(model)) { + levels.push('xhigh') + } if (modelSupportsMaxEffort(model)) { levels.push('max') } @@ -248,6 +267,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 } From ecfa2877aa7edfeec34809b8c900f0aabda0affe Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 3 Jun 2026 21:41:13 +0600 Subject: [PATCH 17/27] fix(effort): address reviewer feedback on xhigh + new models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/advanced-setup.md | 2 +- src/services/api/openaiShim.ts | 2 ++ src/utils/effort.codex.test.ts | 20 ++++++++++++++++++++ src/utils/effort.ts | 12 +++++++++++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 50d3a5ebaa..6354f444df 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -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 diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 9f6e26fe4a..944bb4d36a 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -2290,7 +2290,9 @@ class OpenAIShimMessages { 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') diff --git a/src/utils/effort.codex.test.ts b/src/utils/effort.codex.test.ts index 53326c9694..d23d797c66 100644 --- a/src/utils/effort.codex.test.ts +++ b/src/utils/effort.codex.test.ts @@ -211,3 +211,23 @@ test('xhigh clamps to high on non-supporting models so stale settings.json value // 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']) +}) diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 12ec9d48e6..605ded023f 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -113,7 +113,17 @@ 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[] { From 78a955ec32c4fb04a5e5abc42347c50bf253c88e Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 3 Jun 2026 23:16:11 +0600 Subject: [PATCH 18/27] 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 --- scripts/generate-sdk-types.ts | 9 +++++-- src/cli/print.ts | 9 +++---- src/components/ModelPicker.tsx | 15 ++++++----- src/entrypoints/sdk/coreSchemas.ts | 2 +- src/entrypoints/sdk/coreTypes.generated.ts | 30 +++++++++++----------- src/utils/effort.ts | 4 +-- src/utils/settings/types.ts | 2 +- 7 files changed, 38 insertions(+), 33 deletions(-) diff --git a/scripts/generate-sdk-types.ts b/scripts/generate-sdk-types.ts index 919d9edb5c..1296913d3f 100644 --- a/scripts/generate-sdk-types.ts +++ b/scripts/generate-sdk-types.ts @@ -268,8 +268,13 @@ function convert(schema: any, depth = 0): string { .map(v => JSON.stringify(v)) .join(' | ') } - case 'array': - return `${convert(def.element, depth)}[]` + case 'array': { + const el = convert(def.element, depth) + // Wrap unions/intersections in parens so `[]` binds the whole type, + // not just the last member: `("a" | "b")[]` vs `"a" | "b[]"`. + const wrapped = el.includes(' | ') || el.includes(' & ') ? `(${el})` : el + return `${wrapped}[]` + } case 'tuple': { const items = (def.items as any[]).map(t => convert(t, depth)) return `[${items.join(', ')}]` diff --git a/src/cli/print.ts b/src/cli/print.ts index f98d770e78..c96d340185 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -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 }), diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index f5d1c7ab25..670a018909 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -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 { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel } from '../utils/model/model.js'; import { getModelOptions, type ModelOption } from '../utils/model/modelOptions.js'; import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; @@ -181,6 +181,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); @@ -190,7 +194,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 => { @@ -212,7 +216,7 @@ export function ModelPicker(t0) { if (!focusedSupportsEffort) { return; } - setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax)); + setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedAvailableLevels)); setHasToggledEffort(true); }; $[28] = focusedDefaultEffort; @@ -442,10 +446,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') { diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts index 88638bd309..b57aa29ab5 100644 --- a/src/entrypoints/sdk/coreSchemas.ts +++ b/src/entrypoints/sdk/coreSchemas.ts @@ -1065,7 +1065,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', 'max', 'xhigh'])) .optional() .describe('Available effort levels for this model'), supportsAdaptiveThinking: z diff --git a/src/entrypoints/sdk/coreTypes.generated.ts b/src/entrypoints/sdk/coreTypes.generated.ts index f49cba7900..2095f781f6 100644 --- a/src/entrypoints/sdk/coreTypes.generated.ts +++ b/src/entrypoints/sdk/coreTypes.generated.ts @@ -238,7 +238,7 @@ export type PermissionDecisionClassification = "user_temporary" | "user_permanen export type PermissionResult = ({ behavior: "allow" updatedInput?: Record - updatedPermissions?: ({ + updatedPermissions?: (({ type: "addRules" rules: { toolName: string @@ -274,7 +274,7 @@ export type PermissionResult = ({ type: "removeDirectories" directories: string[] destination: "userSettings" | "projectSettings" | "localSettings" | "session" | "cliArg" - })[] + }))[] toolUseID?: string decisionClassification?: "user_temporary" | "user_permanent" | "user_reject" }) | ({ @@ -504,7 +504,7 @@ export type PermissionRequestHookInput = { hook_event_name: "PermissionRequest" tool_name: string tool_input: unknown - permission_suggestions?: ({ + permission_suggestions?: (({ type: "addRules" rules: { toolName: string @@ -540,7 +540,7 @@ export type PermissionRequestHookInput = { type: "removeDirectories" directories: string[] destination: "userSettings" | "projectSettings" | "localSettings" | "session" | "cliArg" - })[] + }))[] } export type SetupHookInput = { @@ -892,7 +892,7 @@ export type HookInput = ({ hook_event_name: "PermissionRequest" tool_name: string tool_input: unknown - permission_suggestions?: ({ + permission_suggestions?: (({ type: "addRules" rules: { toolName: string @@ -928,7 +928,7 @@ export type HookInput = ({ type: "removeDirectories" directories: string[] destination: "userSettings" | "projectSettings" | "localSettings" | "session" | "cliArg" - })[] + }))[] }) | ({ session_id: string transcript_path: string @@ -1138,7 +1138,7 @@ export type PermissionRequestHookSpecificOutput = { decision: ({ behavior: "allow" updatedInput?: Record - updatedPermissions?: ({ + updatedPermissions?: (({ type: "addRules" rules: { toolName: string @@ -1174,7 +1174,7 @@ export type PermissionRequestHookSpecificOutput = { type: "removeDirectories" directories: string[] destination: "userSettings" | "projectSettings" | "localSettings" | "session" | "cliArg" - })[] + }))[] }) | ({ behavior: "deny" message?: string @@ -1257,7 +1257,7 @@ export type SyncHookJSONOutput = { decision: ({ behavior: "allow" updatedInput?: Record - updatedPermissions?: ({ + updatedPermissions?: (({ type: "addRules" rules: { toolName: string @@ -1293,7 +1293,7 @@ export type SyncHookJSONOutput = { type: "removeDirectories" directories: string[] destination: "userSettings" | "projectSettings" | "localSettings" | "session" | "cliArg" - })[] + }))[] }) | ({ behavior: "deny" message?: string @@ -1367,7 +1367,7 @@ export type HookJSONOutput = ({ decision: ({ behavior: "allow" updatedInput?: Record - updatedPermissions?: ({ + updatedPermissions?: (({ type: "addRules" rules: { toolName: string @@ -1403,7 +1403,7 @@ export type HookJSONOutput = ({ type: "removeDirectories" directories: string[] destination: "userSettings" | "projectSettings" | "localSettings" | "session" | "cliArg" - })[] + }))[] }) | ({ behavior: "deny" message?: string @@ -1470,7 +1470,7 @@ export type ModelInfo = { displayName: string description: string supportsEffort?: boolean - supportedEffortLevels?: "low" | "medium" | "high" | "max"[] + supportedEffortLevels?: ("low" | "medium" | "high" | "max" | "xhigh")[] supportsAdaptiveThinking?: boolean supportsFastMode?: boolean supportsAutoMode?: boolean @@ -1511,7 +1511,7 @@ export type AgentDefinition = { disallowedTools?: string[] prompt: string model?: string - mcpServers?: string | (Record)[] + })>))[] criticalSystemReminder_EXPERIMENTAL?: string skills?: string[] initialPrompt?: string diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 605ded023f..1c115e2906 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -126,7 +126,7 @@ export function modelUsesOpenAIEffort(model: string): boolean { return true } -export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIEffortLevel[] { +export function getAvailableEffortLevels(model: string): EffortLevel[] { if (!modelSupportsEffort(model)) { return [] } @@ -140,7 +140,7 @@ export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIE m.includes('gemini-3') ) && getAPIProvider() === 'openai' if (modelUsesOpenAIEffort(model) && !isOpenCodeNativeFormat) { - return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[] + return [...OPENAI_EFFORT_LEVELS] as EffortLevel[] } const levels: EffortLevel[] = ['low', 'medium', 'high'] if (modelSupportsXHighEffort(model)) { diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 2f572754f4..aa07ed6584 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -728,7 +728,7 @@ export const SettingsSchema = lazySchema(() => 'enabled automatically for supported models.', ), effortLevel: z - .enum(['low', 'medium', 'high', 'max']) + .enum(['low', 'medium', 'high', 'max', 'xhigh']) .optional() .catch(undefined) .describe('Persisted effort level for supported models.'), From 6e2fce5d613a9e73ed811326a30612542c202dd1 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 3 Jun 2026 23:25:44 +0600 Subject: [PATCH 19/27] 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 --- src/utils/effort.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 1c115e2906..3f75deee49 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -15,8 +15,8 @@ export const EFFORT_LEVELS = [ 'low', 'medium', 'high', - 'max', 'xhigh', + 'max', ] as const satisfies readonly EffortLevel[] export const OPENAI_EFFORT_LEVELS = [ From bf45beabb6051e0937d95ed3a2bf77c71e625602 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Wed, 3 Jun 2026 23:28:57 +0600 Subject: [PATCH 20/27] 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 --- src/entrypoints/sdk/coreSchemas.ts | 2 +- src/entrypoints/sdk/coreTypes.generated.ts | 2 +- src/utils/settings/types.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts index b57aa29ab5..31bbef709f 100644 --- a/src/entrypoints/sdk/coreSchemas.ts +++ b/src/entrypoints/sdk/coreSchemas.ts @@ -1065,7 +1065,7 @@ export const ModelInfoSchema = lazySchema(() => .optional() .describe('Whether this model supports effort levels'), supportedEffortLevels: z - .array(z.enum(['low', 'medium', 'high', 'max', 'xhigh'])) + .array(z.enum(['low', 'medium', 'high', 'xhigh', 'max'])) .optional() .describe('Available effort levels for this model'), supportsAdaptiveThinking: z diff --git a/src/entrypoints/sdk/coreTypes.generated.ts b/src/entrypoints/sdk/coreTypes.generated.ts index 2095f781f6..664f37c3c7 100644 --- a/src/entrypoints/sdk/coreTypes.generated.ts +++ b/src/entrypoints/sdk/coreTypes.generated.ts @@ -1470,7 +1470,7 @@ export type ModelInfo = { displayName: string description: string supportsEffort?: boolean - supportedEffortLevels?: ("low" | "medium" | "high" | "max" | "xhigh")[] + supportedEffortLevels?: ("low" | "medium" | "high" | "xhigh" | "max")[] supportsAdaptiveThinking?: boolean supportsFastMode?: boolean supportsAutoMode?: boolean diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index aa07ed6584..91d892db6f 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -728,7 +728,7 @@ export const SettingsSchema = lazySchema(() => 'enabled automatically for supported models.', ), effortLevel: z - .enum(['low', 'medium', 'high', 'max', 'xhigh']) + .enum(['low', 'medium', 'high', 'xhigh', 'max']) .optional() .catch(undefined) .describe('Persisted effort level for supported models.'), From 539a9d468f8fbc48e61572621f4ad3e7f06acfff Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 4 Jun 2026 12:38:18 +0600 Subject: [PATCH 21/27] 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. --- src/components/EffortPicker.tsx | 8 ++++---- src/components/ModelPicker.tsx | 21 ++++++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/components/EffortPicker.tsx b/src/components/EffortPicker.tsx index e392c1ab8d..013cb782c3 100644 --- a/src/components/EffortPicker.tsx +++ b/src/components/EffortPicker.tsx @@ -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: ( { if (!focusedSupportsEffort) { return; @@ -223,9 +223,10 @@ export function ModelPicker(t0) { $[28] = focusedDefaultEffort; $[29] = focusedSupportsEffort; $[30] = focusedSupportsMax; - $[31] = t11; + $[31] = focusedAvailableLevels; + $[32] = t11; } else { - t11 = $[31]; + t11 = $[32]; } const handleCycleEffort = t11; const t12 = { @@ -246,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", { @@ -269,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; @@ -281,6 +286,8 @@ export function ModelPicker(t0) { $[37] = onSelect; $[38] = setAppState; $[39] = skipSettingsWrite; + $[46] = focusedAvailableLevels; + $[47] = focusedDefaultEffort; $[40] = t14; } else { t14 = $[40]; From 7420d65d99b494b1ba575a6b83c4ba1740ce4c7d Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 4 Jun 2026 12:57:24 +0600 Subject: [PATCH 22/27] docs(effort): fix stale EffortPicker comment about xhigh normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/EffortPicker.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/EffortPicker.tsx b/src/components/EffortPicker.tsx index 013cb782c3..ce40c6922b 100644 --- a/src/components/EffortPicker.tsx +++ b/src/components/EffortPicker.tsx @@ -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) From 3cf5de24b75add7d5b00e9906d3e1bde5b750f9f Mon Sep 17 00:00:00 2001 From: Gravirei Date: Thu, 4 Jun 2026 23:29:04 +0600 Subject: [PATCH 23/27] 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 --- src/commands/effort/effort.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/effort/effort.tsx b/src/commands/effort/effort.tsx index c0ece43e2f..aa7fde832c 100644 --- a/src/commands/effort/effort.tsx +++ b/src/commands/effort/effort.tsx @@ -176,7 +176,7 @@ function ApplyEffortAndClose(t0) { export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise { 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') { From 857c55b6ccc230ff68d2749f82f66b4ac6b018ec Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 5 Jun 2026 02:10:55 +0600 Subject: [PATCH 24/27] =?UTF-8?q?fix(sdk):=20address=20reviewer=20P2=20?= =?UTF-8?q?=E2=80=94=20sync=20xhigh=20across=20override=20union,=20schemas?= =?UTF-8?q?,=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/entrypoints/sdk/controlSchemas.ts | 2 +- src/entrypoints/sdk/coreSchemas.ts | 2 +- src/entrypoints/sdk/coreTypes.generated.ts | 2 +- src/main.tsx | 4 ++-- src/utils/model/modelSupportOverrides.ts | 1 + 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/entrypoints/sdk/controlSchemas.ts b/src/entrypoints/sdk/controlSchemas.ts index 8e27aa7813..a00cbb9c8d 100644 --- a/src/entrypoints/sdk/controlSchemas.ts +++ b/src/entrypoints/sdk/controlSchemas.ts @@ -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 enum∪number unions. - effort: z.enum(['low', 'medium', 'high', 'max']).nullable(), + effort: z.enum(['low', 'medium', 'high', 'xhigh', 'max']).nullable(), }) .optional() .describe( diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts index 31bbef709f..7a00cfc772 100644 --- a/src/entrypoints/sdk/coreSchemas.ts +++ b/src/entrypoints/sdk/coreSchemas.ts @@ -1174,7 +1174,7 @@ export const AgentDefinitionSchema = lazySchema(() => "Scope for auto-loading agent memory files. 'user' - ~/.claude/agent-memory//, 'project' - .claude/agent-memory//, 'local' - .claude/agent-memory-local//", ), 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', diff --git a/src/entrypoints/sdk/coreTypes.generated.ts b/src/entrypoints/sdk/coreTypes.generated.ts index 664f37c3c7..f45cc35f4f 100644 --- a/src/entrypoints/sdk/coreTypes.generated.ts +++ b/src/entrypoints/sdk/coreTypes.generated.ts @@ -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" } diff --git a/src/main.tsx b/src/main.tsx index 579dc344ce..f07e103806 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -942,9 +942,9 @@ async function run(): Promise { 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 ', 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)').argParser(String).hideHelp()).addOption(new Option('--rewind-files ', '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 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 ', `AI provider to use (anthropic, openai, gemini, github, bedrock, vertex, ollama). Reads API keys from environment variables.`).addOption(new Option('--effort ', `Effort level for the current session (low, medium, high, max)`).argParser((rawValue: string) => { + .option('--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 ', `AI provider to use (anthropic, openai, gemini, github, bedrock, vertex, ollama). Reads API keys from environment variables.`).addOption(new Option('--effort ', `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(', ')}`); } diff --git a/src/utils/model/modelSupportOverrides.ts b/src/utils/model/modelSupportOverrides.ts index 562fac78f5..a8d5050fca 100644 --- a/src/utils/model/modelSupportOverrides.ts +++ b/src/utils/model/modelSupportOverrides.ts @@ -4,6 +4,7 @@ import { getAPIProvider } from './providers.js' export type ModelCapabilityOverride = | 'effort' | 'max_effort' + | 'xhigh_effort' | 'thinking' | 'adaptive_thinking' | 'interleaved_thinking' From 7d6556982f75304d813a2e381166316130dec7b7 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Fri, 5 Jun 2026 10:44:51 +0600 Subject: [PATCH 25/27] 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 --- src/utils/effort.codex.test.ts | 35 ++++++++++++++++++++++++++++++++++ src/utils/effort.ts | 19 ++++++++++-------- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/utils/effort.codex.test.ts b/src/utils/effort.codex.test.ts index d23d797c66..a1e4fd8dc9 100644 --- a/src/utils/effort.codex.test.ts +++ b/src/utils/effort.codex.test.ts @@ -200,6 +200,41 @@ test('xhigh does not appear in available levels for non-supporting models', asyn 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', diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 3f75deee49..5e06197cec 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -42,13 +42,16 @@ 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('opus-4-8') || m.includes('sonnet-4-6')) { - return true - } - // OpenCode Claude models that support thinking via /messages endpoint - if (m.includes('claude-opus-4') || m.includes('claude-sonnet-4') || - m.includes('opus-4') || m.includes('sonnet-4')) { + // 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 @@ -350,7 +353,7 @@ 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' } From fee6c327c4a686d126638720a938df4f82dbe448 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Mon, 8 Jun 2026 22:02:11 +0600 Subject: [PATCH 26/27] chore: trigger CodeRabbit re-review From 72fb746c3b7f64cea4cce2eb648ad1b0b3effbd8 Mon Sep 17 00:00:00 2001 From: Gravirei Date: Tue, 9 Jun 2026 05:44:03 +0600 Subject: [PATCH 27/27] fix(effort): gate modelSupportsXHighEffort on modelSupportsEffort --- src/utils/effort.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 5e06197cec..d17286ea3d 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -93,6 +93,9 @@ export function modelSupportsMaxEffort(model: string): boolean { // 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