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);
|
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", () => {
|
it("prices OpenAI Codex GPT-5 models instead of reporting unavailable", () => {
|
||||||
// gpt-5-codex: input $1.25/1M, output $10/1M.
|
// gpt-5-codex: input $1.25/1M, output $10/1M.
|
||||||
// 1,000,000 input + 200,000 output = 1.25 + 2.00 = 3.25
|
// 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"]);
|
).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", () => {
|
it("falls back to a bare model id when provider is unset", () => {
|
||||||
expect(lookupPricing({ model: "gemini-2.5-pro" })).toBe(
|
expect(lookupPricing({ model: "gemini-2.5-pro" })).toBe(
|
||||||
MODEL_PRICING["google:gemini-2.5-pro"],
|
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,
|
EntryPointBranchAssignment,
|
||||||
} from "./branch-assignment.js";
|
} from "./branch-assignment.js";
|
||||||
export { customProviderRegistryKey } from "./custom-provider-key.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 { detectImageMimeFromBytes } from "./image-mime.js";
|
||||||
export type { DetectedImageMime } from "./image-mime.js";
|
export type { DetectedImageMime } from "./image-mime.js";
|
||||||
export { redactSecrets } from "./redact-secrets.js";
|
export { redactSecrets } from "./redact-secrets.js";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
* The date the rates in {@link MODEL_PRICING} were last verified, ISO-8601.
|
* 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".
|
* 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
|
* 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>> = {
|
export const MODEL_PRICING: Readonly<Record<string, ModelPricing>> = {
|
||||||
// ── Anthropic Claude ────────────────────────────────────────────────
|
// ── Anthropic Claude ────────────────────────────────────────────────
|
||||||
// input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL)
|
// 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": {
|
"anthropic:claude-opus-4-8": {
|
||||||
inputPer1M: 5,
|
inputPer1M: 5,
|
||||||
outputPer1M: 25,
|
outputPer1M: 25,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import express from "express";
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import * as fsPromises from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
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", () => {
|
describe("GET /models", () => {
|
||||||
let store: TaskStore;
|
let store: TaskStore;
|
||||||
|
|
||||||
@@ -383,6 +398,58 @@ describe("GET /models", () => {
|
|||||||
expect(res.body.models).toEqual([]);
|
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,
|
// Regression guard: FN-2370's auto-resolved squash inverted this filter,
|
||||||
// emptying every model picker in the UI. The filter is small but the
|
// 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
|
// 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 os from "node:os";
|
||||||
import v8 from "node:v8";
|
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 {
|
import {
|
||||||
type Task,
|
type Task,
|
||||||
type PiExtensionEntry,
|
type PiExtensionEntry,
|
||||||
@@ -206,6 +206,10 @@ export interface ModelRegistryLike {
|
|||||||
refresh(): void;
|
refresh(): void;
|
||||||
/** Get models that have auth configured. */
|
/** Get models that have auth configured. */
|
||||||
getAvailable(): Array<{ id: string; name: string; provider: string; reasoning: boolean; contextWindow: number }>;
|
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 { access, readFile } from "node:fs/promises";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
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 type { CustomProvider } from "@fusion/core";
|
||||||
import { ApiError } from "../api-error.js";
|
import { ApiError } from "../api-error.js";
|
||||||
import type { ApiRouteRegistrar } from "./types.js";
|
import type { ApiRouteRegistrar } from "./types.js";
|
||||||
@@ -129,6 +129,9 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
options.modelRegistry.refresh();
|
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) => ({
|
let models = options.modelRegistry.getAvailable().map((m) => ({
|
||||||
provider: m.provider,
|
provider: m.provider,
|
||||||
id: m.id,
|
id: m.id,
|
||||||
|
|||||||
@@ -1108,6 +1108,7 @@ describe("createFnAgent", () => {
|
|||||||
readFileSyncMock.mockReturnValue("{}");
|
readFileSyncMock.mockReturnValue("{}");
|
||||||
realpathSyncNativeMock.mockImplementation((path: PathLike) => String(path));
|
realpathSyncNativeMock.mockImplementation((path: PathLike) => String(path));
|
||||||
readCustomProvidersMock.mockReturnValue([]);
|
readCustomProvidersMock.mockReturnValue([]);
|
||||||
|
getAllMock.mockReturnValue([]);
|
||||||
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
||||||
// #1675: re-establish default auth + session-id mock returns after clearAllMocks.
|
// #1675: re-establish default auth + session-id mock returns after clearAllMocks.
|
||||||
getApiKeyAndHeadersMock.mockResolvedValue({ ok: true, apiKey: undefined, headers: undefined });
|
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 () => {
|
it("backfills the resolved model onto sessions that do not mirror it", async () => {
|
||||||
const session = {
|
const session = {
|
||||||
prompt: vi.fn(),
|
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", () => {
|
it("widens tool access ONLY when posture.autoApprove is true", () => {
|
||||||
const off = piAdapter.buildLaunch({ settings: {}, posture: { autoApprove: false } });
|
const off = piAdapter.buildLaunch({ settings: {}, posture: { autoApprove: false } });
|
||||||
expect(off.args).not.toContain("--tools");
|
expect(off.args).not.toContain("--tools");
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
reconcileClaudeCliPaths,
|
reconcileClaudeCliPaths,
|
||||||
reconcileDroidCliPaths,
|
reconcileDroidCliPaths,
|
||||||
mergeBuiltInZaiProviderModels,
|
mergeBuiltInZaiProviderModels,
|
||||||
|
mergeSupplementalAnthropicModels,
|
||||||
registerBuiltInZaiProvider,
|
registerBuiltInZaiProvider,
|
||||||
resolvePiExtensionProjectRoot,
|
resolvePiExtensionProjectRoot,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
@@ -2039,6 +2040,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
modelRegistry.refresh();
|
modelRegistry.refresh();
|
||||||
|
mergeSupplementalAnthropicModels(modelRegistry, (message) => extensionsLog.warn(message));
|
||||||
|
|
||||||
// Build the pi built-in tool set. We deliberately do NOT use the bundled
|
// Build the pi built-in tool set. We deliberately do NOT use the bundled
|
||||||
// `createCodingTools` / `createReadOnlyTools` presets — they're missing
|
// `createCodingTools` / `createReadOnlyTools` presets — they're missing
|
||||||
|
|||||||
@@ -190,6 +190,19 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// catalog catches up.
|
// catalog catches up.
|
||||||
// https://platform.claude.com/docs/en/about-claude/models/overview
|
// https://platform.claude.com/docs/en/about-claude/models/overview
|
||||||
const extraModels: typeof catalogModels = [
|
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",
|
id: "claude-opus-4-7",
|
||||||
name: "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", () => {
|
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",
|
resumeSessionId: "sess-1",
|
||||||
effort: "high",
|
effort: "high",
|
||||||
mcpConfigPath: "/tmp/mcp.json",
|
mcpConfigPath: "/tmp/mcp.json",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(args).toContain("--model");
|
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("--resume");
|
||||||
expect(args).toContain("sess-1");
|
expect(args).toContain("sess-1");
|
||||||
expect(args).toContain("--effort");
|
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));
|
const modelIds = new Set(config.models.map((m: { id: string }) => m.id));
|
||||||
|
|
||||||
for (const id of [
|
for (const id of [
|
||||||
|
"claude-sonnet-5",
|
||||||
"claude-opus-4-7",
|
"claude-opus-4-7",
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
"claude-sonnet-4-5",
|
"claude-sonnet-4-5",
|
||||||
@@ -134,6 +135,15 @@ describe("provider registration (default export)", () => {
|
|||||||
]) {
|
]) {
|
||||||
expect(modelIds.has(id)).toBe(true);
|
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 () => {
|
it("deduplicates extra models when catalog already includes them", async () => {
|
||||||
@@ -143,6 +153,17 @@ describe("provider registration (default export)", () => {
|
|||||||
|
|
||||||
getModelsMock.mockReturnValueOnce([
|
getModelsMock.mockReturnValueOnce([
|
||||||
...mockModels,
|
...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",
|
id: "claude-sonnet-4-6",
|
||||||
name: "Claude Sonnet 4.6",
|
name: "Claude Sonnet 4.6",
|
||||||
@@ -160,10 +181,12 @@ describe("provider registration (default export)", () => {
|
|||||||
mod.default(mockPi);
|
mod.default(mockPi);
|
||||||
|
|
||||||
const config = registerProvider.mock.calls[0][1];
|
const config = registerProvider.mock.calls[0][1];
|
||||||
const matches = config.models.filter(
|
for (const id of ["claude-sonnet-5", "claude-sonnet-4-6"]) {
|
||||||
(m: { id: string }) => m.id === "claude-sonnet-4-6",
|
const matches = config.models.filter(
|
||||||
);
|
(m: { id: string }) => m.id === id,
|
||||||
expect(matches).toHaveLength(1);
|
);
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user