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
|
||||
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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user