FN-5833: fix custom Responses API model lookup

Ensure custom OpenAI Responses providers resolve configured models without requiring registry entries.

- update global settings model resolution to preserve custom provider model configuration
- adjust engine custom-provider and fn-agent creation paths to avoid false 'model not found' failures
- add regression tests for OpenAI Responses custom providers and pi create-fn-agent flows
- document the custom provider model behavior and add a changeset for @runfusion/fusion

Files changed:
 .../FN-5833-custom-provider-responses-fix.md       | 10 ++++
 docs/settings-reference.md                         |  2 +-
 packages/core/src/global-settings.ts               | 56 +++++++++----------
 packages/core/src/index.ts                         |  2 +-
 .../custom-providers-openai-responses.test.ts      | 64 ++++++++++++++++++++++
 .../engine/src/__tests__/custom-providers.test.ts  | 33 ++++++++++-
 .../src/__tests__/pi-create-fn-agent.test.ts       | 11 +++-
 packages/engine/src/custom-providers.ts            |  4 +-
 packages/engine/src/pi.ts                          | 21 +++++--
 9 files changed, 162 insertions(+), 41 deletions(-)

Fusion-Task-Id: FN-5833

Fusion-Task-Lineage: a59481cf-faf5-42b8-97ce-7b3eca27b4fb
This commit is contained in:
gsxdsm
2026-06-01 08:23:02 -07:00
parent b4230c06b1
commit f76716e55b
9 changed files with 167 additions and 46 deletions

View File

@@ -39,6 +39,38 @@ export function defaultGlobalDir(): string {
return join(getHomeDir(), ".fusion");
}
/** Resolve the active global directory for an explicit home directory. */
export function resolveGlobalDirForHome(homeDir: string): string {
const preferredDir = join(homeDir, ".fusion");
if (existsSync(preferredDir)) {
return preferredDir;
}
const legacyDir = join(homeDir, ".pi", "fusion");
if (existsSync(legacyDir)) {
try {
mkdirSync(dirname(preferredDir), { recursive: true });
renameSync(legacyDir, preferredDir);
return preferredDir;
} catch {
return legacyDir;
}
}
const legacyDirOriginal = join(homeDir, ".pi", "kb");
if (existsSync(legacyDirOriginal)) {
try {
mkdirSync(dirname(preferredDir), { recursive: true });
renameSync(legacyDirOriginal, preferredDir);
return preferredDir;
} catch {
return legacyDirOriginal;
}
}
return preferredDir;
}
/**
* Resolve the active global directory.
*
@@ -59,39 +91,7 @@ export function resolveGlobalDir(dir?: string): string {
if (hasExplicitDir) return dir;
const preferredDir = defaultGlobalDir();
// Case 1: New directory already exists
if (existsSync(preferredDir)) {
return preferredDir;
}
// Case 2: Check for legacy ~/.pi/fusion directory
const legacyDir = legacyGlobalDir();
if (existsSync(legacyDir)) {
try {
mkdirSync(dirname(preferredDir), { recursive: true });
renameSync(legacyDir, preferredDir);
return preferredDir;
} catch {
return legacyDir;
}
}
// Case 3: Check for original legacy ~/.pi/kb directory
const legacyDirOriginal = legacyGlobalDirOriginal();
if (existsSync(legacyDirOriginal)) {
try {
mkdirSync(dirname(preferredDir), { recursive: true });
renameSync(legacyDirOriginal, preferredDir);
return preferredDir;
} catch {
return legacyDirOriginal;
}
}
// Case 4: Return the preferred directory (will be created on first use)
return preferredDir;
return resolveGlobalDirForHome(getHomeDir());
}
export class GlobalSettingsStore {

View File

@@ -274,7 +274,7 @@ export type { ProjectIdentity } from "./project-identity.js";
export type { EnsureProjectForPathInput, EnsureProjectForPathResult } from "./central-core.js";
export { ArchiveDatabase } from "./archive-db.js";
export { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "./db-migrate.js";
export { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js";
export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from "./global-settings.js";
export { isValidSqliteDatabaseFile } from "./sqlite-validation.js";
export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDaemonTokenFormat } from "./daemon-token.js";
export {

View File

@@ -0,0 +1,64 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import { customProviderRegistryKey, type CustomProvider } from "@fusion/core";
import { readCustomProviders } from "../custom-providers.js";
describe("custom providers openai-responses regression", () => {
let homeDir: string;
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), "fn-custom-providers-responses-"));
await mkdir(join(homeDir, ".pi", "fusion"), { recursive: true });
});
afterEach(async () => {
await rm(homeDir, { recursive: true, force: true });
});
it("loads openai-responses providers from active global settings path and resolves model", async () => {
const providers: CustomProvider[] = [
{
id: "550e8400-e29b-41d4-a716-446655440002",
name: "MyAPI",
apiType: "openai-responses",
baseUrl: "https://responses.example.test/v1",
apiKey: "RESPONSES_KEY",
models: [{ id: "gpt-5.4", name: "GPT 5.4" }],
},
];
await writeFile(
join(homeDir, ".pi", "fusion", "settings.json"),
JSON.stringify({ customProviders: providers }),
"utf-8",
);
const loadedProviders = readCustomProviders(homeDir);
expect(loadedProviders).toEqual(providers);
const authStorage = AuthStorage.inMemory();
const modelRegistry = ModelRegistry.inMemory(authStorage);
const provider = loadedProviders[0]!;
modelRegistry.registerProvider(customProviderRegistryKey(provider, loadedProviders), {
baseUrl: provider.baseUrl,
api: "openai-responses",
apiKey: provider.apiKey,
models: [{
id: "gpt-5.4",
name: "GPT 5.4",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
}],
});
modelRegistry.refresh();
expect(modelRegistry.find("myapi", "gpt-5.4")).toBeDefined();
});
});

View File

@@ -11,7 +11,6 @@ describe("readCustomProviders", () => {
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), "fn-custom-providers-home-"));
settingsPath = join(homeDir, ".fusion", "settings.json");
await mkdir(join(homeDir, ".fusion"), { recursive: true });
});
afterEach(async () => {
@@ -21,6 +20,7 @@ describe("readCustomProviders", () => {
it("returns an empty list when settings are missing or malformed", async () => {
expect(readCustomProviders(homeDir)).toEqual([]);
await mkdir(join(homeDir, ".fusion"), { recursive: true });
await writeFile(settingsPath, "{ invalid json", "utf-8");
expect(readCustomProviders(homeDir)).toEqual([]);
@@ -49,6 +49,7 @@ describe("readCustomProviders", () => {
baseUrl: "https://anthropic.example.test",
},
];
await mkdir(join(homeDir, ".fusion"), { recursive: true });
await writeFile(
settingsPath,
JSON.stringify({ customProviders: providers }),
@@ -57,4 +58,34 @@ describe("readCustomProviders", () => {
expect(readCustomProviders(homeDir)).toEqual(providers);
});
it("reads from legacy ~/.pi/fusion when ~/.fusion does not exist", async () => {
const providers = [{
id: "legacy-provider",
name: "Legacy Provider",
apiType: "openai-responses",
baseUrl: "https://legacy.example.test/v1",
models: [{ id: "gpt-legacy", name: "GPT Legacy" }],
}];
const legacyPath = join(homeDir, ".pi", "fusion", "settings.json");
await mkdir(join(homeDir, ".pi", "fusion"), { recursive: true });
await writeFile(legacyPath, JSON.stringify({ customProviders: providers }), "utf-8");
expect(readCustomProviders(homeDir)).toEqual(providers);
});
it("reads from legacy ~/.pi/kb when newer settings dirs do not exist", async () => {
const providers = [{
id: "legacy-original-provider",
name: "Legacy Original Provider",
apiType: "openai-compatible",
baseUrl: "https://legacy-original.example.test/v1",
models: [{ id: "gpt-original", name: "GPT Original" }],
}];
const legacyPath = join(homeDir, ".pi", "kb", "settings.json");
await mkdir(join(homeDir, ".pi", "kb"), { recursive: true });
await writeFile(legacyPath, JSON.stringify({ customProviders: providers }), "utf-8");
expect(readCustomProviders(homeDir)).toEqual(providers);
});
});

View File

@@ -1342,7 +1342,14 @@ describe("createFnAgent", () => {
tools: "readonly",
defaultProvider: "zai",
defaultModelId: "glm-5.1",
})).rejects.toThrow("Configured primary model zai/glm-5.1 was not found");
})).rejects.toThrow("Configured model zai/glm-5.1 (primary selection) was not found in the pi model registry");
await expect(createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "zai",
defaultModelId: "glm-5.1",
})).rejects.toThrow("Settings → Custom Providers");
expect(createAgentSessionMock).not.toHaveBeenCalled();
});
@@ -1386,7 +1393,7 @@ describe("createFnAgent", () => {
defaultModelId: "gpt-5.4",
fallbackProvider: "openai-codex",
fallbackModelId: "missing-model",
})).rejects.toThrow("Configured fallback model openai-codex/missing-model was not found");
})).rejects.toThrow("Configured model openai-codex/missing-model (fallback selection) was not found in the pi model registry");
expect(createAgentSessionMock).not.toHaveBeenCalled();
});

View File

@@ -1,11 +1,11 @@
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { CustomProvider } from "@fusion/core";
import { resolveGlobalDirForHome, type CustomProvider } from "@fusion/core";
export function readCustomProviders(homeDir = homedir()): CustomProvider[] {
try {
const settingsPath = join(homeDir, ".fusion", "settings.json");
const settingsPath = join(resolveGlobalDirForHome(homeDir), "settings.json");
const raw = readFileSync(settingsPath, "utf-8");
const parsed = JSON.parse(raw) as { customProviders?: CustomProvider[] };
return Array.isArray(parsed.customProviders) ? parsed.customProviders : [];

View File

@@ -979,6 +979,16 @@ export interface AgentOptions {
permanentAgentGating?: PermanentAgentGatingContext;
}
function resolveCustomProviderApiType(apiType: string): "anthropic" | "openai-responses" | "openai-completions" {
if (apiType === "anthropic-compatible") {
return "anthropic";
}
if (apiType === "openai-responses") {
return "openai-responses";
}
return "openai-completions";
}
function resolveConfiguredModel(
modelRegistry: ModelRegistry,
kind: "primary" | "fallback",
@@ -1006,8 +1016,9 @@ function resolveConfiguredModel(
}
throw new Error(
`Configured ${kind} model ${provider}/${modelId} was not found in the pi model registry. ` +
"Open Settings and choose a model from /api/models, or update your pi model configuration.",
`Configured model ${provider}/${modelId} (${kind} selection) was not found in the pi model registry. `
+ "If this model comes from a custom provider, verify Settings → Custom Providers (stored in ~/.fusion/settings.json) includes this provider/model, "
+ "or choose an available model from /api/models.",
);
}
@@ -1807,9 +1818,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
const registryKey = customProviderRegistryKey(provider, customProviders);
modelRegistry.registerProvider(registryKey, {
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic"
: provider.apiType === "openai-responses" ? "openai-responses"
: "openai-completions",
api: resolveCustomProviderApiType(provider.apiType),
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({
id: model.id,
@@ -1830,7 +1839,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const registryKey = customProviderRegistryKey(provider, customProviders);
piLog.warn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}): ${message}`);
piLog.warn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}, apiType=${provider.apiType}, baseUrl=${provider.baseUrl}): ${message}`);
}
}
modelRegistry.refresh();