fix: include inline model settings in the model picker (#1488)

When `CLAUDE_MODEL_CONFIG` or caller-provided SDK settings list a model
that the SDK omits, include those settings when rebuilding the picker.
Configured models such as Bedrock Haiku versions appear with their
provider override IDs and can be selected in both ACP v1 and v2.

Merge file and inline model lists with the SDK's array semantics,
preserve managed allowlists and override precedence, and keep the
existing shallow caller-over-environment merge. The refreshed change
preserves upstream's resumed-model alias handling and uses the v2 setup
path introduced on current main.

This repairs explicitly configured models; it does not discover an older
model from a gateway listing or change the SDK's family defaults.

Validation on upstream main `a1d83c60310f33cbc3cd78f6b6133278e754efd5`
with SDK 0.3.321:
- TypeScript compilation, ESLint and Prettier checks passed using their
installed executables (the host npm script launcher exits 194).
- 181 focused tests passed, including managed policy and environment,
caller-object and caller-file settings; both v1 and v2 select the
configured provider ID.
- The three new v2 regression cases fail on unchanged main because the
configured models are missing.
- Full suite passed: 3,952 tests, 33 skipped. The first sandboxed run
could not open a local socket or receive filesystem watcher events; the
unchanged candidate passed with host test execution.
- No real model inference or live provider calls were used.

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Aleksandr Slapoguzov <aleksandr.slapoguzov@jetbrains.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Slapoguzov Alexander
2026-11-10 21:11:22 +01:00
committed by GitHub
parent f5d79167a6
commit 36cb674589
7 changed files with 309 additions and 5 deletions

View File

@@ -37,6 +37,8 @@ export interface SettingsManagerOptions {
logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void };
}
type ModelSelectionSettings = Pick<Settings, "availableModels" | "modelOverrides">;
/**
* Manages Claude Code settings using the SDK's `resolveSettings` merge engine
* so the values we see match what `query()` would observe.
@@ -50,6 +52,7 @@ export class SettingsManager {
private cwd: string;
private effective: Settings = {};
private managedDeniedModels: string[] = [];
private managedModelSettings: ModelSelectionSettings = {};
private managedApiKeyHelper?: string;
private watchers: fs.FSWatcher[] = [];
private onChange?: () => void;
@@ -112,6 +115,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 +132,7 @@ export class SettingsManager {
this.logger.error("Failed to resolve settings:", error);
this.effective = {};
this.managedDeniedModels = [];
this.managedModelSettings = {};
this.managedApiKeyHelper = undefined;
}
}
@@ -193,6 +206,26 @@ export class SettingsManager {
);
}
/** Model selection settings including the inline SDK settings tier. */
getModelSelectionSettings(override?: Settings): ModelSelectionSettings {
// 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

@@ -10339,15 +10339,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

@@ -915,6 +915,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)