From c9aae708f9d37bd6673011d5967c31ec618c365e Mon Sep 17 00:00:00 2001 From: sosidudku1 <273119990+sosidudku1@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:50:11 +0300 Subject: [PATCH] fix(tui): keyless local providers pass the startup gate A configured local provider (Ollama, LM Studio) has no API key, so isCloudTextProviderReady sent its users back into the local models wizard on every start even though the provider was fully set up. An entry now also counts as ready when its id maps to a local: true preset, or when its base URL points at a loopback host, which covers manual openai-compatible entries typed in before the preset existed. Keyless remote entries still fail the gate and surface the wizard. Co-Authored-By: Claude Fable 5 --- .../run-local-models-config-wizard.test.ts | 125 ++++++++++++++++++ src/tui/run-local-models-config-wizard.ts | 33 ++++- 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/tui/run-local-models-config-wizard.test.ts b/src/tui/run-local-models-config-wizard.test.ts index 4158166d..7dccd47c 100644 --- a/src/tui/run-local-models-config-wizard.test.ts +++ b/src/tui/run-local-models-config-wizard.test.ts @@ -79,6 +79,131 @@ describe("isCloudTextProviderReady", () => { expect(isCloudTextProviderReady()).toBe(true); }); + it("treats an active keyless local preset entry as ready", () => { + const cfg = getConfig(); + const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); + writeUserConfigFileSync(cfg.paths.userConfigFile, { + ...file, + llm: { + activeTextProvider: "ollama", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: cfg.localModels.url }, + { + id: "ollama", + kind: "openai-compatible", + baseUrl: "http://localhost:11434", + defaultChatModel: "qwen3.6", + }, + ], + }, + }); + resetConfigCache(); + + expect(isCloudTextProviderReady()).toBe(true); + }); + + it("resolves suffixed preset entry ids to their local preset", () => { + const cfg = getConfig(); + const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); + writeUserConfigFileSync(cfg.paths.userConfigFile, { + ...file, + llm: { + activeTextProvider: "ollama-2", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: cfg.localModels.url }, + { + id: "ollama-2", + kind: "openai-compatible", + baseUrl: "http://localhost:11434", + defaultChatModel: "qwen3.6", + }, + ], + }, + }); + resetConfigCache(); + + expect(isCloudTextProviderReady()).toBe(true); + }); + + it("treats a manual keyless entry with a loopback base URL as ready", () => { + const cfg = getConfig(); + const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); + writeUserConfigFileSync(cfg.paths.userConfigFile, { + ...file, + llm: { + activeTextProvider: "my-ollama", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: cfg.localModels.url }, + { + id: "my-ollama", + kind: "openai-compatible", + baseUrl: "http://127.0.0.1:11434", + defaultChatModel: "qwen3.6", + }, + ], + }, + }); + resetConfigCache(); + + expect(isCloudTextProviderReady()).toBe(true); + }); + + it("does not treat a keyless remote openai-compatible entry as ready", () => { + const cfg = getConfig(); + const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); + writeUserConfigFileSync(cfg.paths.userConfigFile, { + ...file, + llm: { + activeTextProvider: "groq", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: cfg.localModels.url }, + { + id: "groq", + kind: "openai-compatible", + baseUrl: "https://api.groq.com/openai", + defaultChatModel: "llama-3.3-70b-versatile", + }, + ], + }, + }); + resetConfigCache(); + + expect(isCloudTextProviderReady()).toBe(false); + }); + + it("does not treat keyless Ollama Cloud as ready", () => { + const cfg = getConfig(); + const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); + writeUserConfigFileSync(cfg.paths.userConfigFile, { + ...file, + llm: { + activeTextProvider: "ollama-cloud", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: cfg.localModels.url }, + { + id: "ollama-cloud", + kind: "openai-compatible", + baseUrl: "https://ollama.com", + defaultChatModel: "qwen3.6:cloud", + }, + ], + }, + }); + resetConfigCache(); + + expect(isCloudTextProviderReady()).toBe(false); + }); + it("does not treat an active cloud text provider without a key as ready", () => { const cfg = getConfig(); const file = ensureUserConfigFileSync(cfg.paths.userConfigFile); diff --git a/src/tui/run-local-models-config-wizard.ts b/src/tui/run-local-models-config-wizard.ts index 36bf66d7..47d5b671 100644 --- a/src/tui/run-local-models-config-wizard.ts +++ b/src/tui/run-local-models-config-wizard.ts @@ -1,6 +1,7 @@ import { render } from "ink"; import React from "react"; import { getConfig, USER_CONFIG_DEFAULTS } from "../config/index.js"; +import type { UserLlmProviderEntry } from "../config/llm-config.js"; import { resolveLlmProviderApiKey } from "../config/resolve-llm-api-key.js"; import { getLocalModelDef, @@ -13,6 +14,7 @@ import { LocalModelsConfigWizard, type LocalModelsWizardOutcome, } from "./components/local-models-config-wizard.js"; +import { presetForEntryId } from "./providers/provider-presets.js"; export type LocalModelsStartupGateResult = | "ok" @@ -78,7 +80,36 @@ export function isCloudTextProviderReady(): boolean { const active = cfg.llm?.activeTextProvider; if (!active || active === "local-llama") return false; const entry = cfg.llm?.providers.find((provider) => provider.id === active); - return Boolean(entry && resolveLlmProviderApiKey(entry)); + if (!entry) return false; + if (resolveLlmProviderApiKey(entry)) return true; + return isKeylessLocalProviderEntry(entry); +} + +/** + * Local servers (Ollama, LM Studio) have no API key at all, so a missing + * key must not send the user back into the startup wizard: a configured + * local provider is as ready as a cloud one with a key. An entry counts + * as keyless-local when its id maps to a `local: true` preset, or when + * its base URL points at the operator's own machine — the latter covers + * manual openai-compatible entries typed in before the preset existed. + */ +function isKeylessLocalProviderEntry(entry: UserLlmProviderEntry): boolean { + if (presetForEntryId(entry.id)?.local) return true; + if (!entry.baseUrl) return false; + let host: string; + try { + host = new URL(entry.baseUrl).hostname; + } catch { + return false; + } + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "0.0.0.0" || + host === "::1" || + host === "[::1]" || + host.endsWith(".localhost") + ); } /**