feat(settings): Universal API Config panel with server-side model fetch (#90) (#101)

* feat(settings): Universal API Config panel with server-side model fetch (#90)

Consolidates provider configuration into one "Universal API Config" panel in
Settings — provider dropdown (OpenAI-compatible / Anthropic), base URL, API key,
model with a live "Fetch models" button, and max context tokens — above the
existing per-provider key sections.

Backward-compatible by design: the panel reads and writes the SAME state.settings
keys the individual sections already use (${provider}Key, selectedModel), so
nothing migrates and nothing is lost; the individual sections keep working and
stay in sync.

- src/config/provider-models.ts (new): listProviderModels queries a provider's
  own model endpoint (OpenAI-compatible Bearer /models, Anthropic x-api-key
  /v1/models, OpenRouter public list); resolveModels wraps it fail-open.
- POST /api/models (new route): server-side so the browser dodges provider CORS.
  Binds key+baseUrl by source — a caller-supplied base URL must bring its own
  key, so the server never lends its configured key to a body-chosen URL. Always
  responds, failing open to the static AVAILABLE_MODELS list on a missing key,
  network error, or unknown provider (incl. local-agent) — never hangs.
- Model options are built via the DOM API (createElement/textContent), never
  innerHTML, so untrusted provider-returned model ids cannot inject markup.

Scope notes: augment-not-replace (removing the individual sections is deferred
to maintainer preference); base URL + context cap are stored and used for model
discovery and server/agent flows — in-browser mission routing still uses
OpenRouter/Venice, matching the existing "not wired" hints; the "Model Reasoning
(Eino)" sub-proposal is a separate follow-up pending a mapping.

Verified on macOS: 12 unit tests (RED->GREEN) for the model module, full suite
462 green, and Playwright QA (17 checks) driving the real panel — render,
provider switch, persistence across reload, backward-compat, and both the static
and live Fetch paths.

Refs #90

* fix(settings): harden model list fetch

---------

Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
This commit is contained in:
Lyubomir BozhinovandJoseph Magly authored and GitHub committed 2026-07-20 11:34:26 -04:00
1 parent 010be32377
commit 9cce68e804
4 files changed
+457 -2

No files matched your search

+35 -2
View File
@@ -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<void> => {
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
// =============================================================================