feat(claude): discover models from settings

Fixes #649, #475, #455, #558
This commit is contained in:
Mohamed Boudra
2026-05-13 18:47:50 +08:00
committed by GitHub
parent 81697f85b2
commit 2bd2e8bc46
6 changed files with 372 additions and 38 deletions

View File

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

View File

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

View File

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

View File

@@ -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<AgentModelDefinition[]> {
// 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(

View File

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

View File

@@ -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<AgentModelDefinition[]> {
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<AgentModelDefinition[]> {
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<string, unknown> {
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.