fix(claude): respect profile models for built-in claude provider (#1311)

* 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
This commit is contained in:
Matteo Pietro Dazzi
2026-06-05 14:58:38 +02:00
committed by GitHub
parent 350bc08fc4
commit 1377adbece
6 changed files with 75 additions and 34 deletions

View File

@@ -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

View File

@@ -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");
});
});

View File

@@ -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) =>

View File

@@ -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");
});
});

View File

@@ -279,6 +279,7 @@ interface ClaudeAgentClientOptions {
runtimeSettings?: ProviderRuntimeSettings;
queryFactory?: ClaudeQueryFactory;
resolveBinary?: () => Promise<string>;
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<string>;
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<AgentModelDefinition[]> {
// 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<AgentFeature[]> {

View File

@@ -98,9 +98,12 @@ export function getClaudeModels(): AgentModelDefinition[] {
return CLAUDE_MODELS.map((model) => ({ ...model }));
}
export async function getClaudeModelsWithSettings(logger: Logger): Promise<AgentModelDefinition[]> {
export async function getClaudeModelsWithSettings(
logger: Logger,
configDir?: string,
): Promise<AgentModelDefinition[]> {
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<Agent
return models;
}
async function readClaudeSettingsModels(logger: Logger): Promise<AgentModelDefinition[]> {
const settingsPath = path.join(resolveClaudeConfigDir(), "settings.json");
async function readClaudeSettingsModels(
logger: Logger,
configDir?: string,
): Promise<AgentModelDefinition[]> {
const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json");
let parsed: unknown;
try {
@@ -155,8 +161,8 @@ async function readClaudeSettingsModels(logger: Logger): Promise<AgentModelDefin
return models;
}
function resolveClaudeConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
function resolveClaudeConfigDir(configDir?: string): string {
return configDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
}
function addSettingsModel(