mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(claude): discover models from settings
Fixes #649, #475, #455, #558
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user