From 56f10cd0e10e9e478d903a496ddf71e3cf03715f Mon Sep 17 00:00:00 2001 From: SecFathy Date: Wed, 10 Jun 2026 15:05:22 +0300 Subject: [PATCH] Add configurable local LLM endpoints and required config discovery. Move Ollama and LM Studio default URLs into user config, search multiple config paths on startup, and print a clear error when no config file is found. Co-authored-by: Cursor --- .gitignore | 1 + src/cli/index.ts | 32 ++++++--- src/config/config.test.ts | 80 ++++++++++++++++++++-- src/config/config.ts | 126 ++++++++++++++++++++++++++++++++--- src/llm/factory.test.ts | 11 +++ src/llm/factory.ts | 6 +- src/llm/models.ts | 10 ++- src/llm/ollama.ts | 4 +- src/llm/openai.test.ts | 2 +- src/llm/openai.ts | 4 +- src/ui/App.commands.test.tsx | 37 +++++++--- src/ui/App.tsx | 43 ++++++++---- 12 files changed, 298 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index 6d41a0b..d685bf1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dist/ # Local agent/harness config — never commit (may contain target hosts) .claude/ +/config.json # Local agent state & scan output — never commit. Anchored to the repo # root so they don't also exclude source dirs like src/findings/. diff --git a/src/cli/index.ts b/src/cli/index.ts index 0cf049d..be59236 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -213,8 +213,19 @@ async function main(): Promise { process.on('SIGTERM', () => onSig('SIGTERM')); process.on('SIGHUP', () => onSig('SIGHUP')); - // Config. - let cfg = config.load(); + // Config — required on every launch; see ~/.pentesterflow/config.json or ./config.json. + let cfg: config.Config; + try { + cfg = config.load(); + } catch (err) { + if (err instanceof config.ConfigNotFoundError) { + process.stderr.write(`${err.formatMessage()}\n`); + return 1; + } + process.stderr.write(`${(err as Error).message}\n`); + return 1; + } + process.stderr.write(`config: ${config.loadedConfigPath()}\n`); if (flags.backend) cfg = { ...cfg, backend: flags.backend as config.Config['backend'] }; if (flags.model) cfg.model = flags.model; if (flags.baseURL) cfg.base_url = flags.baseURL; @@ -563,7 +574,7 @@ async function main(): Promise { const bannerData: BannerData = { provider: providerLabel(cfg.backend), model: client.model() || cfg.model || '(unset)', - endpoint: cfg.base_url || defaultEndpoint(cfg.backend), + endpoint: cfg.base_url || defaultEndpoint(cfg, cfg.backend), state: localityFor(cfg.backend), status: `Session ${sessionID.slice(0, 8)} — type /help to begin`, cwd: prettyCwd(), @@ -596,7 +607,7 @@ async function main(): Promise { const ctxP = cfg.backend === 'ollama' || cfg.backend === '' ? detectOllamaContextWindow( - cfg.base_url || defaultEndpoint(cfg.backend), + config.resolveBackendBaseUrl(cfg, 'ollama'), agent.client.model(), signal, ).then((info) => { @@ -653,6 +664,7 @@ async function main(): Promise { baseURL: cfg.base_url, apiKey: cfg.api_key, model: cfg.model, + backendBaseURL: (backend) => config.resolveBackendBaseUrl(cfg, backend), }), persistDisabledSkills: async (names: string[]) => { cfg.disabled_skills = [...names].sort(); @@ -673,7 +685,7 @@ async function main(): Promise { bannerHolder.publish?.({ provider: providerLabel(cfg.backend), model: next.model() || cfg.model || '(unset)', - endpoint: cfg.base_url || defaultEndpoint(cfg.backend), + endpoint: cfg.base_url || defaultEndpoint(cfg, cfg.backend), state: localityFor(cfg.backend), }); void runProbes(rootCtl.signal); @@ -735,13 +747,11 @@ function localityFor(b: string): string { : 'local'; } -function defaultEndpoint(b: string): string { +function defaultEndpoint(cfg: config.Config, b: string): string { + if (b === 'ollama' || b === '' || b === 'lmstudio') { + return config.resolveBackendBaseUrl(cfg, b as config.Backend); + } switch (b) { - case 'ollama': - case '': - return 'http://localhost:11434'; - case 'lmstudio': - return 'http://localhost:1234/v1'; case 'kimi': return KIMI_DEFAULT_BASE_URL; case 'groq': diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 8f2f540..b8ac162 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2,11 +2,20 @@ // rejection set matches and that a clean config round-trips through save // and load. -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { defaultConfig, load, save } from './config.js'; +import { + ConfigNotFoundError, + DEFAULT_LMSTUDIO_BASE_URL, + DEFAULT_OLLAMA_BASE_URL, + configSearchPaths, + defaultConfig, + load, + resolveBackendBaseUrl, + save, +} from './config.js'; let tmp = ''; const originalEnv = process.env.PENTESTERFLOW_CONFIG; @@ -14,6 +23,7 @@ const originalEnv = process.env.PENTESTERFLOW_CONFIG; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'pf-config-')); process.env.PENTESTERFLOW_CONFIG = join(tmp, 'config.json'); + writeFileSync(process.env.PENTESTERFLOW_CONFIG, '{}\n', 'utf8'); }); afterEach(() => { @@ -26,10 +36,72 @@ afterEach(() => { }); describe('config', () => { - it('returns a default config when the file is missing', () => { + it('throws a helpful error when the config file is missing', () => { + rmSync(process.env.PENTESTERFLOW_CONFIG ?? '', { force: true }); + try { + load(); + throw new Error('expected load() to throw'); + } catch (err) { + expect(err).toBeInstanceOf(ConfigNotFoundError); + const msg = (err as ConfigNotFoundError).formatMessage(); + expect(msg).toContain('config file not found'); + expect(msg).toContain('ollama_base_url'); + expect(msg).toContain(process.env.PENTESTERFLOW_CONFIG ?? ''); + } + }); + + it('fills schema defaults from an empty config file', () => { const cfg = load(); expect(cfg.backend).toBe(''); expect(cfg.mcp_servers).toEqual([]); + expect(cfg.ollama_base_url).toBe(DEFAULT_OLLAMA_BASE_URL); + expect(cfg.lmstudio_base_url).toBe(DEFAULT_LMSTUDIO_BASE_URL); + }); + + it('prefers project-local config.json over the user-global path', () => { + const prev = process.env.PENTESTERFLOW_CONFIG; + process.env.PENTESTERFLOW_CONFIG = ''; + const root = mkdtempSync(join(tmpdir(), 'pf-config-root-')); + const project = join(root, 'config.json'); + writeFileSync(project, JSON.stringify({ backend: 'lmstudio', model: 'local-model' })); + try { + const paths = configSearchPaths(root); + expect(paths[1]).toBe(project); + const originalCwd = process.cwd(); + process.chdir(root); + try { + const cfg = load(); + expect(cfg.backend).toBe('lmstudio'); + expect(cfg.model).toBe('local-model'); + } finally { + process.chdir(originalCwd); + } + } finally { + rmSync(root, { recursive: true, force: true }); + if (prev === undefined) process.env.PENTESTERFLOW_CONFIG = undefined; + else process.env.PENTESTERFLOW_CONFIG = prev; + writeFileSync(join(tmp, 'config.json'), '{}\n', 'utf8'); + } + }); + + it('persists custom local backend URLs and resolves them', async () => { + const cfg = defaultConfig(); + cfg.backend = 'ollama'; + cfg.ollama_base_url = 'http://192.168.1.10:11434'; + cfg.lmstudio_base_url = 'http://192.168.1.10:1234/v1'; + await save(cfg); + const reloaded = load(); + expect(reloaded.ollama_base_url).toBe('http://192.168.1.10:11434'); + expect(resolveBackendBaseUrl(reloaded, 'ollama')).toBe('http://192.168.1.10:11434'); + expect(resolveBackendBaseUrl(reloaded, 'lmstudio')).toBe('http://192.168.1.10:1234/v1'); + }); + + it('prefers base_url override for the active backend', () => { + const cfg = defaultConfig(); + cfg.backend = 'ollama'; + cfg.base_url = 'http://custom:11434'; + expect(resolveBackendBaseUrl(cfg, 'ollama')).toBe('http://custom:11434'); + expect(resolveBackendBaseUrl(cfg, 'lmstudio')).toBe(DEFAULT_LMSTUDIO_BASE_URL); }); it('round-trips through save and load', async () => { @@ -124,7 +196,7 @@ describe('config', () => { }); it('leaves tooling_profile undefined when never set (signals first run)', () => { - const cfg = load(); // file doesn't exist → defaults + const cfg = load(); expect(cfg.tooling_profile).toBeUndefined(); }); diff --git a/src/config/config.ts b/src/config/config.ts index ee74836..6fdea61 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { chmod, open, rename, unlink } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { z } from 'zod'; // ---------- Schema ---------- @@ -53,10 +53,19 @@ export type ToolingProfile = z.infer; * default" and size the threshold to the model's real context window. */ export const DEFAULT_AUTO_COMPACT_THRESHOLD = 16000; +/** Default Ollama API base when not overridden in ~/.pentesterflow/config.json. */ +export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434'; +/** Default LM Studio OpenAI-compatible base when not overridden in config. */ +export const DEFAULT_LMSTUDIO_BASE_URL = 'http://localhost:1234/v1'; + const ConfigSchema = z.object({ backend: Backend.default(''), model: z.string().default(''), base_url: z.string().default(''), + // Per-backend local endpoint defaults. Used when base_url is empty or when + // listing models for a backend other than the active one. + ollama_base_url: z.string().default(DEFAULT_OLLAMA_BASE_URL), + lmstudio_base_url: z.string().default(DEFAULT_LMSTUDIO_BASE_URL), api_key: z.string().default(''), skills_dirs: z.array(z.string()).default([]), // Skill names the user has disabled via /skills. Hidden from the system @@ -104,20 +113,95 @@ function noShellMeta(s: string): boolean { // ---------- Paths ---------- -export function configPath(): string { +/** User-global config path (recommended for installed binaries). */ +export function canonicalConfigPath(): string { const override = process.env.PENTESTERFLOW_CONFIG; - if (override && override.length > 0) return override; - const home = homedir(); - return join(home, '.pentesterflow', 'config.json'); + if (override && override.length > 0) return resolve(override); + return join(homedir(), '.pentesterflow', 'config.json'); +} + +/** Paths checked in order when PENTESTERFLOW_CONFIG is not set. */ +export function configSearchPaths(cwd = process.cwd()): string[] { + return [ + join(cwd, '.pentesterflow', 'config.json'), + join(cwd, 'config.json'), + join(homedir(), '.pentesterflow', 'config.json'), + ]; +} + +/** @deprecated Use canonicalConfigPath() or loadedConfigPath(). */ +export function configPath(): string { + return loadedConfigPath() ?? canonicalConfigPath(); +} + +let activeConfigPath: string | undefined; + +/** Absolute path of the config file used by the current process, if load() succeeded. */ +export function loadedConfigPath(): string | undefined { + return activeConfigPath; +} + +export class ConfigNotFoundError extends Error { + readonly searched: readonly string[]; + + constructor(searched: readonly string[]) { + super('config file not found'); + this.name = 'ConfigNotFoundError'; + this.searched = searched; + } + + formatMessage(): string { + const recommended = join(homedir(), '.pentesterflow', 'config.json'); + return [ + 'pentesterflow: config file not found.', + '', + 'PentesterFlow needs a config.json with your LLM settings (backend, model, endpoints).', + '', + 'Searched:', + ...this.searched.map((p) => ` - ${p}`), + '', + `Recommended location (persists across installs): ${recommended}`, + '', + 'Example config.json:', + '{', + ' "backend": "ollama",', + ' "model": "qwen2.5-coder:32b",', + ' "ollama_base_url": "http://localhost:11434",', + ' "lmstudio_base_url": "http://localhost:1234/v1"', + '}', + '', + 'Or point at a specific file:', + ' PENTESTERFLOW_CONFIG=C:\\path\\to\\config.json pentesterflow', + ].join('\n'); + } +} + +function resolveConfigLoadPath(): string { + const envOverride = process.env.PENTESTERFLOW_CONFIG; + if (envOverride && envOverride.length > 0) { + const path = resolve(envOverride); + if (!existsSync(path)) { + throw new ConfigNotFoundError([path]); + } + return path; + } + const searched = configSearchPaths(); + for (const path of searched) { + if (existsSync(path)) return path; + } + throw new ConfigNotFoundError(searched); +} + +function configSavePath(): string { + if (activeConfigPath) return activeConfigPath; + return canonicalConfigPath(); } // ---------- Load / save ---------- export function load(): Config { - const path = configPath(); - if (!existsSync(path)) { - return ConfigSchema.parse({}); - } + const path = resolveConfigLoadPath(); + activeConfigPath = path; let raw: unknown; try { const buf = readFileSync(path, 'utf8'); @@ -138,7 +222,8 @@ export function load(): Config { * rename. Cleans up the .tmp on any error path. */ export async function save(cfg: Config): Promise { - const path = configPath(); + const path = configSavePath(); + activeConfigPath = path; const dir = dirname(path); mkdirSync(dir, { recursive: true, mode: 0o700 }); @@ -184,3 +269,24 @@ function stringifyError(err: unknown): string { export function defaultConfig(): Config { return ConfigSchema.parse({}); } + +/** + * Resolve the effective API base URL for a backend. + * `base_url` overrides the active backend only; other backends use their + * dedicated config fields (ollama_base_url / lmstudio_base_url). + */ +export function resolveBackendBaseUrl(cfg: Config, backend?: Backend): string { + const b: Exclude = + backend === undefined || backend === '' ? (cfg.backend === '' ? 'ollama' : cfg.backend) : backend; + const isActive = + b === cfg.backend || (b === 'ollama' && (cfg.backend === '' || cfg.backend === 'ollama')); + if (isActive && cfg.base_url) return cfg.base_url; + switch (b) { + case 'ollama': + return cfg.ollama_base_url; + case 'lmstudio': + return cfg.lmstudio_base_url; + default: + return cfg.base_url; + } +} diff --git a/src/llm/factory.test.ts b/src/llm/factory.test.ts index fbdab3e..d798a4d 100644 --- a/src/llm/factory.test.ts +++ b/src/llm/factory.test.ts @@ -92,4 +92,15 @@ describe('newFromConfig', () => { expect(() => newFromConfig(cfg)).toThrow(/GEMINI_API_KEY/); }); + + it('uses ollama_base_url from config when base_url is empty', () => { + const cfg = defaultConfig(); + cfg.backend = 'ollama'; + cfg.ollama_base_url = 'http://192.168.1.5:11434'; + + const client = newFromConfig(cfg); + + expect(client.name()).toBe('ollama'); + expect((client as { baseURL: string }).baseURL).toBe('http://192.168.1.5:11434'); + }); }); diff --git a/src/llm/factory.ts b/src/llm/factory.ts index 80ce5bc..7a3245c 100644 --- a/src/llm/factory.ts +++ b/src/llm/factory.ts @@ -1,6 +1,6 @@ // Build the right Client from the parsed Config. -import type { Config } from '../config/config.js'; +import { type Config, resolveBackendBaseUrl } from '../config/config.js'; import type { Client } from './client.js'; import { GeminiClient } from './gemini.js'; import { OllamaClient } from './ollama.js'; @@ -26,9 +26,9 @@ export function newFromConfig(cfg: Config): Client { switch (cfg.backend) { case 'ollama': case '': - return new OllamaClient(cfg.base_url, cfg.model); + return new OllamaClient(resolveBackendBaseUrl(cfg, 'ollama'), cfg.model); case 'lmstudio': - return OpenAIClient.lmStudio(cfg.base_url, cfg.model); + return OpenAIClient.lmStudio(resolveBackendBaseUrl(cfg, 'lmstudio'), cfg.model); case 'openai-compat': if (!cfg.base_url) { throw new Error('openai-compat backend requires base_url'); diff --git a/src/llm/models.ts b/src/llm/models.ts index 15d5f13..84fbaf8 100644 --- a/src/llm/models.ts +++ b/src/llm/models.ts @@ -2,7 +2,11 @@ // interactive /provider flow to populate the model picker after the user // chooses Ollama / LM Studio / openai-compat / Kimi / Groq / OpenRouter / DeepSeek / Gemini. -import type { Backend } from '../config/config.js'; +import { + type Backend, + DEFAULT_LMSTUDIO_BASE_URL, + DEFAULT_OLLAMA_BASE_URL, +} from '../config/config.js'; import { DEEPSEEK_DEFAULT_BASE_URL, DEEPSEEK_MODELS, @@ -19,8 +23,8 @@ import { const DEFAULT_TIMEOUT_MS = 5_000; const DEFAULT_BASE_URL: Record, string> = { - ollama: 'http://localhost:11434', - lmstudio: 'http://localhost:1234/v1', + ollama: DEFAULT_OLLAMA_BASE_URL, + lmstudio: DEFAULT_LMSTUDIO_BASE_URL, 'openai-compat': '', kimi: KIMI_DEFAULT_BASE_URL, groq: GROQ_DEFAULT_BASE_URL, diff --git a/src/llm/ollama.ts b/src/llm/ollama.ts index 65e3804..8e172f5 100644 --- a/src/llm/ollama.ts +++ b/src/llm/ollama.ts @@ -1,3 +1,5 @@ +import { DEFAULT_OLLAMA_BASE_URL } from '../config/config.js'; + // Ollama backend. behavior: // - POST /api/chat with stream=true emits ND-JSON; accumulate tool calls // as they arrive (the terminal `done:true` chunk often carries an empty @@ -35,7 +37,7 @@ export class OllamaClient implements Client, StreamingClient, Pinger { readonly modelID: string; constructor(baseURL: string, model: string) { - this.baseURL = baseURL || 'http://localhost:11434'; + this.baseURL = baseURL || DEFAULT_OLLAMA_BASE_URL; this.modelID = model; } diff --git a/src/llm/openai.test.ts b/src/llm/openai.test.ts index a5cd2ee..2fcf087 100644 --- a/src/llm/openai.test.ts +++ b/src/llm/openai.test.ts @@ -207,7 +207,7 @@ describe('OpenAIClient', () => { it('lmStudio factory uses the right default URL', () => { const c = OpenAIClient.lmStudio('', 'q'); - expect(c.baseURL).toBe('http://localhost:1234/v1'); + expect(c.baseURL).toBe('http://localhost:1234/v1'); // DEFAULT_LMSTUDIO_BASE_URL expect(c.name()).toBe('lmstudio'); }); diff --git a/src/llm/openai.ts b/src/llm/openai.ts index 3b55383..74ff01b 100644 --- a/src/llm/openai.ts +++ b/src/llm/openai.ts @@ -1,3 +1,5 @@ +import { DEFAULT_LMSTUDIO_BASE_URL } from '../config/config.js'; + // OpenAI-compatible backend. Covers LM Studio, vLLM, llama.cpp server, // and remote OpenAI-compatible providers. // @@ -100,7 +102,7 @@ export class OpenAIClient implements Client, StreamingClient, Pinger { static lmStudio(baseURL: string, model: string): OpenAIClient { // LM Studio ignores auth — pass empty so the Authorization header is // omitted entirely (the chat/ping paths already guard on apiKey). - return new OpenAIClient(baseURL || 'http://localhost:1234/v1', '', model, 'lmstudio'); + return new OpenAIClient(baseURL || DEFAULT_LMSTUDIO_BASE_URL, '', model, 'lmstudio'); } name(): string { diff --git a/src/ui/App.commands.test.tsx b/src/ui/App.commands.test.tsx index d71b670..1b59f88 100644 --- a/src/ui/App.commands.test.tsx +++ b/src/ui/App.commands.test.tsx @@ -14,6 +14,7 @@ import { newRegistry } from '../skills/registry.js'; import { Target } from '../target/target.js'; import { Registry as ToolRegistry } from '../tools/registry.js'; import { runSelfUpdate } from '../update/selfUpdate.js'; +import { DEFAULT_OLLAMA_BASE_URL } from '../config/config.js'; import { App, type AppProps } from './App.js'; import type { BannerData } from './Banner.js'; import { TerminalSizeProvider } from './TerminalSize.js'; @@ -54,12 +55,25 @@ let setYolo: ReturnType; let applyProvider: ReturnType; let mounted: ReturnType | null = null; +function stubReadConfig( + overrides: Partial> = {}, +): ReturnType { + return { + backend: 'ollama', + baseURL: '', + apiKey: '', + model: 'stub-model', + backendBaseURL: () => DEFAULT_OLLAMA_BASE_URL, + ...overrides, + }; +} + function makeProps(overrides: Partial = {}): AppProps { return { agent, bannerData, parentSignal: new AbortController().signal, - readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model' }), + readConfig: () => stubReadConfig(), applyProvider, setYolo, ...overrides, @@ -185,7 +199,7 @@ describe('UI slash commands (terminal integration)', () => { it('/provider can collect and test a Kimi API key before model selection', async () => { mounted = renderApp({ - readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model' }), + readConfig: () => stubReadConfig(), }); await tick(); await submit(mounted.stdin, '/provider'); @@ -219,12 +233,13 @@ describe('UI slash commands (terminal integration)', () => { it('/provider asks for a Kimi key instead of reusing another provider key', async () => { mounted = renderApp({ - readConfig: () => ({ - backend: 'groq', - baseURL: 'https://api.groq.com/openai/v1', - apiKey: 'gsk-existing', - model: 'openai/gpt-oss-20b', - }), + readConfig: () => + stubReadConfig({ + backend: 'groq', + baseURL: 'https://api.groq.com/openai/v1', + apiKey: 'gsk-existing', + model: 'openai/gpt-oss-20b', + }), }); await tick(); await submit(mounted.stdin, '/provider'); @@ -247,7 +262,7 @@ describe('UI slash commands (terminal integration)', () => { it('/provider can collect and test a Groq API key before model selection', async () => { mounted = renderApp({ - readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model' }), + readConfig: () => stubReadConfig(), }); await tick(); await submit(mounted.stdin, '/provider'); @@ -291,7 +306,7 @@ describe('UI slash commands (terminal integration)', () => { 'models/gemini-flash-lite-latest', ]); mounted = renderApp({ - readConfig: () => ({ backend: 'ollama', baseURL: '', apiKey: '', model: 'stub-model' }), + readConfig: () => stubReadConfig(), }); await tick(); await submit(mounted.stdin, '/provider'); @@ -340,7 +355,7 @@ describe('UI slash commands (terminal integration)', () => { await submit(mounted.stdin, '/model list'); await tick(); - expect(listModels).toHaveBeenCalledWith('ollama', '', ''); + expect(listModels).toHaveBeenCalledWith('ollama', DEFAULT_OLLAMA_BASE_URL, ''); const frame = mounted.lastFrame() ?? ''; expect(frame).toContain('Select model for ollama'); expect(frame).toContain('stub-model'); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 47a0c9e..21e08a3 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -81,7 +81,14 @@ export interface AppProps { bindAskPublisher?: (publish: (req: import('./askBridge.js').AskRequest | null) => void) => void; yoloInitial?: boolean; /** Read the live config so /provider picker knows current backend / URL / key. */ - readConfig: () => { backend: Backend; baseURL: string; apiKey: string; model: string }; + readConfig: () => { + backend: Backend; + baseURL: string; + apiKey: string; + model: string; + /** Effective base URL for a backend (respects per-backend config defaults). */ + backendBaseURL: (backend: Backend) => string; + }; /** Mutate config + swap agent client + persist. Used by /provider and /model. */ applyProvider: ApplyProvider; /** Flip live YOLO gating on the prompter. Wired by the CLI to @@ -841,7 +848,7 @@ function handleSlash( clearScreen: () => void, yolo: boolean, applyYolo: (on: boolean) => void, - readConfig: () => { backend: Backend; baseURL: string; apiKey: string; model: string }, + readConfig: AppProps['readConfig'], applyProvider: ApplyProvider, promptSecret: (req: Omit) => Promise, persistDisabledSkills: PersistDisabledSkills | undefined, @@ -1011,10 +1018,17 @@ function handleSlash( } const cur = readConfig(); if (m.toLowerCase() === 'list' || m.toLowerCase() === 'ls') { - void fetchAndPickModel(cur.backend, cur.baseURL, cur.apiKey, dispatch, applyProvider, { - currentModel: cur.model || agent.client.model(), - successText: (picked) => `model set to ${picked}`, - }); + void fetchAndPickModel( + cur.backend, + cur.backendBaseURL(cur.backend), + cur.apiKey, + dispatch, + applyProvider, + { + currentModel: cur.model || agent.client.model(), + successText: (picked) => `model set to ${picked}`, + }, + ); return true; } // Validate the id against the live backend catalog before swapping @@ -1024,7 +1038,7 @@ function handleSlash( void (async () => { let known: string[] = []; try { - known = await listModels(cur.backend, cur.baseURL, cur.apiKey); + known = await listModels(cur.backend, cur.backendBaseURL(cur.backend), cur.apiKey); } catch (err) { // Soft fail: if listing isn't available we still proceed, // since some custom endpoints don't implement /models. @@ -1412,10 +1426,7 @@ function pad(s: string, n: number): string { return s.length >= n ? s : s + ' '.repeat(n - s.length); } -function buildHelpText( - agent: Agent, - readConfig: () => { backend: Backend; baseURL: string; apiKey: string; model: string }, -): string { +function buildHelpText(agent: Agent, readConfig: AppProps['readConfig']): string { const c = helpChalk; const cfg = readConfig(); const enabled = agent.skills.listEnabled().length; @@ -1496,7 +1507,13 @@ function buildHelpText( * arrow-key + Enter handling works without modification. */ function openProviderPicker( dispatch: React.Dispatch, - readConfig: () => { backend: Backend; baseURL: string; apiKey: string; model: string }, + readConfig: () => { + backend: Backend; + baseURL: string; + apiKey: string; + model: string; + backendBaseURL: (backend: Backend) => string; + }, applyProvider: ApplyProvider, promptSecret: (req: Omit) => Promise, ): void { @@ -1670,7 +1687,7 @@ function openProviderPicker( ? config.backend === 'gemini' ? config.baseURL || GEMINI_DEFAULT_BASE_URL : GEMINI_DEFAULT_BASE_URL - : ''; + : config.backendBaseURL(backend); const apiKey = backend === 'openai-compat' || config.backend === backend ? config.apiKey : ''; void fetchAndPickModel(backend, baseURL, apiKey, dispatch, applyProvider); },