diff --git a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md index ad5b528b96..fc753e557b 100644 --- a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md +++ b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md @@ -129,6 +129,48 @@ assistants: The `settingSources` option controls which `CLAUDE.md`, skill, command, and agent files the Claude Code SDK loads. The default is `['project', 'user']`, which loads both the project-level `/.claude/` and your personal `~/.claude/`. Set it to `['project']` if you want to scope a workflow to project-only resources. +### Custom Claude-Compatible Providers + +Claude workflows can reference custom provider models by friendly aliases. Define them in `~/.archon/claude-models.json`, then use `provider/name` in `.archon/config.yaml` or workflow node `model` fields. + +```json +{ + "providers": { + "gateway": { + "baseUrl": "https://llm-gateway.example.com", + "apiKey": "CLAUDE_GATEWAY_API_KEY", + "headers": { + "X-Team": "platform", + "X-Workspace-Token": "CLAUDE_GATEWAY_WORKSPACE_TOKEN" + }, + "models": [ + { + "id": "openai/gpt-5.4", + "name": "gpt", + "reasoning": true + }, + { + "id": "zai-org/glm-5", + "name": "glm" + } + ] + } + } +} +``` + +Then configure Claude to use the alias: + +```yaml +assistants: + claude: + model: gateway/gpt +``` + +Use `apiKey` for providers that expect `ANTHROPIC_API_KEY`, or `authToken` for providers that expect a bearer token via `ANTHROPIC_AUTH_TOKEN`. `baseUrl` maps to `ANTHROPIC_BASE_URL`, and `headers` are sent through `ANTHROPIC_CUSTOM_HEADERS`. + +For `baseUrl`, `apiKey`, `authToken`, and header values, Archon first checks whether the configured value is an environment variable name. If it exists, Archon uses the environment value; otherwise it uses the configured value literally. For example, `"apiKey": "CLAUDE_GATEWAY_API_KEY"` reads `process.env.CLAUDE_GATEWAY_API_KEY` when it is set. + ### Set as Default (Optional) If you want Claude to be the default AI assistant for new conversations without codebase context, set this environment variable: diff --git a/packages/docs-web/src/content/docs/reference/configuration.md b/packages/docs-web/src/content/docs/reference/configuration.md index 1800c69e84..8ce1c347ba 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -96,6 +96,53 @@ concurrency: ``` +### Claude Custom Models + +For Claude-compatible gateways or local providers, create `~/.archon/claude-models.json`. This file is separate from `config.yaml` so provider credentials and model catalogs can be managed without editing workflow YAML. + +```json +{ + "providers": { + "gateway": { + "baseUrl": "https://llm-gateway.example.com", + "apiKey": "CLAUDE_GATEWAY_API_KEY", + "headers": { + "X-Team": "platform", + "X-Workspace-Token": "CLAUDE_GATEWAY_WORKSPACE_TOKEN" + }, + "models": [ + { + "id": "openai/gpt-5.4", + "name": "gpt", + "reasoning": true + } + ] + } + } +} +``` + +Use the alias in config or workflow nodes: + +```yaml +assistants: + claude: + model: gateway/gpt +``` + +Field mapping: + +| Field | Claude Code env | +|---|---| +| `baseUrl` | `ANTHROPIC_BASE_URL` | +| `apiKey` | `ANTHROPIC_API_KEY` | +| `authToken` | `ANTHROPIC_AUTH_TOKEN` | +| `headers` | `ANTHROPIC_CUSTOM_HEADERS` | + +For `baseUrl`, `apiKey`, `authToken`, and header values, Archon first checks whether the configured value is an environment variable name. If it exists, Archon uses the environment value; otherwise it uses the configured value literally. For example, `"apiKey": "CLAUDE_GATEWAY_API_KEY"` reads `process.env.CLAUDE_GATEWAY_API_KEY` when it is set. + +An example file is available at `packages/providers/src/claude/claude-models.json.example`. It contains placeholders only; do not commit real gateway URLs, API keys, or tokens. + ## Repository Configuration Create `.archon/config.yaml` in any repository for project-specific settings: diff --git a/packages/providers/package.json b/packages/providers/package.json index d59911b9a6..b754945759 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -18,7 +18,7 @@ "./registry": "./src/registry.ts" }, "scripts": { - "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts && bun test src/community/pi/provider-lazy-load.test.ts", + "test": "bun test src/claude/model-registry.test.ts && bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts && bun test src/community/pi/provider-lazy-load.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { diff --git a/packages/providers/src/claude/claude-models.json.example b/packages/providers/src/claude/claude-models.json.example new file mode 100644 index 0000000000..9f53947ed4 --- /dev/null +++ b/packages/providers/src/claude/claude-models.json.example @@ -0,0 +1,34 @@ +{ + "providers": { + "gateway": { + "baseUrl": "https://llm-gateway.example.com", + "apiKey": "CLAUDE_GATEWAY_API_KEY", + "headers": { + "X-Team": "platform", + "X-Workspace-Token": "CLAUDE_GATEWAY_WORKSPACE_TOKEN" + }, + "models": [ + { + "id": "openai/gpt-5.4", + "name": "gpt", + "reasoning": true + }, + { + "id": "zai-org/glm-5", + "name": "glm", + "reasoning": false + } + ] + }, + "local": { + "baseUrl": "http://localhost:4000", + "authToken": "CLAUDE_LOCAL_GATEWAY_TOKEN", + "models": [ + { + "id": "local/model-name", + "name": "local-model" + } + ] + } + } +} diff --git a/packages/providers/src/claude/model-registry.test.ts b/packages/providers/src/claude/model-registry.test.ts new file mode 100644 index 0000000000..6ba206e0fa --- /dev/null +++ b/packages/providers/src/claude/model-registry.test.ts @@ -0,0 +1,514 @@ +import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +let tempHome: string; +const TEST_ENV_VARS = [ + 'ARCHON_TEST_CLAUDE_API_KEY', + 'ARCHON_TEST_CLAUDE_AUTH_TOKEN', + 'ARCHON_TEST_CLAUDE_BASE_URL', + 'ARCHON_TEST_CLAUDE_HEADER', +]; + +mock.module('@archon/paths', () => ({ + getArchonHome: () => tempHome, + BUNDLED_IS_BINARY: false, + createLogger: () => ({ + info: mock(() => {}), + warn: mock(() => {}), + error: mock(() => {}), + debug: mock(() => {}), + }), +})); + +const { ClaudeModelRegistry } = await import('./model-registry'); + +function writeModelsConfig(config: unknown): void { + writeFileSync(join(tempHome, 'claude-models.json'), JSON.stringify(config)); +} + +const SAMPLE_CONFIG = { + providers: { + acme: { + baseUrl: 'https://api.acme-corp.example.com', + apiKey: 'sk-test-acme-key-123', + models: [ + { id: 'acme/fast-model-v2', name: 'fast' }, + { id: 'acme/reasoning-xl', name: 'smart' }, + { id: 'acme/code-gen-4', name: 'coder' }, + ], + }, + local: { + baseUrl: 'http://localhost:11434/v1', + apiKey: 'local-key', + models: [{ id: 'llama3.1:8b', name: 'llama' }], + }, + }, +}; + +describe('ClaudeModelRegistry', () => { + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'archon-claude-models-test-')); + }); + + afterEach(() => { + rmSync(tempHome, { recursive: true, force: true }); + for (const name of TEST_ENV_VARS) delete process.env[name]; + }); + + describe('file loading', () => { + test('returns passthrough when no claude-models.json exists', () => { + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('claude-sonnet-4-20250514'); + expect(result).toEqual({ resolvedId: 'claude-sonnet-4-20250514', matchedBy: 'passthrough' }); + expect(registry.getError()).toBeUndefined(); + }); + + test('reports error for invalid JSON', () => { + writeFileSync(join(tempHome, 'claude-models.json'), 'not valid json{{{'); + const registry = new ClaudeModelRegistry(); + expect(registry.getError()).toContain('Invalid JSON'); + expect(registry.getAll()).toEqual([]); + expect(registry.resolve('gpt').matchedBy).toBe('passthrough'); + }); + + test('reports error when providers field is missing', () => { + writeModelsConfig({ wrong: 'shape' }); + const registry = new ClaudeModelRegistry(); + expect(registry.getError()).toContain('must have a "providers" object'); + }); + + test('reports error when providers field is null or an array', () => { + writeModelsConfig({ providers: null }); + const nullRegistry = new ClaudeModelRegistry(); + expect(nullRegistry.getError()).toContain('must have a "providers" object'); + expect(nullRegistry.getAll()).toEqual([]); + + writeModelsConfig({ providers: [] }); + const arrayRegistry = new ClaudeModelRegistry(); + expect(arrayRegistry.getError()).toContain('must have a "providers" object'); + expect(arrayRegistry.getAll()).toEqual([]); + }); + + test('skips providers with missing baseUrl or credentials', () => { + writeModelsConfig({ + providers: { + nokey: { baseUrl: 'http://example.com', models: [{ id: 'x', name: 'y' }] }, + nourl: { apiKey: 'key', models: [{ id: 'x', name: 'y' }] }, + token: { + baseUrl: 'http://token.example.com', + authToken: 'token', + models: [{ id: 'model-2', name: 'two' }], + }, + valid: { + baseUrl: 'http://example.com', + apiKey: 'key', + models: [{ id: 'model-1', name: 'one' }], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + expect(registry.getAll()).toEqual([ + { providerName: 'token', model: { id: 'model-2', name: 'two' } }, + { providerName: 'valid', model: { id: 'model-1', name: 'one' } }, + ]); + }); + + test('skips providers with empty baseUrl or credentials and reports validation error', () => { + writeModelsConfig({ + providers: { + emptyBaseUrl: { baseUrl: '', apiKey: 'key', models: [{ id: 'x', name: 'x' }] }, + emptyApiKey: { + baseUrl: 'http://empty-key.example.com', + apiKey: '', + models: [{ id: 'y', name: 'y' }], + }, + valid: { + baseUrl: 'http://example.com', + apiKey: 'key', + models: [{ id: 'model-1', name: 'one' }], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + expect(registry.getAll()).toEqual([ + { providerName: 'valid', model: { id: 'model-1', name: 'one' } }, + ]); + expect(registry.getError()).toContain('baseUrl must be a non-empty string'); + expect(registry.getError()).toContain('apiKey or authToken must be a non-empty string'); + expect(registry.resolve('x').matchedBy).toBe('passthrough'); + expect(registry.resolve('y').matchedBy).toBe('passthrough'); + }); + + test('filters out malformed model entries', () => { + writeModelsConfig({ + providers: { + test: { + baseUrl: 'http://test.com', + apiKey: 'key', + models: [ + { id: 'valid-id', name: 'valid' }, + { id: 123, name: 'bad-id' }, + { id: 'no-name' }, + null, + { id: 'also-valid', name: 'good' }, + ], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + const all = registry.getAll(); + expect(all).toHaveLength(2); + expect(all[0].model.id).toBe('valid-id'); + expect(all[1].model.id).toBe('also-valid'); + }); + + test('handles non-ENOENT read errors gracefully', () => { + mkdirSync(join(tempHome, 'claude-models.json')); + const registry = new ClaudeModelRegistry(); + expect(registry.getError()).toContain('Failed to read'); + }); + }); + + describe('model resolution', () => { + test('resolves by exact model id', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('acme/fast-model-v2'); + expect(result.resolvedId).toBe('acme/fast-model-v2'); + expect(result.matchedBy).toBe('id'); + expect(result.providerName).toBe('acme'); + }); + + test('resolves by name (case-insensitive)', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + + const result = registry.resolve('fast'); + expect(result.resolvedId).toBe('acme/fast-model-v2'); + expect(result.matchedBy).toBe('name'); + expect(result.providerName).toBe('acme'); + + const upper = registry.resolve('FAST'); + expect(upper.resolvedId).toBe('acme/fast-model-v2'); + expect(upper.matchedBy).toBe('name'); + }); + + test('resolves by case-insensitive id', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('ACME/Fast-Model-V2'); + expect(result.resolvedId).toBe('acme/fast-model-v2'); + expect(result.matchedBy).toBe('id'); + }); + + test('passthrough when no match found', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('claude-sonnet-4-20250514'); + expect(result).toEqual({ resolvedId: 'claude-sonnet-4-20250514', matchedBy: 'passthrough' }); + }); + + test('resolves models from different providers', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + + const fast = registry.resolve('fast'); + expect(fast.providerName).toBe('acme'); + expect(fast.env?.ANTHROPIC_BASE_URL).toBe('https://api.acme-corp.example.com'); + + const llama = registry.resolve('llama'); + expect(llama.providerName).toBe('local'); + expect(llama.env?.ANTHROPIC_BASE_URL).toBe('http://localhost:11434/v1'); + }); + + test('resolves "provider/name" format to the correct model within that provider', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + + const result = registry.resolve('acme/fast'); + expect(result.resolvedId).toBe('acme/fast-model-v2'); + expect(result.matchedBy).toBe('name'); + expect(result.providerName).toBe('acme'); + expect(result.env?.ANTHROPIC_BASE_URL).toBe('https://api.acme-corp.example.com'); + }); + + test('resolves "provider/name" case-insensitively', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + + const result = registry.resolve('acme/SMART'); + expect(result.resolvedId).toBe('acme/reasoning-xl'); + expect(result.matchedBy).toBe('name'); + expect(result.providerName).toBe('acme'); + }); + + test('"provider/id" format also works for model id lookup within provider', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + + const result = registry.resolve('local/llama3.1:8b'); + expect(result.resolvedId).toBe('llama3.1:8b'); + expect(result.matchedBy).toBe('id'); + expect(result.providerName).toBe('local'); + }); + + test('"provider/name" falls through to global search if provider not found', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + + // "unknown" is not a registered provider, so "unknown/fast" won't match + // provider-scoped, but "fast" won't match globally either since full string is searched + const result = registry.resolve('unknown/fast'); + expect(result.matchedBy).toBe('passthrough'); + }); + + test('each node in a workflow gets its own provider env (multi-provider scenario)', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + + // Node 1: uses acme provider model + const node1 = registry.resolve('fast'); + expect(node1.resolvedId).toBe('acme/fast-model-v2'); + expect(node1.env?.ANTHROPIC_BASE_URL).toBe('https://api.acme-corp.example.com'); + expect(node1.env?.ANTHROPIC_API_KEY).toBe('sk-test-acme-key-123'); + + // Node 2: uses local provider model + const node2 = registry.resolve('llama'); + expect(node2.resolvedId).toBe('llama3.1:8b'); + expect(node2.env?.ANTHROPIC_BASE_URL).toBe('http://localhost:11434/v1'); + expect(node2.env?.ANTHROPIC_API_KEY).toBe('local-key'); + + // Node 3: uses standard Claude model (no custom provider) + const node3 = registry.resolve('claude-sonnet-4-20250514'); + expect(node3.resolvedId).toBe('claude-sonnet-4-20250514'); + expect(node3.matchedBy).toBe('passthrough'); + expect(node3.env).toBeUndefined(); + }); + }); + + describe('env injection', () => { + test('injects ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY for matched models', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('fast'); + expect(result.env).toEqual({ + ANTHROPIC_BASE_URL: 'https://api.acme-corp.example.com', + ANTHROPIC_API_KEY: 'sk-test-acme-key-123', + }); + }); + + test('injects ANTHROPIC_AUTH_TOKEN when provider uses bearer token auth', () => { + writeModelsConfig({ + providers: { + gateway: { + baseUrl: 'https://gateway.example.com', + authToken: 'gateway-token', + models: [{ id: 'gateway/model', name: 'gateway' }], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('gateway'); + expect(result.env).toEqual({ + ANTHROPIC_BASE_URL: 'https://gateway.example.com', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + }); + }); + + test('resolves apiKey from an environment variable name with literal fallback', () => { + process.env.ARCHON_TEST_CLAUDE_API_KEY = 'resolved-api-key'; + writeModelsConfig({ + providers: { + envProvider: { + baseUrl: 'https://gateway.example.com', + apiKey: 'ARCHON_TEST_CLAUDE_API_KEY', + models: [{ id: 'gateway/model', name: 'gateway' }], + }, + literalProvider: { + baseUrl: 'https://literal.example.com', + apiKey: 'literal-api-key', + models: [{ id: 'literal/model', name: 'literal' }], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + + expect(registry.resolve('gateway').env?.ANTHROPIC_API_KEY).toBe('resolved-api-key'); + expect(registry.resolve('literal').env?.ANTHROPIC_API_KEY).toBe('literal-api-key'); + }); + + test('resolves authToken, baseUrl, and headers from environment variable names', () => { + process.env.ARCHON_TEST_CLAUDE_AUTH_TOKEN = 'resolved-auth-token'; + process.env.ARCHON_TEST_CLAUDE_BASE_URL = 'https://resolved.example.com'; + process.env.ARCHON_TEST_CLAUDE_HEADER = 'resolved-header-value'; + writeModelsConfig({ + providers: { + gateway: { + baseUrl: 'ARCHON_TEST_CLAUDE_BASE_URL', + authToken: 'ARCHON_TEST_CLAUDE_AUTH_TOKEN', + headers: { 'X-Test': 'ARCHON_TEST_CLAUDE_HEADER' }, + models: [{ id: 'gateway/model', name: 'gateway' }], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('gateway'); + + expect(result.env).toEqual({ + ANTHROPIC_BASE_URL: 'https://resolved.example.com', + ANTHROPIC_AUTH_TOKEN: 'resolved-auth-token', + ANTHROPIC_CUSTOM_HEADERS: 'X-Test: resolved-header-value', + }); + expect(result.headers).toEqual({ 'X-Test': 'resolved-header-value' }); + }); + + test('supports $-prefixed environment variable names', () => { + process.env.ARCHON_TEST_CLAUDE_API_KEY = 'resolved-dollar-api-key'; + writeModelsConfig({ + providers: { + gateway: { + baseUrl: 'https://gateway.example.com', + apiKey: '$ARCHON_TEST_CLAUDE_API_KEY', + models: [{ id: 'gateway/model', name: 'gateway' }], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + + expect(registry.resolve('gateway').env?.ANTHROPIC_API_KEY).toBe('resolved-dollar-api-key'); + }); + + test('does not inject env for passthrough models', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('claude-sonnet-4-20250514'); + expect(result.env).toBeUndefined(); + }); + + test('different providers inject their own credentials', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + + const acme = registry.resolve('smart'); + expect(acme.env?.ANTHROPIC_API_KEY).toBe('sk-test-acme-key-123'); + + const local = registry.resolve('llama'); + expect(local.env?.ANTHROPIC_API_KEY).toBe('local-key'); + }); + }); + + describe('headers', () => { + test('returns headers when provider has them', () => { + writeModelsConfig({ + providers: { + withHeaders: { + baseUrl: 'http://test.com', + apiKey: 'key', + headers: { 'X-Custom': 'value', Authorization: 'Bearer xyz' }, + models: [{ id: 'model-1', name: 'test' }], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('test'); + expect(result.headers).toEqual({ 'X-Custom': 'value', Authorization: 'Bearer xyz' }); + expect(result.env?.ANTHROPIC_CUSTOM_HEADERS).toBe( + 'X-Custom: value\nAuthorization: Bearer xyz' + ); + }); + + test('no headers field when provider has none', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('fast'); + expect(result.headers).toBeUndefined(); + }); + + test('filters non-string custom header values', () => { + writeModelsConfig({ + providers: { + withHeaders: { + baseUrl: 'http://test.com', + apiKey: 'key', + headers: { 'X-Custom': 'value', 'X-Bad': 123 }, + models: [{ id: 'model-1', name: 'test' }], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('test'); + expect(result.headers).toEqual({ 'X-Custom': 'value' }); + expect(result.env?.ANTHROPIC_CUSTOM_HEADERS).toBe('X-Custom: value'); + }); + + test('skips unsafe custom headers and reports validation error', () => { + writeModelsConfig({ + providers: { + withHeaders: { + baseUrl: 'http://test.com', + apiKey: 'key', + headers: { + 'X-Custom': 'value', + 'X:Bad': 'bad', + 'X-Newline': 'bad\nvalue', + 'X-Carriage': 'bad\rvalue', + }, + models: [{ id: 'model-1', name: 'test' }], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('test'); + expect(result.headers).toEqual({ 'X-Custom': 'value' }); + expect(result.env?.ANTHROPIC_CUSTOM_HEADERS).toBe('X-Custom: value'); + expect(registry.getError()).toContain('names cannot contain colon or newlines'); + expect(registry.getError()).toContain('values cannot contain newlines'); + }); + + test('skips custom headers with unsafe resolved values', () => { + process.env.ARCHON_TEST_CLAUDE_HEADER = 'bad\nvalue'; + writeModelsConfig({ + providers: { + withHeaders: { + baseUrl: 'http://test.com', + apiKey: 'key', + headers: { + 'X-Custom': 'value', + 'X-Resolved': 'ARCHON_TEST_CLAUDE_HEADER', + }, + models: [{ id: 'model-1', name: 'test' }], + }, + }, + }); + const registry = new ClaudeModelRegistry(); + const result = registry.resolve('test'); + + expect(result.headers).toEqual({ 'X-Custom': 'value' }); + expect(result.env?.ANTHROPIC_CUSTOM_HEADERS).toBe('X-Custom: value'); + }); + }); + + describe('getAll', () => { + test('returns all models across all providers', () => { + writeModelsConfig(SAMPLE_CONFIG); + const registry = new ClaudeModelRegistry(); + const all = registry.getAll(); + expect(all).toHaveLength(4); + expect(all[0]).toEqual({ + providerName: 'acme', + model: { id: 'acme/fast-model-v2', name: 'fast' }, + }); + expect(all[3]).toEqual({ + providerName: 'local', + model: { id: 'llama3.1:8b', name: 'llama' }, + }); + }); + + test('returns empty when no file exists', () => { + const registry = new ClaudeModelRegistry(); + expect(registry.getAll()).toEqual([]); + }); + }); +}); diff --git a/packages/providers/src/claude/model-registry.ts b/packages/providers/src/claude/model-registry.ts new file mode 100644 index 0000000000..868f19c08c --- /dev/null +++ b/packages/providers/src/claude/model-registry.ts @@ -0,0 +1,339 @@ +/** + * Custom model registry for Claude provider. + * + * Similar to Pi's ~/.pi/agent/models.json, allows users to define custom + * providers with their own base URLs, credentials, and model catalogs in + * `~/.archon/claude-models.json`. Workflows reference models by friendly + * name and the registry resolves to the actual model ID + injects the + * appropriate Claude Code provider env vars. + * + * File format (mirrors Pi's providers structure): + * ```json + * { + * "providers": { + * "my-provider": { + * "baseUrl": "https://api.example.com", + * "apiKey": "MY_PROVIDER_API_KEY", + * "authToken": "MY_PROVIDER_AUTH_TOKEN", + * "headers": { "X-Custom": "MY_PROVIDER_HEADER_VALUE" }, + * "models": [ + * { "id": "vendor/model-name", "name": "friendly-alias" } + * ] + * } + * } + * } + * ``` + * + * Credential and header values may be either environment variable names or + * literal values. If an env var with the configured name exists, its value is + * used; otherwise the configured value is passed through unchanged. + * + * Resolution: workflow uses `model: "friendly-alias"` → registry finds the + * model entry, returns the real `id` plus provider env overrides. + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { getArchonHome } from '@archon/paths'; + +export interface ClaudeCustomModel { + /** Actual model ID passed to the Claude SDK (format: "vendor/model-name") */ + id: string; + /** Friendly name used in workflows */ + name: string; + /** Whether the model supports extended thinking/reasoning (same as Pi agent's models.json) */ + reasoning?: boolean; +} + +export interface ClaudeCustomProvider { + baseUrl: string; + apiKey?: string; + authToken?: string; + headers?: Record; + models: ClaudeCustomModel[]; +} + +export interface ClaudeModelsConfig { + providers: Record; +} + +export interface ResolvedModel { + /** The model ID to pass to the SDK */ + resolvedId: string; + /** How the model was matched */ + matchedBy: 'name' | 'id' | 'passthrough'; + /** Provider name (undefined for passthrough) */ + providerName?: string; + /** Env vars to inject into the Claude subprocess (baseUrl, credentials, headers) */ + env?: Record; + /** Custom headers encoded into ANTHROPIC_CUSTOM_HEADERS */ + headers?: Record; +} + +const MODELS_FILENAME = 'claude-models.json'; + +/** + * Return true only for strings that contain visible non-whitespace content. + */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * Return true when a header name can be serialized into ANTHROPIC_CUSTOM_HEADERS. + */ +function isValidHeaderName(name: string): boolean { + return name.length > 0 && !/[:\r\n]/.test(name); +} + +/** + * Return true when a header value can be serialized into ANTHROPIC_CUSTOM_HEADERS. + */ +function isValidHeaderValue(value: string): boolean { + return !/[\r\n]/.test(value); +} + +/** + * Resolve Pi-style config values: env var name first, literal fallback. + */ +function resolveConfigValue(value: string): string { + const envName = value.startsWith('$') ? value.slice(1) : value; + if (envName.length > 0) { + const envValue = process.env[envName]; + if (isNonEmptyString(envValue)) return envValue; + } + return value; +} + +/** + * Resolve custom header values and keep only values safe for env serialization. + */ +function resolveHeaders(headers?: Record): Record | undefined { + if (!headers) return undefined; + + const resolvedHeaders: Record = {}; + for (const [name, value] of Object.entries(headers)) { + const resolvedValue = resolveConfigValue(value); + if (!isValidHeaderValue(resolvedValue)) continue; + resolvedHeaders[name] = resolvedValue; + } + + return Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined; +} + +export class ClaudeModelRegistry { + private providers: Record = {}; + private loadError: string | undefined; + + /** + * Create a registry and eagerly load `~/.archon/claude-models.json`. + */ + constructor() { + this.load(); + } + + /** + * Load, validate, and normalize custom Claude provider definitions from disk. + */ + private load(): void { + const filePath = join(getArchonHome(), MODELS_FILENAME); + let raw: string; + try { + raw = readFileSync(filePath, 'utf-8'); + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'ENOENT') { + this.providers = {}; + return; + } + this.loadError = `Failed to read ${filePath}: ${e.message}`; + this.providers = {}; + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (parseErr) { + this.loadError = `Invalid JSON in ${filePath}: ${(parseErr as SyntaxError).message}`; + this.providers = {}; + return; + } + + const providers = + parsed && typeof parsed === 'object' && 'providers' in parsed + ? (parsed as ClaudeModelsConfig).providers + : undefined; + if (!providers || typeof providers !== 'object' || Array.isArray(providers)) { + this.loadError = `${filePath} must have a "providers" object`; + this.providers = {}; + return; + } + + this.providers = {}; + const validationMessages: string[] = []; + + for (const [name, provider] of Object.entries(providers)) { + if (!provider || typeof provider !== 'object') continue; + if (!isNonEmptyString(provider.baseUrl)) { + validationMessages.push(`Provider "${name}" skipped: baseUrl must be a non-empty string`); + continue; + } + if (!isNonEmptyString(provider.apiKey) && !isNonEmptyString(provider.authToken)) { + validationMessages.push( + `Provider "${name}" skipped: apiKey or authToken must be a non-empty string` + ); + continue; + } + if (!Array.isArray(provider.models)) continue; + + const validModels = provider.models.filter( + (m): m is ClaudeCustomModel => + typeof m === 'object' && m !== null && isNonEmptyString(m.id) && isNonEmptyString(m.name) + ); + + const validHeaders: Record = {}; + if (provider.headers && typeof provider.headers === 'object') { + for (const [headerName, headerValue] of Object.entries(provider.headers)) { + if (typeof headerValue !== 'string') continue; + if (!isValidHeaderName(headerName)) { + validationMessages.push( + `Provider "${name}" header "${headerName}" skipped: names cannot contain colon or newlines` + ); + continue; + } + if (!isValidHeaderValue(headerValue)) { + validationMessages.push( + `Provider "${name}" header "${headerName}" skipped: values cannot contain newlines` + ); + continue; + } + validHeaders[headerName] = headerValue; + } + } + const headers = Object.keys(validHeaders).length > 0 ? validHeaders : undefined; + + this.providers[name] = { + baseUrl: provider.baseUrl, + ...(isNonEmptyString(provider.apiKey) ? { apiKey: provider.apiKey } : {}), + ...(isNonEmptyString(provider.authToken) ? { authToken: provider.authToken } : {}), + ...(headers && Object.keys(headers).length > 0 ? { headers } : {}), + models: validModels, + }; + } + + if (validationMessages.length > 0) { + this.loadError = `Invalid entries in ${filePath}: ${validationMessages.join('; ')}`; + } + } + + /** + * Resolve a model reference to its actual ID and provider configuration. + * + * Resolution order: + * 1. Provider-scoped lookup: if input is "providerName/modelRef", search + * within that specific provider by name or id + * 2. Exact model `id` match across all providers + * 3. Case-insensitive `name` match across all providers + * 4. Case-insensitive `id` match across all providers + * 5. Pass-through (return input unchanged, no env overrides) + */ + resolve(modelRef: string): ResolvedModel { + // 1. Provider-scoped: "providerName/modelRef" format + const slashIdx = modelRef.indexOf('/'); + if (slashIdx > 0) { + const prefix = modelRef.slice(0, slashIdx); + const remainder = modelRef.slice(slashIdx + 1); + const provider = this.providers[prefix]; + if (provider && remainder.length > 0) { + const remainderLower = remainder.toLowerCase(); + const byName = provider.models.find(m => m.name.toLowerCase() === remainderLower); + if (byName) { + return this.buildResult(byName.id, 'name', prefix, provider); + } + const byId = provider.models.find(m => m.id.toLowerCase() === remainderLower); + if (byId) { + return this.buildResult(byId.id, 'id', prefix, provider); + } + } + } + + const searchLower = modelRef.toLowerCase(); + + // 2. Exact id match + for (const [providerName, provider] of Object.entries(this.providers)) { + const exactId = provider.models.find(m => m.id === modelRef); + if (exactId) { + return this.buildResult(exactId.id, 'id', providerName, provider); + } + } + + // 3. Case-insensitive name match + for (const [providerName, provider] of Object.entries(this.providers)) { + const byName = provider.models.find(m => m.name.toLowerCase() === searchLower); + if (byName) { + return this.buildResult(byName.id, 'name', providerName, provider); + } + } + + // 4. Case-insensitive id match + for (const [providerName, provider] of Object.entries(this.providers)) { + const byId = provider.models.find(m => m.id.toLowerCase() === searchLower); + if (byId) { + return this.buildResult(byId.id, 'id', providerName, provider); + } + } + + // 5. Pass-through + return { resolvedId: modelRef, matchedBy: 'passthrough' }; + } + + /** + * Build the resolved model payload consumed by the Claude provider. + */ + private buildResult( + resolvedId: string, + matchedBy: 'name' | 'id', + providerName: string, + provider: ClaudeCustomProvider + ): ResolvedModel { + const headers = resolveHeaders(provider.headers); + const env: Record = { + ANTHROPIC_BASE_URL: resolveConfigValue(provider.baseUrl), + }; + if (provider.apiKey) env.ANTHROPIC_API_KEY = resolveConfigValue(provider.apiKey); + if (provider.authToken) env.ANTHROPIC_AUTH_TOKEN = resolveConfigValue(provider.authToken); + if (headers) { + env.ANTHROPIC_CUSTOM_HEADERS = Object.entries(headers) + .map(([name, value]) => `${name}: ${value}`) + .join('\n'); + } + return { + resolvedId, + matchedBy, + providerName, + env, + ...(headers ? { headers } : {}), + }; + } + + /** + * Return every custom model loaded from every valid configured provider. + */ + getAll(): { providerName: string; model: ClaudeCustomModel }[] { + const result: { providerName: string; model: ClaudeCustomModel }[] = []; + for (const [providerName, provider] of Object.entries(this.providers)) { + for (const model of provider.models) { + result.push({ providerName, model }); + } + } + return result; + } + + /** + * Return the last registry load error, if reading or parsing the config failed. + */ + getError(): string | undefined { + return this.loadError; + } +} diff --git a/packages/providers/src/claude/provider.test.ts b/packages/providers/src/claude/provider.test.ts index c8b618d7ef..ac1950fa73 100644 --- a/packages/providers/src/claude/provider.test.ts +++ b/packages/providers/src/claude/provider.test.ts @@ -1,9 +1,15 @@ -import { describe, test, expect, mock, beforeEach, spyOn } from 'bun:test'; +import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createMockLogger } from '../test/mocks/logger'; const mockLogger = createMockLogger(); +let mockArchonHome = mkdtempSync(join(tmpdir(), 'archon-claude-provider-test-')); mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger), + getArchonHome: () => mockArchonHome, + BUNDLED_IS_BINARY: false, })); // Create mock query function @@ -16,6 +22,10 @@ mock.module('@anthropic-ai/claude-agent-sdk', () => ({ query: mockQuery, })); +function writeClaudeModelsConfig(config: unknown): void { + writeFileSync(join(mockArchonHome, 'claude-models.json'), JSON.stringify(config)); +} + import { ClaudeProvider, shouldPassNoEnvFile } from './provider'; import * as claudeModule from './provider'; import * as binaryResolver from './binary-resolver'; @@ -72,6 +82,8 @@ describe('ClaudeProvider', () => { let client: ClaudeProvider; beforeEach(() => { + rmSync(mockArchonHome, { recursive: true, force: true }); + mockArchonHome = mkdtempSync(join(tmpdir(), 'archon-claude-provider-test-')); client = new ClaudeProvider({ retryBaseDelayMs: 1 }); mockQuery.mockClear(); mockLogger.info.mockClear(); @@ -80,6 +92,10 @@ describe('ClaudeProvider', () => { mockLogger.debug.mockClear(); }); + afterEach(() => { + rmSync(mockArchonHome, { recursive: true, force: true }); + }); + describe('constructor', () => { test('throws when running as root (UID 0)', () => { const spy = spyOn(claudeModule, 'getProcessUid').mockReturnValue(0); @@ -389,6 +405,80 @@ describe('ClaudeProvider', () => { }); }); + test('resolves custom model alias and injects provider env into SDK options', async () => { + writeClaudeModelsConfig({ + providers: { + v3e: { + baseUrl: 'https://gateway.example.com', + apiKey: 'sk-v3e-test', + headers: { 'X-Workspace': 'eee' }, + models: [{ id: 'openai/gpt-5.4', name: 'gpt' }], + }, + }, + }); + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'sid' }; + }); + + for await (const _ of client.sendQuery('test', '/workspace', undefined, { + model: 'v3e/gpt', + })) { + // consume + } + + expect(mockQuery).toHaveBeenCalledTimes(1); + const callArgs = mockQuery.mock.calls[0][0] as { options: Record }; + expect(callArgs.options.model).toBe('openai/gpt-5.4'); + const env = callArgs.options.env as Record; + expect(env.ANTHROPIC_BASE_URL).toBe('https://gateway.example.com'); + expect(env.ANTHROPIC_API_KEY).toBe('sk-v3e-test'); + expect(env.ANTHROPIC_CUSTOM_HEADERS).toBe('X-Workspace: eee'); + expect(mockLogger.info).toHaveBeenCalledWith( + { authMode: 'explicit' }, + 'using_explicit_tokens' + ); + expect(mockLogger.info).toHaveBeenCalledWith( + { + requestedModel: 'v3e/gpt', + resolvedId: 'openai/gpt-5.4', + matchedBy: 'name', + provider: 'v3e', + }, + 'claude.model_resolved_from_registry' + ); + }); + + test('request env overrides custom provider registry env', async () => { + writeClaudeModelsConfig({ + providers: { + v3e: { + baseUrl: 'https://gateway.example.com', + apiKey: 'sk-v3e-test', + models: [{ id: 'openai/gpt-5.4', name: 'gpt' }], + }, + }, + }); + mockQuery.mockImplementation(async function* () { + yield { type: 'result', session_id: 'sid' }; + }); + + for await (const _ of client.sendQuery('test', '/workspace', undefined, { + model: 'v3e/gpt', + env: { + ANTHROPIC_BASE_URL: 'https://override.example.com', + ANTHROPIC_API_KEY: 'sk-override', + }, + })) { + // consume + } + + const callArgs = mockQuery.mock.calls[0][0] as { options: Record }; + const env = callArgs.options.env as Record; + expect(callArgs.options.model).toBe('openai/gpt-5.4'); + expect(env.ANTHROPIC_BASE_URL).toBe('https://override.example.com'); + expect(env.ANTHROPIC_API_KEY).toBe('sk-override'); + }); + test('omits persistSession from SDK options by default', async () => { mockQuery.mockImplementation(async function* () { // Empty generator diff --git a/packages/providers/src/claude/provider.ts b/packages/providers/src/claude/provider.ts index 5609156fad..d13ea5249a 100644 --- a/packages/providers/src/claude/provider.ts +++ b/packages/providers/src/claude/provider.ts @@ -39,6 +39,7 @@ import type { import { parseClaudeConfig } from './config'; import { CLAUDE_CAPABILITIES } from './capabilities'; import { resolveClaudeBinaryPath } from './binary-resolver'; +import { ClaudeModelRegistry } from './model-registry'; import { createLogger } from '@archon/paths'; import { readFile } from 'fs/promises'; import { resolve, isAbsolute } from 'path'; @@ -85,17 +86,23 @@ function normalizeClaudeUsage(usage?: { * - stripCwdEnv() at entry point removed CWD .env keys + CLAUDECODE markers * - ~/.archon/.env loaded with override:true as the trusted source */ -function buildSubprocessEnv(): NodeJS.ProcessEnv { - // Using || intentionally: empty string should be treated as missing credential +function buildSubprocessEnv(envOverrides?: Record): NodeJS.ProcessEnv { + const env = { ...process.env, ...(envOverrides ?? {}) }; + // Using || intentionally: empty string should be treated as missing credential. + // Claude Code's documented SDK/CLI env names use ANTHROPIC_*; keep the + // historical CLAUDE_* names here for existing Archon deployments. const hasExplicitTokens = Boolean( - process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.CLAUDE_API_KEY + env.CLAUDE_CODE_OAUTH_TOKEN || + env.CLAUDE_API_KEY || + env.ANTHROPIC_API_KEY || + env.ANTHROPIC_AUTH_TOKEN ); const authMode = hasExplicitTokens ? 'explicit' : 'global'; getLog().info( { authMode }, authMode === 'global' ? 'using_global_auth' : 'using_explicit_tokens' ); - return { ...process.env }; + return env; } /** Max retries for transient subprocess failures */ @@ -931,14 +938,51 @@ export class ClaudeProvider implements IAgentProvider { let lastError: Error | undefined; const assistantDefaults = parseClaudeConfig(requestOptions?.assistantConfig ?? {}); + // Resolve custom model aliases before anything else. The registry reads + // ~/.archon/claude-models.json and maps friendly names to real model IDs, + // plus injects provider-specific Claude Code env vars (base URL, + // credentials, and custom headers). + let registryEnv: Record | undefined; + const rawModel = requestOptions?.model ?? assistantDefaults.model; + if (rawModel) { + const registry = new ClaudeModelRegistry(); + const registryError = registry.getError(); + if (registryError) { + getLog().warn({ error: registryError }, 'claude.model_registry_load_error'); + } + const resolved = registry.resolve(rawModel); + if (resolved.matchedBy !== 'passthrough') { + getLog().info( + { + requestedModel: rawModel, + resolvedId: resolved.resolvedId, + matchedBy: resolved.matchedBy, + provider: resolved.providerName, + }, + 'claude.model_resolved_from_registry' + ); + registryEnv = resolved.env; + } + if (requestOptions) { + requestOptions = { ...requestOptions, model: resolved.resolvedId }; + } else { + requestOptions = { model: resolved.resolvedId }; + } + } + // Resolve Claude CLI path once before the retry loop. In binary mode this // throws immediately if neither env nor config supplies a valid path, so // the user gets a clean error rather than N retries of "Module not found". const resolvedCliPath = await resolveClaudeBinaryPath(assistantDefaults.claudeBinaryPath); - // Build subprocess env once (avoids re-logging auth mode per retry) - const subprocessEnv = buildSubprocessEnv(); - const env = requestOptions?.env ? { ...subprocessEnv, ...requestOptions.env } : subprocessEnv; + // Build subprocess env once (avoids re-logging auth mode per retry). + // Registry env (custom provider baseUrl/credentials) is layered on top so + // custom providers override default Anthropic credentials. Request env is + // last because workflow/codebase env is the most specific override. + const env = buildSubprocessEnv({ + ...(registryEnv ?? {}), + ...(requestOptions?.env ?? {}), + }); // Apply nodeConfig translation once (deterministic, not retry-dependent) // We need a throwaway Options to extract warnings from applyNodeConfig,