diff --git a/.changeset/fix-anthropic-compatible-custom-provider.md b/.changeset/fix-anthropic-compatible-custom-provider.md new file mode 100644 index 0000000000..227f5ac008 --- /dev/null +++ b/.changeset/fix-anthropic-compatible-custom-provider.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +Fix anthropic-compatible custom providers failing with "No API provider registered for api: anthropic". + +`resolveCustomProviderApiType` mapped the `anthropic-compatible` provider type to the api key `"anthropic"`, but pi-ai registers the Anthropic Messages API under `"anthropic-messages"`. Any custom provider configured as `anthropic-compatible` (self-hosted Claude proxy, gateway, etc.) therefore selected a model whose `api` did not match a registered provider and threw at stream time. Mapped it to `"anthropic-messages"` and added a regression assertion alongside the existing openai-compatible / openai-responses coverage. 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 321358c385..2d2b169ba6 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1363,6 +1363,14 @@ describe("createFnAgent", () => { apiKey: "RESPONSES_API_KEY", models: [{ id: "responses-model", name: "Responses Model" }], }, + { + id: "770e8400-e29b-41d4-a716-446655440002", + name: "Custom Anthropic", + apiType: "anthropic-compatible", + baseUrl: "https://anthropic.example", + apiKey: "ANTHROPIC_API_KEY", + models: [{ id: "anthropic-model", name: "Anthropic Model" }], + }, ] as any); const { createFnAgent } = await import("../pi.js"); @@ -1387,6 +1395,40 @@ describe("createFnAgent", () => { apiKey: "RESPONSES_API_KEY", models: [expect.objectContaining({ id: "responses-model", name: "Responses Model" })], })); + /* + FNXC:CustomProviders 2026-06-21-13:45: + Invariant (FN-5893 surface = providers/execution paths): every custom-provider apiType must map to an api key pi-ai's registry actually registers. anthropic-compatible maps to "anthropic-messages", NOT bare "anthropic" — the latter registered fine but threw "No API provider registered for api: anthropic" the moment a task streamed. Assert the corrected value AND that the broken bare key is never used, so a future regression to "anthropic" fails here. + */ + expect(registerProviderMock).toHaveBeenCalledWith("custom-anthropic", expect.objectContaining({ + baseUrl: "https://anthropic.example", + api: "anthropic-messages", + apiKey: "ANTHROPIC_API_KEY", + models: [expect.objectContaining({ id: "anthropic-model", name: "Anthropic Model" })], + })); + // Negative guard: the unregistered bare "anthropic" api key must never be emitted for any provider. + expect(registerProviderMock).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ api: "anthropic" }), + ); + // Invariant: every api key handed to registerProvider must be one pi-ai registers + // (mirrors @earendil-works/pi-ai register-builtins). Catches a typo in any arm. + const PI_AI_REGISTERED_APIS = new Set([ + "anthropic-messages", + "openai-completions", + "openai-responses", + "azure-openai-responses", + "openai-codex-responses", + "google-generative-ai", + "google-vertex", + "mistral-conversations", + "bedrock-converse-stream", + ]); + for (const [, config] of registerProviderMock.mock.calls) { + const api = (config as { api?: string } | undefined)?.api; + if (typeof api === "string") { + expect(PI_AI_REGISTERED_APIS.has(api)).toBe(true); + } + } }); it("avoids lock-based SettingsManager.create when loading extension providers", async () => { diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 84ee5cc048..84c2bdac7e 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1006,9 +1006,23 @@ export interface AgentOptions { permanentAgentGating?: PermanentAgentGatingContext; } -function resolveCustomProviderApiType(apiType: string): "anthropic" | "openai-responses" | "openai-completions" { +/** + * Map a user-facing custom-provider `apiType` to the pi-ai api-registry key. + * + * FNXC:CustomProviders 2026-06-21-13:45: + * Every arm must return a key that pi-ai's api-registry actually registers + * (see @earendil-works/pi-ai register-builtins). `anthropic-compatible` resolves + * to "anthropic-messages" — the key the Anthropic Messages API is registered + * under. The bare "anthropic" key is never registered, so returning it let a + * provider register but threw "No API provider registered for api: anthropic" + * the moment a task tried to stream. + * + * @param apiType - the custom provider's declared compatibility type. + * @returns the registered pi-ai api key to stream against. + */ +function resolveCustomProviderApiType(apiType: string): "anthropic-messages" | "openai-responses" | "openai-completions" { if (apiType === "anthropic-compatible") { - return "anthropic"; + return "anthropic-messages"; } if (apiType === "openai-responses") { return "openai-responses";