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
11 changes: 11 additions & 0 deletions packages/runtime/src/cloud-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import {
resolveStringMapLenient,
type PersonaSpec
} from '@agentworkforce/persona-kit';
import { createDefaultLlm } from './cloud-llm.js';
import { SandboxNotAvailableError } from './errors.js';
import type {
FilesContext,
HarnessRunArgs,
HarnessRunResult,
HarnessUsage,
LlmContext,
SandboxContext,
WorkforceAgentContext,
WorkforceCtx,
Expand Down Expand Up @@ -57,6 +59,7 @@ export interface CloudRuntimeDefaults {
sandbox: SandboxContext;
files: FilesContext;
workflow?: WorkflowContext;
llm?: LlmContext;
harnessRunner: (args: HarnessRunArgs) => Promise<HarnessRunResult>;
}

Expand All @@ -73,10 +76,18 @@ export function createCloudRuntimeDefaults(options: CloudDefaultOptions): CloudR
workspaceRoot: root,
env
});
// ctx.llm from sandbox credentials — without this, no cloud persona ever
// gets a working ctx.llm (buildCtx falls back to a throwing stub).
const llm = createDefaultLlm({
persona: options.persona,
env,
log: options.log
});
return {
sandbox,
files,
...(workflow ? { workflow } : {}),
...(llm ? { llm } : {}),
harnessRunner: createProcessHarnessRunner({
...options,
workspaceRoot: root,
Expand Down
188 changes: 188 additions & 0 deletions packages/runtime/src/cloud-llm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { PersonaSpec } from '@agentworkforce/persona-kit';
import { createDefaultLlm } from './cloud-llm.js';

const basePersona: PersonaSpec = {
id: 'demo',
intent: 'documentation',
tags: ['documentation'],
description: 'test persona',
skills: [],
harness: 'claude',
model: 'anthropic/claude-sonnet-4-6',
systemPrompt: 'be helpful',
harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 },
cloud: true
};

const noopLog = () => {};

interface CapturedRequest {
url: string;
headers: Record<string, string>;
body: Record<string, unknown>;
}

function stubFetch(
t: { after(fn: () => void): void },
response: { status?: number; payload?: unknown; rawBody?: string }
): CapturedRequest[] {
const captured: CapturedRequest[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
captured.push({
url: String(input),
headers: Object.fromEntries(
Object.entries((init?.headers ?? {}) as Record<string, string>).map(([key, value]) => [
key.toLowerCase(),
value
])
),
body: JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
});
const status = response.status ?? 200;
const body = response.rawBody ?? JSON.stringify(response.payload ?? {});
return new Response(body, {
status,
headers: { 'content-type': 'application/json' }
});
}) as typeof fetch;
t.after(() => {
globalThis.fetch = original;
});
return captured;
}

test('returns undefined when the sandbox has no LLM credentials', () => {
const llm = createDefaultLlm({ persona: basePersona, env: {}, log: noopLog });
assert.equal(llm, undefined);
});

test('ANTHROPIC_API_KEY produces an x-api-key Messages API client', async (t) => {
const requests = stubFetch(t, {
payload: { content: [{ type: 'text', text: 'hello from claude' }] }
});
const llm = createDefaultLlm({
persona: basePersona,
env: { ANTHROPIC_API_KEY: 'sk-ant-test' },
log: noopLog
});
assert.ok(llm);
const result = await llm.complete('hi', { maxTokens: 64 });
assert.equal(result, 'hello from claude');
assert.equal(requests.length, 1);
const request = requests[0]!;
assert.equal(request.url, 'https://api.anthropic.com/v1/messages');
assert.equal(request.headers['x-api-key'], 'sk-ant-test');
assert.equal(request.headers['anthropic-version'], '2023-06-01');
assert.equal(request.headers['authorization'], undefined);
assert.equal(request.headers['anthropic-beta'], undefined); // beta header is OAuth-leg-only
assert.equal(request.body.model, 'claude-sonnet-4-6'); // anthropic/ prefix stripped
assert.equal(request.body.max_tokens, 64);
assert.deepEqual(request.body.messages, [{ role: 'user', content: 'hi' }]);
});

test('CLAUDE_CODE_OAUTH_TOKEN authenticates via Authorization: Bearer only', async (t) => {
const requests = stubFetch(t, {
payload: { content: [{ type: 'text', text: 'ok' }] }
});
const llm = createDefaultLlm({
persona: basePersona,
env: { CLAUDE_CODE_OAUTH_TOKEN: 'oat-token' },
log: noopLog
});
assert.ok(llm);
await llm.complete('hi');
const request = requests[0]!;
assert.equal(request.headers['authorization'], 'Bearer oat-token');
assert.equal(request.headers['x-api-key'], undefined);
// Setup-tokens are rejected by /v1/messages without the OAuth beta header.
assert.equal(request.headers['anthropic-beta'], 'oauth-2025-04-20');
});

test('codex-only persona models fall back to the default chat model', async (t) => {
const requests = stubFetch(t, {
payload: { choices: [{ message: { content: 'ok' } }] }
});
const llm = createDefaultLlm({
persona: { ...basePersona, harness: 'codex', model: 'openai-codex/gpt-5.5-codex' },
env: { OPENAI_API_KEY: 'sk-openai-test' },
log: noopLog
});
assert.ok(llm);
await llm.complete('hi');
// gpt-*-codex is a Codex CLI model, not served by /v1/chat/completions.
assert.equal(requests[0]!.body.model, 'gpt-5.5');
});

test('OPENAI_API_KEY routes gpt-family personas to chat completions', async (t) => {
const requests = stubFetch(t, {
payload: { choices: [{ message: { content: 'hello from gpt' } }] }
});
const llm = createDefaultLlm({
persona: { ...basePersona, harness: 'codex', model: 'openai/gpt-5.4' },
env: { OPENAI_API_KEY: 'sk-openai-test' },
log: noopLog
});
assert.ok(llm);
const result = await llm.complete('hi', { maxTokens: 32 });
assert.equal(result, 'hello from gpt');
const request = requests[0]!;
assert.equal(request.url, 'https://api.openai.com/v1/chat/completions');
assert.equal(request.headers['authorization'], 'Bearer sk-openai-test');
assert.equal(request.body.model, 'gpt-5.4'); // openai/ prefix stripped
assert.equal(request.body.max_completion_tokens, 32);
});

test('persona model family wins when multiple credentials exist', async (t) => {
const requests = stubFetch(t, {
payload: { choices: [{ message: { content: 'gpt answer' } }] }
});
const llm = createDefaultLlm({
persona: { ...basePersona, harness: 'codex', model: 'gpt-5.1' },
env: { ANTHROPIC_API_KEY: 'sk-ant-test', OPENAI_API_KEY: 'sk-openai-test' },
log: noopLog
});
assert.ok(llm);
await llm.complete('hi');
assert.equal(requests[0]!.url, 'https://api.openai.com/v1/chat/completions');
});

test('anthropic credential is the default when the persona model names no family', async (t) => {
const requests = stubFetch(t, {
payload: { content: [{ type: 'text', text: 'ok' }] }
});
const llm = createDefaultLlm({
persona: { ...basePersona, model: undefined },
env: { ANTHROPIC_API_KEY: 'sk-ant-test', OPENAI_API_KEY: 'sk-openai-test' },
log: noopLog
});
assert.ok(llm);
await llm.complete('hi');
const request = requests[0]!;
assert.equal(request.url, 'https://api.anthropic.com/v1/messages');
assert.equal(request.body.model, 'claude-opus-4-8');
});

test('non-2xx responses throw with status and detail', async (t) => {
stubFetch(t, { status: 401, rawBody: '{"error":{"message":"bad key"}}' });
const llm = createDefaultLlm({
persona: basePersona,
env: { ANTHROPIC_API_KEY: 'sk-ant-test' },
log: noopLog
});
assert.ok(llm);
await assert.rejects(llm.complete('hi'), /401/);
});

test('empty text content throws instead of returning an empty string', async (t) => {
stubFetch(t, { payload: { content: [], stop_reason: 'max_tokens' } });
const llm = createDefaultLlm({
persona: basePersona,
env: { ANTHROPIC_API_KEY: 'sk-ant-test' },
log: noopLog
});
assert.ok(llm);
await assert.rejects(llm.complete('hi'), /no text content/);
});
Loading
Loading