From 57e347ddf455a48119cc0407d90d4e04339484b9 Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Wed, 3 Jun 2026 13:43:43 +0200 Subject: [PATCH 1/3] fix(claude): respect profile models for built-in claude provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/custom-providers.md | 2 +- .../server/agent/provider-registry.test.ts | 40 +++++++++++-- .../src/server/agent/provider-registry.ts | 2 +- .../agent/providers/claude/agent.test.ts | 59 ++++++++++++------- 4 files changed, 75 insertions(+), 28 deletions(-) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 5ae98afb28..69ed912122 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 9d8acc8aec..0b61241f3b 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,37 @@ 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 () => { + // Simulate the runtime catalog for the built-in Claude provider: nine + // first-party models discovered by getClaudeModelsWithSettings(). + 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" }, + ]); + + // User pointed Claude Code at a third-party Anthropic-compatible API and + // explicitly listed the models they want to pick from. The hardcoded + // first-party list must not bleed through. + 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 b474047fe7..823ea6546a 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 be905bf9e6..9d5d809509 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,47 @@ 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 }); - - 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", - ]); + // Use a clean CLAUDE_CONFIG_DIR so we don't pick up env-driven model + // entries from the host's ~/.claude/settings.json (e.g. third-party + // Anthropic-compatible gateways). The contract under test is the + // hardcoded first-party catalog. + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-")); + process.env.CLAUDE_CONFIG_DIR = emptyConfigDir; + try { + const client = new ClaudeAgentClient({ + logger, + resolveBinary: async () => "/test/claude/bin", + }); + 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", + ]); - 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"); + const defaultModel = models.find((m) => m.isDefault); + expect(defaultModel?.id).toBe("claude-opus-4-8"); + } finally { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + await fs.rm(emptyConfigDir, { recursive: true, force: true }); + } }); }); From ee85589c2ff8841bcc162a9800e64ac56ffa864b Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Wed, 3 Jun 2026 14:02:53 +0200 Subject: [PATCH 2/3] test(claude): drop explanatory comments from new tests --- packages/server/src/server/agent/provider-registry.test.ts | 5 ----- .../server/src/server/agent/providers/claude/agent.test.ts | 4 ---- 2 files changed, 9 deletions(-) diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 0b61241f3b..565ca69994 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -1126,8 +1126,6 @@ describe("model merging", () => { }); test("built-in Claude models override replaces hardcoded first-party models (issue #1299)", async () => { - // Simulate the runtime catalog for the built-in Claude provider: nine - // first-party models discovered by getClaudeModelsWithSettings(). 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" }, @@ -1135,9 +1133,6 @@ describe("model merging", () => { { provider: "claude", id: "claude-haiku-4-5", label: "Haiku 4.5" }, ]); - // User pointed Claude Code at a third-party Anthropic-compatible API and - // explicitly listed the models they want to pick from. The hardcoded - // first-party list must not bleed through. const registry = buildProviderRegistry(logger, { providerOverrides: { claude: { 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 9d5d809509..0e3c271e1e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -397,10 +397,6 @@ describe("ClaudeAgentClient.listModels", () => { const logger = createTestLogger(); test("returns hardcoded claude models", async () => { - // Use a clean CLAUDE_CONFIG_DIR so we don't pick up env-driven model - // entries from the host's ~/.claude/settings.json (e.g. third-party - // Anthropic-compatible gateways). The contract under test is the - // hardcoded first-party catalog. const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-")); process.env.CLAUDE_CONFIG_DIR = emptyConfigDir; From e58352e8d5f2c03e9a30f2d54d81542056653775 Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Wed, 3 Jun 2026 14:23:25 +0200 Subject: [PATCH 3/3] 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 --- .../agent/providers/claude/agent.test.ts | 8 +------- .../src/server/agent/providers/claude/agent.ts | 5 ++++- .../server/agent/providers/claude/models.ts | 18 ++++++++++++------ 3 files changed, 17 insertions(+), 14 deletions(-) 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 0e3c271e1e..382885e8e4 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -397,13 +397,12 @@ describe("ClaudeAgentClient.listModels", () => { const logger = createTestLogger(); test("returns hardcoded claude models", async () => { - const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-")); - process.env.CLAUDE_CONFIG_DIR = emptyConfigDir; try { const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin", + configDir: emptyConfigDir, }); const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); @@ -427,11 +426,6 @@ describe("ClaudeAgentClient.listModels", () => { const defaultModel = models.find((m) => m.isDefault); expect(defaultModel?.id).toBe("claude-opus-4-8"); } finally { - if (previousConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR; - } else { - process.env.CLAUDE_CONFIG_DIR = previousConfigDir; - } await fs.rm(emptyConfigDir, { recursive: true, force: true }); } }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index d35aa1bd18..60a2064482 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -278,6 +278,7 @@ interface ClaudeAgentClientOptions { runtimeSettings?: ProviderRuntimeSettings; queryFactory?: ClaudeQueryFactory; resolveBinary?: () => Promise; + configDir?: string; } interface ClaudeAgentSessionOptions { @@ -1280,6 +1281,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; @@ -1287,6 +1289,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( @@ -1337,7 +1340,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 644d26ff61..31c2cc77f4 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