Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/deploy/src/modes/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,14 +367,37 @@ 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' }),
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');
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: [] });
Expand Down
27 changes: 19 additions & 8 deletions packages/deploy/src/modes/cloud/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -953,22 +953,33 @@ async function saveProviderCredential(args: {
return readCredentialId(body);
}

const HARNESS_TO_PROVIDER: Record<string, string> = {
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';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function matchesProviderToken(model: string, tokens: readonly string[]): boolean {
Expand Down Expand Up @@ -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';
Expand Down
33 changes: 33 additions & 0 deletions packages/runtime/src/cloud-llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
71 changes: 63 additions & 8 deletions packages/runtime/src/cloud-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,16 @@ 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;
env: NodeJS.ProcessEnv;
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;
Expand Down Expand Up @@ -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);
}

Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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') ??
Expand All @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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';
}
Expand Down Expand Up @@ -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,
Expand Down
Loading