diff --git a/.changeset/fn-5834-developer-role-compat.md b/.changeset/fn-5834-developer-role-compat.md new file mode 100644 index 000000000..2b0e1f1b7 --- /dev/null +++ b/.changeset/fn-5834-developer-role-compat.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Custom OpenAI-compatible providers now register with explicit conservative role compatibility: Fusion defaults `compat.supportsDeveloperRole` to `false` so reasoning-capable models emit the legacy `system` role instead of relying on provider URL auto-detection. Advanced users can opt in per provider with `supportsDeveloperRole: true` when their endpoint explicitly supports the `developer` role. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index a35bbd450..b62b24b99 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -57,7 +57,7 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `webhookFormat` | `"slack" \| "discord" \| "generic"` | `"generic"` | Webhook payload format. Part of legacy flat settings. | | `webhookEvents` | `string[]` | `[]` | Event filter for webhook notifications. Empty/omitted means all events. Part of legacy flat settings. | | `notificationProviders` | `NotificationProviderConfig[]` | `[]` | Array of pluggable notification provider configurations. Each entry uses `{ id, name, enabled, config }` and is dispatched by provider ID (for example `ntfy` or `webhook`). | -| `customProviders` | `CustomProvider[]` | `[]` | User-defined OpenAI-compatible, OpenAI Responses API (`apiType: "openai-responses"`), or Anthropic-compatible providers used by the custom-provider API (`/api/custom-providers`). Each entry uses `{ id, name, apiType, baseUrl, apiKey?, models? }`; API keys are stored raw but masked in API responses. Fusion resolves these providers from the active global settings directory (`~/.fusion`, with legacy `~/.pi/fusion` and `~/.pi/kb` migration support) so custom-provider models remain available after restart. | +| `customProviders` | `CustomProvider[]` | `[]` | User-defined OpenAI-compatible, OpenAI Responses API (`apiType: "openai-responses"`), or Anthropic-compatible providers used by the custom-provider API (`/api/custom-providers`). Each entry uses `{ id, name, apiType, baseUrl, apiKey?, supportsDeveloperRole?, models? }`; `supportsDeveloperRole` is an OpenAI-compatible opt-in that enables `developer` role emission (default/omitted is `false`, forcing safe `system` role). API keys are stored raw but masked in API responses. Fusion resolves these providers from the active global settings directory (`~/.fusion`, with legacy `~/.pi/fusion` and `~/.pi/kb` migration support) so custom-provider models remain available after restart. | | `defaultProjectId` | `string` | `undefined` | Default project for multi-project CLI operations when `--project` is omitted. | | `setupComplete` | `boolean` | `undefined` | Tracks completion of first-run setup. | | `favoriteProviders` | `string[]` | `undefined` | Pinned providers shown first in model selectors. | diff --git a/packages/cli/src/commands/__tests__/custom-provider-registry.test.ts b/packages/cli/src/commands/__tests__/custom-provider-registry.test.ts index 5eeae7fcb..833694854 100644 --- a/packages/cli/src/commands/__tests__/custom-provider-registry.test.ts +++ b/packages/cli/src/commands/__tests__/custom-provider-registry.test.ts @@ -44,7 +44,7 @@ describe("custom-provider-registry", () => { baseUrl: "https://example.test/v1", api: "openai-completions", apiKey: "CUSTOM_KEY", - models: [expect.objectContaining({ id: "m1", name: "Model 1" })], + models: [expect.objectContaining({ id: "m1", name: "Model 1", compat: { supportsDeveloperRole: false } })], })); expect(registerProvider).toHaveBeenNthCalledWith(2, "anthropic-custom", expect.objectContaining({ baseUrl: "https://anthropic.test", @@ -165,6 +165,30 @@ describe("custom-provider-registry", () => { expect(refresh).toHaveBeenCalledTimes(1); }); + it("sets supportsDeveloperRole true only when opted in", () => { + const registerProvider = vi.fn(); + const refresh = vi.fn(); + + registerCustomProviders( + { registerProvider, refresh }, + [ + { id: "optout", name: "Optout", apiType: "openai-compatible", baseUrl: "https://one.test", models: [{ id: "m", name: "M" }] }, + { id: "optin", name: "Optin", apiType: "openai-compatible", baseUrl: "https://two.test", supportsDeveloperRole: true, models: [{ id: "m", name: "M" }] }, + { id: "other", name: "Other", apiType: "anthropic-compatible", baseUrl: "https://three.test", models: [{ id: "m", name: "M" }] }, + ], + vi.fn(), + ); + + expect(registerProvider).toHaveBeenNthCalledWith(1, "optout", expect.objectContaining({ + models: [expect.objectContaining({ compat: { supportsDeveloperRole: false } })], + })); + expect(registerProvider).toHaveBeenNthCalledWith(2, "optin", expect.objectContaining({ + models: [expect.objectContaining({ compat: { supportsDeveloperRole: true } })], + })); + const anthropicModels = registerProvider.mock.calls[2]?.[1]?.models as Array>; + expect(anthropicModels[0]).not.toHaveProperty("compat"); + }); + it("reregisters changed providers", () => { const registerProvider = vi.fn(); const refresh = vi.fn(); @@ -184,6 +208,24 @@ describe("custom-provider-registry", () => { expect(refresh).toHaveBeenCalledTimes(1); }); + it("reregisters when only supportsDeveloperRole changes", () => { + const registerProvider = vi.fn(); + const refresh = vi.fn(); + + reregisterCustomProviders( + { registerProvider, refresh }, + [{ id: "role", name: "Provider", apiType: "openai-compatible", baseUrl: "https://one.test", models: [{ id: "m", name: "M" }] }], + [{ id: "role", name: "Provider", apiType: "openai-compatible", baseUrl: "https://one.test", supportsDeveloperRole: true, models: [{ id: "m", name: "M" }] }], + vi.fn(), + ); + + expect(registerProvider).toHaveBeenCalledTimes(1); + expect(registerProvider).toHaveBeenCalledWith("provider", expect.objectContaining({ + models: [expect.objectContaining({ compat: { supportsDeveloperRole: true } })], + })); + expect(refresh).toHaveBeenCalledTimes(1); + }); + it("handles empty previous/current arrays", () => { const registerProvider = vi.fn(); const refresh = vi.fn(); diff --git a/packages/cli/src/commands/custom-provider-registry.ts b/packages/cli/src/commands/custom-provider-registry.ts index b170a2b3a..4d67b9a5e 100644 --- a/packages/cli/src/commands/custom-provider-registry.ts +++ b/packages/cli/src/commands/custom-provider-registry.ts @@ -13,6 +13,9 @@ interface ModelRegistryLike { cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; maxTokens: number; + compat?: { + supportsDeveloperRole?: boolean; + }; }>; }) => void; refresh: () => void; @@ -29,9 +32,12 @@ export function resolveApiType(apiType: string): string { } function toProviderConfig(provider: CustomProvider) { + const api = resolveApiType(provider.apiType); + const supportsDeveloperRole = provider.supportsDeveloperRole === true; + return { baseUrl: provider.baseUrl, - api: resolveApiType(provider.apiType), + api, apiKey: provider.apiKey, models: (provider.models ?? []).map((model) => ({ id: model.id, @@ -46,6 +52,7 @@ function toProviderConfig(provider: CustomProvider) { }, contextWindow: 128000, maxTokens: 16384, + ...(api === "openai-completions" ? { compat: { supportsDeveloperRole } } : {}), })), }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d47c4702f..c8a518c90 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -562,6 +562,11 @@ export interface CustomProvider { apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses"; baseUrl: string; apiKey?: string; + /** + * OpenAI-compatible opt-in for providers that explicitly support the `developer` role. + * Omitted/false forces legacy `system` role emission to avoid provider 400s. + */ + supportsDeveloperRole?: boolean; models?: { id: string; name: string }[]; } diff --git a/packages/engine/src/__tests__/custom-providers-openai-completions.test.ts b/packages/engine/src/__tests__/custom-providers-openai-completions.test.ts index 41ba94d96..5242903da 100644 --- a/packages/engine/src/__tests__/custom-providers-openai-completions.test.ts +++ b/packages/engine/src/__tests__/custom-providers-openai-completions.test.ts @@ -1,69 +1,73 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { completeSimple } from "@earendil-works/pi-ai"; +import { convertMessages } from "@earendil-works/pi-ai/openai-completions"; import { customProviderRegistryKey, type CustomProvider } from "@fusion/core"; +function createSseResponse(): Response { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello from mock transport\"},\"finish_reason\":null}]}\n\n")); + controller.enqueue(new TextEncoder().encode("data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n")); + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + + describe("custom providers openai-completions regression", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it("registers under slug key and completes a chat round-trip", async () => { const authStorage = AuthStorage.inMemory(); const modelRegistry = ModelRegistry.inMemory(authStorage); - - const providers: CustomProvider[] = [ - { - id: "550e8400-e29b-41d4-a716-446655440000", - name: "My AI Provider", - apiType: "openai-compatible", - baseUrl: "https://example.test/v1", - apiKey: "CUSTOM_KEY", - models: [{ id: "my-model", name: "My Model" }], - }, - ]; + const providers: CustomProvider[] = [{ + id: "550e8400-e29b-41d4-a716-446655440000", + name: "My AI Provider", + apiType: "openai-compatible", + baseUrl: "https://example.test/v1", + apiKey: "CUSTOM_KEY", + models: [{ id: "my-model", name: "My Model" }], + }]; const provider = providers[0]!; modelRegistry.registerProvider(customProviderRegistryKey(provider, providers), { baseUrl: provider.baseUrl, api: "openai-completions", apiKey: provider.apiKey, - models: [{ - id: "my-model", - name: "My Model", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 16384, - }], + models: [{ id: "my-model", name: "My Model", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 16384 }], }); modelRegistry.refresh(); const registered = modelRegistry.getAll().find((model) => model.id === "my-model"); expect(registered?.provider).toBe("my-ai-provider"); - vi.stubGlobal("fetch", vi.fn(async () => { - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello from mock transport\"},\"finish_reason\":null}]}\n\n")); - controller.enqueue(new TextEncoder().encode("data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\n")); - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); - controller.close(); - }, - }); - - return new Response(stream, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }); - })); - + vi.stubGlobal("fetch", vi.fn(async () => createSseResponse())); const model = modelRegistry.find("my-ai-provider", "my-model"); - expect(model).toBeDefined(); - const response = await completeSimple(model!, { - messages: [{ role: "user", content: "Hi", timestamp: Date.now() }], - }); + const response = await completeSimple(model!, { messages: [{ role: "user", content: "Hi", timestamp: Date.now() }] }); expect(response.role).toBe("assistant"); }); + + it("uses system role when reasoning model explicitly disables developer role compat", () => { + const params = convertMessages( + { provider: "openai", reasoning: true, input: ["text"] } as never, + { systemPrompt: "system instruction", messages: [] } as never, + { supportsDeveloperRole: false } as never, + ); + expect(params[0]?.role).toBe("system"); + }); + + it("emits developer role when compat allows it on reasoning models", () => { + const params = convertMessages( + { provider: "openai", reasoning: true, input: ["text"] } as never, + { systemPrompt: "system instruction", messages: [] } as never, + { supportsDeveloperRole: true } as never, + ); + expect(params[0]?.role).toBe("developer"); + }); });