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
71 changes: 60 additions & 11 deletions src/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2008,7 +2011,7 @@ export class ClaudeAcpAgent implements Agent {
const configOptions = buildConfigOptions(
modes,
models,
initializationResult.models,
allowedModels,
settingsManager.getSettings().effortLevel,
);

Expand All @@ -2035,7 +2038,7 @@ export class ClaudeAcpAgent implements Agent {
},
modes,
models,
modelInfos: initializationResult.models,
modelInfos: allowedModels,
configOptions,
promptRunning: false,
pendingMessages: new Map(),
Expand Down Expand Up @@ -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<string>([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[],
Expand Down
10 changes: 10 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ClaudeCodeSettings {
env?: Record<string, string>;
model?: string;
effortLevel?: string;
availableModels?: string[];
}

/**
Expand Down Expand Up @@ -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,
Expand Down
193 changes: 193 additions & 0 deletions src/tests/acp-agent-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,199 @@ describe("ClaudeAcpAgent settings", () => {
});
});

describe("availableModels allowlist from settings", () => {
function mockQueryWithModels(models: any[]): {
setModelSpy: ReturnType<typeof vi.fn>;
} {
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"),
Expand Down
51 changes: 51 additions & 0 deletions src/tests/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading