mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
fix: Honor availableModels settings allowlist (#637)
Merge and dedupe entries across settings sources, preserve the default model, and pass user-specified model IDs through to setModel. Closes #620
This commit is contained in:
@@ -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<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[],
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ClaudeCodeSettings {
|
||||
env?: Record<string, string>;
|
||||
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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user