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
3 changes: 2 additions & 1 deletion apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,8 @@ BASE_DOMAIN=workspaces.example.com

# AI Inference Proxy (Workers AI gateway for trial/zero-config users)
# AI_PROXY_ENABLED=true # Kill switch: set "false" to disable (default: enabled)
# AI_PROXY_DEFAULT_MODEL=@cf/meta/llama-4-scout-17b-16e-instruct # Default model (override via admin UI or env var)
# AI_PROXY_DEFAULT_MODEL=@cf/meta/llama-4-scout-17b-16e-instruct # Default model for OpenCode (override via admin UI or env var)
# AI_PROXY_DEFAULT_ANTHROPIC_MODEL=claude-sonnet-4-6 # Default model for Claude Code proxy fallback
# AI_PROXY_ALLOWED_MODELS=@cf/meta/llama-4-scout-17b-16e-instruct,claude-haiku-4-5-20251001,@cf/qwen/qwen3-30b-a3b-fp8,@cf/google/gemma-3-12b-it
# AI_PROXY_DAILY_INPUT_TOKEN_LIMIT=500000 # Per-user daily input token cap
# AI_PROXY_DAILY_OUTPUT_TOKEN_LIMIT=200000 # Per-user daily output token cap
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,8 @@ export interface Env {
TRIGGER_STALE_RECOVERY_BATCH_SIZE?: string; // Max stale executions to recover per sweep (default: 100)
// AI Inference Proxy (Cloudflare AI Gateway — Workers AI + Anthropic)
AI_PROXY_ENABLED?: string; // Kill switch: "false" to disable (default: enabled)
AI_PROXY_DEFAULT_MODEL?: string; // Default model (default: claude-haiku-4-5-20251001)
AI_PROXY_DEFAULT_MODEL?: string; // Default model for OpenCode (default: claude-haiku-4-5-20251001)
AI_PROXY_DEFAULT_ANTHROPIC_MODEL?: string; // Default model for Claude Code proxy (default: claude-sonnet-4-6)
AI_PROXY_ALLOWED_MODELS?: string; // Comma-separated allowed models
AI_PROXY_DAILY_INPUT_TOKEN_LIMIT?: string; // Per-user daily input token cap (default: 500000)
AI_PROXY_DAILY_OUTPUT_TOKEN_LIMIT?: string; // Per-user daily output token cap (default: 200000)
Expand Down
39 changes: 25 additions & 14 deletions apps/api/src/routes/workspaces/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AI_PROXY_DEFAULT_MODEL_KV_KEY, type AIProxyConfig, type BootstrapTokenData, DEFAULT_AI_PROXY_MODEL, getAgentDefinition, isValidAgentType } from '@simple-agent-manager/shared';
import { AI_PROXY_DEFAULT_MODEL_KV_KEY, type AIProxyConfig, type BootstrapTokenData, DEFAULT_AI_PROXY_ANTHROPIC_MODEL, DEFAULT_AI_PROXY_MODEL, getAgentDefinition, isValidAgentType } from '@simple-agent-manager/shared';
import { and, eq, isNull } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/d1';
import { Hono } from 'hono';
Expand Down Expand Up @@ -74,23 +74,34 @@ runtimeRoutes.post('/:id/agent-key', jsonValidator(AgentTypeBodySchema), async (

// AI proxy fallback: if no user credential and the AI proxy is enabled,
// return platform inference config so the VM agent can use the proxy.
// Only applies to OpenCode — the proxy uses Workers AI for inference.
// Applies to OpenCode (openai-compatible format) and Claude Code (native Anthropic format).
const aiProxyEnabled = (c.env.AI_PROXY_ENABLED ?? 'true') !== 'false';
if (!credentialData && body.agentType === 'opencode' && aiProxyEnabled) {
if (!credentialData && (body.agentType === 'opencode' || body.agentType === 'claude-code') && aiProxyEnabled) {
const baseDomain = c.env.BASE_DOMAIN;
const proxyBaseUrl = `https://api.${baseDomain}/ai/v1`;

// Agent-specific proxy config: OpenCode uses openai-compatible, Claude Code uses native Anthropic
const isClaudeCode = body.agentType === 'claude-code';
const proxyBaseUrl = isClaudeCode
? `https://api.${baseDomain}/ai/anthropic`
: `https://api.${baseDomain}/ai/v1`;
const proxyProvider = isClaudeCode ? 'anthropic-proxy' : 'openai-compatible';

// Resolve default model: KV (admin-set) > env var > shared constant
let defaultModel = c.env.AI_PROXY_DEFAULT_MODEL ?? DEFAULT_AI_PROXY_MODEL;
try {
const kvConfig = await c.env.KV.get(AI_PROXY_DEFAULT_MODEL_KV_KEY);
if (kvConfig) {
const parsed: AIProxyConfig = JSON.parse(kvConfig);
if (parsed.defaultModel) defaultModel = parsed.defaultModel;
}
} catch { /* KV unavailable or corrupt data — use env/default */ }
let defaultModel: string;
if (isClaudeCode) {
defaultModel = c.env.AI_PROXY_DEFAULT_ANTHROPIC_MODEL ?? DEFAULT_AI_PROXY_ANTHROPIC_MODEL;
} else {
defaultModel = c.env.AI_PROXY_DEFAULT_MODEL ?? DEFAULT_AI_PROXY_MODEL;
try {
const kvConfig = await c.env.KV.get(AI_PROXY_DEFAULT_MODEL_KV_KEY);
if (kvConfig) {
const parsed: AIProxyConfig = JSON.parse(kvConfig);
if (parsed.defaultModel) defaultModel = parsed.defaultModel;
}
} catch { /* KV unavailable or corrupt data — use env/default */ }
}

log.info('agent_key.ai_proxy_fallback', { workspaceId, userId: workspace.userId, proxyBaseUrl });
log.info('agent_key.ai_proxy_fallback', { workspaceId, userId: workspace.userId, proxyBaseUrl, agentType: body.agentType });

// Track credential source on associated task
const taskRows = await db
Expand All @@ -111,7 +122,7 @@ runtimeRoutes.post('/:id/agent-key', jsonValidator(AgentTypeBodySchema), async (
credentialKind: 'api-key' as const,
credentialSource: 'platform' as const,
inferenceConfig: {
provider: 'openai-compatible',
provider: proxyProvider,
baseURL: proxyBaseUrl,
model: defaultModel,
apiKeySource: 'callback-token',
Expand Down
237 changes: 237 additions & 0 deletions apps/api/tests/unit/routes/claude-code-proxy-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/**
* Tests for Claude Code agent key fallback to AI proxy.
*
* When agentType === 'claude-code' and no dedicated agent credential exists,
* the agent-key endpoint falls back to the platform AI proxy with
* inferenceConfig { provider: 'anthropic-proxy' }.
*/
import { drizzle } from 'drizzle-orm/d1';
import { Hono } from 'hono';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { Env } from '../../../src/env';
import { workspacesRoutes } from '../../../src/routes/workspaces';

vi.mock('drizzle-orm/d1');
vi.mock('../../../src/middleware/auth', () => ({
requireAuth: () => vi.fn((_c: unknown, next: () => Promise<void>) => next()),
requireApproved: () => vi.fn((_c: unknown, next: () => Promise<void>) => next()),
getUserId: () => 'test-user-id',
getAuth: () => ({ userId: 'test-user-id' }),
}));
vi.mock('../../../src/services/jwt', () => ({
verifyCallbackToken: vi.fn().mockResolvedValue({ workspace: 'ws-123', type: 'callback', scope: 'workspace' }),
signCallbackToken: vi.fn(),
}));
vi.mock('../../../src/services/encryption', () => ({
encrypt: vi.fn(),
decrypt: vi.fn(),
}));

const { decrypt } = await import('../../../src/services/encryption');
const mockDecrypt = vi.mocked(decrypt);

describe('POST /workspaces/:id/agent-key — Claude Code AI proxy fallback', () => {
let app: Hono<{ Bindings: Env }>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let mockDB: any;

const mockEnv = {
DATABASE: {} as D1Database,
ENCRYPTION_KEY: 'test-key',
JWT_PUBLIC_KEY: 'test-public-key',
CALLBACK_TOKEN_AUDIENCE: 'test-audience',
CALLBACK_TOKEN_ISSUER: 'test-issuer',
BASE_DOMAIN: 'sammy.party',
KV: { get: vi.fn().mockResolvedValue(null) },
} as unknown as Env;

function postAgentKey(body: unknown, env?: Env): Promise<Response> {
return app.request(
'/api/workspaces/ws-123/agent-key',
{
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-callback-token',
},
},
env ?? mockEnv,
);
}

beforeEach(() => {
vi.clearAllMocks();

app = new Hono<{ Bindings: Env }>();
app.onError((err, c) => {
const appError = err as {
statusCode?: number;
error?: string;
message?: string;
};
if (
typeof appError.statusCode === 'number' &&
typeof appError.error === 'string'
) {
return c.json(
{ error: appError.error, message: appError.message },
appError.statusCode as 400 | 401 | 403 | 404 | 500,
);
}
return c.json({ error: 'INTERNAL_ERROR', message: err.message }, 500);
});
app.route('/api/workspaces', workspacesRoutes);

mockDB = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn(),
update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
};
vi.mocked(drizzle).mockReturnValue(mockDB as ReturnType<typeof drizzle>);
});

it('returns anthropic-proxy inferenceConfig when no claude-code credential exists', async () => {
let queryCount = 0;
mockDB.limit.mockImplementation(() => {
queryCount++;
if (queryCount === 1) {
// workspace lookup
return [{ userId: 'user-1', projectId: null }];
}
// All credential lookups return empty
return [];
});

const resp = await postAgentKey({ agentType: 'claude-code' });
expect(resp.status).toBe(200);

const body = await resp.json();
expect(body.apiKey).toBe('__platform_proxy__');
expect(body.credentialSource).toBe('platform');
expect(body.credentialKind).toBe('api-key');
expect(body.inferenceConfig).toBeDefined();
expect(body.inferenceConfig.provider).toBe('anthropic-proxy');
expect(body.inferenceConfig.baseURL).toBe('https://api.sammy.party/ai/anthropic');
expect(body.inferenceConfig.apiKeySource).toBe('callback-token');
expect(body.inferenceConfig.model).toBe('claude-sonnet-4-6');
});

it('returns user credential when claude-code credential exists (no proxy fallback)', async () => {
let queryCount = 0;
mockDB.limit.mockImplementation(() => {
queryCount++;
if (queryCount === 1) {
// workspace lookup
return [{ userId: 'user-1', projectId: null }];
}
if (queryCount === 2) {
// agent-api-key for 'claude-code' (user-scoped) → found
return [{
encryptedToken: 'encrypted-key',
iv: 'iv-key',
credentialKind: 'api-key',
isActive: true,
}];
}
return [];
});

mockDecrypt.mockResolvedValueOnce('sk-ant-user-key-123');

const resp = await postAgentKey({ agentType: 'claude-code' });
expect(resp.status).toBe(200);

const body = await resp.json();
expect(body.apiKey).toBe('sk-ant-user-key-123');
expect(body.credentialKind).toBe('api-key');
// Should NOT have inferenceConfig — user credential takes precedence
expect(body.inferenceConfig).toBeUndefined();
});

it('returns 404 when no credential and AI proxy is disabled', async () => {
let queryCount = 0;
mockDB.limit.mockImplementation(() => {
queryCount++;
if (queryCount === 1) {
return [{ userId: 'user-1', projectId: null }];
}
return [];
});

const disabledEnv = { ...mockEnv, AI_PROXY_ENABLED: 'false' } as unknown as Env;
const resp = await postAgentKey({ agentType: 'claude-code' }, disabledEnv);
expect(resp.status).toBe(404);
});

it('uses custom model from env var when set', async () => {
let queryCount = 0;
mockDB.limit.mockImplementation(() => {
queryCount++;
if (queryCount === 1) {
return [{ userId: 'user-1', projectId: null }];
}
return [];
});

const customEnv = {
...mockEnv,
AI_PROXY_DEFAULT_ANTHROPIC_MODEL: 'claude-opus-4-6',
} as unknown as Env;

const resp = await postAgentKey({ agentType: 'claude-code' }, customEnv);
expect(resp.status).toBe(200);

const body = await resp.json();
expect(body.inferenceConfig.model).toBe('claude-opus-4-6');
});

it('tracks credential source on associated task', async () => {
let queryCount = 0;
mockDB.limit.mockImplementation(() => {
queryCount++;
if (queryCount === 1) {
// workspace lookup
return [{ userId: 'user-1', projectId: null }];
}
if (queryCount <= 3) {
// Credential lookups (user-scoped + platform) → empty
return [];
}
// Task lookup (inside AI proxy fallback block)
if (queryCount === 4) return [{ id: 'task-1' }];
return [];
});
// After the proxy fallback response, the update call chain:
// db.update().set().where() — mockDB already chains these via mockReturnThis()

const resp = await postAgentKey({ agentType: 'claude-code' });
expect(resp.status).toBe(200);

// Verify update was called (task credential source tracking)
expect(mockDB.update).toHaveBeenCalled();
});

it('does NOT use Scaleway fallback for claude-code', async () => {
// Claude Code has no fallbackCloudProvider, so it should skip directly to AI proxy
let queryCount = 0;
mockDB.limit.mockImplementation(() => {
queryCount++;
if (queryCount === 1) {
return [{ userId: 'user-1', projectId: null }];
}
return [];
});

const resp = await postAgentKey({ agentType: 'claude-code' });
expect(resp.status).toBe(200);

const body = await resp.json();
// Should get proxy fallback, not Scaleway
expect(body.inferenceConfig.provider).toBe('anthropic-proxy');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe('POST /workspaces/:id/agent-key — OpenCode Scaleway fallback', () =>
return [];
});

const resp = await postAgentKey({ agentType: 'claude-code' });
const resp = await postAgentKey({ agentType: 'google-gemini' });
expect(resp.status).toBe(404);
});

Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/constants/ai-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export const DEFAULT_TTS_RETRY_BASE_DELAY_MS = 500;
* the AI_PROXY_DEFAULT_MODEL env var. */
export const DEFAULT_AI_PROXY_MODEL = '@cf/meta/llama-4-scout-17b-16e-instruct';

/** Default model for Anthropic proxy fallback (Claude Code agent).
* Override via AI_PROXY_DEFAULT_ANTHROPIC_MODEL env var. */
export const DEFAULT_AI_PROXY_ANTHROPIC_MODEL = 'claude-sonnet-4-6';

/** Budget tier for platform AI models. */
export type PlatformAIModelTier = 'free' | 'standard' | 'premium';

Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export {
AI_PROXY_DEFAULT_MODEL_KV_KEY,
type AIProxyConfig,
DEFAULT_AI_PROXY_ALLOWED_MODELS,
DEFAULT_AI_PROXY_ANTHROPIC_MODEL,
DEFAULT_AI_PROXY_DAILY_INPUT_TOKEN_LIMIT,
DEFAULT_AI_PROXY_DAILY_OUTPUT_TOKEN_LIMIT,
DEFAULT_AI_PROXY_MAX_INPUT_TOKENS_PER_REQUEST,
Expand Down
1 change: 1 addition & 0 deletions packages/vm-agent/internal/acp/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ func parseEnvExportLines(content string) []string {
// not appear in docker exec command-line arguments (visible in /proc/*/cmdline).
var secretEnvNames = map[string]bool{
"ANTHROPIC_API_KEY": true,
"ANTHROPIC_AUTH_TOKEN": true,
"CLAUDE_CODE_OAUTH_TOKEN": true,
"OPENAI_API_KEY": true,
"GH_TOKEN": true,
Expand Down
Loading
Loading