diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 93ba6ef..40bb689 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -1956,18 +1956,21 @@ export class ClaudeAcpAgent implements Agent { ); } - const models = await getAvailableModels( - q, - initializationResult.models, - settingsManager, - this.logger, - ); + // Apply user's `availableModels` allowlist from settings.json before any + // downstream model handling. The SDK only enforces this allowlist in its + // own UI, not in `initializationResult.models`, so we filter here to keep + // configOptions, the current-model resolver, and the stored modelInfos + // consistent with what the user configured. + const settingsAvailableModels = settingsManager.getSettings().availableModels; + const allowedModels = Array.isArray(settingsAvailableModels) + ? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels) + : initializationResult.models; + + const models = await getAvailableModels(q, allowedModels, settingsManager, this.logger); // Gate `auto` (and future model-specific modes) on the resolved model's // `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal. - const currentModelInfo = initializationResult.models.find( - (m) => m.value === models.currentModelId, - ); + const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId); const availableModes = buildAvailableModes(currentModelInfo); // Clamp `permissionMode` if the resolved session does not offer it. The @@ -2008,7 +2011,7 @@ export class ClaudeAcpAgent implements Agent { const configOptions = buildConfigOptions( modes, models, - initializationResult.models, + allowedModels, settingsManager.getSettings().effortLevel, ); @@ -2035,7 +2038,7 @@ export class ClaudeAcpAgent implements Agent { }, modes, models, - modelInfos: initializationResult.models, + modelInfos: allowedModels, configOptions, promptRunning: false, pendingMessages: new Map(), @@ -2364,6 +2367,52 @@ function resolveSettingsModel( return resolveModelPreference(models, settingsModel); } +/** + * Restrict the SDK's model list to the user's `availableModels` allowlist + * (already merged-and-deduped across settings sources by `SettingsManager`). + * The user's exact entries become the model IDs surfaced via configOptions + * and passed to `setModel`, which prevents Claude Code from silently + * substituting a date-pinned variant (e.g. `haiku` → + * `claude-haiku-4-5-20251001`) that the user may not have access to. + * + * Display info and capability flags are copied from the closest SDK match so + * the UI still renders sensible names and effort levels. + * + * Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection: + * - `undefined` is handled by the caller (no allowlist applied). + * - The Default option is unaffected by `availableModels` — it always remains + * available, even when the allowlist is `[]`. + */ +function applyAvailableModelsAllowlist(sdkModels: ModelInfo[], allowlist: string[]): ModelInfo[] { + // Default is always preserved per the docs. Synthesize one if the SDK + // didn't surface it so downstream code (e.g. `getAvailableModels` picking + // `models[0]` as a fallback) still has something to work with. + const defaultModel = sdkModels.find((m) => m.value === "default") ?? { + value: "default", + displayName: "Default", + description: "", + }; + const result: ModelInfo[] = [defaultModel]; + const seen = new Set([defaultModel.value]); + + const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default"); + + for (const entry of allowlist) { + const trimmed = entry.trim(); + if (!trimmed || seen.has(trimmed)) continue; + + const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed); + if (sdkMatch) { + result.push({ ...sdkMatch, value: trimmed }); + } else { + result.push({ value: trimmed, displayName: trimmed, description: "" }); + } + seen.add(trimmed); + } + + return result; +} + async function getAvailableModels( query: Query, models: ModelInfo[], diff --git a/src/settings.ts b/src/settings.ts index e4fffd7..2bdc98a 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -23,6 +23,7 @@ export interface ClaudeCodeSettings { env?: Record; model?: string; effortLevel?: string; + availableModels?: string[]; } /** @@ -193,6 +194,15 @@ export class SettingsManager { merged.effortLevel = settings.effortLevel; } + if (settings.availableModels !== undefined) { + // Per Claude Code docs: "When `availableModels` is set at multiple + // levels, such as user settings and project settings, arrays are + // merged and deduplicated." + // https://code.claude.com/docs/en/model-config#merge-behavior + const combined = [...(merged.availableModels ?? []), ...settings.availableModels]; + merged.availableModels = Array.from(new Set(combined)); + } + if (settings.permissions?.defaultMode !== undefined) { merged.permissions = { ...merged.permissions, diff --git a/src/tests/acp-agent-settings.test.ts b/src/tests/acp-agent-settings.test.ts index a8865ca..326e7e8 100644 --- a/src/tests/acp-agent-settings.test.ts +++ b/src/tests/acp-agent-settings.test.ts @@ -362,6 +362,199 @@ describe("ClaudeAcpAgent settings", () => { }); }); + describe("availableModels allowlist from settings", () => { + function mockQueryWithModels(models: any[]): { + setModelSpy: ReturnType; + } { + const setModelSpy = vi.fn(); + querySpy.mockImplementation(() => { + return { + initializationResult: async () => ({ models }), + setModel: setModelSpy, + supportedCommands: async () => [], + } as any; + }); + return { setModelSpy }; + } + + it("restricts configOptions to the user's allowlist using their exact IDs", async () => { + // Reproduces the scenario from + // https://github.com/agentclientprotocol/claude-agent-acp/issues/620: + // user lists `claude-haiku-4-5` (no date pin) in availableModels, but + // the SDK still surfaces its `haiku` alias which resolves to a + // date-pinned variant the user doesn't have access to. + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + availableModels: [ + "claude-sonnet-4-6[1m]", + "claude-opus-4-6[1m]", + "claude-haiku-4-5", + "claude-opus-4-7[1m]", + ], + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { + value: "sonnet[1m]", + displayName: "Sonnet (1M context)", + description: "Sonnet 4.6 long context", + }, + { + value: "opus[1m]", + displayName: "Opus (1M context)", + description: "Opus 1M context", + }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + expect(modelOption.options.map((o: any) => o.value)).toEqual([ + "default", + "claude-sonnet-4-6[1m]", + "claude-opus-4-6[1m]", + "claude-haiku-4-5", + "claude-opus-4-7[1m]", + ]); + }); + + it("unions availableModels across user and project settings", async () => { + // https://code.claude.com/docs/en/model-config#merge-behavior + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ availableModels: ["claude-haiku-4-5"] }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(path.join(projectDir, ".claude"), { recursive: true }); + await fs.promises.writeFile( + path.join(projectDir, ".claude", "settings.json"), + JSON.stringify({ + availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"], + }), + ); + + mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + // User and project entries are unioned and deduplicated. + expect(modelOption.options.map((o: any) => o.value)).toEqual([ + "default", + "claude-haiku-4-5", + "claude-opus-4-7[1m]", + ]); + }); + + it("returns only the default entry when availableModels is an empty array", async () => { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ availableModels: [] }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + expect(modelOption.options.map((o: any) => o.value)).toEqual(["default"]); + }); + + it("does not filter when availableModels is absent from settings", async () => { + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + expect(modelOption.options.map((o: any) => o.value)).toEqual(["default", "haiku"]); + }); + + it("passes the user's exact ID to setModel when it matches an SDK alias", async () => { + // Without the allowlist, the SDK would resolve `haiku` to a + // date-pinned variant. Forcing setModel to receive `claude-haiku-4-5` + // is exactly what the issue's workaround + // (`ANTHROPIC_DEFAULT_HAIKU_MODEL`) achieves manually. + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + availableModels: ["claude-haiku-4-5"], + model: "claude-haiku-4-5", + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + const { setModelSpy } = mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { value: "haiku", displayName: "Haiku", description: "Fast" }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5"); + expect(response.models.currentModelId).toBe("claude-haiku-4-5"); + }); + }); + it("resolves model aliases like opus[1m] to the correct model", async () => { await fs.promises.writeFile( path.join(tempDir, "settings.json"), diff --git a/src/tests/settings.test.ts b/src/tests/settings.test.ts index 43724fe..14c26c9 100644 --- a/src/tests/settings.test.ts +++ b/src/tests/settings.test.ts @@ -53,6 +53,57 @@ describe("SettingsManager", () => { expect(settings.model).toBe("claude-3-5-haiku"); }); + it("should expose availableModels from settings", async () => { + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"], + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const settings = settingsManager.getSettings(); + expect(settings.availableModels).toEqual(["claude-haiku-4-5", "claude-opus-4-7[1m]"]); + }); + + it("should union and dedupe availableModels across sources", async () => { + // Per Claude Code docs: "When `availableModels` is set at multiple + // levels, such as user settings and project settings, arrays are + // merged and deduplicated." + // https://code.claude.com/docs/en/model-config#merge-behavior + const claudeDir = path.join(tempDir, ".claude"); + await fs.promises.mkdir(claudeDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(claudeDir, "settings.json"), + JSON.stringify({ + availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"], + }), + ); + await fs.promises.writeFile( + path.join(claudeDir, "settings.local.json"), + JSON.stringify({ + // claude-opus-4-7[1m] overlaps with project; should be deduped. + availableModels: ["claude-opus-4-7[1m]", "claude-sonnet-4-6[1m]"], + }), + ); + + settingsManager = new SettingsManager(tempDir); + await settingsManager.initialize(); + + const settings = settingsManager.getSettings(); + expect(settings.availableModels).toEqual([ + "claude-haiku-4-5", + "claude-opus-4-7[1m]", + "claude-sonnet-4-6[1m]", + ]); + }); + it("should merge permissions.defaultMode with later sources taking precedence", async () => { const claudeDir = path.join(tempDir, ".claude"); await fs.promises.mkdir(claudeDir, { recursive: true });