FN-7291: add Claude Sonnet 5 Anthropic support
Adds Claude Sonnet 5 to Anthropic model catalogs, pricing, and pi execution paths. - Register supplemental direct Anthropic model metadata for Claude Sonnet 5 with deduping. - Surface the model through dashboard model routes and engine session creation for non-Claude-CLI pi sessions. - Add Claude CLI provider metadata, pricing coverage, tests, and a published package changeset. Files changed: .changeset/fn-7291-sonnet-5-anthropic.md | 7 ++ packages/core/src/__tests__/model-pricing.test.ts | 28 +++++ packages/core/src/anthropic-models.ts | 127 +++++++++++++++++++++ packages/core/src/index.ts | 7 ++ packages/core/src/model-pricing.ts | 13 ++- .../dashboard/src/__tests__/routes-auth.test.ts | 67 +++++++++++ packages/dashboard/src/routes.ts | 6 +- .../dashboard/src/routes/register-model-routes.ts | 5 +- .../src/__tests__/pi-create-fn-agent.test.ts | 67 +++++++++++ .../src/cli-agent/adapters/__tests__/pi.test.ts | 16 +++ packages/engine/src/pi.ts | 2 + packages/pi-claude-cli/index.ts | 13 +++ .../src/__tests__/process-manager.test.ts | 4 +- .../pi-claude-cli/src/__tests__/provider.test.ts | 31 ++++- 14 files changed, 384 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-7291 Fusion-Task-Lineage: 6494fce8-4101-47f5-9137-306408fd7920 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7291-sonnet-5-anthropic.md
Normal file
7
.changeset/fn-7291-sonnet-5-anthropic.md
Normal file
@@ -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`.
|
||||
@@ -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"],
|
||||
|
||||
127
packages/core/src/anthropic-models.ts
Normal file
127
packages/core/src/anthropic-models.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<Omit<AnthropicModelRegistration, "name" | "compat">> & {
|
||||
id: string;
|
||||
name?: unknown;
|
||||
provider?: string;
|
||||
compat?: unknown;
|
||||
};
|
||||
|
||||
interface AnthropicModelRegistryLike {
|
||||
registerProvider(providerName: string, config: AnthropicProviderRegistration): void;
|
||||
getAll?: () => AnthropicModelLike[];
|
||||
}
|
||||
|
||||
type RegistryWithProviderState = AnthropicModelRegistryLike & {
|
||||
registeredProviders?: Map<string, Partial<AnthropicProviderRegistration>>;
|
||||
};
|
||||
|
||||
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<string, unknown>) }
|
||||
: 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Record<string, ModelPricing>> = {
|
||||
// ── 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,
|
||||
|
||||
@@ -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<ModelRegistryLike> = {}): Mo
|
||||
};
|
||||
}
|
||||
|
||||
function createMutableModelRegistry(initialModels: Array<Record<string, any>>): ModelRegistryLike & { models: Array<Record<string, any>> } {
|
||||
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<Record<string, any>> }) => {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<typeof mergeSupplementalAnthropicModels>[0], (message) => runtimeLogger.child("models").warn(message));
|
||||
}
|
||||
let models = options.modelRegistry.getAvailable().map((m) => ({
|
||||
provider: m.provider,
|
||||
id: m.id,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<AgentResult>
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user