diff --git a/docs/index.html b/docs/index.html
index bbb67ea..83cc41e 100755
--- a/docs/index.html
+++ b/docs/index.html
@@ -5514,6 +5514,49 @@
+
+
ποΈ Universal API Config
+
Configure your LLM providers in one place β provider, key, base URL, model, and context cap β shared with the individual provider sections below (fully backward-compatible).
+
+
+ API Provider
+
+ OpenRouter Β· OpenAI-compatible
+ Venice Β· OpenAI-compatible
+ OpenAI Β· OpenAI-compatible
+ Claude Β· Anthropic Messages API
+
+
+
+ Base URL β optional, blank uses the provider default
+
+
+
+
+
+ Max Context Tokens β optional
+
+
+
In-browser mission routing currently uses OpenRouter or Venice (or a connected local agent). Keys for other providers, custom base URLs, and the context cap are stored and used for model discovery and server-side / agent flows.
+
Save config
+
+
+
π API Keys
@@ -7386,6 +7429,7 @@ Correlating findings across agents, identifying patterns, producing actionable i
if (page === 'operators' && typeof window.loadOperativesRoster === 'function') setTimeout(window.loadOperativesRoster, 80);
if (page === 'evidence' && typeof window.hydrateFindings === 'function') setTimeout(window.hydrateFindings, 60);
if (page === 'selfimprove' && typeof window.renderSelfImprove === 'function') setTimeout(window.renderSelfImprove, 60);
+ if (page === 'settings' && typeof window.uacInit === 'function') setTimeout(window.uacInit, 40);
}
document.querySelectorAll('.nav-item').forEach(i => i.addEventListener('click', () => navigateTo(i.dataset.page)));
@@ -8816,6 +8860,110 @@ o.id === 'coordinator' ? `1. MISSION_STATUS: Progress assessment
if (input) input.value = state.settings?.fallbackModel || 'nousresearch/hermes-3-llama-3.1-405b';
}
+ // ββ Universal API Config panel (#90) ββββββββββββββββββββββββββββββββββββββββββββββββ
+ // One place to set the active provider's key / base URL / model / max-context. Reads and
+ // writes the SAME state.settings keys the individual provider sections use (${p}Key,
+ // selectedModel), so it is fully backward-compatible β nothing migrates, nothing is lost.
+ const UAC_DEFAULT_BASE = {
+ openrouter: 'https://openrouter.ai/api/v1',
+ venice: 'https://api.venice.ai/api/v1',
+ openai: 'https://api.openai.com/v1',
+ anthropic: 'https://api.anthropic.com',
+ };
+ function uacProvider() { return document.getElementById('uacProvider')?.value || 'openrouter'; }
+ // Model ids come from a provider's /models response (and the user can point Base URL at ANY
+ // endpoint), so they are untrusted. Build
s with the DOM API β value/textContent are
+ // injection-proof β never string-interpolated into innerHTML.
+ function uacSetModelOptions(selEl, ids, current) {
+ if (!selEl) return;
+ selEl.replaceChildren();
+ const none = document.createElement('option');
+ none.value = ''; none.textContent = 'β none selected β';
+ selEl.appendChild(none);
+ for (const id of ids) {
+ const o = document.createElement('option');
+ o.value = id; o.textContent = id;
+ if (id && id === current) o.selected = true;
+ selEl.appendChild(o);
+ }
+ }
+ function uacLoadProvider() {
+ const p = uacProvider();
+ const s = state.settings || {};
+ const keyEl = document.getElementById('uacApiKey');
+ const baseEl = document.getElementById('uacBaseUrl');
+ if (keyEl) keyEl.value = s[p + 'Key'] || '';
+ if (baseEl) { baseEl.value = s[p + 'BaseUrl'] || ''; baseEl.placeholder = UAC_DEFAULT_BASE[p] || ''; }
+ const modelEl = document.getElementById('uacModel');
+ if (modelEl) {
+ const sel = s.selectedModel || '';
+ uacSetModelOptions(modelEl, sel ? [sel] : [], sel);
+ }
+ const hint = document.getElementById('uacModelHint'); if (hint) hint.textContent = '';
+ }
+ function uacInit() {
+ const sel = document.getElementById('uacProvider');
+ if (sel && state.settings && state.settings.activeProvider) sel.value = state.settings.activeProvider;
+ const mt = document.getElementById('uacMaxTokens');
+ if (mt) mt.value = (state.settings && state.settings.maxContextTokens) || '';
+ uacLoadProvider();
+ }
+ function uacSave() {
+ const p = uacProvider();
+ const s = state.settings || (state.settings = {});
+ s.activeProvider = p;
+ // Only write the key when the field is non-empty β mirrors saveApiKey's guard so a cleared
+ // field (or a select-all-delete) can't silently wipe a working stored key.
+ const keyVal = document.getElementById('uacApiKey')?.value?.trim() || '';
+ if (keyVal) s[p + 'Key'] = keyVal;
+ const base = document.getElementById('uacBaseUrl')?.value?.trim() || '';
+ if (base) s[p + 'BaseUrl'] = base; else delete s[p + 'BaseUrl'];
+ const model = document.getElementById('uacModel')?.value || '';
+ if (model) s.selectedModel = model;
+ const mtRaw = document.getElementById('uacMaxTokens')?.value;
+ const mt = mtRaw ? parseInt(mtRaw, 10) : 0;
+ if (Number.isFinite(mt) && mt > 0) s.maxContextTokens = mt; else delete s.maxContextTokens;
+ saveState();
+ // keep the individual-section input in sync so both views agree
+ const legacy = document.getElementById(p + 'Key'); if (legacy) legacy.value = s[p + 'Key'] || '';
+ if (typeof updatePreflightChecklist === 'function') updatePreflightChecklist();
+ if (typeof renderModels === 'function') renderModels();
+ toast(`${p} config saved`, 'success');
+ }
+ async function uacFetchModels() {
+ const p = uacProvider();
+ const btn = document.getElementById('uacFetchBtn');
+ const hint = document.getElementById('uacModelHint');
+ const key = document.getElementById('uacApiKey')?.value?.trim() || (state.settings && state.settings[p + 'Key']) || '';
+ const base = document.getElementById('uacBaseUrl')?.value?.trim() || '';
+ const orig = btn ? btn.textContent : '';
+ if (btn) { btn.disabled = true; btn.textContent = 'Fetchingβ¦'; }
+ if (hint) hint.textContent = '';
+ try {
+ const apiBase = (typeof T3MP3ST_API !== 'undefined' && T3MP3ST_API.baseUrl) || '';
+ const r = await fetch(apiBase + '/api/models', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ provider: p, apiKey: key, baseUrl: base }),
+ signal: AbortSignal.timeout(15000), // never leave the button stuck on a hung/slow server
+ });
+ if (!r.ok) throw new Error('server returned HTTP ' + r.status);
+ const data = await r.json();
+ const models = (data && data.models) || [];
+ const sel = document.getElementById('uacModel');
+ const current = state.settings?.selectedModel || '';
+ uacSetModelOptions(sel, models.map(m => m.id).filter(id => typeof id === 'string'), current);
+ if (hint) hint.textContent = models.length
+ ? `${models.length} models Β· ${data.source === 'live' ? 'live from provider' : 'built-in list β add a key/base URL for a live list'}`
+ : 'No models returned';
+ } catch (e) {
+ if (hint) hint.textContent = 'Fetch failed: ' + e.message + ' β is the T3MP3ST server running?';
+ } finally {
+ if (btn) { btn.disabled = false; btn.textContent = orig; }
+ }
+ }
+ window.uacInit = uacInit;
+
function saveApiServerUrl() {
const url = document.getElementById('apiServerUrl').value.trim().replace(/\/+$/, '');
if (!url) {
diff --git a/src/__tests__/provider-models.test.ts b/src/__tests__/provider-models.test.ts
new file mode 100644
index 0000000..5c223f0
--- /dev/null
+++ b/src/__tests__/provider-models.test.ts
@@ -0,0 +1,149 @@
+/**
+ * Model-list fetching for the Universal API Config panel (issue #90).
+ *
+ * The Settings "Fetch models" button can't call api.openai.com/v1/models etc. directly from the
+ * browser (CORS), and today there are TWO hardcoded, already-drifting model lists (config
+ * AVAILABLE_MODELS vs the UI's MODELS array). `listProviderModels` is the server-side, testable core
+ * that queries a provider's own model endpoint with the OpenAI-compatible / Anthropic wire shape.
+ * It THROWS on any failure so the caller (the /api/models route) can fail open to the static list β
+ * a broken/keyless provider must never leave the panel empty.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { listProviderModels, resolveModels } from '../config/provider-models.js';
+
+/** A fake fetch returning `body` as JSON with the given ok/status. Records the call. */
+function fakeFetch(body: unknown, ok = true, status = 200) {
+ return vi.fn(async (_url: string, _init?: unknown) => ({
+ ok, status, json: async () => body,
+ }));
+}
+
+describe('listProviderModels β OpenAI-compatible providers', () => {
+ it('GETs {baseUrl}/models with a Bearer key and returns the data[].id list', async () => {
+ const f = fakeFetch({ data: [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }] });
+ const models = await listProviderModels('openai', { baseUrl: 'https://api.openai.com/v1', apiKey: 'sk-test', fetchImpl: f });
+
+ expect(models.map((m) => m.id)).toEqual(['gpt-4o', 'gpt-4o-mini']);
+ const [url, init] = f.mock.calls[0];
+ expect(url).toBe('https://api.openai.com/v1/models');
+ expect((init as { headers: Record }).headers.Authorization).toBe('Bearer sk-test');
+ });
+
+ it('passes an abort signal so a non-responsive provider cannot hold the route open indefinitely', async () => {
+ const f = fakeFetch({ data: [{ id: 'gpt-4o' }] });
+ await listProviderModels('openai', { baseUrl: 'https://api.openai.com/v1', apiKey: 'k', fetchImpl: f, timeoutMs: 123 });
+
+ const init = f.mock.calls[0][1] as { signal?: AbortSignal };
+ expect(init.signal).toBeTruthy();
+ });
+
+ it('handles venice/xai/local the same OpenAI-compatible way (base already ends in /v1)', async () => {
+ const f = fakeFetch({ data: [{ id: 'llama-3.3-70b' }] });
+ const models = await listProviderModels('venice', { baseUrl: 'https://api.venice.ai/api/v1', apiKey: 'k', fetchImpl: f });
+ expect(models.map((m) => m.id)).toEqual(['llama-3.3-70b']);
+ expect(f.mock.calls[0][0]).toBe('https://api.venice.ai/api/v1/models');
+ });
+});
+
+describe('listProviderModels β Anthropic wire', () => {
+ it('GETs {baseUrl}/v1/models with x-api-key + anthropic-version headers', async () => {
+ const f = fakeFetch({ data: [{ id: 'claude-opus-4-8' }, { id: 'claude-sonnet-4-5' }] });
+ const models = await listProviderModels('anthropic', { baseUrl: 'https://api.anthropic.com', apiKey: 'sk-ant', fetchImpl: f });
+
+ expect(models.map((m) => m.id)).toEqual(['claude-opus-4-8', 'claude-sonnet-4-5']);
+ const [url, init] = f.mock.calls[0];
+ expect(url).toBe('https://api.anthropic.com/v1/models');
+ const headers = (init as { headers: Record }).headers;
+ expect(headers['x-api-key']).toBe('sk-ant');
+ expect(headers['anthropic-version']).toBeTruthy();
+ expect(headers.Authorization).toBeUndefined(); // Anthropic does NOT use Bearer
+ });
+});
+
+describe('listProviderModels β OpenRouter', () => {
+ it('lists from the OpenRouter models endpoint (works without a key)', async () => {
+ const f = fakeFetch({ data: [{ id: 'anthropic/claude-opus-4.8' }, { id: 'google/gemini-2.5-pro' }] });
+ const models = await listProviderModels('openrouter', { fetchImpl: f });
+ expect(models.map((m) => m.id)).toEqual(['anthropic/claude-opus-4.8', 'google/gemini-2.5-pro']);
+ expect(String(f.mock.calls[0][0])).toContain('openrouter.ai');
+ });
+
+ it('honors a custom OpenRouter-compatible base URL when provided', async () => {
+ const f = fakeFetch({ data: [{ id: 'x' }] });
+ await listProviderModels('openrouter', { baseUrl: 'https://my-gateway.local/v1', fetchImpl: f });
+ expect(f.mock.calls[0][0]).toBe('https://my-gateway.local/v1/models');
+ });
+});
+
+describe('listProviderModels β base URL normalization', () => {
+ it('does not double /v1 for Anthropic when the base already ends in /v1', async () => {
+ const f = fakeFetch({ data: [{ id: 'claude-x' }] });
+ await listProviderModels('anthropic', { baseUrl: 'https://api.anthropic.com/v1', apiKey: 'k', fetchImpl: f });
+ expect(f.mock.calls[0][0]).toBe('https://api.anthropic.com/v1/models'); // not /v1/v1/models
+ });
+});
+
+describe('listProviderModels β failure modes (caller fails open to the static list)', () => {
+ it('throws on a non-ok HTTP response', async () => {
+ const f = fakeFetch({ error: 'unauthorized' }, false, 401);
+ await expect(listProviderModels('openai', { baseUrl: 'https://api.openai.com/v1', apiKey: 'bad', fetchImpl: f }))
+ .rejects.toThrow();
+ });
+
+ it('throws for a keyless/local-only provider that has no remote model list', async () => {
+ const f = fakeFetch({});
+ await expect(listProviderModels('codex', { fetchImpl: f })).rejects.toThrow();
+ expect(f).not.toHaveBeenCalled(); // no pointless network call
+ });
+
+ it('throws for an unknown provider instead of treating it as OpenAI-compatible', async () => {
+ const f = fakeFetch({});
+ await expect(listProviderModels('not-a-provider', { baseUrl: 'https://example.test/v1', fetchImpl: f }))
+ .rejects.toThrow(/not supported/);
+ expect(f).not.toHaveBeenCalled();
+ });
+
+ it('throws when the response has no data[] array (malformed)', async () => {
+ const f = fakeFetch({ nonsense: true });
+ await expect(listProviderModels('openai', { baseUrl: 'https://api.openai.com/v1', apiKey: 'k', fetchImpl: f }))
+ .rejects.toThrow();
+ });
+});
+
+describe('resolveModels β fail-open orchestrator (never throws; the panel is never empty)', () => {
+ const STATIC = [{ id: 'static-fallback-model' }];
+
+ it('returns live models when the fetch succeeds', async () => {
+ const f = fakeFetch({ data: [{ id: 'gpt-4o' }] });
+ const r = await resolveModels('openai', { baseUrl: 'https://api.openai.com/v1', apiKey: 'k', fetchImpl: f, staticFallback: STATIC });
+ expect(r.source).toBe('live');
+ expect(r.models.map((m) => m.id)).toEqual(['gpt-4o']);
+ });
+
+ it('fails open to the static list (with a note) when the fetch errors', async () => {
+ const f = fakeFetch({ error: 'nope' }, false, 500);
+ const r = await resolveModels('openai', { baseUrl: 'https://api.openai.com/v1', apiKey: 'k', fetchImpl: f, staticFallback: STATIC });
+ expect(r.source).toBe('static');
+ expect(r.models).toEqual(STATIC);
+ expect(r.note).toContain('500');
+ });
+
+ it('fails open for a keyless provider with no remote list', async () => {
+ const r = await resolveModels('codex', { staticFallback: STATIC, fetchImpl: fakeFetch({}) });
+ expect(r.source).toBe('static');
+ expect(r.models).toEqual(STATIC);
+ });
+
+ it('fails open for an unknown provider without calling a caller-supplied base URL', async () => {
+ const f = fakeFetch({});
+ const r = await resolveModels('not-a-provider', {
+ baseUrl: 'https://example.test/v1',
+ staticFallback: STATIC,
+ fetchImpl: f,
+ });
+
+ expect(r.source).toBe('static');
+ expect(r.models).toEqual(STATIC);
+ expect(f).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/config/provider-models.ts b/src/config/provider-models.ts
new file mode 100644
index 0000000..d40c47f
--- /dev/null
+++ b/src/config/provider-models.ts
@@ -0,0 +1,125 @@
+/**
+ * Server-side model-list fetching for the Universal API Config panel (issue #90).
+ *
+ * The Settings "Fetch models" button can't hit api.openai.com/v1/models (etc.) directly from the
+ * browser (CORS), so the /api/models route calls this. It queries a provider's own model endpoint
+ * using the correct wire shape (OpenAI-compatible Bearer `/models`, or the Anthropic `x-api-key`
+ * `/v1/models`), and THROWS on any failure so the route can fail open to the static AVAILABLE_MODELS
+ * list β a broken/keyless provider must never leave the panel empty.
+ */
+
+export interface ProviderModel {
+ id: string;
+}
+
+type FetchLike = (
+ url: string,
+ init?: { headers?: Record; signal?: AbortSignal },
+) => Promise<{ ok: boolean; status: number; json: () => Promise }>;
+
+const ANTHROPIC_VERSION = '2023-06-01';
+const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
+const DEFAULT_MODEL_LIST_TIMEOUT_MS = 15_000;
+
+// Pseudo-providers with no remote model list (CLI-driven or in-process).
+const NO_REMOTE_LIST = new Set(['codex', 'mock', 'local-agent']);
+const OPENAI_COMPATIBLE_REMOTE_LIST = new Set(['openai', 'venice', 'xai', 'gemini', 'local']);
+const DIRECT_REMOTE_LIST = new Set(['anthropic', 'openrouter']);
+
+const stripTrailingSlash = (u: string): string => u.replace(/\/+$/, '');
+
+export function providerCanListModels(provider: string): boolean {
+ return DIRECT_REMOTE_LIST.has(provider) || OPENAI_COMPATIBLE_REMOTE_LIST.has(provider);
+}
+
+function timeoutSignal(timeoutMs: number): AbortSignal | undefined {
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return undefined;
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
+ return AbortSignal.timeout(timeoutMs);
+ }
+ if (typeof AbortController === 'undefined') return undefined;
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ if (typeof timeout.unref === 'function') timeout.unref();
+ return controller.signal;
+}
+
+/** Extract `data[].id` from an OpenAI-/Anthropic-shaped model-list response, or throw if malformed/empty. */
+function parseModelList(body: unknown): ProviderModel[] {
+ const data = (body as { data?: unknown } | null)?.data;
+ if (!Array.isArray(data)) throw new Error('provider model-list response has no data[] array');
+ const models = data
+ .map((m) => (m && typeof m === 'object' ? (m as { id?: unknown }).id : undefined))
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
+ .map((id) => ({ id }));
+ if (!models.length) throw new Error('provider returned an empty model list');
+ return models;
+}
+
+/**
+ * List a provider's available models via its own endpoint. Throws on any failure (missing baseUrl,
+ * non-2xx, malformed/empty body, or a provider with no remote list) β the caller fails open.
+ */
+export async function listProviderModels(
+ provider: string,
+ opts: { baseUrl?: string; apiKey?: string; fetchImpl?: FetchLike; timeoutMs?: number } = {},
+): Promise {
+ if (NO_REMOTE_LIST.has(provider)) {
+ throw new Error(`provider '${provider}' has no remote model list`);
+ }
+ if (!providerCanListModels(provider)) {
+ throw new Error(`provider '${provider}' is not supported for remote model listing`);
+ }
+ const doFetch = opts.fetchImpl ?? (globalThis.fetch as unknown as FetchLike | undefined);
+ if (!doFetch) throw new Error('no fetch implementation available');
+
+ let url: string;
+ const headers: Record = {};
+
+ if (provider === 'anthropic') {
+ // Strip a trailing /v1 first so a user who types ".../v1" (matching every other provider's base)
+ // doesn't get a double /v1/v1/models β 404.
+ const abase = stripTrailingSlash(opts.baseUrl || 'https://api.anthropic.com').replace(/\/v1$/, '');
+ url = `${abase}/v1/models`;
+ headers['anthropic-version'] = ANTHROPIC_VERSION; // Anthropic uses x-api-key, NOT Bearer
+ if (opts.apiKey) headers['x-api-key'] = opts.apiKey;
+ } else if (provider === 'openrouter') {
+ // Honor a custom OpenRouter-compatible base URL if given; else the public list.
+ url = opts.baseUrl && opts.baseUrl.trim() ? `${stripTrailingSlash(opts.baseUrl)}/models` : OPENROUTER_MODELS_URL;
+ if (opts.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`;
+ } else {
+ // OpenAI-compatible: openai / venice / xai / gemini / local / litellm / openai-compat.
+ const base = opts.baseUrl && opts.baseUrl.trim() ? stripTrailingSlash(opts.baseUrl) : undefined;
+ if (!base) throw new Error(`provider '${provider}' requires a baseUrl to list models`);
+ url = `${base}/models`;
+ if (opts.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`;
+ }
+
+ const signal = timeoutSignal(opts.timeoutMs ?? DEFAULT_MODEL_LIST_TIMEOUT_MS);
+ const res = await doFetch(url, { headers, signal });
+ if (!res.ok) throw new Error(`model-list request failed: HTTP ${res.status}`);
+ return parseModelList(await res.json());
+}
+
+export interface ResolvedModels {
+ source: 'live' | 'static';
+ models: ProviderModel[];
+ note?: string;
+}
+
+/**
+ * Live models with a guaranteed fail-open to `staticFallback` β NEVER throws. The /api/models route
+ * uses this so a missing key, CORS/network error, or unlisted provider still returns the static
+ * AVAILABLE_MODELS list (the panel is never empty) with `source:'static'` so the UI can flag it.
+ */
+export async function resolveModels(
+ provider: string,
+ opts: { baseUrl?: string; apiKey?: string; fetchImpl?: FetchLike; timeoutMs?: number; staticFallback: ProviderModel[] },
+): Promise {
+ try {
+ return { source: 'live', models: await listProviderModels(provider, opts) };
+ } catch (e) {
+ return { source: 'static', models: opts.staticFallback, note: (e as Error).message };
+ }
+}
diff --git a/src/server.ts b/src/server.ts
index dbc6e18..56ae86c 100755
--- a/src/server.ts
+++ b/src/server.ts
@@ -16,7 +16,8 @@ import { appendFile, mkdir, readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { promisify } from 'util';
import { createHash, randomUUID } from 'crypto';
-import { config } from './config/index.js';
+import { config, AVAILABLE_MODELS } from './config/index.js';
+import { resolveModels } from './config/provider-models.js';
import { redactString, redactLedgerText, redactSecrets } from './redact.js';
import { LLMBackbone } from './llm/index.js';
import { TempestCommand } from './index.js';
@@ -28,13 +29,17 @@ import { AGENT_PROMPT_PACKS, FOREFRONT_PRESSURE_LANES, OPERATOR_RUNBOOKS, RESOUR
import { AI_REDTEAM_PLAYBOOK, AI_REDTEAM_TECHNIQUE_IDS, aiRedTeamBriefing } from './resources/ai-redteam-playbook.js';
import { OPERATOR_SYSTEM_PROMPTS, PLINIAN_OPERATOR_DOCTRINE, THE_FIXER_SYSTEM_PROMPT } from './prompts/index.js';
import { createTargetFromUrl, createTargetFromIP } from './target/index.js';
-import type { OperatorArchetype } from './types/index.js';
+import type { OperatorArchetype, LLMProvider } from './types/index.js';
import { listOperatorPrompts, setOperatorOverride, resetOperatorOverride, type OperatorOverride } from './operators/index.js';
import { ingestRepoToSourceContext, runWhiteboxAnalysis, resolveRepoSourceForAnalysis, RepoCloneError, RepoPathError } from './recon/whitebox.js';
import { redactCredential } from './evidence/index.js';
const execFileAsync = promisify(execFile);
+function isKnownLLMProvider(provider: string): provider is LLMProvider {
+ return Object.prototype.hasOwnProperty.call(AVAILABLE_MODELS, provider);
+}
+
// =============================================================================
// PAYLOAD DATABASES (Shared with MCP server)
// =============================================================================
@@ -6088,6 +6093,34 @@ app.get('/api/llm/status', (_req: Request, res: Response) => {
});
});
+// POST /api/models β live model list for the Universal API Config panel (#90).
+// POST (not GET) so the browser can pass its own localStorage-held key/baseUrl in the body β the
+// standalone UI holds the key, not the server (consistent with the existing per-request mission key,
+// and it stays on localhost). Body values override server config; missing β server config. Runs
+// server-side so the browser dodges provider CORS, and fails open to the static AVAILABLE_MODELS list
+// (source:'static') so a missing key / network error never leaves the panel empty.
+app.post('/api/models', async (req: Request, res: Response): Promise => {
+ const body = (req.body ?? {}) as { provider?: string; apiKey?: string; baseUrl?: string };
+ const defaultProvider = config.getLLMConfig().provider;
+ const requestedProvider = (typeof body.provider === 'string' && body.provider.trim()) || defaultProvider;
+ const provider = isKnownLLMProvider(requestedProvider) ? requestedProvider : defaultProvider;
+ // getLLMConfig throws for any provider it doesn't switch on (e.g. the legitimate 'local-agent', or a
+ // garbage string). A synchronous throw inside this async handler would hang the request (Express 4
+ // never responds), so swallow it and fall through to the static list β the route must always answer.
+ let cfg: { baseUrl?: string; apiKey?: string } = {};
+ try { cfg = config.getLLMConfig(provider); } catch { /* provider has no remote/server config β static fallback */ }
+ const bodyBaseUrl = typeof body.baseUrl === 'string' && body.baseUrl.trim() ? body.baseUrl : undefined;
+ const bodyKey = typeof body.apiKey === 'string' && body.apiKey ? body.apiKey : undefined;
+ // Bind key+baseUrl by source: a caller-supplied custom baseUrl must bring its OWN key β never lend
+ // the server's configured apiKey to a body-chosen URL (that would disclose the stored key). The
+ // server key is only ever sent to the server's own configured provider endpoint.
+ const baseUrl = bodyBaseUrl ?? cfg.baseUrl;
+ const apiKey = bodyBaseUrl ? bodyKey : (bodyKey ?? cfg.apiKey);
+ const staticFallback = AVAILABLE_MODELS[provider].map((m: { id: string }) => ({ id: m.id }));
+ const resolved = await resolveModels(provider, { baseUrl, apiKey, staticFallback });
+ res.json({ provider, ...resolved });
+});
+
// =============================================================================
// TOOL EXECUTION ENDPOINTS
// =============================================================================