From 2bd2e8bc468cc7fad17a3fbaffae379bd0d2a013 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 13 May 2026 18:47:50 +0800 Subject: [PATCH] feat(claude): discover models from settings Fixes #649, #475, #455, #558 --- docs/custom-providers.md | 6 + .../server/agent/provider-registry.test.ts | 99 +++++++++--- .../src/server/agent/provider-registry.ts | 53 +++++-- .../server/agent/providers/claude/agent.ts | 6 +- .../agent/providers/claude/models.test.ts | 146 +++++++++++++++++- .../server/agent/providers/claude/models.ts | 100 ++++++++++++ 6 files changed, 372 insertions(+), 38 deletions(-) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index b1bbb8c95..40933afc8 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -536,6 +536,12 @@ Each entry in the `models` array: | `description` | `string` | No | Short description | | `isDefault` | `boolean` | No | Mark as the default thinking option | +### Claude settings.json model discovery + +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. + ### Gotcha: `extends: "claude"` with third-party endpoints When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors. diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 882c36ba7..ade0d34e4 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -646,9 +646,9 @@ test("extension inherits base override — override claude command, zai extends describe("model merging", () => { test("profile models replace runtime models", async () => { - mockState.runtimeModels.set("claude", [ + mockState.runtimeModels.set("codex", [ { - provider: "claude", + provider: "codex", id: "runtime-pro", label: "Runtime Pro", }, @@ -656,7 +656,7 @@ describe("model merging", () => { const registry = buildProviderRegistry(logger, { providerOverrides: { - claude: { + codex: { models: [ { id: "profile-fast", @@ -667,7 +667,7 @@ describe("model merging", () => { }, }); - const models = await registry.claude.fetchModels({ + const models = await registry.codex.fetchModels({ cwd: "/tmp/registry-models", force: false, }); @@ -676,14 +676,14 @@ describe("model merging", () => { }); test("profile models exclude runtime models entirely", async () => { - mockState.runtimeModels.set("claude", [ + mockState.runtimeModels.set("codex", [ { - provider: "claude", + provider: "codex", id: "shared-model", label: "Runtime Label", }, { - provider: "claude", + provider: "codex", id: "runtime-only", label: "Runtime Only", }, @@ -691,7 +691,7 @@ describe("model merging", () => { const registry = buildProviderRegistry(logger, { providerOverrides: { - claude: { + codex: { models: [ { id: "shared-model", @@ -702,14 +702,14 @@ describe("model merging", () => { }, }); - const models = await registry.claude.fetchModels({ + const models = await registry.codex.fetchModels({ cwd: "/tmp/registry-models", force: false, }); expect(models).toEqual([ { - provider: "claude", + provider: "codex", id: "shared-model", label: "Profile Label", }, @@ -717,9 +717,9 @@ describe("model merging", () => { }); test("profile isDefault preserved without runtime models", async () => { - mockState.runtimeModels.set("claude", [ + mockState.runtimeModels.set("codex", [ { - provider: "claude", + provider: "codex", id: "runtime-default", label: "Runtime Default", isDefault: true, @@ -728,7 +728,7 @@ describe("model merging", () => { const registry = buildProviderRegistry(logger, { providerOverrides: { - claude: { + codex: { models: [ { id: "profile-default", @@ -740,14 +740,14 @@ describe("model merging", () => { }, }); - const models = await registry.claude.fetchModels({ + const models = await registry.codex.fetchModels({ cwd: "/tmp/registry-models", force: false, }); expect(models).toEqual([ { - provider: "claude", + provider: "codex", id: "profile-default", label: "Profile Default", isDefault: true, @@ -796,10 +796,65 @@ describe("model merging", () => { ]); }); - test("additional models merge onto profile replacement models", async () => { + test("built-in Claude profile models append to runtime models", async () => { mockState.runtimeModels.set("claude", [ { provider: "claude", + id: "runtime-model", + label: "Runtime Model", + }, + { + provider: "claude", + id: "shared-model", + label: "Runtime Label", + }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { + id: "shared-model", + label: "Profile Label", + }, + { + id: "profile-model", + label: "Profile Model", + }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models).toEqual([ + { + provider: "claude", + id: "runtime-model", + label: "Runtime Model", + }, + { + provider: "claude", + id: "shared-model", + label: "Profile Label", + }, + { + provider: "claude", + id: "profile-model", + label: "Profile Model", + }, + ]); + }); + + test("additional models merge onto profile replacement models", async () => { + mockState.runtimeModels.set("codex", [ + { + provider: "codex", id: "runtime-pro", label: "Runtime Pro", }, @@ -807,7 +862,7 @@ describe("model merging", () => { const registry = buildProviderRegistry(logger, { providerOverrides: { - claude: { + codex: { models: [ { id: "profile-curated", @@ -824,7 +879,7 @@ describe("model merging", () => { }, }); - const models = await registry.claude.fetchModels({ + const models = await registry.codex.fetchModels({ cwd: "/tmp/registry-models", force: false, }); @@ -969,9 +1024,9 @@ describe("model merging", () => { }); test("built-in createClient().listModels() honors profile model replacement (issue #579)", async () => { - mockState.runtimeModels.set("claude", [ + mockState.runtimeModels.set("codex", [ { - provider: "claude", + provider: "codex", id: "runtime-default", label: "Runtime Default", isDefault: true, @@ -980,7 +1035,7 @@ describe("model merging", () => { const registry = buildProviderRegistry(logger, { providerOverrides: { - claude: { + codex: { models: [ { id: "profile-fast", @@ -992,7 +1047,7 @@ describe("model merging", () => { }, }); - const client = registry.claude.createClient(logger); + const client = registry.codex.createClient(logger); const models = await client.listModels({ cwd: "/tmp/registry-models", force: false, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 06e5bcc4d..d2d0cb5b4 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -90,6 +90,7 @@ interface ResolvedProvider { runtimeSettings?: ProviderRuntimeSettings; profileModels: ProviderProfileModel[]; additionalModels: ProviderProfileModel[]; + profileModelsAreAdditive: boolean; enabled: boolean; derivedFromProviderId: string | null; createBaseClient: (logger: Logger) => AgentClient; @@ -274,23 +275,36 @@ function mergeModels( profileModels: ProviderProfileModel[], additionalModels: ProviderProfileModel[], runtimeModels: AgentModelDefinition[], + options?: { profileModelsAreAdditive?: boolean }, ): AgentModelDefinition[] { - const baseModels = - profileModels.length === 0 - ? runtimeModels.map((model) => mapModel(provider, model)) - : profileModels.map((model) => ({ - ...model, - provider, - })); + const baseModels = runtimeModels.map((model) => mapModel(provider, model)); + if (profileModels.length > 0 && options?.profileModelsAreAdditive !== true) { + return mergeModelAdditions( + provider, + profileModels.map((model) => ({ + ...model, + provider, + })), + additionalModels, + ); + } - if (additionalModels.length === 0) { + return mergeModelAdditions(provider, baseModels, [...profileModels, ...additionalModels]); +} + +function mergeModelAdditions( + provider: AgentProvider, + baseModels: AgentModelDefinition[], + modelAdditions: ProviderProfileModel[], +): AgentModelDefinition[] { + if (modelAdditions.length === 0) { return baseModels; } const mergedModels = [...baseModels]; let hasAdditionalDefault = false; - for (const model of additionalModels) { + for (const model of modelAdditions) { const additionalModel = { ...model, provider, @@ -314,7 +328,7 @@ function mergeModels( } const additionalDefaultIds = new Set( - additionalModels.filter((model) => model.isDefault === true).map((model) => model.id), + modelAdditions.filter((model) => model.isDefault === true).map((model) => model.id), ); return mergedModels.map((model) => @@ -359,6 +373,7 @@ function wrapClientProvider( inner: AgentClient, profileModels: ProviderProfileModel[], additionalModels: ProviderProfileModel[], + profileModelsAreAdditive: boolean, ): AgentClient { const listPersistedAgents = inner.listPersistedAgents?.bind(inner); @@ -394,7 +409,9 @@ function wrapClientProvider( ), ), listModels: async (options) => - mergeModels(provider, profileModels, additionalModels, await inner.listModels(options)), + mergeModels(provider, profileModels, additionalModels, await inner.listModels(options), { + profileModelsAreAdditive, + }), listModes: inner.listModes?.bind(inner), listPersistedAgents: listPersistedAgents ? async (options?: ListPersistedAgentsOptions) => @@ -426,6 +443,9 @@ function createRegistryEntry( resolved.profileModels, resolved.additionalModels, await modelClient.listModels(options), + { + profileModelsAreAdditive: resolved.profileModelsAreAdditive, + }, ), fetchModes: async (options: ListModesOptions) => { const modes = modelClient.listModes @@ -455,7 +475,13 @@ function createResolvedProviderClient( if (inner.provider === provider && !hasModelOverrides) { return inner; } - return wrapClientProvider(provider, inner, resolved.profileModels, resolved.additionalModels); + return wrapClientProvider( + provider, + inner, + resolved.profileModels, + resolved.additionalModels, + resolved.profileModelsAreAdditive, + ); } function buildResolvedBuiltinProviders( @@ -483,6 +509,7 @@ function buildResolvedBuiltinProviders( runtimeSettings: mergedRuntimeSettings, profileModels: override?.models ?? [], additionalModels: override?.additionalModels ?? [], + profileModelsAreAdditive: definition.id === "claude", enabled: override?.enabled !== false, derivedFromProviderId: null, createBaseClient: (logger) => @@ -530,6 +557,7 @@ function addDerivedProviders( runtimeSettings: toRuntimeSettings(override), profileModels: override.models ?? [], additionalModels: override.additionalModels ?? [], + profileModelsAreAdditive: false, enabled: override.enabled !== false, derivedFromProviderId: null, createBaseClient: (logger) => @@ -572,6 +600,7 @@ function addDerivedProviders( runtimeSettings: mergedRuntimeSettings, profileModels: override.models ?? [], additionalModels: override.additionalModels ?? [], + profileModelsAreAdditive: false, enabled: override.enabled !== false, derivedFromProviderId: baseProviderId, createBaseClient: (logger) => diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 170094bd1..1475721ad 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -29,7 +29,7 @@ import { mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationUserContentToToolCall, } from "./task-notification-tool-call.js"; -import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js"; +import { getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId } from "./models.js"; import { parsePartialJsonObject } from "./partial-json.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; import { @@ -1263,8 +1263,8 @@ export class ClaudeAgentClient implements AgentClient { } async listModels(_options: ListModelsOptions): Promise { - // Claude exposes a static catalog here; cwd/force are intentionally irrelevant. - return getClaudeModels(); + // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. + return await getClaudeModelsWithSettings(this.logger); } async listPersistedAgents( diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 1d3ae41a1..f021192b9 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -1,7 +1,36 @@ -import { describe, expect, it } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js"; +const createdClaudeConfigDirs: string[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( + createdClaudeConfigDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + createdClaudeConfigDirs.length = 0; +}); + +async function createClaudeConfigDir(settings: unknown): Promise { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-")); + createdClaudeConfigDirs.push(configDir); + await fs.writeFile(path.join(configDir, "settings.json"), JSON.stringify(settings, null, 2)); + return configDir; +} + +async function createClaudeConfigDirWithRawSettings(settings: string): Promise { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-")); + createdClaudeConfigDirs.push(configDir); + await fs.writeFile(path.join(configDir, "settings.json"), settings); + return configDir; +} + describe("getClaudeModels", () => { it("returns all claude models", () => { const models = getClaudeModels(); @@ -31,6 +60,121 @@ describe("getClaudeModels", () => { }); }); +describe("ClaudeAgentClient.listModels", () => { + it("appends concrete models from Claude settings.json", async () => { + const configDir = await createClaudeConfigDir({ + model: "us.anthropic.claude-opus-4-7[1m]", + env: { + ANTHROPIC_MODEL: "openrouter/anthropic/claude-sonnet-4.5", + ANTHROPIC_SMALL_FAST_MODEL: "ollama/qwen3-coder", + ANTHROPIC_DEFAULT_OPUS_MODEL: "bedrock-opus-from-env", + ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5.1", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5", + }, + }); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + const client = new ClaudeAgentClient({ logger: createTestLogger() }); + + const models = await client.listModels({ cwd: os.tmpdir(), force: true }); + + expect(models).toEqual([ + ...getClaudeModels(), + { + provider: "claude", + id: "us.anthropic.claude-opus-4-7[1m]", + label: "us.anthropic.claude-opus-4-7[1m]", + description: "From Claude settings.json model", + }, + { + provider: "claude", + id: "openrouter/anthropic/claude-sonnet-4.5", + label: "openrouter/anthropic/claude-sonnet-4.5", + description: "From Claude settings.json env.ANTHROPIC_MODEL", + }, + { + provider: "claude", + id: "ollama/qwen3-coder", + label: "ollama/qwen3-coder", + description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL", + }, + { + provider: "claude", + id: "bedrock-opus-from-env", + label: "bedrock-opus-from-env", + description: "From Claude settings.json env.ANTHROPIC_DEFAULT_OPUS_MODEL", + }, + { + provider: "claude", + id: "glm-5.1", + label: "glm-5.1", + description: "From Claude settings.json env.ANTHROPIC_DEFAULT_SONNET_MODEL", + }, + { + provider: "claude", + id: "glm-5", + label: "glm-5", + description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL", + }, + ]); + }); + + it("falls back to hardcoded models when settings.json is missing", async () => { + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-")); + createdClaudeConfigDirs.push(configDir); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + const client = new ClaudeAgentClient({ logger: createTestLogger() }); + + const models = await client.listModels({ cwd: os.tmpdir(), force: true }); + + expect(models).toEqual(getClaudeModels()); + }); + + it("falls back to hardcoded models when settings.json is malformed", async () => { + const configDir = await createClaudeConfigDirWithRawSettings("{ nope"); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + const client = new ClaudeAgentClient({ logger: createTestLogger() }); + + const models = await client.listModels({ cwd: os.tmpdir(), force: true }); + + expect(models).toEqual(getClaudeModels()); + }); + + it("ignores empty env blocks and unexpected settings shapes", async () => { + const configDir = await createClaudeConfigDir({ + model: " ", + env: { + ANTHROPIC_MODEL: "", + ANTHROPIC_DEFAULT_OPUS_MODEL: 42, + }, + }); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + const client = new ClaudeAgentClient({ logger: createTestLogger() }); + + const models = await client.listModels({ cwd: os.tmpdir(), force: true }); + + expect(models).toEqual(getClaudeModels()); + }); + + it("deduplicates discovered settings models by ID", async () => { + const configDir = await createClaudeConfigDir({ + model: "glm-5.1", + env: { + ANTHROPIC_MODEL: "glm-5.1", + ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-opus-4-6", + }, + }); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + const client = new ClaudeAgentClient({ logger: createTestLogger() }); + + const models = await client.listModels({ cwd: os.tmpdir(), force: true }); + + expect(models.map((model) => model.id)).toEqual([ + ...getClaudeModels().map((model) => model.id), + "glm-5.1", + ]); + }); +}); + describe("normalizeClaudeRuntimeModelId", () => { it("returns exact match for known model IDs", () => { expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6"); diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index fa931bbe0..23c3bf761 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -1,3 +1,8 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { Logger } from "pino"; + import type { AgentModelDefinition } from "../../agent-sdk-types.js"; const CLAUDE_THINKING_OPTIONS = [ @@ -67,10 +72,105 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [ }, ]; +const CLAUDE_SETTINGS_MODEL_ENV_KEYS = [ + "ANTHROPIC_MODEL", + "ANTHROPIC_SMALL_FAST_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", +] as const; + export function getClaudeModels(): AgentModelDefinition[] { return CLAUDE_MODELS.map((model) => ({ ...model })); } +export async function getClaudeModelsWithSettings(logger: Logger): Promise { + const hardcodedModels = getClaudeModels(); + const settingsModels = await readClaudeSettingsModels(logger); + if (settingsModels.length === 0) { + return hardcodedModels; + } + + const seenModelIds = new Set(hardcodedModels.map((model) => model.id)); + const models = [...hardcodedModels]; + + for (const model of settingsModels) { + if (seenModelIds.has(model.id)) { + continue; + } + seenModelIds.add(model.id); + models.push(model); + } + + return models; +} + +async function readClaudeSettingsModels(logger: Logger): Promise { + const settingsPath = path.join(resolveClaudeConfigDir(), "settings.json"); + + let parsed: unknown; + try { + const rawSettings = await fs.readFile(settingsPath, "utf8"); + parsed = JSON.parse(rawSettings); + } catch (error) { + logger.debug({ err: error, settingsPath }, "Failed to read Claude settings models"); + return []; + } + + if (!isRecord(parsed)) { + logger.debug({ settingsPath }, "Claude settings.json is not an object"); + return []; + } + + const models: AgentModelDefinition[] = []; + addSettingsModel(models, parsed.model, "model"); + + const env = parsed.env; + if (env === undefined) { + return models; + } + if (!isRecord(env)) { + logger.debug({ settingsPath }, "Claude settings.json env is not an object"); + return models; + } + + for (const envKey of CLAUDE_SETTINGS_MODEL_ENV_KEYS) { + addSettingsModel(models, env[envKey], `env.${envKey}`); + } + + return models; +} + +function resolveClaudeConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); +} + +function addSettingsModel( + models: AgentModelDefinition[], + value: unknown, + settingsKey: string, +): void { + if (typeof value !== "string") { + return; + } + + const id = value.trim(); + if (id.length === 0 || models.some((model) => model.id === id)) { + return; + } + + models.push({ + provider: "claude", + id, + label: id, + description: `From Claude settings.json ${settingsKey}`, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Normalize a runtime model string (from SDK init message) to a known model ID. * Handles the `[1m]` suffix that the SDK appends for 1M context sessions.