diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index d1836de5..e12ae4ff 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -367,6 +367,9 @@ 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 — 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' }), @@ -374,7 +377,27 @@ 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, 'my-openai-alternative'); + assert.equal(JSON.parse(String(init?.body)).modelProvider, 'openai'); + 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 opencode harness derives opencode provider', async () => { + await launch({ + defaultPlanCredential: false, + persona: persona({ harness: 'opencode', model: 'deepseek-v4-flash-free' }), + env: { WORKFORCE_DEPLOY_CLOUD_URL: 'https://cloud.example.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, '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 03d2e106..d0454831 100644 --- a/packages/deploy/src/modes/cloud/index.ts +++ b/packages/deploy/src/modes/cloud/index.ts @@ -953,22 +953,33 @@ async function saveProviderCredential(args: { return readCredentialId(body); } +const HARNESS_TO_PROVIDER: Record = { + opencode: 'opencode', + 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), 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, ['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(); - return harnessFallback; + return harness || 'anthropic'; } function matchesProviderToken(model: string, tokens: readonly string[]): boolean { @@ -1069,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 f64ebe55..dfebf986 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('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: { OPENCODE_API_KEY: 'sk-oc-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-oc-test'); + assert.equal(request.body.model, 'deepseek-v4-flash-free'); + assert.equal(request.body.max_tokens, 512); +}); + +test('opencode harness prefers OPENCODE_API_KEY over ANTHROPIC_API_KEY', async (t) => { + const requests = stubFetch(t, { + 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', OPENCODE_API_KEY: 'sk-oc-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..03a0d71a 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' | 'opencode'; +type LlmProviderFamily = 'anthropic' | 'openai' | 'codex-backend' | 'opencode'; 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 === 'opencode') { + return opencodeLlm(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 opencodeApiKey = nonEmpty(env.OPENCODE_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 (opencodeApiKey) { + candidates.push({ + family: 'opencode', + headers: { authorization: `Bearer ${opencodeApiKey}` }, + source: 'OPENCODE_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 === 'opencode') { + return candidates.find((candidate) => candidate.family === 'opencode'); + } 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('opencode/')) return 'opencode'; } + if (harness === 'opencode') return 'opencode'; 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 === 'opencode') 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 === 'opencode') return personaFamily === 'opencode'; if (credentialFamily === 'codex-backend') { return personaFamily === 'codex' || personaFamily === 'openai'; } @@ -308,6 +328,41 @@ function openaiLlm( }; } +function opencodeLlm( + 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: OpenCode response contained no message content'); + } + return text; + } + }; +} + function codexBackendLlm( credential: LlmCredential, model: string,