mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
co-authored by
Gravirei
OpenClaude
parent
9190bd0c50
commit
5a22d604f8
@@ -17,3 +17,4 @@ package-lock.json
|
||||
coverage/
|
||||
agent.log
|
||||
plan/
|
||||
temp_reference/
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -130,6 +130,8 @@ const PRESET_ORDER = [
|
||||
'Moonshot AI - Kimi Code',
|
||||
'NVIDIA NIM',
|
||||
'OpenAI',
|
||||
'OpenCode Go',
|
||||
'OpenCode Zen',
|
||||
'OpenRouter',
|
||||
'Together AI',
|
||||
'Venice',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -218,6 +218,7 @@ function buildPresetManifestEntry(
|
||||
modelEnvVars: preset.modelEnvVars,
|
||||
fallbackBaseUrl: preset.fallbackBaseUrl,
|
||||
fallbackModel: preset.fallbackModel,
|
||||
badge: preset.badge,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ const EXPECTED_PRESETS = [
|
||||
'bankr',
|
||||
'atomic-chat',
|
||||
'gitlawb-opengateway',
|
||||
'opencode',
|
||||
'opencode-go',
|
||||
] as const satisfies readonly ProviderPreset[]
|
||||
|
||||
describe('compatibility mappings', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user