Compare commits

...

4 Commits

7 changed files with 309 additions and 5 deletions

View File

@@ -50,6 +50,7 @@ export class SettingsManager {
private cwd: string;
private effective: Settings = {};
private managedDeniedModels: string[] = [];
private managedModelSettings: Pick<Settings, "availableModels" | "modelOverrides"> = {};
private managedApiKeyHelper?: string;
private watchers: fs.FSWatcher[] = [];
private onChange?: () => void;
@@ -112,6 +113,15 @@ export class SettingsManager {
this.managedDeniedModels = resolved.sources
.filter((entry) => entry.source === "managed")
.flatMap((entry) => entry.settings.deniedModels ?? []);
this.managedModelSettings = Object.assign(
{},
...resolved.sources
.filter((entry) => entry.source === "managed")
.map((entry) => ({
availableModels: entry.settings.availableModels,
modelOverrides: entry.settings.modelOverrides,
})),
);
this.managedApiKeyHelper =
resolved.provenance.apiKeyHelper?.source === "managed"
? resolved.effective.apiKeyHelper
@@ -120,6 +130,7 @@ export class SettingsManager {
this.logger.error("Failed to resolve settings:", error);
this.effective = {};
this.managedDeniedModels = [];
this.managedModelSettings = {};
this.managedApiKeyHelper = undefined;
}
}
@@ -193,6 +204,28 @@ export class SettingsManager {
);
}
/** Model selection settings including the inline SDK settings tier. */
getModelSelectionSettings(
override?: Settings,
): Pick<Settings, "availableModels" | "modelOverrides"> {
// The SDK unions non-managed allowlists, but a managed list owns the
// whole allowlist. Keep that policy when adding
// models the SDK did not include in its initialization result.
const lists = [this.effective.availableModels, override?.availableModels].filter(
(list): list is string[] => Array.isArray(list),
);
const managed = this.managedModelSettings;
return {
availableModels:
managed.availableModels ?? (lists.length > 0 ? [...new Set(lists.flat())] : undefined),
modelOverrides: {
...this.effective.modelOverrides,
...override?.modelOverrides,
...managed.modelOverrides,
},
};
}
/**
* `deniedModels` entries from the managed tier, the only tier the CLI
* honors them from.

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
import { SettingsManager } from "../settings.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => ({
...(await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>()),
resolveSettings: vi.fn(),
}));
describe("model selection settings policy", () => {
let manager: SettingsManager;
afterEach(() => manager?.dispose());
it.each([{ availableModels: ["claude-haiku-4-5"] }, { availableModels: [] }])(
"keeps the managed allowlist %j and mappings above inline settings",
async ({ availableModels }) => {
const managed = {
availableModels,
modelOverrides: { "claude-haiku-4-5": "managed-haiku" },
deniedModels: ["claude-opus-5"],
};
vi.mocked(resolveSettings).mockResolvedValue({
effective: managed,
provenance: {},
sources: [{ source: "managed", settings: managed }],
});
manager = new SettingsManager(process.cwd());
await manager.initialize();
expect(
manager.getModelSelectionSettings({
availableModels: ["claude-opus-5"],
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
}),
).toEqual({
availableModels: managed.availableModels,
modelOverrides: managed.modelOverrides,
});
expect(manager.getManagedDeniedModels()).toEqual(managed.deniedModels);
},
);
it("keeps managed override keys above inline keys without discarding other inline mappings", async () => {
const managed = { modelOverrides: { "claude-haiku-4-5": "managed-haiku" } };
vi.mocked(resolveSettings).mockResolvedValue({
effective: managed,
provenance: {},
sources: [{ source: "managed", settings: managed }],
});
manager = new SettingsManager(process.cwd());
await manager.initialize();
expect(
manager.getModelSelectionSettings({
availableModels: ["claude-haiku-4-5", "claude-sonnet-4-6"],
modelOverrides: {
"claude-haiku-4-5": "inline-haiku",
"claude-sonnet-4-6": "inline-sonnet",
},
}),
).toEqual({
availableModels: ["claude-haiku-4-5", "claude-sonnet-4-6"],
modelOverrides: { "claude-haiku-4-5": "managed-haiku", "claude-sonnet-4-6": "inline-sonnet" },
});
});
it("preserves file and inline mappings when the managed allowlist supplies no mappings", async () => {
vi.mocked(resolveSettings).mockResolvedValue({
effective: {
availableModels: ["claude-haiku-4-5"],
modelOverrides: { "claude-haiku-4-5": "file-haiku" },
},
provenance: {},
sources: [
{ source: "user", settings: { modelOverrides: { "claude-haiku-4-5": "file-haiku" } } },
{ source: "managed", settings: { availableModels: ["claude-haiku-4-5"] } },
],
});
manager = new SettingsManager(process.cwd());
await manager.initialize();
expect(manager.getModelSelectionSettings()).toEqual({
availableModels: ["claude-haiku-4-5"],
modelOverrides: { "claude-haiku-4-5": "file-haiku" },
});
expect(
manager.getModelSelectionSettings({
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
}),
).toEqual({
availableModels: ["claude-haiku-4-5"],
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
});
});
});

View File

@@ -921,6 +921,136 @@ describe("ClaudeAcpAgent settings", () => {
return { setModelSpy };
}
it.each(["environment", "caller object", "caller file"])(
"includes configured models omitted by the SDK from %s settings",
async (source) => {
const configured = {
availableModels: ["claude-haiku-4-5", "claude-haiku-5-5"],
modelOverrides: {
"claude-haiku-4-5": "global.anthropic.claude-haiku-4-5-20251001-v1:0",
"claude-haiku-5-5": "global.anthropic.claude-haiku-5-5",
},
};
let settings: object | string | undefined;
if (source === "environment") {
vi.stubEnv("CLAUDE_MODEL_CONFIG", JSON.stringify(configured));
} else if (source === "caller file") {
settings = "caller-settings.json";
await fs.promises.writeFile(path.join(tempDir, settings), JSON.stringify(configured));
} else {
settings = configured;
}
const { setModelSpy } = mockQueryWithModels([
{ value: "default", displayName: "Default", description: "" },
{ value: "sonnet", displayName: "Sonnet", description: "" },
]);
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
try {
const response = await (agent as any).createSession({
cwd: tempDir,
mcpServers: [],
_meta: { claudeCode: { options: { settings } } },
});
expect(querySpy.mock.calls[0][0].options.settings).toEqual(configured);
const ids = ["default", ...Object.values(configured.modelOverrides)];
expect(
agent.sessions[response.sessionId].models.availableModels.map(
(model: any) => model.modelId,
),
).toEqual(ids);
const modelOption = response.configOptions.find((option: any) => option.id === "model");
expect(modelOption.options.map((option: any) => option.value)).toEqual(ids);
await agent.setSessionConfigOption({
sessionId: response.sessionId,
configId: "model",
value: ids[2],
});
expect(setModelSpy).toHaveBeenLastCalledWith(ids[2]);
expect(agent.sessions[response.sessionId].modelInfos.map((model) => model.value)).toEqual(
ids,
);
} finally {
await agent.dispose();
vi.unstubAllEnvs();
}
},
);
it("merges file settings with caller settings after caller keys replace the env config", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
availableModels: ["claude-sonnet-4-6", "claude-haiku-4-5"],
modelOverrides: {
"claude-sonnet-4-6": "file-sonnet",
"claude-haiku-4-5": "file-haiku",
},
}),
);
vi.stubEnv(
"CLAUDE_MODEL_CONFIG",
JSON.stringify({
availableModels: ["claude-opus-4-6"],
modelOverrides: { "claude-sonnet-4-6": "env-sonnet", "claude-haiku-4-5": "env-haiku" },
}),
);
mockQueryWithModels([{ value: "default", displayName: "Default", description: "" }]);
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
const settings = {
availableModels: ["claude-haiku-4-5", "claude-haiku-5-5"],
modelOverrides: { "claude-haiku-4-5": "caller-haiku" },
};
try {
const response = await (agent as any).createSession({
cwd: tempDir,
mcpServers: [],
_meta: { claudeCode: { options: { settings } } },
});
expect(querySpy.mock.calls[0][0].options.settings).toEqual(settings);
const modelOption = response.configOptions.find((option: any) => option.id === "model");
expect(modelOption.options.map((option: any) => option.value)).toEqual([
"default",
"file-sonnet",
"caller-haiku",
"claude-haiku-5-5",
]);
} finally {
await agent.dispose();
vi.unstubAllEnvs();
}
});
it.each([[], undefined])(
"preserves empty and absent configured allowlists: %j",
async (availableModels) => {
vi.stubEnv(
"CLAUDE_MODEL_CONFIG",
JSON.stringify({
availableModels,
modelOverrides: { "claude-haiku-4-5": "provider-haiku" },
}),
);
mockQueryWithModels([
{ value: "default", displayName: "Default", description: "" },
{ value: "sonnet", displayName: "Sonnet", description: "" },
]);
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
try {
const response = await (agent as any).createSession({ cwd: tempDir, mcpServers: [] });
const modelOption = response.configOptions.find((option: any) => option.id === "model");
expect(modelOption.options.map((option: any) => option.value)).toEqual(
availableModels ? ["default"] : ["default", "sonnet"],
);
} finally {
await agent.dispose();
vi.unstubAllEnvs();
}
},
);
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:

View File

@@ -9,7 +9,7 @@
* `session_state_changed`.
*/
import { randomUUID } from "node:crypto";
import { mkdtemp, realpath, rm } from "node:fs/promises";
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -495,6 +495,51 @@ describe("ACP v2 core", () => {
);
});
it.each(["environment", "caller object", "caller file"])(
"includes configured models omitted by the SDK from %s settings",
async (source) => {
const configured = {
availableModels: ["claude-haiku-4-5", "claude-haiku-5-5"],
modelOverrides: {
"claude-haiku-4-5": "global.anthropic.claude-haiku-4-5-20251001-v1:0",
"claude-haiku-5-5": "global.anthropic.claude-haiku-5-5",
},
};
let settings: typeof configured | string | undefined;
if (source === "environment") {
vi.stubEnv("CLAUDE_MODEL_CONFIG", JSON.stringify(configured));
} else if (source === "caller file") {
settings = "caller-settings.json";
await writeFile(path.join(cwd, settings), JSON.stringify(configured));
} else {
settings = configured;
}
const client = v2Client();
await client.connect(async (agent) => {
await agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: { name: "v2-test-client", version: "1" },
});
const response = await agent.request(v2.methods.agent.session.new, {
cwd,
_meta: { claudeCode: { options: { settings } } },
});
const ids = ["default", ...Object.values(configured.modelOverrides)];
const model = response.configOptions?.find((option) => option.configId === "model");
expect(model).toMatchObject({
options: ids.map((value) => expect.objectContaining({ value })),
});
await agent.request(v2.methods.agent.session.setConfigOption, {
sessionId: response.sessionId,
configId: "model",
type: "id",
value: ids[2],
} as v2.SetSessionConfigOptionRequest);
expect(sdk.processes[0].setModel).toHaveBeenLastCalledWith(ids[2]);
});
},
);
it("routes the sessions started after a gateway login through the gateway", async () => {
const client = v2Client();
const baseUrl = "https://gateway.example.com";

View File

@@ -10409,15 +10409,15 @@ export class ClaudeAcpAgent {
}
}
// Apply user's `availableModels` allowlist from settings.json before any
// Apply the effective `availableModels` allowlist before any
// downstream model handling. `initializationResult.models` is already
// policy-filtered by the CLI; we rebuild the picker from the allowlist
// so the user's exact spellings (and `modelOverrides` targets) are the
// values passed to `setModel`, keeping configOptions, the current-model
// resolver, and the stored modelInfos consistent with what the user
// configured. Managed `deniedModels` drops entries the CLI would refuse.
const settingsAvailableModels = settingsManager.getSettings().availableModels;
const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
const { availableModels: settingsAvailableModels, modelOverrides: settingsModelOverrides } =
settingsManager.getModelSelectionSettings(configuredSettingsObject);
const resumedModel = await resumedModelHint;
// A resumed Fable 5.2 session lists Fable 5.2 as `fable`, as a new
// session does (see `aliasResumedModelRow`).

View File

@@ -857,6 +857,7 @@ class V2Agent implements AgentHandle, SessionConnection {
resumed,
sessionId,
this.logger,
configured,
);
} catch (error) {
// Claude Code refused a fresh session's model pin.

View File

@@ -11,6 +11,7 @@ import {
type ModelInfo,
type Options,
type Query,
type Settings,
} from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "../logger.js";
import type { NewSessionMeta } from "../new-session-meta.js";
@@ -67,13 +68,15 @@ export async function sessionModels(
resumed: { model?: string } | undefined,
sessionId: string,
logger: Logger,
configuredSettings?: Settings,
): Promise<{
models: SessionModelState;
modelInfos: ModelInfo[];
currentModelInfo: ModelInfo | undefined;
allowlistedModelInfo: ModelInfo | undefined;
}> {
const { availableModels, modelOverrides } = settings.getSettings();
const { availableModels, modelOverrides } =
settings.getModelSelectionSettings(configuredSettings);
// A resumed Fable 5.2 session lists Fable 5.2 as `fable`, as a new session does.
const listed = aliasResumedModelRow(sdkModels, resumed?.model);
const allowed = Array.isArray(availableModels)