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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user