diff --git a/docs/model-configuration.md b/docs/model-configuration.md new file mode 100644 index 00000000..6cc58aab --- /dev/null +++ b/docs/model-configuration.md @@ -0,0 +1,56 @@ +# Model Configuration + +When using claude-agent-acp with alternative providers (e.g. AWS Bedrock), model IDs differ from the direct Anthropic API. The `CLAUDE_MODEL_CONFIG` environment variable lets you configure model overrides and availability at the deployment level. + +## `CLAUDE_MODEL_CONFIG` + +A JSON string with two optional fields: + +| Field | Type | Description | +| ----------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------- | +| `modelOverrides` | `Record` | Maps Anthropic model IDs to provider-specific model IDs (e.g. Bedrock model IDs or ARNs) | +| `availableModels` | `string[]` | Restricts which models are offered to users. Accepts aliases (`"opus"`), prefixes (`"opus-4-5"`), or full IDs | + +### Examples + +**Bedrock model overrides:** + +```bash +CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"}}' +``` + +**Restrict available models:** + +```bash +CLAUDE_MODEL_CONFIG='{"availableModels":["opus","sonnet"]}' +``` + +**Both together:** + +```bash +CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"},"availableModels":["opus","sonnet"]}' +``` + +**Full Bedrock example:** + +```bash +CLAUDE_CODE_USE_BEDROCK=1 \ +AWS_REGION=us-west-2 \ +CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1"}}' \ +node dist/index.js +``` + +## Precedence + +When an ACP caller provides `settings` via `_meta.claudeCode.options.settings` in the `sessions/create` request, `CLAUDE_MODEL_CONFIG` is ignored entirely. The env var is a deployment-level fallback for cases where the caller does not configure model settings itself. + +| Source | Priority | +| -------------------------------------------- | ----------------------------------------------------- | +| `_meta.claudeCode.options.settings` (caller) | Highest — used if present | +| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings | + +## Format details + +- The value must be valid JSON. Invalid JSON will cause session creation to fail with a parse error. +- Only `modelOverrides` and `availableModels` keys are read; other keys in the JSON are ignored. +- Both fields map directly to the Claude Agent SDK's `Settings` type. diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 9e89c33b..0015f047 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -1588,6 +1588,9 @@ export class ClaudeAcpAgent implements Agent { ? parseInt(process.env.MAX_THINKING_TOKENS, 10) : undefined; + // Parse model configuration from environment (e.g. Bedrock model overrides) + const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG); + // Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit) const disallowedTools = ["AskUserQuestion"]; @@ -1606,6 +1609,17 @@ export class ClaudeAcpAgent implements Agent { settingSources: ["user", "project", "local"], ...(maxThinkingTokens !== undefined && { maxThinkingTokens }), ...userProvidedOptions, + // CLAUDE_MODEL_CONFIG env var is a fallback for model + // configuration (e.g. Bedrock model ID overrides). When the caller + // provides settings via _meta, we intentionally ignore the env var — + // the caller is assumed to have full control over model configuration. + ...(!userProvidedOptions?.settings && + modelConfig && { + settings: { + ...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }), + ...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }), + }, + }), env: { ...process.env, ...userProvidedOptions?.env, @@ -2530,6 +2544,20 @@ function inferContextWindowFromModel(model: string): number | null { return null; } +function parseModelConfig( + raw: string | undefined, +): { modelOverrides?: Record; availableModels?: string[] } | undefined { + if (!raw) return undefined; + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object"); + } + const result: { modelOverrides?: Record; availableModels?: string[] } = {}; + if (parsed.modelOverrides !== undefined) result.modelOverrides = parsed.modelOverrides; + if (parsed.availableModels !== undefined) result.availableModels = parsed.availableModels; + return Object.keys(result).length > 0 ? result : undefined; +} + function getMatchingModelUsage(modelUsage: Record, currentModel: string) { let bestKey: string | null = null; let bestLen = 0; diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index 331d4126..270c0b73 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; import type { Options } from "@anthropic-ai/claude-agent-sdk"; import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; @@ -253,6 +253,100 @@ describe("createSession options merging", () => { expect(capturedOptions!.tools).toEqual([]); }); + describe("CLAUDE_MODEL_CONFIG", () => { + let originalModelConfig: string | undefined; + + beforeEach(() => { + originalModelConfig = process.env.CLAUDE_MODEL_CONFIG; + delete process.env.CLAUDE_MODEL_CONFIG; + }); + + afterEach(() => { + if (originalModelConfig !== undefined) { + process.env.CLAUDE_MODEL_CONFIG = originalModelConfig; + } else { + delete process.env.CLAUDE_MODEL_CONFIG; + } + }); + + it("passes modelOverrides as settings", async () => { + process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ + modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" }, + }); + + await agent.newSession({ cwd: "/test", mcpServers: [] }); + + expect(capturedOptions!.settings).toEqual({ + modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" }, + }); + }); + + it("passes availableModels as settings", async () => { + process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ + availableModels: ["opus", "sonnet"], + }); + + await agent.newSession({ cwd: "/test", mcpServers: [] }); + + expect(capturedOptions!.settings).toEqual({ + availableModels: ["opus", "sonnet"], + }); + }); + + it("passes both modelOverrides and availableModels", async () => { + process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ + modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" }, + availableModels: ["opus"], + }); + + await agent.newSession({ cwd: "/test", mcpServers: [] }); + + expect(capturedOptions!.settings).toEqual({ + modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" }, + availableModels: ["opus"], + }); + }); + + it("does not add settings when env var is not set", async () => { + await agent.newSession({ cwd: "/test", mcpServers: [] }); + + expect(capturedOptions!.settings).toBeUndefined(); + }); + + it("ignores env var when _meta provides settings", async () => { + process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({ + modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" }, + }); + + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + settings: { + model: "claude-sonnet-4-5", + modelOverrides: { "claude-opus-4-6": "meta-value" }, + }, + }, + }, + }, + }); + + // _meta settings take precedence; env var is ignored entirely + expect(capturedOptions!.settings).toEqual({ + model: "claude-sonnet-4-5", + modelOverrides: { "claude-opus-4-6": "meta-value" }, + }); + }); + + it("throws on invalid JSON", async () => { + process.env.CLAUDE_MODEL_CONFIG = "not-json"; + + await expect(agent.newSession({ cwd: "/test", mcpServers: [] })).rejects.toThrow(); + }); + }); + it("merges user-provided mcpServers with ACP mcpServers", async () => { await agent.newSession({ cwd: "/test",