From 0d355f371cb84da94325a0d20bc16d234e02d1dc Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 12:31:55 -0700 Subject: [PATCH] FN-8547: add local OpenAI-compatible provider onboarding Add guided onboarding for OpenAI-compatible local model providers. - Add custom/local provider prompts and atomic models registry updates. - Preserve registry fields, validate endpoint configuration, and support Qwen thinking compatibility. - Document setup and cover registry persistence with onboarding tests. Files changed: .changeset/fn-8547-local-provider-onboarding.md | 7 + docs/cli-reference.md | 13 ++ docs/settings-reference.md | 35 ++++- packages/cli/src/commands/__tests__/onboard.test.ts | 55 ++++++- packages/cli/src/commands/onboard.ts | 161 ++++++++++++++++++++- 5 files changed, 261 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-8547 Fusion-Task-Lineage: 740221de-fce8-43ae-a095-13b8abf1904a Co-authored-by: Fusion (runfusion.ai) --- .../fn-8547-local-provider-onboarding.md | 7 + docs/cli-reference.md | 13 ++ docs/settings-reference.md | 35 +++- .../src/commands/__tests__/onboard.test.ts | 55 +++++- packages/cli/src/commands/onboard.ts | 161 +++++++++++++++++- 5 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 .changeset/fn-8547-local-provider-onboarding.md diff --git a/.changeset/fn-8547-local-provider-onboarding.md b/.changeset/fn-8547-local-provider-onboarding.md new file mode 100644 index 0000000000..b94a573bef --- /dev/null +++ b/.changeset/fn-8547-local-provider-onboarding.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add guided setup for local OpenAI-compatible model providers. +category: feature +dev: Writes non-destructive pi models.json entries with optional Qwen thinking compatibility. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 3d1b775d1b..33e897af63 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -160,6 +160,19 @@ FUSION_SKIP_ONBOARDING=1 fn dashboard On successful completion, Fusion records `cliOnboardingCompletedAt` in global settings. +### Custom / Local OpenAI-compatible providers + +The **AI provider setup** picker includes **Custom / Local (OpenAI-compatible)**, +for llama.cpp server, vLLM, LM Studio, and other OpenAI-compatible endpoints. The +wizard collects a provider ID/name, an absolute `http:` or `https:` base URL, an +optional API key, comma-separated model IDs, and optional reasoning/Qwen +chat-template compatibility. Blank API keys remain absent for unauthenticated +local servers. The entry is merged safely into the path selected by the existing +registry compatibility resolver: `~/.fusion/agent/models.json`, or an existing +legacy `~/.pi/agent/models.json` / `~/.pi/models.json`. Re-running onboarding +preserves unrelated providers and unknown fields; malformed registry JSON stops +without overwriting it. + Auto-launch behavior: before interactive commands, Fusion auto-launches onboarding only when the central DB at `getDefaultCentralDbPath()` is missing and CLI onboarding has not already completed. Auto-launch is skipped for `serve`, diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 67166bf534..19029313a7 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -855,7 +855,40 @@ Anthropic has three independent authentication/routing paths: Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. On Fusion desktop, Anthropic Subscription OAuth login URLs are delegated to the operating system browser instead of an Electron child window so the existing polling/callback flow can complete. `/api/auth/status` returns only masked key hints for the API-key card. -### Authentication troubleshooting (mobile OAuth fallback) +### CLI local OpenAI-compatible registry + +`fn onboard` can add a local/custom endpoint to pi's model registry without +replacing existing fields. It writes to `~/.fusion/agent/models.json`, unless an +existing legacy `~/.pi/agent/models.json` or `~/.pi/models.json` is selected by +the compatibility resolver. For an unauthenticated server, omit `apiKey`: + +```json +{ + "providers": { + "local": { + "name": "Local server", + "api": "openai-completions", + "baseUrl": "http://localhost:8080/v1", + "models": [{ + "id": "qwen3", + "reasoning": true, + "compat": { + "thinkingFormat": "qwen-chat-template", + "chatTemplateKwargs": { + "enable_thinking": { "$var": "thinking.enabled" } + } + } + }] + } + } +} +``` + +Enable the Qwen option only when the upstream requires +`chat_template_kwargs.enable_thinking`; the registry uses the schema-valid +camel-case `compat.chatTemplateKwargs`, never a top-level compatibility field. + +## Authentication troubleshooting (mobile OAuth fallback) #### `/api/auth/login` response shape for device-code providers diff --git a/packages/cli/src/commands/__tests__/onboard.test.ts b/packages/cli/src/commands/__tests__/onboard.test.ts index 86a5c50906..0c0d1ed628 100644 --- a/packages/cli/src/commands/__tests__/onboard.test.ts +++ b/packages/cli/src/commands/__tests__/onboard.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { PassThrough } from "node:stream"; @@ -8,6 +8,7 @@ const mockRunInit = vi.fn(async () => {}); const mockResolveProject = vi.fn(); const mockProviderAuthFactory = vi.fn(); const mockGetDefaultCentralDbPath = vi.fn(); +const mockGetModelRegistryModelsPath = vi.fn(() => "/tmp/models.json"); const globalSettingsState: Record = {}; @@ -38,7 +39,7 @@ vi.mock("../provider-auth.js", () => ({ wrapAuthStorageWithApiKeyProviders: vi.fn(() => mockProviderAuthFactory()), })); vi.mock("../auth-paths.js", () => ({ - getModelRegistryModelsPath: vi.fn(() => "/tmp/models.json"), + getModelRegistryModelsPath: mockGetModelRegistryModelsPath, })); vi.mock("@earendil-works/pi-coding-agent", () => ({ ModelRegistry: { create: vi.fn(() => ({})) }, @@ -54,6 +55,7 @@ vi.mock("@fusion/core", () => ({ })); const { __testUtils, runOnboard } = await import("../onboard.js"); +const { composeLocalProviderRegistry, normalizeLocalBaseUrl, parseModelIds, persistLocalProviderRegistry } = __testUtils; function inputFrom(lines: string[]): PassThrough { const input = new PassThrough(); @@ -96,6 +98,7 @@ describe("onboard", () => { vi.clearAllMocks(); for (const key of Object.keys(globalSettingsState)) delete globalSettingsState[key]; mockGetDefaultCentralDbPath.mockReturnValue(join(mkdtempSync(join(tmpdir(), "fn-onboard-db-")), "fusion-central.db")); + mockGetModelRegistryModelsPath.mockReturnValue(join(mkdtempSync(join(tmpdir(), "fn-onboard-models-")), "models.json")); mockResolveProject.mockRejectedValue(new Error("no project")); }); @@ -130,13 +133,37 @@ describe("onboard", () => { choiceSession.close(); }); + it("composes and persists custom providers without losing registry fields", () => { + const path = join(mkdtempSync(join(tmpdir(), "fn-models-")), "nested", "models.json"); + const config = { + id: "local", name: "Local", baseUrl: normalizeLocalBaseUrl("http://localhost:8080/v1/"), + modelIds: parseModelIds("qwen, , qwen, llama"), reasoning: true, qwenChatTemplate: true, + }; + persistLocalProviderRegistry(path, config); + const first = JSON.parse(readFileSync(path, "utf8")); + expect(first.providers.local.apiKey).toBeUndefined(); + expect(first.providers.local.baseUrl).toBe("http://localhost:8080/v1"); + expect(first.providers.local.models).toEqual([ + { id: "qwen", reasoning: true, compat: { thinkingFormat: "qwen-chat-template", chatTemplateKwargs: { enable_thinking: { $var: "thinking.enabled" } } } }, + { id: "llama", reasoning: true, compat: { thinkingFormat: "qwen-chat-template", chatTemplateKwargs: { enable_thinking: { $var: "thinking.enabled" } } } }, + ]); + + const merged = composeLocalProviderRegistry({ unknownTop: true, providers: { local: { unknown: "kept", models: [{ id: "old" }] }, other: { api: "other" } } }, { ...config, apiKey: "secret", modelIds: ["new"], reasoning: false, qwenChatTemplate: false }); + expect(merged).toMatchObject({ unknownTop: true, providers: { other: { api: "other" }, local: { unknown: "kept", apiKey: "secret", models: [{ id: "old" }, { id: "new" }] } } }); + expect(() => composeLocalProviderRegistry([], config)).toThrow("JSON object"); + writeFileSync(path, "{ not json"); + expect(() => persistLocalProviderRegistry(path, config)).toThrow("Cannot update malformed"); + expect(readFileSync(path, "utf8")).toBe("{ not json"); + expect(() => normalizeLocalBaseUrl("relative")).toThrow("absolute http"); + }); + it("runOnboard initializes central db when missing", async () => { const providerAuth = makeProviderAuth(); mockProviderAuthFactory.mockReturnValue(providerAuth); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); await runOnboard({ - input: inputFrom(["y", "y", "3", "y", "y", "n", "y"]), + input: inputFrom(["y", "y", "4", "y", "y", "n", "y"]), }); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Creating central DB")); expect(centralInitMock).toHaveBeenCalled(); @@ -150,10 +177,26 @@ describe("onboard", () => { writeFileSync(existingPath, "db"); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - await runOnboard({ input: inputFrom(["y", "3", "y", "y", "n", "y"]) }); + await runOnboard({ input: inputFrom(["y", "4", "y", "y", "n", "y"]) }); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Central DB already exists")); }); + it("registers a blank-key local provider through the custom picker", async () => { + mockProviderAuthFactory.mockReturnValue(makeProviderAuth()); + const path = mockGetModelRegistryModelsPath(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runOnboard({ + input: inputFrom(["y", "y", "3", "local", "Local server", "http://localhost:8080/v1/", "", "qwen, , qwen", "y", "y", "n", "n", "n"]), + }); + + const registry = JSON.parse(readFileSync(path, "utf8")); + expect(registry.providers.local).toMatchObject({ api: "openai-completions", baseUrl: "http://localhost:8080/v1" }); + expect(registry.providers.local.apiKey).toBeUndefined(); + expect(registry.providers.local.models).toHaveLength(1); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("local/qwen")); + }); + it("waits for API-key persistence before reporting onboarding success", async () => { const providerAuth = makeProviderAuth(); let persisted = false; @@ -193,7 +236,7 @@ describe("onboard", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); globalSettingsState.cliOnboardingCompletedAt = "2026-06-01T00:00:00.000Z"; - await runOnboard({ input: inputFrom(["y", "3", "y", "y", "n", "y"]) }); + await runOnboard({ input: inputFrom(["y", "4", "y", "y", "n", "y"]) }); expect(logSpy).toHaveBeenCalledWith("Onboarding already completed. Re-run with --force to run it again."); expect(providerAuth.setApiKey).not.toHaveBeenCalled(); expect(mockRunInit).not.toHaveBeenCalled(); @@ -264,7 +307,7 @@ describe("onboard", () => { const providerAuth = makeProviderAuth(); mockProviderAuthFactory.mockReturnValue(providerAuth); - await runOnboard({ input: inputFrom(["y", "y", "3", "n", "y", "n", "y"]) }); + await runOnboard({ input: inputFrom(["y", "y", "4", "n", "y", "n", "y"]) }); expect(mockRunInit).not.toHaveBeenCalled(); expect(globalSettingsState.testMode).toBe(false); expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string"); diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 75cfb8ac6a..b422a5e6f6 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,10 +1,12 @@ -import { existsSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; import { createInterface } from "node:readline"; import { CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core"; import { createFusionAuthStorage, createFusionModelRegistry } from "@fusion/engine"; import { resolveProject } from "../project-context.js"; import { runInit } from "./init.js"; import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; +import { getModelRegistryModelsPath } from "./auth-paths.js"; export interface OnboardOptions { force?: boolean; @@ -24,6 +26,7 @@ interface PromptChoiceOptions { interface PromptSession { prompt(question: string, defaultValue?: string): Promise; + promptOptional(question: string, defaultValue?: string): Promise; promptYesNo(question: string, defaultValue: boolean): Promise; promptChoice( question: string, @@ -75,6 +78,12 @@ function createPromptSession(input: NodeJS.ReadableStream = process.stdin): Prom } }; + const promptOptional = async (question: string, defaultValue?: string): Promise => { + const suffix = defaultValue !== undefined ? ` [${defaultValue}]` : ""; + const answer = await ask(`${question}${suffix}: `); + return answer === "" && defaultValue !== undefined ? defaultValue : answer; + }; + const promptYesNo = async (question: string, defaultValue: boolean): Promise => { const hint = defaultValue ? "Y/n" : "y/N"; while (true) { @@ -112,12 +121,117 @@ function createPromptSession(input: NodeJS.ReadableStream = process.stdin): Prom return { prompt, + promptOptional, promptYesNo, promptChoice, close: cleanup, }; } +export interface LocalProviderConfig { + id: string; + name: string; + baseUrl: string; + apiKey?: string; + modelIds: string[]; + reasoning: boolean; + qwenChatTemplate: boolean; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function normalizeLocalBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + throw new Error("Base URL must be an absolute http: or https: URL."); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Base URL must be an absolute http: or https: URL."); + } + return url.toString().replace(/\/$/, ""); +} + +export function parseModelIds(value: string): string[] { + return [...new Set(value.split(",").map((id) => id.trim()).filter(Boolean))]; +} + +/** + * FNXC:LocalProviderOnboarding 2026-07-23-12:00: + * Local endpoints must share pi's existing models.json registry without replacing + * unrelated provider or extension fields. A malformed registry is a hard stop so + * onboarding never turns a recoverable user configuration error into data loss. + */ +export function composeLocalProviderRegistry(existing: unknown, config: LocalProviderConfig): Record { + if (!isObject(existing)) throw new Error("models.json must contain a JSON object."); + const existingProviders = existing.providers; + if (existingProviders !== undefined && !isObject(existingProviders)) { + throw new Error("models.json providers must be a JSON object."); + } + const providers: Record = { ...(existingProviders ?? {}) }; + const priorValue = providers[config.id]; + const prior: Record = isObject(priorValue) ? priorValue : {}; + const priorModels: Record[] = Array.isArray(prior.models) + ? prior.models.filter(isObject) + : []; + const configuredModels = config.modelIds.map((id) => ({ + id, + ...(config.reasoning ? { reasoning: true } : {}), + ...(config.qwenChatTemplate + ? { compat: { thinkingFormat: "qwen-chat-template", chatTemplateKwargs: { enable_thinking: { $var: "thinking.enabled" } } } } + : {}), + })); + const configuredIds = new Set(config.modelIds); + providers[config.id] = { + ...prior, + name: config.name, + baseUrl: config.baseUrl, + api: "openai-completions", + ...(config.apiKey ? { apiKey: config.apiKey } : {}), + models: [...priorModels.filter((model) => typeof model.id !== "string" || !configuredIds.has(model.id)), ...configuredModels], + }; + if (!config.apiKey) delete (providers[config.id] as Record).apiKey; + return { ...existing, providers }; +} + +/** + * FNXC:LocalProviderOnboarding 2026-07-23-12:00: + * Atomic sibling replacement prevents an interrupted custom-provider setup from + * truncating pi's registry; credentials are only persisted in the registry, never logged. + */ +function registryHasProvider(path: string, providerId: string): boolean { + if (!existsSync(path)) return false; + try { + const registry = JSON.parse(readFileSync(path, "utf8")); + return isObject(registry) && isObject(registry.providers) && isObject(registry.providers[providerId]); + } catch { + return false; + } +} + +export function persistLocalProviderRegistry(path: string, config: LocalProviderConfig): void { + let existing: unknown = {}; + if (existsSync(path)) { + try { + existing = JSON.parse(readFileSync(path, "utf8")); + } catch { + throw new Error(`Cannot update malformed models.json: ${path}`); + } + } + const registry = composeLocalProviderRegistry(existing, config); + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(tempPath, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 }); + renameSync(tempPath, path); + } finally { + if (existsSync(tempPath)) unlinkSync(tempPath); + } +} + function validateMaxConcurrent(input: string): number { const value = parseInt(input, 10); if (Number.isNaN(value) || value < 1 || value > 10) { @@ -183,8 +297,6 @@ export async function runOnboard(options: OnboardOptions = {}): Promise { await runSkippableStep(prompts, "AI provider setup", async () => { const apiProviders = providerAuth.getApiKeyProviders(); - if (apiProviders.length === 0) return; - const oauthProviders = new Set(providerAuth.getOAuthProviders().map((provider) => provider.id)); const providerChoices = apiProviders.map((provider) => { const configured = providerAuth.hasApiKey(provider.id) || providerAuth.hasAuth(provider.id); @@ -195,12 +307,51 @@ export async function runOnboard(options: OnboardOptions = {}): Promise { label: `${provider.name}${configuredHint}${oauthHint}`, }; }); + providerChoices.push({ id: "custom-local", label: "Custom / Local (OpenAI-compatible)" }); const selectedProvider = await prompts.promptChoice("Select provider", providerChoices, { allowSkip: true, }); if (!selectedProvider) return; + if (selectedProvider === "custom-local") { + const path = getModelRegistryModelsPath(); + const providerId = await prompts.prompt("Provider ID", "custom-local"); + if (registryHasProvider(path, providerId) && !await prompts.promptYesNo( + `Provider ${providerId} already exists. Merge these settings into it?`, + false, + )) return; + const providerName = await prompts.prompt("Provider name", "Custom / Local"); + let baseUrl: string; + while (true) { + try { + baseUrl = normalizeLocalBaseUrl(await prompts.prompt("Base URL (for example http://localhost:8080/v1)")); + break; + } catch (error) { + console.log(error instanceof Error ? error.message : "Invalid base URL."); + } + } + const apiKey = await prompts.promptOptional("API key (optional; leave blank for unauthenticated servers)"); + let modelIds: string[]; + while (true) { + modelIds = parseModelIds(await prompts.prompt("Model IDs (comma-separated)")); + if (modelIds.length > 0) break; + console.log("Enter at least one model ID."); + } + const reasoning = await prompts.promptYesNo("Do these models support reasoning/thinking?", false); + const qwenChatTemplate = reasoning && await prompts.promptYesNo( + "Use Qwen chat-template thinking compatibility? Enable only for servers requiring chat_template_kwargs.enable_thinking.", + false, + ); + try { + persistLocalProviderRegistry(path, { id: providerId, name: providerName, baseUrl, apiKey, modelIds, reasoning, qwenChatTemplate }); + console.log(`✓ Registered ${modelIds.map((id) => `${providerId}/${id}`).join(", ")} in ${path}`); + } catch (error) { + console.log(`Could not save custom provider: ${error instanceof Error ? error.message : "unknown error"}`); + throw error; + } + return; + } if (oauthProviders.has(selectedProvider)) { console.log(`Provider ${selectedProvider} uses OAuth. Authenticate with: fn dashboard`); return; @@ -262,6 +413,10 @@ export async function runOnboard(options: OnboardOptions = {}): Promise { export const __testUtils = { createPromptSession, validateMaxConcurrent, + normalizeLocalBaseUrl, + parseModelIds, + composeLocalProviderRegistry, + persistLocalProviderRegistry, runSkippableStep, isCliOnboardingComplete, PROMPT_CANCELLED_ERROR,