diff --git a/.changeset/fn-7291-sonnet-5-anthropic.md b/.changeset/fn-7291-sonnet-5-anthropic.md new file mode 100644 index 0000000000..6ec17d363b --- /dev/null +++ b/.changeset/fn-7291-sonnet-5-anthropic.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add Claude Sonnet 5 across Anthropic model selection and execution paths. +category: feature +dev: Adds supplemental direct Anthropic and pi-claude-cli model metadata plus pricing for `claude-sonnet-5`. diff --git a/packages/core/src/__tests__/model-pricing.test.ts b/packages/core/src/__tests__/model-pricing.test.ts index 52c96ca9f4..02895052ac 100644 --- a/packages/core/src/__tests__/model-pricing.test.ts +++ b/packages/core/src/__tests__/model-pricing.test.ts @@ -36,6 +36,25 @@ describe("model-pricing", () => { expect(result.usd).toBeCloseTo(10.0, 2); }); + it("prices Claude Sonnet 5 for Anthropic and bare-model fallback", () => { + // claude-sonnet-5 introductory pricing: input $2/1M, output $10/1M, + // cache read $0.20/1M, 5m cache write $2.50/1M. + const usage = { + inputTokens: 1_000_000, + outputTokens: 200_000, + cachedTokens: 500_000, + cacheWriteTokens: 400_000, + }; + + const anthropic = costFor(usage, { provider: "anthropic", model: "claude-sonnet-5" }); + expect(anthropic.unavailable).toBe(false); + expect(anthropic.usd).toBeCloseTo(5.1, 3); + + const bare = costFor(usage, { model: "claude-sonnet-5" }); + expect(bare.unavailable).toBe(false); + expect(bare.usd).toBeCloseTo(5.1, 3); + }); + it("prices OpenAI Codex GPT-5 models instead of reporting unavailable", () => { // gpt-5-codex: input $1.25/1M, output $10/1M. // 1,000,000 input + 200,000 output = 1.25 + 2.00 = 3.25 @@ -187,6 +206,15 @@ describe("model-pricing", () => { ).toBe(MODEL_PRICING["openai-codex:gpt-5-codex"]); }); + it("resolves Claude Sonnet 5 by explicit provider and bare model", () => { + expect( + lookupPricing({ provider: " Anthropic ", model: " Claude-Sonnet-5 " }), + ).toBe(MODEL_PRICING["anthropic:claude-sonnet-5"]); + expect(lookupPricing({ model: "claude-sonnet-5" })).toBe( + MODEL_PRICING["anthropic:claude-sonnet-5"], + ); + }); + it("falls back to a bare model id when provider is unset", () => { expect(lookupPricing({ model: "gemini-2.5-pro" })).toBe( MODEL_PRICING["google:gemini-2.5-pro"], diff --git a/packages/core/src/anthropic-models.ts b/packages/core/src/anthropic-models.ts new file mode 100644 index 0000000000..c46586736d --- /dev/null +++ b/packages/core/src/anthropic-models.ts @@ -0,0 +1,127 @@ +type AnthropicModelInput = "text" | "image"; + +export const ANTHROPIC_PROVIDER_ID = "anthropic"; +export const CLAUDE_SONNET_5_MODEL_ID = "claude-sonnet-5"; + +interface AnthropicModelRegistration { + id: string; + name: string; + reasoning: boolean; + input: AnthropicModelInput[]; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; + contextWindow: number; + maxTokens: number; + compat?: Record; +} + +export interface AnthropicProviderRegistration { + name: string; + baseUrl: string; + apiKey: string; + api: "anthropic-messages"; + models: AnthropicModelRegistration[]; +} + +/* + * FNXC:ModelCatalog 2026-06-30-12:22: + * Claude Sonnet 5 support must not depend on the installed pi-ai catalog version or on the Claude CLI provider. Keep this supplemental Anthropic registration shared by engine sessions and dashboard model routes, and dedupe by model id so upstream catalog catch-up does not create duplicate picker rows. + */ +export const SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION: AnthropicProviderRegistration = { + name: "Anthropic", + baseUrl: "https://api.anthropic.com/v1", + apiKey: "$ANTHROPIC_API_KEY", + api: "anthropic-messages", + models: [ + { + id: CLAUDE_SONNET_5_MODEL_ID, + name: "Claude Sonnet 5", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1_000_000, + maxTokens: 128_000, + compat: { + supportsDeveloperRole: false, + }, + }, + ], +}; + +type AnthropicModelLike = Partial> & { + id: string; + name?: unknown; + provider?: string; + compat?: unknown; +}; + +interface AnthropicModelRegistryLike { + registerProvider(providerName: string, config: AnthropicProviderRegistration): void; + getAll?: () => AnthropicModelLike[]; +} + +type RegistryWithProviderState = AnthropicModelRegistryLike & { + registeredProviders?: Map>; +}; + +function toAnthropicModelRegistration(model: AnthropicModelLike): AnthropicModelRegistration { + const supplemental = SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION.models.find((entry) => entry.id === model.id); + return { + id: model.id, + name: String(model.name ?? supplemental?.name ?? model.id), + reasoning: model.reasoning ?? supplemental?.reasoning ?? false, + input: Array.isArray(model.input) ? model.input as AnthropicModelInput[] : supplemental?.input ?? ["text"], + cost: model.cost ?? supplemental?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: Number(model.contextWindow ?? supplemental?.contextWindow ?? 0), + maxTokens: Number(model.maxTokens ?? supplemental?.maxTokens ?? 0), + compat: typeof model.compat === "object" && model.compat !== null + ? { ...(model.compat as Record) } + : supplemental?.compat ? { ...supplemental.compat } : undefined, + }; +} + +function cloneAnthropicProviderRegistration(config: AnthropicProviderRegistration): AnthropicProviderRegistration { + return { + ...config, + models: config.models.map((model) => toAnthropicModelRegistration(model)), + }; +} + +export function mergeSupplementalAnthropicModels( + modelRegistry: AnthropicModelRegistryLike, + logWarning: (message: string) => void = () => {}, +): void { + try { + const registryWithState = modelRegistry as RegistryWithProviderState; + const registeredProvider = registryWithState.registeredProviders?.get(ANTHROPIC_PROVIDER_ID); + const registeredModels = registeredProvider?.models?.map((model) => toAnthropicModelRegistration(model)) ?? []; + const currentModels = registeredModels.length > 0 + ? registeredModels + : modelRegistry.getAll?.() + .filter((model) => model.provider === ANTHROPIC_PROVIDER_ID) + .map((model) => toAnthropicModelRegistration(model)) ?? []; + const currentModelIds = new Set(currentModels.map((model) => model.id)); + const missingModels = SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION.models + .filter((model) => !currentModelIds.has(model.id)); + + if (missingModels.length === 0) return; + + modelRegistry.registerProvider(ANTHROPIC_PROVIDER_ID, { + ...cloneAnthropicProviderRegistration(SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION), + ...registeredProvider, + models: [...currentModels, ...missingModels.map((model) => toAnthropicModelRegistration(model))], + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logWarning(`Failed to merge supplemental ${ANTHROPIC_PROVIDER_ID} models: ${message}`); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d69cbeb21f..33750927c5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,6 +16,13 @@ export type { EntryPointBranchAssignment, } from "./branch-assignment.js"; export { customProviderRegistryKey } from "./custom-provider-key.js"; +export { + ANTHROPIC_PROVIDER_ID, + CLAUDE_SONNET_5_MODEL_ID, + SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION, + mergeSupplementalAnthropicModels, +} from "./anthropic-models.js"; +export type { AnthropicProviderRegistration } from "./anthropic-models.js"; export { detectImageMimeFromBytes } from "./image-mime.js"; export type { DetectedImageMime } from "./image-mime.js"; export { redactSecrets } from "./redact-secrets.js"; diff --git a/packages/core/src/model-pricing.ts b/packages/core/src/model-pricing.ts index 73421fadff..785363088c 100644 --- a/packages/core/src/model-pricing.ts +++ b/packages/core/src/model-pricing.ts @@ -28,7 +28,7 @@ * The date the rates in {@link MODEL_PRICING} were last verified, ISO-8601. * Bump this whenever you edit a rate. Surfaced in the UI as "prices as of". */ -export const pricingAsOf = "2026-06-21"; +export const pricingAsOf = "2026-06-30"; /** * Pricing entries older than this (relative to a caller-supplied `now`) are @@ -104,6 +104,17 @@ export interface CostResult { export const MODEL_PRICING: Readonly> = { // ── Anthropic Claude ──────────────────────────────────────────────── // input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL) + /* + * FNXC:ModelCatalog 2026-06-30-12:10: + * Anthropic's docs publish Claude Sonnet 5 as the dateless pinned API ID `claude-sonnet-5`. Keep it in Fusion's hand-maintained support data before upstream pi-ai catalogs necessarily refresh so direct Anthropic runtime and cost surfaces can resolve it consistently. + */ + "anthropic:claude-sonnet-5": { + inputPer1M: 2, + outputPer1M: 10, + cacheReadPer1M: 0.2, + cacheWritePer1M: 2.5, + source: "platform.claude.com/docs/en/pricing#claude-sonnet-5-introductory-pricing", + }, "anthropic:claude-opus-4-8": { inputPer1M: 5, outputPer1M: 25, diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index de5256b380..c82b24fc5f 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -5,6 +5,7 @@ import express from "express"; import http from "node:http"; import { EventEmitter } from "node:events"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import * as fsPromises from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; @@ -328,6 +329,20 @@ function createMockModelRegistry(overrides: Partial = {}): Mo }; } +function createMutableModelRegistry(initialModels: Array>): ModelRegistryLike & { models: Array> } { + const registry = { + models: [...initialModels], + refresh: vi.fn(), + getAll: vi.fn(() => registry.models as any), + getAvailable: vi.fn(() => registry.models as any), + registerProvider: vi.fn((providerName: string, config: { models?: Array> }) => { + registry.models = registry.models.filter((model) => model.provider !== providerName); + registry.models.push(...(config.models ?? []).map((model) => ({ ...model, provider: providerName }))); + }), + }; + return registry; +} + describe("GET /models", () => { let store: TaskStore; @@ -383,6 +398,58 @@ describe("GET /models", () => { expect(res.body.models).toEqual([]); }); + it("adds Claude Sonnet 5 for configured direct Anthropic users without relying on Claude CLI", async () => { + const modelRegistry = createMutableModelRegistry([ + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 }, + { id: "gpt-4o", name: "GPT-4o", provider: "openai", reasoning: false, contextWindow: 128000 }, + ]); + + const res = await GET(buildApp(modelRegistry), "/api/models"); + + expect(res.status).toBe(200); + expect(res.body.models).toEqual(expect.arrayContaining([ + expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-5", name: "Claude Sonnet 5", reasoning: true, contextWindow: 1_000_000 }), + ])); + expect(modelRegistry.registerProvider).toHaveBeenCalledWith("anthropic", expect.objectContaining({ + models: expect.arrayContaining([expect.objectContaining({ id: "claude-sonnet-5" })]), + })); + }); + + it("does not expose Claude Sonnet 5 when direct Anthropic is not configured", async () => { + const readFileSpy = vi.spyOn(fsPromises, "readFile").mockImplementation(async (path: any) => { + const value = String(path); + if (value.endsWith("auth.json")) return "{}" as never; + if (value.endsWith("models.json")) return '{"providers":{}}' as never; + return "{}" as never; + }); + try { + const modelRegistry = createMutableModelRegistry([ + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 }, + ]); + + const res = await GET(buildApp(modelRegistry), "/api/models"); + + expect(res.status).toBe(200); + expect(res.body.models).toEqual([]); + expect(modelRegistry.models.some((model) => model.id === "claude-sonnet-5")).toBe(true); + } finally { + readFileSpy.mockRestore(); + } + }); + + it("does not duplicate Claude Sonnet 5 when upstream registry already includes it", async () => { + const modelRegistry = createMutableModelRegistry([ + { id: "claude-sonnet-5", name: "Claude Sonnet 5 Upstream", provider: "anthropic", reasoning: true, contextWindow: 1_000_000, maxTokens: 128_000 }, + ]); + + const res = await GET(buildApp(modelRegistry), "/api/models"); + + expect(res.status).toBe(200); + const sonnetFiveRows = res.body.models.filter((model: { provider: string; id: string }) => model.provider === "anthropic" && model.id === "claude-sonnet-5"); + expect(sonnetFiveRows).toHaveLength(1); + expect(modelRegistry.registerProvider).not.toHaveBeenCalled(); + }); + // Regression guard: FN-2370's auto-resolved squash inverted this filter, // emptying every model picker in the UI. The filter is small but the // failure mode is silent and project-wide — keep these tests close to the diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 0142f89c42..f01da4f940 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -13,7 +13,7 @@ import * as nodeFs from "node:fs"; import os from "node:os"; import v8 from "node:v8"; -import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType, McpServerDefinition } from "@fusion/core"; +import type { AnthropicProviderRegistration, TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType, McpServerDefinition } from "@fusion/core"; import { type Task, type PiExtensionEntry, @@ -206,6 +206,10 @@ export interface ModelRegistryLike { refresh(): void; /** Get models that have auth configured. */ getAvailable(): Array<{ id: string; name: string; provider: string; reasoning: boolean; contextWindow: number }>; + /** Optional pi ModelRegistry surface used for supplemental model registration. */ + getAll?: () => Array<{ id: string; name?: string; provider: string; reasoning?: boolean; input?: string[]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow?: number; maxTokens?: number; compat?: unknown }>; + /** Optional pi ModelRegistry surface used for supplemental model registration. */ + registerProvider?: (providerName: string, config: AnthropicProviderRegistration) => void; } /** diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index e332c1c4e9..8fd6c19275 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -1,7 +1,7 @@ import { access, readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { customProviderRegistryKey, resolvePlanningSettingsModel } from "@fusion/core"; +import { customProviderRegistryKey, mergeSupplementalAnthropicModels, resolvePlanningSettingsModel } from "@fusion/core"; import type { CustomProvider } from "@fusion/core"; import { ApiError } from "../api-error.js"; import type { ApiRouteRegistrar } from "./types.js"; @@ -129,6 +129,9 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { try { options.modelRegistry.refresh(); + if (options.modelRegistry.registerProvider) { + mergeSupplementalAnthropicModels(options.modelRegistry as Parameters[0], (message) => runtimeLogger.child("models").warn(message)); + } let models = options.modelRegistry.getAvailable().map((m) => ({ provider: m.provider, id: m.id, diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index bbd170caeb..b19db2d048 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1108,6 +1108,7 @@ describe("createFnAgent", () => { readFileSyncMock.mockReturnValue("{}"); realpathSyncNativeMock.mockImplementation((path: PathLike) => String(path)); readCustomProvidersMock.mockReturnValue([]); + getAllMock.mockReturnValue([]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); // #1675: re-establish default auth + session-id mock returns after clearAllMocks. getApiKeyAndHeadersMock.mockResolvedValue({ ok: true, apiKey: undefined, headers: undefined }); @@ -1624,6 +1625,72 @@ describe("createFnAgent", () => { }); }); + it("resolves direct Anthropic Claude Sonnet 5 when the mocked registry initially lacks it", async () => { + getAllMock.mockReturnValueOnce([]); + findMock.mockImplementation((provider: string, modelId: string) => { + if (provider === "anthropic" && modelId === "claude-sonnet-5") { + const anthropicRegistration = registerProviderMock.mock.calls.find(([name]) => name === "anthropic")?.[1] as { models?: Array<{ id: string; name: string }> } | undefined; + const registeredModel = anthropicRegistration?.models?.find((model) => model.id === modelId); + return registeredModel ? { ...registeredModel, provider } : undefined; + } + return { provider, id: modelId }; + }); + + const { createFnAgent } = await import("../pi.js"); + const result = await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-5", + }); + + expect(registerProviderMock).toHaveBeenCalledWith("anthropic", expect.objectContaining({ + api: "anthropic-messages", + models: expect.arrayContaining([expect.objectContaining({ + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + contextWindow: 1_000_000, + maxTokens: 128_000, + })]), + })); + expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + model: expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-5" }), + })); + expect((result.session as { model?: unknown }).model).toEqual(expect.objectContaining({ + provider: "anthropic", + id: "claude-sonnet-5", + })); + }); + + it("does not duplicate Claude Sonnet 5 when the Anthropic registry already has it", async () => { + getAllMock.mockReturnValue([ + { + provider: "anthropic", + id: "claude-sonnet-5", + name: "Claude Sonnet 5 Upstream", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, + ]); + findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); + + const { createFnAgent } = await import("../pi.js"); + await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-5", + }); + + const anthropicRegistrations = registerProviderMock.mock.calls.filter(([name]) => name === "anthropic"); + expect(anthropicRegistrations).toHaveLength(0); + }); + it("backfills the resolved model onto sessions that do not mirror it", async () => { const session = { prompt: vi.fn(), diff --git a/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts b/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts index 16e43e4502..6fa7b7551c 100644 --- a/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts +++ b/packages/engine/src/cli-agent/adapters/__tests__/pi.test.ts @@ -58,6 +58,22 @@ describe("piAdapter — buildLaunch", () => { ]); }); + it("forwards direct Anthropic Claude Sonnet 5 without Claude CLI routing", () => { + const spec = piAdapter.buildLaunch({ + settings: { provider: "anthropic", model: "claude-sonnet-5", sessionDir: "/tmp/sess/pi" }, + posture: null, + }); + expect(spec.command).toBe("pi"); + expect(spec.args).toEqual([ + "--provider", + "anthropic", + "--model", + "claude-sonnet-5", + "--session-dir", + "/tmp/sess/pi", + ]); + }); + it("widens tool access ONLY when posture.autoApprove is true", () => { const off = piAdapter.buildLaunch({ settings: {}, posture: { autoApprove: false } }); expect(off.args).not.toContain("--tools"); diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 8661462594..e951d47337 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -43,6 +43,7 @@ import { reconcileClaudeCliPaths, reconcileDroidCliPaths, mergeBuiltInZaiProviderModels, + mergeSupplementalAnthropicModels, registerBuiltInZaiProvider, resolvePiExtensionProjectRoot, } from "@fusion/core"; @@ -2039,6 +2040,7 @@ export async function createFnAgent(options: AgentOptions): Promise } } modelRegistry.refresh(); + mergeSupplementalAnthropicModels(modelRegistry, (message) => extensionsLog.warn(message)); // Build the pi built-in tool set. We deliberately do NOT use the bundled // `createCodingTools` / `createReadOnlyTools` presets — they're missing diff --git a/packages/pi-claude-cli/index.ts b/packages/pi-claude-cli/index.ts index e446af3572..99e719f585 100644 --- a/packages/pi-claude-cli/index.ts +++ b/packages/pi-claude-cli/index.ts @@ -190,6 +190,19 @@ export default function (pi: ExtensionAPI) { // catalog catches up. // https://platform.claude.com/docs/en/about-claude/models/overview const extraModels: typeof catalogModels = [ + /* + * FNXC:ModelCatalog 2026-06-30-12:31: + * The vendored Claude CLI provider has its own model list because it exposes `pi-claude-cli` independently from direct `anthropic`. Add Claude Sonnet 5 here as supplemental metadata so Claude CLI users can select it before the upstream pi-ai catalog catches up, while the dedupe below prevents duplicate rows after it does. + */ + { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, { id: "claude-opus-4-7", name: "Claude Opus 4.7", diff --git a/packages/pi-claude-cli/src/__tests__/process-manager.test.ts b/packages/pi-claude-cli/src/__tests__/process-manager.test.ts index 6ddd8bf916..101e1af846 100644 --- a/packages/pi-claude-cli/src/__tests__/process-manager.test.ts +++ b/packages/pi-claude-cli/src/__tests__/process-manager.test.ts @@ -61,14 +61,14 @@ describe("buildClaudeSpawnArgs", () => { }); it("builds args including model and optional session/mcp flags", () => { - const args = buildClaudeSpawnArgs("claude-sonnet-4-6", undefined, { + const args = buildClaudeSpawnArgs("claude-sonnet-5", undefined, { resumeSessionId: "sess-1", effort: "high", mcpConfigPath: "/tmp/mcp.json", }); expect(args).toContain("--model"); - expect(args).toContain("claude-sonnet-4-6"); + expect(args[args.indexOf("--model") + 1]).toBe("claude-sonnet-5"); expect(args).toContain("--resume"); expect(args).toContain("sess-1"); expect(args).toContain("--effort"); diff --git a/packages/pi-claude-cli/src/__tests__/provider.test.ts b/packages/pi-claude-cli/src/__tests__/provider.test.ts index 535629a25e..8916ccffbb 100644 --- a/packages/pi-claude-cli/src/__tests__/provider.test.ts +++ b/packages/pi-claude-cli/src/__tests__/provider.test.ts @@ -127,6 +127,7 @@ describe("provider registration (default export)", () => { const modelIds = new Set(config.models.map((m: { id: string }) => m.id)); for (const id of [ + "claude-sonnet-5", "claude-opus-4-7", "claude-sonnet-4-6", "claude-sonnet-4-5", @@ -134,6 +135,15 @@ describe("provider registration (default export)", () => { ]) { expect(modelIds.has(id)).toBe(true); } + + expect(config.models.find((m: { id: string }) => m.id === "claude-sonnet-5")).toMatchObject({ + name: "Claude Sonnet 5", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }); }); it("deduplicates extra models when catalog already includes them", async () => { @@ -143,6 +153,17 @@ describe("provider registration (default export)", () => { getModelsMock.mockReturnValueOnce([ ...mockModels, + { + id: "claude-sonnet-5", + name: "Claude Sonnet 5 Upstream", + api: "anthropic", + provider: "anthropic", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + } as any, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", @@ -160,10 +181,12 @@ describe("provider registration (default export)", () => { mod.default(mockPi); const config = registerProvider.mock.calls[0][1]; - const matches = config.models.filter( - (m: { id: string }) => m.id === "claude-sonnet-4-6", - ); - expect(matches).toHaveLength(1); + for (const id of ["claude-sonnet-5", "claude-sonnet-4-6"]) { + const matches = config.models.filter( + (m: { id: string }) => m.id === id, + ); + expect(matches).toHaveLength(1); + } }); });