FN-043: add canonical thinking levels for custom providers

Custom-provider models now expose the full thinking-level selector and faithfully transmit the selected effort.

- Register all canonical thinking levels and preserve provider-specific API dialects.
- Remove the redundant reasoning toggle and update provider status and settings documentation.
- Add dashboard, engine, and provider registration coverage for thinking-level behavior.
- Add a minor changeset for the published CLI package.

Files changed:
 .../fn-043-custom-provider-thinking-levels.md      |  7 ++
 docs/settings-reference.md                         |  2 +
 .../dashboard/app/api/settings/provider-status.ts  |  1 -
 .../app/components/CustomProviderForm.css          |  5 +-
 .../app/components/CustomProviderForm.tsx          | 18 ++---
 .../__tests__/CustomProviderForm.test.tsx          | 35 ++++++---
 ...r-model-routes-custom-provider-thinking.test.ts | 91 ++++++++++++++++++++++
 .../custom-provider-thinking-levels.test.ts        | 49 ++++++++++++
 .../custom-providers-openai-completions.test.ts    |  2 +-
 .../custom-providers-openai-responses.test.ts      |  3 +-
 .../src/__tests__/pi-create-fn-agent.test.ts       | 31 +++++++-
 .../src/__tests__/provider-registration.test.ts    | 20 ++++-
 .../engine/src/auth/custom-provider-registry.ts    | 14 +++-
 packages/engine/src/pi.ts                          |  9 ++-
 14 files changed, 251 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-043

Fusion-Task-Lineage: fdd3308a-d5bd-4f54-911a-226c2b46a927

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 15:34:45 +00:00
parent a81c9b80d9
commit b1893a63af
14 changed files with 251 additions and 36 deletions

View File

@@ -0,0 +1,49 @@
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { THINKING_LEVELS, type CustomProvider } from "@fusion/core";
import { describe, expect, it } from "vitest";
import { buildCustomProviderModels, resolveApiType } from "../auth/custom-provider-registry.js";
const API_TYPES = [
"openai-compatible",
"openai-responses",
"anthropic-compatible",
"google-generative-ai",
] as const;
describe("custom-provider thinking levels", () => {
it.each(API_TYPES)("registers every %s model with the canonical transmissible thinking levels", (apiType) => {
const provider: CustomProvider = {
id: "550e8400-e29b-41d4-a716-446655440000",
name: "Custom Provider",
apiType,
baseUrl: "https://custom.example/v1",
models: [{ id: "custom-model", name: "Custom Model" }],
};
const resolvedApi = resolveApiType(apiType);
const [model] = buildCustomProviderModels(provider, resolvedApi);
expect(resolvedApi).toBe(
apiType === "openai-compatible" ? "openai-completions"
: apiType === "anthropic-compatible" ? "anthropic-messages"
: apiType,
);
expect(model).toMatchObject({
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
});
expect(getSupportedThinkingLevels(model)).toEqual(THINKING_LEVELS);
});
it("keeps providers with absent or empty model lists empty", () => {
const provider = {
id: "550e8400-e29b-41d4-a716-446655440000",
name: "Custom Provider",
apiType: "openai-compatible",
baseUrl: "https://custom.example/v1",
} as CustomProvider;
expect(buildCustomProviderModels(provider, "openai-completions")).toEqual([]);
expect(buildCustomProviderModels({ ...provider, models: [] }, "openai-completions")).toEqual([]);
});
});

View File

@@ -47,7 +47,7 @@ describe("custom providers openai-completions regression", () => {
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: true, thinkingLevelMap: { xhigh: "xhigh", max: "max" }, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 16384 }],
});
await modelRegistry.refresh();

View File

@@ -53,7 +53,8 @@ describe("custom providers openai-responses regression", () => {
models: [{
id: "gpt-5.4",
name: "GPT 5.4",
reasoning: false,
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,

View File

@@ -2041,6 +2041,14 @@ describe("createFnAgent", () => {
anthropicPromptCaching: true,
models: [{ id: "anthropic-model", name: "Anthropic Model" }],
},
{
id: "880e8400-e29b-41d4-a716-446655440003",
name: "Custom Google Provider",
apiType: "google-generative-ai",
baseUrl: "https://google.example",
apiKey: "GOOGLE_API_KEY",
models: [{ id: "google-model", name: "Google Model" }],
},
] as any);
const { createPiAgentSessionRaw: createFnAgent } = await import("../pi.js");
@@ -2058,6 +2066,8 @@ describe("createFnAgent", () => {
api: "openai-completions",
models: [expect.objectContaining({
id: "custom-model",
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
compat: expect.objectContaining({ cacheControlFormat: "anthropic" }),
})],
}));
@@ -2065,16 +2075,33 @@ describe("createFnAgent", () => {
// Opted-out (default) openai-compatible provider: no forced cache_control marker.
const noCacheCall = registerProviderMock.mock.calls.find(([key]: [string]) => key === "custom-openai-no-caching");
expect(noCacheCall).toBeDefined();
const [, noCacheConfig] = noCacheCall as [string, { models: Array<{ compat?: Record<string, unknown> }> }];
const [, noCacheConfig] = noCacheCall as [string, { models: Array<{ reasoning: boolean; thinkingLevelMap: Record<string, string>; compat?: Record<string, unknown> }> }];
expect(noCacheConfig.models[0]).toMatchObject({
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
});
expect(noCacheConfig.models[0].compat).not.toHaveProperty("cacheControlFormat");
// anthropic-compatible provider: opt-in is a documented no-op — pi-ai's anthropic path already
// auto-caches without this compat flag, and openai-completions-only compat must not leak in.
const anthropicCall = registerProviderMock.mock.calls.find(([key]: [string]) => key === "custom-anthropic-caching-opt-in");
expect(anthropicCall).toBeDefined();
const [, anthropicConfig] = anthropicCall as [string, { api: string; models: Array<{ compat?: Record<string, unknown> }> }];
const [, anthropicConfig] = anthropicCall as [string, { api: string; models: Array<{ reasoning: boolean; thinkingLevelMap: Record<string, string>; compat?: Record<string, unknown> }> }];
expect(anthropicConfig.api).toBe("anthropic-messages");
expect(anthropicConfig.models[0]).toMatchObject({
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
});
expect(anthropicConfig.models[0].compat).toBeUndefined();
const googleCall = registerProviderMock.mock.calls.find(([key]: [string]) => key === "custom-google-provider");
expect(googleCall).toBeDefined();
const [, googleConfig] = googleCall as [string, { api: string; models: Array<{ reasoning: boolean; thinkingLevelMap: Record<string, string> }> }];
expect(googleConfig.api).toBe("google-generative-ai");
expect(googleConfig.models[0]).toMatchObject({
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
});
});
it("avoids lock-based SettingsManager.create when loading extension providers", async () => {

View File

@@ -146,7 +146,7 @@ describe("seedDashboardProviders", () => {
baseUrl: provider.baseUrl,
api: "openai-completions",
apiKey: provider.apiKey,
models: [{ id: "acme-1", name: "Acme Model 1", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 16384 }],
models: [{ id: "acme-1", name: "Acme Model 1", reasoning: true, thinkingLevelMap: { xhigh: "xhigh", max: "max" }, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 16384 }],
});
const registryB = await createInMemoryModelRegistry();
@@ -184,6 +184,24 @@ describe("seedDashboardProviders", () => {
expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter", "acme-one", "acme-two"]));
});
it("registers a google-generative-ai custom provider under the Google api key", async () => {
const modelRegistry = makeModelRegistry();
const provider = customProvider({ apiType: "google-generative-ai", baseUrl: "https://google.test" });
await registerCustomProviders(modelRegistry, [provider], vi.fn());
expect(modelRegistry.registerProvider).toHaveBeenCalledWith(
"acme-ai",
expect.objectContaining({
api: "google-generative-ai",
models: [expect.objectContaining({
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
})],
}),
);
});
it("registers an anthropic-compatible custom provider under the anthropic-messages api key (FN-7690)", async () => {
const store = makeStore([
customProvider({

View File

@@ -17,6 +17,7 @@ interface ModelRegistryLike extends RefreshableModelRegistry {
id: string;
name: string;
reasoning: boolean;
thinkingLevelMap?: { xhigh?: string | null; max?: string | null };
input: ("text" | "image")[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
@@ -50,6 +51,10 @@ export function resolveApiType(apiType: string): string {
if (apiType === "openai-responses") {
return "openai-responses";
}
// FNXC:CustomProviders 2026-08-19-15:28: Google-compatible custom providers must retain pi's Google API dialect so its shared thinking translation handles Off and every selected effort.
if (apiType === "google-generative-ai") {
return "google-generative-ai";
}
return "openai-completions";
}
@@ -66,6 +71,11 @@ export function resolveApiType(apiType: string): string {
* anthropic path already auto-caches without any flag, and `openai-responses` uses OpenAI's
* native `prompt_cache_key`/`prompt_cache_retention` mechanism (no `cache_control` marker concept
* per pi-ai's `OpenAIResponsesCompat`), so the opt-in is inert there by construction.
*
* FNXC:CustomProviders 2026-08-19-15:13:
* Custom-provider models are presumed thinking-capable without a user-declared capability. Register
* all seven canonical levels as transmissible so pi owns Off translation and up-then-down clamping;
* the same registration is the source for selector display and execution.
*/
export function buildCustomProviderModels(
provider: CustomProvider,
@@ -74,6 +84,7 @@ export function buildCustomProviderModels(
id: string;
name: string;
reasoning: boolean;
thinkingLevelMap: { xhigh: string; max: string };
input: ("text" | "image")[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
@@ -86,7 +97,8 @@ export function buildCustomProviderModels(
return (provider.models ?? []).map((model) => ({
id: model.id,
name: model.name,
reasoning: false,
reasoning: true,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
input: ["text" as const],
cost: {
input: 0,

View File

@@ -1147,16 +1147,23 @@ export interface AgentOptions {
* provider register but threw "No API provider registered for api: anthropic"
* the moment a task tried to stream.
*
* FNXC:CustomProviders 2026-08-19-15:28:
* Google-compatible custom providers keep the registered Google dialect. This path and dashboard
* registration must agree so pi performs the same thinking-level translation for every custom model.
*
* @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" {
function resolveCustomProviderApiType(apiType: string): "anthropic-messages" | "openai-responses" | "google-generative-ai" | "openai-completions" {
if (apiType === "anthropic-compatible") {
return "anthropic-messages";
}
if (apiType === "openai-responses") {
return "openai-responses";
}
if (apiType === "google-generative-ai") {
return "google-generative-ai";
}
return "openai-completions";
}