From 1377adbece752a97795f4001518ec4f7ce6aa5ff Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Fri, 5 Jun 2026 14:58:38 +0200 Subject: [PATCH] fix(claude): respect profile models for built-in claude provider (#1311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(claude): respect profile models for built-in claude provider The built-in Claude provider was the only one in the registry whose profile `models` array was treated as additive on top of the hardcoded first-party catalog. Every other built-in provider (and every custom provider) replaces the runtime model list when `models` is set, which matches the documented behavior in docs/custom-providers.md. For users pointing Claude Code at a third-party Anthropic-compatible gateway (Z.AI, Alibaba/Qwen, MiniMax, custom proxies, …) and curating the picker with `agents.providers.claude.models`, the old behavior leaked the nine first-party Claude models into the dropdown, making it impossible to ship a curated list. This is issue #1299. The fix flips the built-in Claude entry in `provider-registry.ts` to match the other providers, so `models` replaces the runtime catalog (including the settings.json-discovered entries surfaced by getClaudeModelsWithSettings). The existing `additionalModels` field keeps its additive semantics for anyone who still wants to append entries on top of the first-party list. - provider-registry.ts: drop the claude-only `profileModelsAreAdditive` branch and align with the rest of the registry. - provider-registry.test.ts: update the "append to runtime models" expectation for built-in Claude to "replace runtime models" and add a regression test that mirrors the issue scenario (hardcoded catalog + configured profile models, expect only the profile models). - agent.test.ts: make the "returns hardcoded claude models" hermetic by pointing CLAUDE_CONFIG_DIR at an empty temp dir; the test was reading the host's real ~/.claude/settings.json (which now contains MiniMax env vars from the issue report) and leaking that into the assertion. - custom-providers.md: correct the note about Claude profile models being additive and document the new replace semantics alongside additionalModels. Closes #1299 * test(claude): drop explanatory comments from new tests * refactor(claude): inject configDir into ClaudeAgentClient for test hermeticity Greptile flagged the previous hermeticity fix on agent.test.ts: mutating process.env.CLAUDE_CONFIG_DIR inside a test leaks the redirection into any concurrent test in the same process for the duration of the try block. Thread a `configDir` option through ClaudeAgentClient -> getClaudeModelsWithSettings -> readClaudeSettingsModels -> resolveClaudeConfigDir instead. The constructor follows the same injection pattern as `resolveBinary`, so the test passes an empty temp dir via the option and no shared global state is touched. - models.ts: add an optional `configDir` to getClaudeModelsWithSettings, readClaudeSettingsModels, and resolveClaudeConfigDir. Env var remains the fallback for production callers. - agent.ts: add `configDir?` to ClaudeAgentClientOptions, store it on the instance, and forward it to getClaudeModelsWithSettings from listModels. - agent.test.ts: drop the process.env save/mutate/restore dance and pass `configDir: emptyConfigDir` to the constructor instead. Refs #1311 --- docs/custom-providers.md | 2 +- .../server/agent/provider-registry.test.ts | 35 +++++++++++--- .../src/server/agent/provider-registry.ts | 2 +- .../agent/providers/claude/agent.test.ts | 47 +++++++++++-------- .../server/agent/providers/claude/agent.ts | 5 +- .../server/agent/providers/claude/models.ts | 18 ++++--- 6 files changed, 75 insertions(+), 34 deletions(-) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 5ae98afb2..69ed91212 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -577,7 +577,7 @@ Each entry in the `models` array: The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. -This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated. +This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top. ### Gotcha: `extends: "claude"` with third-party endpoints diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 9d8acc8ae..565ca6999 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -835,7 +835,7 @@ describe("model merging", () => { ]); }); - test("built-in Claude profile models append to runtime models", async () => { + test("built-in Claude profile models replace runtime models (issue #1299)", async () => { mockState.runtimeModels.set("claude", [ { provider: "claude", @@ -872,11 +872,6 @@ describe("model merging", () => { }); expect(models).toEqual([ - { - provider: "claude", - id: "runtime-model", - label: "Runtime Model", - }, { provider: "claude", id: "shared-model", @@ -1129,4 +1124,32 @@ describe("model merging", () => { const defaultModel = models.find((model) => model.isDefault) ?? models[0]; expect(defaultModel?.id).toBe("profile-default"); }); + + test("built-in Claude models override replaces hardcoded first-party models (issue #1299)", async () => { + mockState.runtimeModels.set("claude", [ + { provider: "claude", id: "claude-opus-4-8", label: "Opus 4.8", isDefault: true }, + { provider: "claude", id: "claude-opus-4-7", label: "Opus 4.7" }, + { provider: "claude", id: "claude-sonnet-4-6", label: "Sonnet 4.6" }, + { provider: "claude", id: "claude-haiku-4-5", label: "Haiku 4.5" }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { id: "MiniMax-M2.7", label: "MiniMax-M2.7" }, + { id: "MiniMax-M3", label: "MiniMax-M3", isDefault: true }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models.map((model) => model.id)).toEqual(["MiniMax-M2.7", "MiniMax-M3"]); + expect(models.find((model) => model.isDefault)?.id).toBe("MiniMax-M3"); + }); }); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index b474047fe..823ea6546 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -523,7 +523,7 @@ function buildResolvedBuiltinProviders( runtimeSettings: mergedRuntimeSettings, profileModels: override?.models ?? [], additionalModels: override?.additionalModels ?? [], - profileModelsAreAdditive: definition.id === "claude", + profileModelsAreAdditive: false, enabled: override?.enabled !== false, derivedFromProviderId: null, createBaseClient: (logger) => diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index be905bf9e..382885e8e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -397,28 +397,37 @@ describe("ClaudeAgentClient.listModels", () => { const logger = createTestLogger(); test("returns hardcoded claude models", async () => { - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); - const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); + const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-")); + try { + const client = new ClaudeAgentClient({ + logger, + resolveBinary: async () => "/test/claude/bin", + configDir: emptyConfigDir, + }); + const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); - expect(models.map((m) => m.id)).toEqual([ - "claude-opus-4-8[1m]", - "claude-opus-4-8", - "claude-opus-4-7[1m]", - "claude-opus-4-7", - "claude-opus-4-6[1m]", - "claude-opus-4-6", - "claude-sonnet-4-6[1m]", - "claude-sonnet-4-6", - "claude-haiku-4-5", - ]); + expect(models.map((m) => m.id)).toEqual([ + "claude-opus-4-8[1m]", + "claude-opus-4-8", + "claude-opus-4-7[1m]", + "claude-opus-4-7", + "claude-opus-4-6[1m]", + "claude-opus-4-6", + "claude-sonnet-4-6[1m]", + "claude-sonnet-4-6", + "claude-haiku-4-5", + ]); - for (const model of models) { - expect(model.provider).toBe("claude"); - expect(model.label.length).toBeGreaterThan(0); + for (const model of models) { + expect(model.provider).toBe("claude"); + expect(model.label.length).toBeGreaterThan(0); + } + + const defaultModel = models.find((m) => m.isDefault); + expect(defaultModel?.id).toBe("claude-opus-4-8"); + } finally { + await fs.rm(emptyConfigDir, { recursive: true, force: true }); } - - const defaultModel = models.find((m) => m.isDefault); - expect(defaultModel?.id).toBe("claude-opus-4-8"); }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b6473c699..60bbf0ee5 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -279,6 +279,7 @@ interface ClaudeAgentClientOptions { runtimeSettings?: ProviderRuntimeSettings; queryFactory?: ClaudeQueryFactory; resolveBinary?: () => Promise; + configDir?: string; } interface ClaudeAgentSessionOptions { @@ -1277,6 +1278,7 @@ export class ClaudeAgentClient implements AgentClient { private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; + private readonly configDir?: string; constructor(options: ClaudeAgentClientOptions) { this.defaults = options.defaults; @@ -1284,6 +1286,7 @@ export class ClaudeAgentClient implements AgentClient { this.runtimeSettings = options.runtimeSettings; this.queryFactory = options.queryFactory; this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings)); + this.configDir = options.configDir; } async createSession( @@ -1334,7 +1337,7 @@ export class ClaudeAgentClient implements AgentClient { async listModels(_options: ListModelsOptions): Promise { // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. - return await getClaudeModelsWithSettings(this.logger); + return await getClaudeModelsWithSettings(this.logger, this.configDir); } async listFeatures(config: AgentSessionConfig): Promise { diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 644d26ff6..31c2cc77f 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -98,9 +98,12 @@ export function getClaudeModels(): AgentModelDefinition[] { return CLAUDE_MODELS.map((model) => ({ ...model })); } -export async function getClaudeModelsWithSettings(logger: Logger): Promise { +export async function getClaudeModelsWithSettings( + logger: Logger, + configDir?: string, +): Promise { const hardcodedModels = getClaudeModels(); - const settingsModels = await readClaudeSettingsModels(logger); + const settingsModels = await readClaudeSettingsModels(logger, configDir); if (settingsModels.length === 0) { return hardcodedModels; } @@ -119,8 +122,11 @@ export async function getClaudeModelsWithSettings(logger: Logger): Promise { - const settingsPath = path.join(resolveClaudeConfigDir(), "settings.json"); +async function readClaudeSettingsModels( + logger: Logger, + configDir?: string, +): Promise { + const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json"); let parsed: unknown; try { @@ -155,8 +161,8 @@ async function readClaudeSettingsModels(logger: Logger): Promise