feat(claude): discover models from settings
Fixes #649, #475, #455, #558
This commit is contained in:
@@ -536,6 +536,12 @@ Each entry in the `models` array:
|
|||||||
| `description` | `string` | No | Short description |
|
| `description` | `string` | No | Short description |
|
||||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
| `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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -646,9 +646,9 @@ test("extension inherits base override — override claude command, zai extends
|
|||||||
|
|
||||||
describe("model merging", () => {
|
describe("model merging", () => {
|
||||||
test("profile models replace runtime models", async () => {
|
test("profile models replace runtime models", async () => {
|
||||||
mockState.runtimeModels.set("claude", [
|
mockState.runtimeModels.set("codex", [
|
||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "codex",
|
||||||
id: "runtime-pro",
|
id: "runtime-pro",
|
||||||
label: "Runtime Pro",
|
label: "Runtime Pro",
|
||||||
},
|
},
|
||||||
@@ -656,7 +656,7 @@ describe("model merging", () => {
|
|||||||
|
|
||||||
const registry = buildProviderRegistry(logger, {
|
const registry = buildProviderRegistry(logger, {
|
||||||
providerOverrides: {
|
providerOverrides: {
|
||||||
claude: {
|
codex: {
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "profile-fast",
|
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",
|
cwd: "/tmp/registry-models",
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
@@ -676,14 +676,14 @@ describe("model merging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("profile models exclude runtime models entirely", async () => {
|
test("profile models exclude runtime models entirely", async () => {
|
||||||
mockState.runtimeModels.set("claude", [
|
mockState.runtimeModels.set("codex", [
|
||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "codex",
|
||||||
id: "shared-model",
|
id: "shared-model",
|
||||||
label: "Runtime Label",
|
label: "Runtime Label",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "codex",
|
||||||
id: "runtime-only",
|
id: "runtime-only",
|
||||||
label: "Runtime Only",
|
label: "Runtime Only",
|
||||||
},
|
},
|
||||||
@@ -691,7 +691,7 @@ describe("model merging", () => {
|
|||||||
|
|
||||||
const registry = buildProviderRegistry(logger, {
|
const registry = buildProviderRegistry(logger, {
|
||||||
providerOverrides: {
|
providerOverrides: {
|
||||||
claude: {
|
codex: {
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "shared-model",
|
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",
|
cwd: "/tmp/registry-models",
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(models).toEqual([
|
expect(models).toEqual([
|
||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "codex",
|
||||||
id: "shared-model",
|
id: "shared-model",
|
||||||
label: "Profile Label",
|
label: "Profile Label",
|
||||||
},
|
},
|
||||||
@@ -717,9 +717,9 @@ describe("model merging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("profile isDefault preserved without runtime models", async () => {
|
test("profile isDefault preserved without runtime models", async () => {
|
||||||
mockState.runtimeModels.set("claude", [
|
mockState.runtimeModels.set("codex", [
|
||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "codex",
|
||||||
id: "runtime-default",
|
id: "runtime-default",
|
||||||
label: "Runtime Default",
|
label: "Runtime Default",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
@@ -728,7 +728,7 @@ describe("model merging", () => {
|
|||||||
|
|
||||||
const registry = buildProviderRegistry(logger, {
|
const registry = buildProviderRegistry(logger, {
|
||||||
providerOverrides: {
|
providerOverrides: {
|
||||||
claude: {
|
codex: {
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "profile-default",
|
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",
|
cwd: "/tmp/registry-models",
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(models).toEqual([
|
expect(models).toEqual([
|
||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "codex",
|
||||||
id: "profile-default",
|
id: "profile-default",
|
||||||
label: "Profile Default",
|
label: "Profile Default",
|
||||||
isDefault: true,
|
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", [
|
mockState.runtimeModels.set("claude", [
|
||||||
{
|
{
|
||||||
provider: "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",
|
id: "runtime-pro",
|
||||||
label: "Runtime Pro",
|
label: "Runtime Pro",
|
||||||
},
|
},
|
||||||
@@ -807,7 +862,7 @@ describe("model merging", () => {
|
|||||||
|
|
||||||
const registry = buildProviderRegistry(logger, {
|
const registry = buildProviderRegistry(logger, {
|
||||||
providerOverrides: {
|
providerOverrides: {
|
||||||
claude: {
|
codex: {
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "profile-curated",
|
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",
|
cwd: "/tmp/registry-models",
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
@@ -969,9 +1024,9 @@ describe("model merging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("built-in createClient().listModels() honors profile model replacement (issue #579)", async () => {
|
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",
|
id: "runtime-default",
|
||||||
label: "Runtime Default",
|
label: "Runtime Default",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
@@ -980,7 +1035,7 @@ describe("model merging", () => {
|
|||||||
|
|
||||||
const registry = buildProviderRegistry(logger, {
|
const registry = buildProviderRegistry(logger, {
|
||||||
providerOverrides: {
|
providerOverrides: {
|
||||||
claude: {
|
codex: {
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "profile-fast",
|
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({
|
const models = await client.listModels({
|
||||||
cwd: "/tmp/registry-models",
|
cwd: "/tmp/registry-models",
|
||||||
force: false,
|
force: false,
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ interface ResolvedProvider {
|
|||||||
runtimeSettings?: ProviderRuntimeSettings;
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
profileModels: ProviderProfileModel[];
|
profileModels: ProviderProfileModel[];
|
||||||
additionalModels: ProviderProfileModel[];
|
additionalModels: ProviderProfileModel[];
|
||||||
|
profileModelsAreAdditive: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
derivedFromProviderId: string | null;
|
derivedFromProviderId: string | null;
|
||||||
createBaseClient: (logger: Logger) => AgentClient;
|
createBaseClient: (logger: Logger) => AgentClient;
|
||||||
@@ -274,23 +275,36 @@ function mergeModels(
|
|||||||
profileModels: ProviderProfileModel[],
|
profileModels: ProviderProfileModel[],
|
||||||
additionalModels: ProviderProfileModel[],
|
additionalModels: ProviderProfileModel[],
|
||||||
runtimeModels: AgentModelDefinition[],
|
runtimeModels: AgentModelDefinition[],
|
||||||
|
options?: { profileModelsAreAdditive?: boolean },
|
||||||
): AgentModelDefinition[] {
|
): AgentModelDefinition[] {
|
||||||
const baseModels =
|
const baseModels = runtimeModels.map((model) => mapModel(provider, model));
|
||||||
profileModels.length === 0
|
if (profileModels.length > 0 && options?.profileModelsAreAdditive !== true) {
|
||||||
? runtimeModels.map((model) => mapModel(provider, model))
|
return mergeModelAdditions(
|
||||||
: profileModels.map((model) => ({
|
provider,
|
||||||
...model,
|
profileModels.map((model) => ({
|
||||||
provider,
|
...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;
|
return baseModels;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedModels = [...baseModels];
|
const mergedModels = [...baseModels];
|
||||||
let hasAdditionalDefault = false;
|
let hasAdditionalDefault = false;
|
||||||
|
|
||||||
for (const model of additionalModels) {
|
for (const model of modelAdditions) {
|
||||||
const additionalModel = {
|
const additionalModel = {
|
||||||
...model,
|
...model,
|
||||||
provider,
|
provider,
|
||||||
@@ -314,7 +328,7 @@ function mergeModels(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const additionalDefaultIds = new Set(
|
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) =>
|
return mergedModels.map((model) =>
|
||||||
@@ -359,6 +373,7 @@ function wrapClientProvider(
|
|||||||
inner: AgentClient,
|
inner: AgentClient,
|
||||||
profileModels: ProviderProfileModel[],
|
profileModels: ProviderProfileModel[],
|
||||||
additionalModels: ProviderProfileModel[],
|
additionalModels: ProviderProfileModel[],
|
||||||
|
profileModelsAreAdditive: boolean,
|
||||||
): AgentClient {
|
): AgentClient {
|
||||||
const listPersistedAgents = inner.listPersistedAgents?.bind(inner);
|
const listPersistedAgents = inner.listPersistedAgents?.bind(inner);
|
||||||
|
|
||||||
@@ -394,7 +409,9 @@ function wrapClientProvider(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
listModels: async (options) =>
|
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),
|
listModes: inner.listModes?.bind(inner),
|
||||||
listPersistedAgents: listPersistedAgents
|
listPersistedAgents: listPersistedAgents
|
||||||
? async (options?: ListPersistedAgentsOptions) =>
|
? async (options?: ListPersistedAgentsOptions) =>
|
||||||
@@ -426,6 +443,9 @@ function createRegistryEntry(
|
|||||||
resolved.profileModels,
|
resolved.profileModels,
|
||||||
resolved.additionalModels,
|
resolved.additionalModels,
|
||||||
await modelClient.listModels(options),
|
await modelClient.listModels(options),
|
||||||
|
{
|
||||||
|
profileModelsAreAdditive: resolved.profileModelsAreAdditive,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
fetchModes: async (options: ListModesOptions) => {
|
fetchModes: async (options: ListModesOptions) => {
|
||||||
const modes = modelClient.listModes
|
const modes = modelClient.listModes
|
||||||
@@ -455,7 +475,13 @@ function createResolvedProviderClient(
|
|||||||
if (inner.provider === provider && !hasModelOverrides) {
|
if (inner.provider === provider && !hasModelOverrides) {
|
||||||
return inner;
|
return inner;
|
||||||
}
|
}
|
||||||
return wrapClientProvider(provider, inner, resolved.profileModels, resolved.additionalModels);
|
return wrapClientProvider(
|
||||||
|
provider,
|
||||||
|
inner,
|
||||||
|
resolved.profileModels,
|
||||||
|
resolved.additionalModels,
|
||||||
|
resolved.profileModelsAreAdditive,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildResolvedBuiltinProviders(
|
function buildResolvedBuiltinProviders(
|
||||||
@@ -483,6 +509,7 @@ function buildResolvedBuiltinProviders(
|
|||||||
runtimeSettings: mergedRuntimeSettings,
|
runtimeSettings: mergedRuntimeSettings,
|
||||||
profileModels: override?.models ?? [],
|
profileModels: override?.models ?? [],
|
||||||
additionalModels: override?.additionalModels ?? [],
|
additionalModels: override?.additionalModels ?? [],
|
||||||
|
profileModelsAreAdditive: definition.id === "claude",
|
||||||
enabled: override?.enabled !== false,
|
enabled: override?.enabled !== false,
|
||||||
derivedFromProviderId: null,
|
derivedFromProviderId: null,
|
||||||
createBaseClient: (logger) =>
|
createBaseClient: (logger) =>
|
||||||
@@ -530,6 +557,7 @@ function addDerivedProviders(
|
|||||||
runtimeSettings: toRuntimeSettings(override),
|
runtimeSettings: toRuntimeSettings(override),
|
||||||
profileModels: override.models ?? [],
|
profileModels: override.models ?? [],
|
||||||
additionalModels: override.additionalModels ?? [],
|
additionalModels: override.additionalModels ?? [],
|
||||||
|
profileModelsAreAdditive: false,
|
||||||
enabled: override.enabled !== false,
|
enabled: override.enabled !== false,
|
||||||
derivedFromProviderId: null,
|
derivedFromProviderId: null,
|
||||||
createBaseClient: (logger) =>
|
createBaseClient: (logger) =>
|
||||||
@@ -572,6 +600,7 @@ function addDerivedProviders(
|
|||||||
runtimeSettings: mergedRuntimeSettings,
|
runtimeSettings: mergedRuntimeSettings,
|
||||||
profileModels: override.models ?? [],
|
profileModels: override.models ?? [],
|
||||||
additionalModels: override.additionalModels ?? [],
|
additionalModels: override.additionalModels ?? [],
|
||||||
|
profileModelsAreAdditive: false,
|
||||||
enabled: override.enabled !== false,
|
enabled: override.enabled !== false,
|
||||||
derivedFromProviderId: baseProviderId,
|
derivedFromProviderId: baseProviderId,
|
||||||
createBaseClient: (logger) =>
|
createBaseClient: (logger) =>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
mapTaskNotificationSystemRecordToToolCall,
|
mapTaskNotificationSystemRecordToToolCall,
|
||||||
mapTaskNotificationUserContentToToolCall,
|
mapTaskNotificationUserContentToToolCall,
|
||||||
} from "./task-notification-tool-call.js";
|
} 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 { parsePartialJsonObject } from "./partial-json.js";
|
||||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||||
import {
|
import {
|
||||||
@@ -1263,8 +1263,8 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
// Claude exposes a static catalog here; cwd/force are intentionally irrelevant.
|
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
|
||||||
return getClaudeModels();
|
return await getClaudeModelsWithSettings(this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listPersistedAgents(
|
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";
|
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", () => {
|
describe("getClaudeModels", () => {
|
||||||
it("returns all claude models", () => {
|
it("returns all claude models", () => {
|
||||||
const models = getClaudeModels();
|
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", () => {
|
describe("normalizeClaudeRuntimeModelId", () => {
|
||||||
it("returns exact match for known model IDs", () => {
|
it("returns exact match for known model IDs", () => {
|
||||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
|
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";
|
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
|
||||||
|
|
||||||
const CLAUDE_THINKING_OPTIONS = [
|
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[] {
|
export function getClaudeModels(): AgentModelDefinition[] {
|
||||||
return CLAUDE_MODELS.map((model) => ({ ...model }));
|
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.
|
* 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.
|
* Handles the `[1m]` suffix that the SDK appends for 1M context sessions.
|
||||||
|
|||||||
Reference in New Issue
Block a user