From 11dceba118b0197b99d968233d137c10a8669cb5 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 16 Jun 2026 23:29:54 +0200 Subject: [PATCH 1/3] feat(deploy,runtime): add OpenRouter/opencode LLM provider support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare model names like `deepseek-v4-flash-free` were treated as provider identifiers by deriveModelProvider, causing deploy to fail with "provider is required" when the managed-credentials endpoint couldn't normalize them. Now bare model names (no `/` or `:` separator) fall back to the harness-derived provider — `opencode` → `openrouter`. Also adds OPENROUTER_API_KEY credential support to the runtime's ctx.llm so opencode personas can call ctx.llm.complete() at run time. Co-Authored-By: Claude Opus 4.6 --- packages/deploy/src/modes/cloud.test.ts | 23 ++++++++ packages/deploy/src/modes/cloud/index.ts | 10 +++- packages/runtime/src/cloud-llm.test.ts | 33 +++++++++++ packages/runtime/src/cloud-llm.ts | 71 +++++++++++++++++++++--- 4 files changed, 127 insertions(+), 10 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index d1836de5..b2b52604 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -367,11 +367,34 @@ test('cloud harness plan and BYOK save provider credentials through the cloud co }); test('cloud BYOK provider detection avoids substring false positives', async () => { + // A bare model name without a provider separator (/) should not match + // "openai" via substring — it falls back to the harness-derived provider. + // The default test persona has harness: 'codex' → provider 'codex'. await launch({ defaultPlanCredential: false, persona: persona({ model: 'my-openai-alternative' }), env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, input: { harnessSource: 'byok', byokKey: 'sk-test' }, + fetch(url, init) { + if (url.endsWith('/provider-credentials/byok')) { + assert.equal(JSON.parse(String(init?.body)).modelProvider, 'codex'); + return okJson({ providerCredentialId: 'cred-byok' }); + } + if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); + if (url.endsWith('/deployments')) { + return okJson({ agentId: 'agent-byok', deploymentId: 'dep-byok', status: 'active' }, 201); + } + throw new Error(`unexpected URL ${url}`); + } + }); +}); + +test('cloud BYOK with explicit provider/model separator uses model prefix', async () => { + await launch({ + defaultPlanCredential: false, + persona: persona({ model: 'my-openai-alternative/fancy-model' }), + env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, + input: { harnessSource: 'byok', byokKey: 'sk-test' }, fetch(url, init) { if (url.endsWith('/provider-credentials/byok')) { assert.equal(JSON.parse(String(init?.body)).modelProvider, 'my-openai-alternative'); diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index 03d2e106..11e0c0db 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -966,8 +966,14 @@ function deriveModelProvider(persona: PersonaSpec): string { if (matchesProviderToken(lower, ['google', 'gemini'])) return 'google'; if (matchesProviderToken(lower, ['openrouter', 'opencode'])) return 'openrouter'; if (matchesProviderToken(lower, ['grok', 'xai', 'x-ai'])) return 'xai'; - const [provider] = model.split(/[/:]/, 1); - if (provider?.trim()) return provider.trim().toLowerCase(); + // Only treat the prefix as a provider when the model contains an explicit + // separator (e.g. "openai/gpt-5-nano"). Bare model names like + // "deepseek-v4-flash-free" are not provider-qualified — fall through to the + // harness-derived provider so normalizeModelProvider can resolve it. + if (/[/:]/.test(model)) { + const [provider] = model.split(/[/:]/, 1); + if (provider?.trim()) return provider.trim().toLowerCase(); + } return harnessFallback; } diff --git a/packages/runtime/src/cloud-llm.test.ts b/packages/runtime/src/cloud-llm.test.ts index f64ebe55..a8893c9a 100644 --- a/packages/runtime/src/cloud-llm.test.ts +++ b/packages/runtime/src/cloud-llm.test.ts @@ -338,3 +338,36 @@ test('empty text content throws instead of returning an empty string', async (t) assert.ok(llm); await assert.rejects(llm.complete('hi'), /no text content/); }); + +test('OPENROUTER_API_KEY routes opencode personas to OpenRouter chat completions', async (t) => { + const requests = stubFetch(t, { + payload: { choices: [{ message: { content: 'hello from deepseek' } }] } + }); + const llm = createDefaultLlm({ + persona: { ...basePersona, harness: 'opencode', model: 'deepseek-v4-flash-free' }, + env: { OPENROUTER_API_KEY: 'sk-or-test' }, + log: noopLog + }); + assert.ok(llm); + const result = await llm.complete('hi', { maxTokens: 512 }); + assert.equal(result, 'hello from deepseek'); + const request = requests[0]!; + assert.equal(request.url, 'https://openrouter.ai/api/v1/chat/completions'); + assert.equal(request.headers['authorization'], 'Bearer sk-or-test'); + assert.equal(request.body.model, 'deepseek-v4-flash-free'); + assert.equal(request.body.max_tokens, 512); +}); + +test('opencode harness prefers OPENROUTER_API_KEY over ANTHROPIC_API_KEY', async (t) => { + const requests = stubFetch(t, { + payload: { choices: [{ message: { content: 'openrouter wins' } }] } + }); + const llm = createDefaultLlm({ + persona: { ...basePersona, harness: 'opencode', model: 'deepseek-v4-flash-free' }, + env: { ANTHROPIC_API_KEY: 'sk-ant-test', OPENROUTER_API_KEY: 'sk-or-test' }, + log: noopLog + }); + assert.ok(llm); + await llm.complete('hi'); + assert.equal(requests[0]!.url, 'https://openrouter.ai/api/v1/chat/completions'); +}); diff --git a/packages/runtime/src/cloud-llm.ts b/packages/runtime/src/cloud-llm.ts index 4562ab96..bc9a73fa 100644 --- a/packages/runtime/src/cloud-llm.ts +++ b/packages/runtime/src/cloud-llm.ts @@ -44,6 +44,7 @@ const OPENAI_BASE_URL = 'https://api.openai.com'; // expect maintenance if chatgpt.com/backend-api/codex changes. const CODEX_BACKEND_BASE_URL = 'https://chatgpt.com/backend-api/codex'; const CODEX_BACKEND_ORIGINATOR = 'codex_cli_rs'; +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api'; export interface CloudLlmOptions { persona: PersonaSpec; @@ -51,8 +52,8 @@ export interface CloudLlmOptions { log: WorkforceCtx['log']; } -type PersonaModelFamily = 'anthropic' | 'openai' | 'codex'; -type LlmProviderFamily = 'anthropic' | 'openai' | 'codex-backend'; +type PersonaModelFamily = 'anthropic' | 'openai' | 'codex' | 'openrouter'; +type LlmProviderFamily = 'anthropic' | 'openai' | 'codex-backend' | 'openrouter'; interface LlmCredential { family: LlmProviderFamily; @@ -92,6 +93,9 @@ export function createDefaultLlm(options: CloudLlmOptions): LlmContext | undefin if (credential.family === 'codex-backend') { return codexBackendLlm(credential, model, options.log); } + if (credential.family === 'openrouter') { + return openrouterLlm(credential, model, options.log); + } return openaiLlm(credential, model, options.log); } @@ -103,6 +107,7 @@ function selectCredential( const anthropicApiKey = nonEmpty(env.ANTHROPIC_API_KEY); const claudeOauth = nonEmpty(env.CLAUDE_CODE_OAUTH_TOKEN); const openaiApiKey = nonEmpty(env.OPENAI_API_KEY); + const openrouterApiKey = nonEmpty(env.OPENROUTER_API_KEY); const codexOauth = codexOauthCredential(env); // Exactly one auth header per request: an OAuth bearer must go on @@ -142,6 +147,13 @@ function selectCredential( source: 'OPENAI_API_KEY' }); } + if (openrouterApiKey) { + candidates.push({ + family: 'openrouter', + headers: { authorization: `Bearer ${openrouterApiKey}` }, + source: 'OPENROUTER_API_KEY' + }); + } if (candidates.length === 0) return null; if (preferred) { @@ -161,6 +173,9 @@ function preferredCredential( if (preferred === 'anthropic') { return candidates.find((candidate) => candidate.family === 'anthropic'); } + if (preferred === 'openrouter') { + return candidates.find((candidate) => candidate.family === 'openrouter'); + } if (preferred === 'codex') { return ( candidates.find((candidate) => candidate.family === 'codex-backend') ?? @@ -175,13 +190,16 @@ function preferredCredential( function personaModelFamily(persona: PersonaSpec): PersonaModelFamily | null { const model = nonEmpty(persona.model); - if (!model) return null; - const normalized = model.toLowerCase(); - if (normalized.startsWith('anthropic/') || normalized.includes('claude')) return 'anthropic'; - if (normalized.startsWith('openai-codex/') || normalized.includes('codex')) return 'codex'; - if (normalized.startsWith('openai/') || normalized.includes('gpt-')) { - return 'openai'; + const harness = nonEmpty(persona.harness); + if (!model && !harness) return null; + if (model) { + const normalized = model.toLowerCase(); + if (normalized.startsWith('anthropic/') || normalized.includes('claude')) return 'anthropic'; + if (normalized.startsWith('openai-codex/') || normalized.includes('codex')) return 'codex'; + if (normalized.startsWith('openai/') || normalized.includes('gpt-')) return 'openai'; + if (normalized.startsWith('openrouter/') || normalized.startsWith('opencode/')) return 'openrouter'; } + if (harness === 'opencode') return 'openrouter'; return null; } @@ -205,6 +223,7 @@ function resolveModel(persona: PersonaSpec, family: LlmProviderFamily): string { } if (family === 'anthropic') return DEFAULT_ANTHROPIC_MODEL; if (family === 'codex-backend') return DEFAULT_CODEX_BACKEND_MODEL; + if (family === 'openrouter') return personaModel ?? DEFAULT_OPENAI_MODEL; return DEFAULT_OPENAI_MODEL; } @@ -214,6 +233,7 @@ function credentialMatchesPersonaFamily( ): boolean { if (!personaFamily) return false; if (credentialFamily === 'anthropic') return personaFamily === 'anthropic'; + if (credentialFamily === 'openrouter') return personaFamily === 'openrouter'; if (credentialFamily === 'codex-backend') { return personaFamily === 'codex' || personaFamily === 'openai'; } @@ -308,6 +328,41 @@ function openaiLlm( }; } +function openrouterLlm( + credential: LlmCredential, + model: string, + log: WorkforceCtx['log'] +): LlmContext { + return { + async complete(prompt, opts) { + const body = { + model, + max_tokens: opts?.maxTokens ?? DEFAULT_MAX_TOKENS, + messages: [{ role: 'user', content: prompt }] + }; + const payload = await postJson( + `${OPENROUTER_BASE_URL}/v1/chat/completions`, + { + ...credential.headers, + 'content-type': 'application/json' + }, + body, + log + ); + const choices = (payload as { choices?: unknown }).choices; + const first = Array.isArray(choices) ? choices[0] : undefined; + const text = + isRecord(first) && isRecord(first.message) && typeof first.message.content === 'string' + ? first.message.content + : ''; + if (!text) { + throw new Error('ctx.llm: OpenRouter response contained no message content'); + } + return text; + } + }; +} + function codexBackendLlm( credential: LlmCredential, model: string, From 3e5c391d0942e223e3256d1836f6e941dcec66c8 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 17 Jun 2026 00:00:59 +0200 Subject: [PATCH 2/3] refactor(deploy): harness-first provider derivation in deriveModelProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Known harnesses (opencode→openrouter, codex→openai, claude→anthropic, grok→xai) now resolve the provider before falling through to model-string heuristics. This avoids bare model names like "deepseek-v4-flash-free" being treated as provider identifiers — the harness is the explicit source of truth for which credential provider to use. Co-Authored-By: Claude Opus 4.6 --- packages/deploy/src/modes/cloud.test.ts | 14 +++++------ packages/deploy/src/modes/cloud/index.ts | 32 +++++++++++++----------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index b2b52604..b9795e54 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -368,8 +368,8 @@ test('cloud harness plan and BYOK save provider credentials through the cloud co test('cloud BYOK provider detection avoids substring false positives', async () => { // A bare model name without a provider separator (/) should not match - // "openai" via substring — it falls back to the harness-derived provider. - // The default test persona has harness: 'codex' → provider 'codex'. + // "openai" via substring — the harness-derived provider wins. + // The default test persona has harness: 'codex' → HARNESS_TO_PROVIDER → 'openai'. await launch({ defaultPlanCredential: false, persona: persona({ model: 'my-openai-alternative' }), @@ -377,7 +377,7 @@ test('cloud BYOK provider detection avoids substring false positives', async () input: { harnessSource: 'byok', byokKey: 'sk-test' }, fetch(url, init) { if (url.endsWith('/provider-credentials/byok')) { - assert.equal(JSON.parse(String(init?.body)).modelProvider, 'codex'); + assert.equal(JSON.parse(String(init?.body)).modelProvider, 'openai'); return okJson({ providerCredentialId: 'cred-byok' }); } if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); @@ -389,15 +389,15 @@ test('cloud BYOK provider detection avoids substring false positives', async () }); }); -test('cloud BYOK with explicit provider/model separator uses model prefix', async () => { +test('cloud BYOK opencode harness derives openrouter provider', async () => { await launch({ defaultPlanCredential: false, - persona: persona({ model: 'my-openai-alternative/fancy-model' }), + persona: persona({ harness: 'opencode', model: 'deepseek-v4-flash-free' }), env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.test' }, - input: { harnessSource: 'byok', byokKey: 'sk-test' }, + input: { harnessSource: 'byok', byokKey: 'sk-or-test' }, fetch(url, init) { if (url.endsWith('/provider-credentials/byok')) { - assert.equal(JSON.parse(String(init?.body)).modelProvider, 'my-openai-alternative'); + assert.equal(JSON.parse(String(init?.body)).modelProvider, 'openrouter'); return okJson({ providerCredentialId: 'cred-byok' }); } if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index 11e0c0db..c191ebe0 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -953,28 +953,32 @@ async function saveProviderCredential(args: { return readCredentialId(body); } +const HARNESS_TO_PROVIDER: Record = { + opencode: 'openrouter', + claude: 'anthropic', + codex: 'openai', + grok: 'xai' +}; + function deriveModelProvider(persona: PersonaSpec): string { - // Callers gate on `persona.harness` being set before reaching here, so the - // harness fallback is always a defined string in practice; default to - // 'anthropic' to keep the return type total. - const harnessFallback = persona.harness ?? 'anthropic'; + // When the harness explicitly maps to a known provider, use it directly. + // The model string is the model ID within that provider's catalog (e.g. + // "deepseek-v4-flash-free" for opencode/openrouter), not a provider name. + const harness = typeof persona.harness === 'string' ? persona.harness.trim().toLowerCase() : ''; + const harnessProvider = HARNESS_TO_PROVIDER[harness]; + if (harnessProvider) return harnessProvider; + const model = typeof persona.model === 'string' ? persona.model.trim() : ''; - if (!model) return harnessFallback; + if (!model) return harness || 'anthropic'; const lower = model.toLowerCase(); if (matchesProviderToken(lower, ['anthropic', 'claude'])) return 'anthropic'; if (matchesProviderToken(lower, ['openai', 'codex', 'gpt'])) return 'openai'; if (matchesProviderToken(lower, ['google', 'gemini'])) return 'google'; if (matchesProviderToken(lower, ['openrouter', 'opencode'])) return 'openrouter'; if (matchesProviderToken(lower, ['grok', 'xai', 'x-ai'])) return 'xai'; - // Only treat the prefix as a provider when the model contains an explicit - // separator (e.g. "openai/gpt-5-nano"). Bare model names like - // "deepseek-v4-flash-free" are not provider-qualified — fall through to the - // harness-derived provider so normalizeModelProvider can resolve it. - if (/[/:]/.test(model)) { - const [provider] = model.split(/[/:]/, 1); - if (provider?.trim()) return provider.trim().toLowerCase(); - } - return harnessFallback; + const [provider] = model.split(/[/:]/, 1); + if (provider?.trim()) return provider.trim().toLowerCase(); + return harness || 'anthropic'; } function matchesProviderToken(model: string, tokens: readonly string[]): boolean { From 5844f9ce6d33df4675c45c9d4588dca39ecccc71 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 17 Jun 2026 00:33:55 +0200 Subject: [PATCH 3/3] refactor: opencode is its own provider, not an alias for openrouter The harness-to-provider mapping, credential env var, and model family derivation all treat opencode as a first-class provider identity. Runtime env var is OPENCODE_API_KEY (not OPENROUTER_API_KEY). OpenRouter remains as a separate provider for explicit openrouter/ model prefixes. Co-Authored-By: Claude Opus 4.6 --- packages/deploy/src/modes/cloud.test.ts | 4 +-- packages/deploy/src/modes/cloud/index.ts | 9 ++++--- packages/runtime/src/cloud-llm.test.ts | 12 ++++----- packages/runtime/src/cloud-llm.ts | 34 ++++++++++++------------ 4 files changed, 30 insertions(+), 29 deletions(-) diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index b9795e54..e12ae4ff 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -389,7 +389,7 @@ test('cloud BYOK provider detection avoids substring false positives', async () }); }); -test('cloud BYOK opencode harness derives openrouter provider', async () => { +test('cloud BYOK opencode harness derives opencode provider', async () => { await launch({ defaultPlanCredential: false, persona: persona({ harness: 'opencode', model: 'deepseek-v4-flash-free' }), @@ -397,7 +397,7 @@ test('cloud BYOK opencode harness derives openrouter provider', async () => { input: { harnessSource: 'byok', byokKey: 'sk-or-test' }, fetch(url, init) { if (url.endsWith('/provider-credentials/byok')) { - assert.equal(JSON.parse(String(init?.body)).modelProvider, 'openrouter'); + assert.equal(JSON.parse(String(init?.body)).modelProvider, 'opencode'); return okJson({ providerCredentialId: 'cred-byok' }); } if (init?.method === 'GET' && url.endsWith('/deployments')) return okJson({ agents: [] }); diff --git a/packages/deploy/src/modes/cloud/index.ts b/packages/deploy/src/modes/cloud/index.ts index c191ebe0..d0454831 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -954,7 +954,7 @@ async function saveProviderCredential(args: { } const HARNESS_TO_PROVIDER: Record = { - opencode: 'openrouter', + opencode: 'opencode', claude: 'anthropic', codex: 'openai', grok: 'xai' @@ -963,7 +963,7 @@ const HARNESS_TO_PROVIDER: Record = { function deriveModelProvider(persona: PersonaSpec): string { // When the harness explicitly maps to a known provider, use it directly. // The model string is the model ID within that provider's catalog (e.g. - // "deepseek-v4-flash-free" for opencode/openrouter), not a provider name. + // "deepseek-v4-flash-free" for opencode), not a provider name. const harness = typeof persona.harness === 'string' ? persona.harness.trim().toLowerCase() : ''; const harnessProvider = HARNESS_TO_PROVIDER[harness]; if (harnessProvider) return harnessProvider; @@ -974,7 +974,8 @@ function deriveModelProvider(persona: PersonaSpec): string { if (matchesProviderToken(lower, ['anthropic', 'claude'])) return 'anthropic'; if (matchesProviderToken(lower, ['openai', 'codex', 'gpt'])) return 'openai'; if (matchesProviderToken(lower, ['google', 'gemini'])) return 'google'; - if (matchesProviderToken(lower, ['openrouter', 'opencode'])) return 'openrouter'; + if (matchesProviderToken(lower, ['openrouter'])) return 'openrouter'; + if (matchesProviderToken(lower, ['opencode'])) return 'opencode'; if (matchesProviderToken(lower, ['grok', 'xai', 'x-ai'])) return 'xai'; const [provider] = model.split(/[/:]/, 1); if (provider?.trim()) return provider.trim().toLowerCase(); @@ -1079,7 +1080,7 @@ function harnessAliasForModelProvider(modelProvider: string): string { return 'codex'; case 'google': return 'gemini'; - case 'openrouter': + case 'opencode': return 'opencode'; case 'xai': return 'grok'; diff --git a/packages/runtime/src/cloud-llm.test.ts b/packages/runtime/src/cloud-llm.test.ts index a8893c9a..dfebf986 100644 --- a/packages/runtime/src/cloud-llm.test.ts +++ b/packages/runtime/src/cloud-llm.test.ts @@ -339,13 +339,13 @@ test('empty text content throws instead of returning an empty string', async (t) await assert.rejects(llm.complete('hi'), /no text content/); }); -test('OPENROUTER_API_KEY routes opencode personas to OpenRouter chat completions', async (t) => { +test('OPENCODE_API_KEY routes opencode personas to OpenRouter chat completions', async (t) => { const requests = stubFetch(t, { payload: { choices: [{ message: { content: 'hello from deepseek' } }] } }); const llm = createDefaultLlm({ persona: { ...basePersona, harness: 'opencode', model: 'deepseek-v4-flash-free' }, - env: { OPENROUTER_API_KEY: 'sk-or-test' }, + env: { OPENCODE_API_KEY: 'sk-oc-test' }, log: noopLog }); assert.ok(llm); @@ -353,18 +353,18 @@ test('OPENROUTER_API_KEY routes opencode personas to OpenRouter chat completions assert.equal(result, 'hello from deepseek'); const request = requests[0]!; assert.equal(request.url, 'https://openrouter.ai/api/v1/chat/completions'); - assert.equal(request.headers['authorization'], 'Bearer sk-or-test'); + assert.equal(request.headers['authorization'], 'Bearer sk-oc-test'); assert.equal(request.body.model, 'deepseek-v4-flash-free'); assert.equal(request.body.max_tokens, 512); }); -test('opencode harness prefers OPENROUTER_API_KEY over ANTHROPIC_API_KEY', async (t) => { +test('opencode harness prefers OPENCODE_API_KEY over ANTHROPIC_API_KEY', async (t) => { const requests = stubFetch(t, { - payload: { choices: [{ message: { content: 'openrouter wins' } }] } + payload: { choices: [{ message: { content: 'opencode wins' } }] } }); const llm = createDefaultLlm({ persona: { ...basePersona, harness: 'opencode', model: 'deepseek-v4-flash-free' }, - env: { ANTHROPIC_API_KEY: 'sk-ant-test', OPENROUTER_API_KEY: 'sk-or-test' }, + env: { ANTHROPIC_API_KEY: 'sk-ant-test', OPENCODE_API_KEY: 'sk-oc-test' }, log: noopLog }); assert.ok(llm); diff --git a/packages/runtime/src/cloud-llm.ts b/packages/runtime/src/cloud-llm.ts index bc9a73fa..03a0d71a 100644 --- a/packages/runtime/src/cloud-llm.ts +++ b/packages/runtime/src/cloud-llm.ts @@ -52,8 +52,8 @@ export interface CloudLlmOptions { log: WorkforceCtx['log']; } -type PersonaModelFamily = 'anthropic' | 'openai' | 'codex' | 'openrouter'; -type LlmProviderFamily = 'anthropic' | 'openai' | 'codex-backend' | 'openrouter'; +type PersonaModelFamily = 'anthropic' | 'openai' | 'codex' | 'opencode'; +type LlmProviderFamily = 'anthropic' | 'openai' | 'codex-backend' | 'opencode'; interface LlmCredential { family: LlmProviderFamily; @@ -93,8 +93,8 @@ export function createDefaultLlm(options: CloudLlmOptions): LlmContext | undefin if (credential.family === 'codex-backend') { return codexBackendLlm(credential, model, options.log); } - if (credential.family === 'openrouter') { - return openrouterLlm(credential, model, options.log); + if (credential.family === 'opencode') { + return opencodeLlm(credential, model, options.log); } return openaiLlm(credential, model, options.log); } @@ -107,7 +107,7 @@ function selectCredential( const anthropicApiKey = nonEmpty(env.ANTHROPIC_API_KEY); const claudeOauth = nonEmpty(env.CLAUDE_CODE_OAUTH_TOKEN); const openaiApiKey = nonEmpty(env.OPENAI_API_KEY); - const openrouterApiKey = nonEmpty(env.OPENROUTER_API_KEY); + const opencodeApiKey = nonEmpty(env.OPENCODE_API_KEY); const codexOauth = codexOauthCredential(env); // Exactly one auth header per request: an OAuth bearer must go on @@ -147,11 +147,11 @@ function selectCredential( source: 'OPENAI_API_KEY' }); } - if (openrouterApiKey) { + if (opencodeApiKey) { candidates.push({ - family: 'openrouter', - headers: { authorization: `Bearer ${openrouterApiKey}` }, - source: 'OPENROUTER_API_KEY' + family: 'opencode', + headers: { authorization: `Bearer ${opencodeApiKey}` }, + source: 'OPENCODE_API_KEY' }); } @@ -173,8 +173,8 @@ function preferredCredential( if (preferred === 'anthropic') { return candidates.find((candidate) => candidate.family === 'anthropic'); } - if (preferred === 'openrouter') { - return candidates.find((candidate) => candidate.family === 'openrouter'); + if (preferred === 'opencode') { + return candidates.find((candidate) => candidate.family === 'opencode'); } if (preferred === 'codex') { return ( @@ -197,9 +197,9 @@ function personaModelFamily(persona: PersonaSpec): PersonaModelFamily | null { if (normalized.startsWith('anthropic/') || normalized.includes('claude')) return 'anthropic'; if (normalized.startsWith('openai-codex/') || normalized.includes('codex')) return 'codex'; if (normalized.startsWith('openai/') || normalized.includes('gpt-')) return 'openai'; - if (normalized.startsWith('openrouter/') || normalized.startsWith('opencode/')) return 'openrouter'; + if (normalized.startsWith('opencode/')) return 'opencode'; } - if (harness === 'opencode') return 'openrouter'; + if (harness === 'opencode') return 'opencode'; return null; } @@ -223,7 +223,7 @@ function resolveModel(persona: PersonaSpec, family: LlmProviderFamily): string { } if (family === 'anthropic') return DEFAULT_ANTHROPIC_MODEL; if (family === 'codex-backend') return DEFAULT_CODEX_BACKEND_MODEL; - if (family === 'openrouter') return personaModel ?? DEFAULT_OPENAI_MODEL; + if (family === 'opencode') return personaModel ?? DEFAULT_OPENAI_MODEL; return DEFAULT_OPENAI_MODEL; } @@ -233,7 +233,7 @@ function credentialMatchesPersonaFamily( ): boolean { if (!personaFamily) return false; if (credentialFamily === 'anthropic') return personaFamily === 'anthropic'; - if (credentialFamily === 'openrouter') return personaFamily === 'openrouter'; + if (credentialFamily === 'opencode') return personaFamily === 'opencode'; if (credentialFamily === 'codex-backend') { return personaFamily === 'codex' || personaFamily === 'openai'; } @@ -328,7 +328,7 @@ function openaiLlm( }; } -function openrouterLlm( +function opencodeLlm( credential: LlmCredential, model: string, log: WorkforceCtx['log'] @@ -356,7 +356,7 @@ function openrouterLlm( ? first.message.content : ''; if (!text) { - throw new Error('ctx.llm: OpenRouter response contained no message content'); + throw new Error('ctx.llm: OpenCode response contained no message content'); } return text; }