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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-23 12:31:55 -07:00
parent 84d7306bd3
commit 0d355f371c
5 changed files with 261 additions and 10 deletions

View File

@@ -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.

View File

@@ -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`,

View File

@@ -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

View File

@@ -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<string, any> = {};
@@ -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");

View File

@@ -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<string>;
promptOptional(question: string, defaultValue?: string): Promise<string>;
promptYesNo(question: string, defaultValue: boolean): Promise<boolean>;
promptChoice(
question: string,
@@ -75,6 +78,12 @@ function createPromptSession(input: NodeJS.ReadableStream = process.stdin): Prom
}
};
const promptOptional = async (question: string, defaultValue?: string): Promise<string> => {
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<boolean> => {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> = { ...(existingProviders ?? {}) };
const priorValue = providers[config.id];
const prior: Record<string, unknown> = isObject(priorValue) ? priorValue : {};
const priorModels: Record<string, unknown>[] = 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<string, unknown>).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<void> {
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<void> {
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<void> {
export const __testUtils = {
createPromptSession,
validateMaxConcurrent,
normalizeLocalBaseUrl,
parseModelIds,
composeLocalProviderRegistry,
persistLocalProviderRegistry,
runSkippableStep,
isCliOnboardingComplete,
PROMPT_CANCELLED_ERROR,