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

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

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

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

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

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

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

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

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

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

Static model list is preserved as fallback when discovery fails.

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

97 tests across 2 files covering:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: prevent OpenCode model descriptors from shadowing canonical limits

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

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

* fix: align OpenCode Go descriptor metadata with Zen

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

* fix: accept OPENAI_API_KEY as fallback in OpenCode validation

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

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

* chore: trigger mergeability recheck

---------

Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
Co-authored-by: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
This commit is contained in:
Gravirei
2026-05-30 14:45:08 +08:00
committed by GitHub
co-authored by Gravirei OpenClaude
parent 9190bd0c50
commit 5a22d604f8
21 changed files with 2608 additions and 27 deletions
+1
View File
@@ -17,3 +17,4 @@ package-lock.json
coverage/
agent.log
plan/
temp_reference/
+2
View File
@@ -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://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 |
+30
View File
@@ -183,6 +183,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
@@ -235,6 +264,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 |
+5 -2
View File
@@ -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]`) 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
+2
View File
@@ -130,6 +130,8 @@ const PRESET_ORDER = [
'Moonshot AI - Kimi Code',
'NVIDIA NIM',
'OpenAI',
'OpenCode Go',
'OpenCode Zen',
'OpenRouter',
'Together AI',
'Venice',
+4 -14
View File
@@ -234,25 +234,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 (
<Text>
<Text>{label} </Text>
<Text color="success" bold>[FREE]</Text>
<Text color={metadata.badge.color ?? 'green'} bold>[{metadata.badge.text}]</Text>
</Text>
)
}
if (preset === 'xiaomi-mimo') {
return (
<Text>
<Text>{label} </Text>
<Text color="success" bold>[Sponsor]</Text>
</Text>
)
}
return label
}
@@ -1838,7 +1828,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,
}
})
+1
View File
@@ -218,6 +218,7 @@ function buildPresetManifestEntry(
modelEnvVars: preset.modelEnvVars,
fallbackBaseUrl: preset.fallbackBaseUrl,
fallbackModel: preset.fallbackModel,
badge: preset.badge,
}
}
+2
View File
@@ -41,6 +41,8 @@ const EXPECTED_PRESETS = [
'bankr',
'atomic-chat',
'gitlawb-opengateway',
'opencode',
'opencode-go',
] as const satisfies readonly ProviderPreset[]
describe('compatibility mappings', () => {
+9
View File
@@ -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 {
@@ -147,6 +149,11 @@ export interface ValidationRoutingMetadata {
skipWhenUseOpenAI?: boolean
}
export interface PresetBadge {
text: string
color?: string
}
export interface ProviderPresetMetadata {
id: string
description: string
@@ -158,6 +165,7 @@ export interface ProviderPresetMetadata {
modelEnvVars?: string[]
fallbackBaseUrl?: string
fallbackModel?: string
badge?: PresetBadge
}
export type ProviderPresetRouteKind =
@@ -179,6 +187,7 @@ export interface ProviderPresetManifestEntry {
modelEnvVars?: readonly string[]
fallbackBaseUrl?: string
fallbackModel?: string
badge?: PresetBadge
}
export type ValidationMetadata =
@@ -52,6 +52,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',
+56
View File
@@ -0,0 +1,56 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'opencode-go',
label: 'OpenCode Go',
category: 'aggregating',
defaultBaseUrl: 'https://opencode.ai/zen/go/v1',
defaultModel: 'glm-5.1',
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['OPENCODE_API_KEY'],
},
validation: {
kind: 'credential-env',
routing: {
matchDefaultBaseUrl: true,
},
credentialEnvVars: ['OPENCODE_API_KEY', 'OPENAI_API_KEY'],
missingCredentialMessage:
'OPENCODE_API_KEY is required. Get your API key from https://opencode.ai',
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
},
},
preset: {
id: 'opencode-go',
vendorId: 'openai',
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', 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 },
})
+455
View File
@@ -0,0 +1,455 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { ensureIntegrationsLoaded } from '../index.js'
import {
_clearRegistryForTesting,
getGateway,
getModelsForGateway,
getCatalogEntriesForRoute,
getAllModels,
validateIntegrationRegistry,
} from '../registry.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
beforeEach(async () => {
await acquireSharedMutationLock('integrations/gateways/opencode.test.ts')
_clearRegistryForTesting()
ensureIntegrationsLoaded()
})
afterEach(() => {
try {
_clearRegistryForTesting()
ensureIntegrationsLoaded()
} finally {
releaseSharedMutationLock()
}
})
// ---------------------------------------------------------------------------
// Zen Gateway Descriptor Tests
// ---------------------------------------------------------------------------
describe('OpenCode Zen gateway descriptor', () => {
test('is registered with correct id', () => {
const gateway = getGateway('opencode')
expect(gateway).not.toBeNull()
expect(gateway!.id).toBe('opencode')
})
test('has correct label', () => {
const gateway = getGateway('opencode')
expect(gateway!.label).toBe('OpenCode Zen')
})
test('has aggregating category', () => {
const gateway = getGateway('opencode')
expect(gateway!.category).toBe('aggregating')
})
test('has correct default base URL', () => {
const gateway = getGateway('opencode')
expect(gateway!.defaultBaseUrl).toBe('https://opencode.ai/zen/v1')
})
test('has correct default model', () => {
const gateway = getGateway('opencode')
expect(gateway!.defaultModel).toBe('gpt-5.4')
})
test('requires auth', () => {
const gateway = getGateway('opencode')
expect(gateway!.setup.requiresAuth).toBe(true)
})
test('uses api-key auth mode', () => {
const gateway = getGateway('opencode')
expect(gateway!.setup.authMode).toBe('api-key')
})
test('has OPENCODE_API_KEY in credential env vars', () => {
const gateway = getGateway('opencode')
expect(gateway!.setup.credentialEnvVars).toContain('OPENCODE_API_KEY')
})
test('has openai-compatible transport kind', () => {
const gateway = getGateway('opencode')
expect(gateway!.transportConfig.kind).toBe('openai-compatible')
})
test('has preset metadata', () => {
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 gateway = getGateway('opencode')
expect(gateway!.validation).toBeDefined()
expect(gateway!.validation!.kind).toBe('credential-env')
})
test('has catalog with static source', () => {
const gateway = getGateway('opencode')
expect(gateway!.catalog).toBeDefined()
expect(gateway!.catalog!.source).toBe('static')
})
test('has static models in catalog', () => {
const gateway = getGateway('opencode')
expect(gateway!.catalog!.models).toBeDefined()
expect(gateway!.catalog!.models!.length).toBeGreaterThan(0)
})
test('has usage metadata', () => {
const gateway = getGateway('opencode')
expect(gateway!.usage).toBeDefined()
expect(gateway!.usage!.supported).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Go 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 aggregating category', () => {
const gateway = getGateway('opencode-go')
expect(gateway!.category).toBe('aggregating')
})
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 vendorId openai', () => {
const gateway = getGateway('opencode-go')
expect(gateway!.preset).toBeDefined()
expect(gateway!.preset!.id).toBe('opencode-go')
expect(gateway!.preset!.vendorId).toBe('openai')
expect(gateway!.preset!.apiKeyEnvVars).toContain('OPENCODE_API_KEY')
})
test('has catalog with static source', () => {
const gateway = getGateway('opencode-go')
expect(gateway!.catalog).toBeDefined()
expect(gateway!.catalog!.source).toBe('static')
})
test('has static models in catalog', () => {
const gateway = getGateway('opencode-go')
expect(gateway!.catalog!.models).toBeDefined()
expect(gateway!.catalog!.models!.length).toBeGreaterThan(0)
})
})
// ---------------------------------------------------------------------------
// Model Catalog Tests
// ---------------------------------------------------------------------------
describe('OpenCode model catalog', () => {
test('zen gateway has models registered', () => {
const models = getModelsForGateway('opencode')
expect(models.length).toBeGreaterThan(0)
})
test('go gateway has models registered', () => {
const models = getModelsForGateway('opencode-go')
expect(models.length).toBeGreaterThan(0)
})
test('models have vendorId openai', () => {
const models = getAllModels().filter(m => m.id.startsWith('opencode-'))
for (const model of models) {
expect(model.vendorId).toBe('openai')
}
})
test('all models have required fields', () => {
const models = getAllModels().filter(m => m.id.startsWith('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 = getAllModels().filter(m => m.id.startsWith('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 = 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 = 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 = 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')
}
})
test('reasoning models have reasoning classification', () => {
const models = getAllModels().filter(m => m.id.startsWith('opencode-'))
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 = 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)
})
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')
expect(models.length).toBe(41)
})
test('go model count matches expected', () => {
const models = getCatalogEntriesForRoute('opencode-go')
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('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))
const entry = getGateway('opencode')
for (const catalogEntry of entry!.catalog!.models!) {
if (catalogEntry.modelDescriptorId) {
expect(modelIds.has(catalogEntry.modelDescriptorId)).toBe(true)
}
}
})
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))
const gateway = getGateway('opencode-go')
for (const catalogEntry of gateway!.catalog!.models!) {
if (catalogEntry.modelDescriptorId) {
expect(modelIds.has(catalogEntry.modelDescriptorId)).toBe(true)
}
}
})
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)
})
})
// ---------------------------------------------------------------------------
// 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('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('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('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('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('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('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)
}
})
test('model descriptors have non-empty contextWindow', () => {
const models = getAllModels().filter(m => m.id.startsWith('opencode-'))
for (const model of models) {
if (model.contextWindow !== undefined) {
expect(model.contextWindow).toBeGreaterThan(0)
}
}
})
test('model descriptors have non-empty maxOutputTokens', () => {
const models = getAllModels().filter(m => m.id.startsWith('opencode-'))
for (const model of models) {
if (model.maxOutputTokens !== undefined) {
expect(model.maxOutputTokens).toBeGreaterThan(0)
}
}
})
test('model descriptors have valid defaultModel format', () => {
const models = getAllModels().filter(m => m.id.startsWith('opencode-'))
for (const model of models) {
expect(model.defaultModel).toMatch(/^[a-z0-9\-\.]+$/)
}
})
test('zen gateway validation message mentions OPENCODE_API_KEY', () => {
const gateway = getGateway('opencode')
expect(gateway!.validation!.missingCredentialMessage).toContain('OPENCODE_API_KEY')
})
test('zen gateway validation message mentions opencode.ai', () => {
const gateway = getGateway('opencode')
expect(gateway!.validation!.missingCredentialMessage).toContain('opencode.ai')
})
})
+88
View File
@@ -0,0 +1,88 @@
import { defineGateway } from '../define.js'
export default defineGateway({
id: 'opencode',
label: 'OpenCode Zen',
category: 'aggregating',
defaultBaseUrl: 'https://opencode.ai/zen/v1',
defaultModel: 'gpt-5.4',
setup: {
requiresAuth: true,
authMode: 'api-key',
credentialEnvVars: ['OPENCODE_API_KEY'],
},
transportConfig: {
kind: 'openai-compatible',
openaiShim: {
supportsAuthHeaders: true,
},
},
preset: {
id: 'opencode',
vendorId: 'openai',
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', 'OPENAI_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', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.5-pro', apiName: 'gpt-5.5-pro', label: 'GPT 5.5 Pro', modelDescriptorId: 'opencode-gpt-5.5-pro', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.4', apiName: 'gpt-5.4', label: 'GPT 5.4', modelDescriptorId: 'opencode-gpt-5.4', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.4-pro', apiName: 'gpt-5.4-pro', label: 'GPT 5.4 Pro', modelDescriptorId: 'opencode-gpt-5.4-pro', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.4-mini', apiName: 'gpt-5.4-mini', label: 'GPT 5.4 Mini', modelDescriptorId: 'opencode-gpt-5.4-mini', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.4-nano', apiName: 'gpt-5.4-nano', label: 'GPT 5.4 Nano', modelDescriptorId: 'opencode-gpt-5.4-nano', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.3-codex', apiName: 'gpt-5.3-codex', label: 'GPT 5.3 Codex', modelDescriptorId: 'opencode-gpt-5.3-codex', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.3-codex-spark', apiName: 'gpt-5.3-codex-spark', label: 'GPT 5.3 Codex Spark', modelDescriptorId: 'opencode-gpt-5.3-codex-spark', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.2', apiName: 'gpt-5.2', label: 'GPT 5.2', modelDescriptorId: 'opencode-gpt-5.2', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.2-codex', apiName: 'gpt-5.2-codex', label: 'GPT 5.2 Codex', modelDescriptorId: 'opencode-gpt-5.2-codex', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.1', apiName: 'gpt-5.1', label: 'GPT 5.1', modelDescriptorId: 'opencode-gpt-5.1', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.1-codex', apiName: 'gpt-5.1-codex', label: 'GPT 5.1 Codex', modelDescriptorId: 'opencode-gpt-5.1-codex', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.1-codex-max', apiName: 'gpt-5.1-codex-max', label: 'GPT 5.1 Codex Max', modelDescriptorId: 'opencode-gpt-5.1-codex-max', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5.1-codex-mini', apiName: 'gpt-5.1-codex-mini', label: 'GPT 5.1 Codex Mini', modelDescriptorId: 'opencode-gpt-5.1-codex-mini', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5', apiName: 'gpt-5', label: 'GPT 5', modelDescriptorId: 'opencode-gpt-5', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5-codex', apiName: 'gpt-5-codex', label: 'GPT 5 Codex', modelDescriptorId: 'opencode-gpt-5-codex', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
{ id: 'gpt-5-nano', apiName: 'gpt-5-nano', label: 'GPT 5 Nano', modelDescriptorId: 'opencode-gpt-5-nano', transportOverrides: { openaiShim: { endpointPath: '/responses' } } },
// Claude family — /zen/v1/messages
{ id: 'claude-opus-4-7', apiName: 'claude-opus-4-7', label: 'Claude Opus 4.7', modelDescriptorId: 'opencode-claude-opus-4-7', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-opus-4-6', apiName: 'claude-opus-4-6', label: 'Claude Opus 4.6', modelDescriptorId: 'opencode-claude-opus-4-6', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-opus-4-5', apiName: 'claude-opus-4-5', label: 'Claude Opus 4.5', modelDescriptorId: 'opencode-claude-opus-4-5', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-opus-4-1', apiName: 'claude-opus-4-1', label: 'Claude Opus 4.1', modelDescriptorId: 'opencode-claude-opus-4-1', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-sonnet-4-6', apiName: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', modelDescriptorId: 'opencode-claude-sonnet-4-6', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-sonnet-4-5', apiName: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', modelDescriptorId: 'opencode-claude-sonnet-4-5', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-sonnet-4', apiName: 'claude-sonnet-4', label: 'Claude Sonnet 4', modelDescriptorId: 'opencode-claude-sonnet-4', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-haiku-4-5', apiName: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', modelDescriptorId: 'opencode-claude-haiku-4-5', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'claude-3-5-haiku', apiName: 'claude-3-5-haiku', label: 'Claude Haiku 3.5', modelDescriptorId: 'opencode-claude-3-5-haiku', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
// Gemini family — /zen/v1/models/<id>
{ id: 'gemini-3.5-flash', apiName: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash', modelDescriptorId: 'opencode-gemini-3.5-flash', transportOverrides: { openaiShim: { endpointPath: '/models/gemini-3.5-flash' } } },
{ id: 'gemini-3.1-pro', apiName: 'gemini-3.1-pro', label: 'Gemini 3.1 Pro', modelDescriptorId: 'opencode-gemini-3.1-pro', transportOverrides: { openaiShim: { endpointPath: '/models/gemini-3.1-pro' } } },
{ id: 'gemini-3-flash', apiName: 'gemini-3-flash', label: 'Gemini 3 Flash', modelDescriptorId: 'opencode-gemini-3-flash', transportOverrides: { openaiShim: { endpointPath: '/models/gemini-3-flash' } } },
// Qwen — /zen/v1/messages
{ id: 'qwen3.6-plus', apiName: 'qwen3.6-plus', label: 'Qwen3.6 Plus', modelDescriptorId: 'opencode-qwen3.6-plus', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
{ id: 'qwen3.5-plus', apiName: 'qwen3.5-plus', label: 'Qwen3.5 Plus', modelDescriptorId: 'opencode-qwen3.5-plus', transportOverrides: { openaiShim: { endpointPath: '/messages' } } },
// OpenAI-compatible — /zen/v1/chat/completions (default, no override needed)
{ id: 'minimax-m2.7', apiName: 'minimax-m2.7', label: 'MiniMax M2.7', modelDescriptorId: 'opencode-minimax-m2.7' },
{ id: 'minimax-m2.5', apiName: 'minimax-m2.5', label: 'MiniMax M2.5', modelDescriptorId: 'opencode-minimax-m2.5' },
{ id: 'glm-5.1', apiName: 'glm-5.1', label: 'GLM 5.1', modelDescriptorId: 'opencode-glm-5.1' },
{ id: 'glm-5', apiName: 'glm-5', label: 'GLM 5', modelDescriptorId: 'opencode-glm-5' },
{ id: 'kimi-k2.5', apiName: 'kimi-k2.5', label: 'Kimi K2.5', modelDescriptorId: 'opencode-kimi-k2.5' },
{ id: 'kimi-k2.6', apiName: 'kimi-k2.6', label: 'Kimi K2.6', modelDescriptorId: 'opencode-kimi-k2.6' },
{ id: 'grok-build-0.1', apiName: 'grok-build-0.1', label: 'Grok Build 0.1', modelDescriptorId: 'opencode-grok-build-0.1' },
{ id: 'big-pickle', apiName: 'big-pickle', label: 'Big Pickle', modelDescriptorId: 'opencode-big-pickle' },
{ id: 'deepseek-v4-flash-free', apiName: 'deepseek-v4-flash-free', label: 'DeepSeek V4 Flash Free', modelDescriptorId: 'opencode-deepseek-v4-flash-free' },
{ id: 'nemotron-3-super-free', apiName: 'nemotron-3-super-free', label: 'Nemotron 3 Super Free', modelDescriptorId: 'opencode-nemotron-3-super-free' },
],
},
usage: { supported: false },
})
@@ -28,6 +28,8 @@ 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 gatewayOpencode from '../gateways/opencode.js'
import gatewayOpenrouter from '../gateways/openrouter.js'
import gatewayTogether from '../gateways/together.js'
import gatewayVertex from '../gateways/vertex.js'
@@ -56,15 +58,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 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, 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 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 = [
@@ -88,7 +91,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",
@@ -296,6 +303,34 @@ export const PROVIDER_PRESET_MANIFEST = [
"OPENAI_API_KEY"
]
},
{
"preset": "opencode-go",
"routeKind": "gateway",
"routeId": "opencode-go",
"vendorId": "openai",
"gatewayId": "opencode-go",
"description": "OpenCode Go — $10/mo subscription for open models (12 models)",
"apiKeyEnvVars": [
"OPENCODE_API_KEY"
],
"modelEnvVars": [
"OPENAI_MODEL"
]
},
{
"preset": "opencode",
"routeKind": "gateway",
"routeId": "opencode",
"vendorId": "openai",
"gatewayId": "opencode",
"description": "OpenCode Zen — pay-as-you-go AI gateway (41 models)",
"apiKeyEnvVars": [
"OPENCODE_API_KEY"
],
"modelEnvVars": [
"OPENAI_MODEL"
]
},
{
"preset": "openrouter",
"routeKind": "gateway",
@@ -357,7 +392,11 @@ export const PROVIDER_PRESET_MANIFEST = [
],
"modelEnvVars": [
"OPENAI_MODEL"
]
],
"badge": {
"text": "Sponsor",
"color": "success"
}
},
{
"preset": "zai",
@@ -417,6 +456,8 @@ export const ORDERED_PROVIDER_PRESETS = [
"kimi-code",
"nvidia-nim",
"openai",
"opencode-go",
"opencode",
"openrouter",
"together",
"venice",
+979
View File
@@ -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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'coding'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'coding'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'coding'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'coding'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'coding'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'coding'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'coding'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'coding'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'coding'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-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',
vendorId: 'openai',
classification: ['chat', 'reasoning'],
defaultModel: 'opencode-qwen3.5-plus',
capabilities: {
supportsVision: false,
supportsStreaming: true,
supportsFunctionCalling: true,
supportsJsonMode: true,
supportsReasoning: true,
supportsPreciseTokenCount: false,
},
contextWindow: 131_072,
maxOutputTokens: 32_768,
}),
]
+3 -1
View File
@@ -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,
+1
View File
@@ -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',
+495 -5
View File
@@ -1010,6 +1010,246 @@ 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<AnthropicStreamEvent> {
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<ReadableStreamReadResult<Uint8Array>> {
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()
}
}
/**
* 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<AnthropicStreamEvent> {
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<AnthropicUsage> | undefined
let finishReason: string | undefined
function readWithAbort(): Promise<ReadableStreamReadResult<Uint8Array>> {
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<string, unknown>
try {
parsed = JSON.parse(rawData) as Record<string, unknown>
} 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<string, number>
usage = buildAnthropicUsageFromRawUsage({
input_tokens: um.promptTokenCount ?? 0,
output_tokens: (um.candidatesTokenCount ?? 0) + (um.thoughtsTokenCount ?? 0),
})
}
const candidates = parsed.candidates as Array<Record<string, unknown>> | 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<Record<string, unknown>> } | 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,
@@ -1606,6 +1846,8 @@ 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' ||
@@ -1613,7 +1855,11 @@ class OpenAIShimMessages {
isResponsesStream
)
? codexStreamToAnthropic(response, request.resolvedModel, options?.signal)
: openaiStreamToAnthropic(response, request.resolvedModel, options?.signal),
: isMessagesStream
? anthropicSsePassthrough(response, request.resolvedModel, options?.signal)
: isGeminiStream
? geminiSseToAnthropic(response, request.resolvedModel, options?.signal)
: openaiStreamToAnthropic(response, request.resolvedModel, options?.signal),
)
}
@@ -1626,6 +1872,8 @@ 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 ||
@@ -1648,6 +1896,24 @@ 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<string, unknown>
}
}
// 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<string, unknown>
return self._convertGeminiToAnthropicResponse(parsed, request.resolvedModel)
}
}
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
const data = await response.json()
@@ -1785,6 +2051,18 @@ 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)
// - /models/gemini-* → Google AI SDK (contents, systemInstruction, generationConfig)
const effectiveTransport = shimConfig.endpointPath === '/responses'
? 'responses'
: shimConfig.endpointPath === '/messages'
? 'anthropic_messages'
: shimConfig.endpointPath?.startsWith('/models/gemini-')
? 'gemini'
: request.transport
const openaiMessages = convertMessages(compressedMessages, params.system, {
preserveReasoningContent: shimConfig.preserveReasoningContent,
reasoningContentFallback: shimConfig.reasoningContentFallback,
@@ -1968,6 +2246,155 @@ 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<string, unknown> => {
const anthropicBody: Record<string, unknown> = {
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
}
// 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<string, unknown> => {
const contents: Array<{ role: string; parts: Array<Record<string, unknown>> }> = []
// 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<string, string>()
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<Record<string, unknown>> = []
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<string, unknown> = { contents }
// System instruction
const systemText = convertSystemPrompt(params.system)
if (systemText) {
geminiBody.systemInstruction = { parts: [{ text: systemText }] }
}
// Generation config
const genConfig: Record<string, unknown> = {}
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<string, unknown>
}>).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<string, string> = {
'Content-Type': 'application/json',
...filterAnthropicHeaders(shimConfig.headers),
@@ -2104,10 +2531,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)
@@ -2149,7 +2580,10 @@ 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()
: effectiveTransport === 'gemini' ? buildGeminiBody()
: body
return fastPath.skipStableStringify
? JSON.stringify(payload)
: stableStringifyJson(payload)
@@ -2380,7 +2814,7 @@ class OpenAIShimMessages {
}
const hasToolsPayload =
request.transport === 'responses'
effectiveTransport === 'responses' || effectiveTransport === 'anthropic_messages' || effectiveTransport === 'gemini'
? Array.isArray(params.tools) && params.tools.length > 0
: Array.isArray(body.tools) && body.tools.length > 0
@@ -2396,6 +2830,8 @@ class OpenAIShimMessages {
delete body.tools
delete body.tool_choice
omitResponsesTools = true
omitAnthropicTools = true
omitGeminiTools = true
refreshSerializedBody()
logForDebugging(
@@ -2578,6 +3014,60 @@ class OpenAIShimMessages {
),
}
}
private _convertGeminiToAnthropicResponse(
data: Record<string, unknown>,
model: string,
) {
const content: Array<Record<string, unknown>> = []
let hasToolUse = false
const candidates = data.candidates as Array<Record<string, unknown>> | undefined
const candidate = candidates?.[0]
const candidateContent = candidate?.content as { parts?: Array<Record<string, unknown>> } | 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<string, number> | undefined
const usage = buildAnthropicUsageFromRawUsage({
input_tokens: usageMetadata?.promptTokenCount ?? 0,
output_tokens: (usageMetadata?.candidatesTokenCount ?? 0) + (usageMetadata?.thoughtsTokenCount ?? 0),
} as unknown as Record<string, unknown>)
return {
id: makeMessageId(),
type: 'message',
role: 'assistant',
content,
model,
stop_reason: stopReason,
stop_sequence: null,
usage,
}
}
}
class OpenAIShimBeta {
+2
View File
@@ -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<string>()
+398
View File
@@ -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')
})
+29 -1
View File
@@ -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 = {
@@ -312,7 +317,8 @@ export function isProviderProfile(value: unknown): value is ProviderProfile {
value === 'github' ||
value === 'bedrock' ||
value === 'vertex' ||
value === 'xai'
value === 'xai' ||
value === 'opencode'
)
}
@@ -1406,6 +1412,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')