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:
7
.changeset/fn-8547-local-provider-onboarding.md
Normal file
7
.changeset/fn-8547-local-provider-onboarding.md
Normal 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.
|
||||||
@@ -160,6 +160,19 @@ FUSION_SKIP_ONBOARDING=1 fn dashboard
|
|||||||
On successful completion, Fusion records `cliOnboardingCompletedAt` in global
|
On successful completion, Fusion records `cliOnboardingCompletedAt` in global
|
||||||
settings.
|
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
|
Auto-launch behavior: before interactive commands, Fusion auto-launches onboarding
|
||||||
only when the central DB at `getDefaultCentralDbPath()` is missing and CLI
|
only when the central DB at `getDefaultCentralDbPath()` is missing and CLI
|
||||||
onboarding has not already completed. Auto-launch is skipped for `serve`,
|
onboarding has not already completed. Auto-launch is skipped for `serve`,
|
||||||
|
|||||||
@@ -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.
|
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
|
#### `/api/auth/login` response shape for device-code providers
|
||||||
|
|
||||||
|
|||||||
@@ -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 { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
@@ -8,6 +8,7 @@ const mockRunInit = vi.fn(async () => {});
|
|||||||
const mockResolveProject = vi.fn();
|
const mockResolveProject = vi.fn();
|
||||||
const mockProviderAuthFactory = vi.fn();
|
const mockProviderAuthFactory = vi.fn();
|
||||||
const mockGetDefaultCentralDbPath = vi.fn();
|
const mockGetDefaultCentralDbPath = vi.fn();
|
||||||
|
const mockGetModelRegistryModelsPath = vi.fn(() => "/tmp/models.json");
|
||||||
|
|
||||||
const globalSettingsState: Record<string, any> = {};
|
const globalSettingsState: Record<string, any> = {};
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ vi.mock("../provider-auth.js", () => ({
|
|||||||
wrapAuthStorageWithApiKeyProviders: vi.fn(() => mockProviderAuthFactory()),
|
wrapAuthStorageWithApiKeyProviders: vi.fn(() => mockProviderAuthFactory()),
|
||||||
}));
|
}));
|
||||||
vi.mock("../auth-paths.js", () => ({
|
vi.mock("../auth-paths.js", () => ({
|
||||||
getModelRegistryModelsPath: vi.fn(() => "/tmp/models.json"),
|
getModelRegistryModelsPath: mockGetModelRegistryModelsPath,
|
||||||
}));
|
}));
|
||||||
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
||||||
ModelRegistry: { create: vi.fn(() => ({})) },
|
ModelRegistry: { create: vi.fn(() => ({})) },
|
||||||
@@ -54,6 +55,7 @@ vi.mock("@fusion/core", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const { __testUtils, runOnboard } = await import("../onboard.js");
|
const { __testUtils, runOnboard } = await import("../onboard.js");
|
||||||
|
const { composeLocalProviderRegistry, normalizeLocalBaseUrl, parseModelIds, persistLocalProviderRegistry } = __testUtils;
|
||||||
|
|
||||||
function inputFrom(lines: string[]): PassThrough {
|
function inputFrom(lines: string[]): PassThrough {
|
||||||
const input = new PassThrough();
|
const input = new PassThrough();
|
||||||
@@ -96,6 +98,7 @@ describe("onboard", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
for (const key of Object.keys(globalSettingsState)) delete globalSettingsState[key];
|
for (const key of Object.keys(globalSettingsState)) delete globalSettingsState[key];
|
||||||
mockGetDefaultCentralDbPath.mockReturnValue(join(mkdtempSync(join(tmpdir(), "fn-onboard-db-")), "fusion-central.db"));
|
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"));
|
mockResolveProject.mockRejectedValue(new Error("no project"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,13 +133,37 @@ describe("onboard", () => {
|
|||||||
choiceSession.close();
|
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 () => {
|
it("runOnboard initializes central db when missing", async () => {
|
||||||
const providerAuth = makeProviderAuth();
|
const providerAuth = makeProviderAuth();
|
||||||
mockProviderAuthFactory.mockReturnValue(providerAuth);
|
mockProviderAuthFactory.mockReturnValue(providerAuth);
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
await runOnboard({
|
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(logSpy).toHaveBeenCalledWith(expect.stringContaining("Creating central DB"));
|
||||||
expect(centralInitMock).toHaveBeenCalled();
|
expect(centralInitMock).toHaveBeenCalled();
|
||||||
@@ -150,10 +177,26 @@ describe("onboard", () => {
|
|||||||
writeFileSync(existingPath, "db");
|
writeFileSync(existingPath, "db");
|
||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
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"));
|
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 () => {
|
it("waits for API-key persistence before reporting onboarding success", async () => {
|
||||||
const providerAuth = makeProviderAuth();
|
const providerAuth = makeProviderAuth();
|
||||||
let persisted = false;
|
let persisted = false;
|
||||||
@@ -193,7 +236,7 @@ describe("onboard", () => {
|
|||||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
globalSettingsState.cliOnboardingCompletedAt = "2026-06-01T00:00:00.000Z";
|
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(logSpy).toHaveBeenCalledWith("Onboarding already completed. Re-run with --force to run it again.");
|
||||||
expect(providerAuth.setApiKey).not.toHaveBeenCalled();
|
expect(providerAuth.setApiKey).not.toHaveBeenCalled();
|
||||||
expect(mockRunInit).not.toHaveBeenCalled();
|
expect(mockRunInit).not.toHaveBeenCalled();
|
||||||
@@ -264,7 +307,7 @@ describe("onboard", () => {
|
|||||||
const providerAuth = makeProviderAuth();
|
const providerAuth = makeProviderAuth();
|
||||||
mockProviderAuthFactory.mockReturnValue(providerAuth);
|
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(mockRunInit).not.toHaveBeenCalled();
|
||||||
expect(globalSettingsState.testMode).toBe(false);
|
expect(globalSettingsState.testMode).toBe(false);
|
||||||
expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string");
|
expect(typeof globalSettingsState.cliOnboardingCompletedAt).toBe("string");
|
||||||
|
|||||||
@@ -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 { createInterface } from "node:readline";
|
||||||
import { CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core";
|
import { CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core";
|
||||||
import { createFusionAuthStorage, createFusionModelRegistry } from "@fusion/engine";
|
import { createFusionAuthStorage, createFusionModelRegistry } from "@fusion/engine";
|
||||||
import { resolveProject } from "../project-context.js";
|
import { resolveProject } from "../project-context.js";
|
||||||
import { runInit } from "./init.js";
|
import { runInit } from "./init.js";
|
||||||
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||||
|
import { getModelRegistryModelsPath } from "./auth-paths.js";
|
||||||
|
|
||||||
export interface OnboardOptions {
|
export interface OnboardOptions {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
@@ -24,6 +26,7 @@ interface PromptChoiceOptions {
|
|||||||
|
|
||||||
interface PromptSession {
|
interface PromptSession {
|
||||||
prompt(question: string, defaultValue?: string): Promise<string>;
|
prompt(question: string, defaultValue?: string): Promise<string>;
|
||||||
|
promptOptional(question: string, defaultValue?: string): Promise<string>;
|
||||||
promptYesNo(question: string, defaultValue: boolean): Promise<boolean>;
|
promptYesNo(question: string, defaultValue: boolean): Promise<boolean>;
|
||||||
promptChoice(
|
promptChoice(
|
||||||
question: string,
|
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 promptYesNo = async (question: string, defaultValue: boolean): Promise<boolean> => {
|
||||||
const hint = defaultValue ? "Y/n" : "y/N";
|
const hint = defaultValue ? "Y/n" : "y/N";
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -112,12 +121,117 @@ function createPromptSession(input: NodeJS.ReadableStream = process.stdin): Prom
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
prompt,
|
prompt,
|
||||||
|
promptOptional,
|
||||||
promptYesNo,
|
promptYesNo,
|
||||||
promptChoice,
|
promptChoice,
|
||||||
close: cleanup,
|
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 {
|
function validateMaxConcurrent(input: string): number {
|
||||||
const value = parseInt(input, 10);
|
const value = parseInt(input, 10);
|
||||||
if (Number.isNaN(value) || value < 1 || value > 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 () => {
|
await runSkippableStep(prompts, "AI provider setup", async () => {
|
||||||
const apiProviders = providerAuth.getApiKeyProviders();
|
const apiProviders = providerAuth.getApiKeyProviders();
|
||||||
if (apiProviders.length === 0) return;
|
|
||||||
|
|
||||||
const oauthProviders = new Set(providerAuth.getOAuthProviders().map((provider) => provider.id));
|
const oauthProviders = new Set(providerAuth.getOAuthProviders().map((provider) => provider.id));
|
||||||
const providerChoices = apiProviders.map((provider) => {
|
const providerChoices = apiProviders.map((provider) => {
|
||||||
const configured = providerAuth.hasApiKey(provider.id) || providerAuth.hasAuth(provider.id);
|
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}`,
|
label: `${provider.name}${configuredHint}${oauthHint}`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
providerChoices.push({ id: "custom-local", label: "Custom / Local (OpenAI-compatible)" });
|
||||||
|
|
||||||
const selectedProvider = await prompts.promptChoice("Select provider", providerChoices, {
|
const selectedProvider = await prompts.promptChoice("Select provider", providerChoices, {
|
||||||
allowSkip: true,
|
allowSkip: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!selectedProvider) return;
|
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)) {
|
if (oauthProviders.has(selectedProvider)) {
|
||||||
console.log(`Provider ${selectedProvider} uses OAuth. Authenticate with: fn dashboard`);
|
console.log(`Provider ${selectedProvider} uses OAuth. Authenticate with: fn dashboard`);
|
||||||
return;
|
return;
|
||||||
@@ -262,6 +413,10 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
|
|||||||
export const __testUtils = {
|
export const __testUtils = {
|
||||||
createPromptSession,
|
createPromptSession,
|
||||||
validateMaxConcurrent,
|
validateMaxConcurrent,
|
||||||
|
normalizeLocalBaseUrl,
|
||||||
|
parseModelIds,
|
||||||
|
composeLocalProviderRegistry,
|
||||||
|
persistLocalProviderRegistry,
|
||||||
runSkippableStep,
|
runSkippableStep,
|
||||||
isCliOnboardingComplete,
|
isCliOnboardingComplete,
|
||||||
PROMPT_CANCELLED_ERROR,
|
PROMPT_CANCELLED_ERROR,
|
||||||
|
|||||||
Reference in New Issue
Block a user