* 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:
1 parent
010be32377
commit
9cce68e804
4 files changed
+457
-2
No files matched your search
+148
@@ -5514,6 +5514,49 @@
|
|||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<div class="page" id="page-settings">
|
<div class="page" id="page-settings">
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2 class="settings-title">🎛️ Universal API Config</h2>
|
||||||
|
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 12px;">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).</p>
|
||||||
|
<div class="card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display:block; font-size:12px; color:#9fb0bd; margin-bottom:6px; font-weight:600;">API Provider</label>
|
||||||
|
<select class="form-input" id="uacProvider" onchange="uacLoadProvider()">
|
||||||
|
<option value="openrouter">OpenRouter · OpenAI-compatible</option>
|
||||||
|
<option value="venice">Venice · OpenAI-compatible</option>
|
||||||
|
<option value="openai">OpenAI · OpenAI-compatible</option>
|
||||||
|
<option value="anthropic">Claude · Anthropic Messages API</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display:block; font-size:12px; color:#9fb0bd; margin-bottom:6px; font-weight:600;">Base URL <span style="color:#6b7a85; font-weight:400;">— optional, blank uses the provider default</span></label>
|
||||||
|
<input type="text" class="form-input" id="uacBaseUrl" placeholder="https://openrouter.ai/api/v1">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display:block; font-size:12px; color:#9fb0bd; margin-bottom:6px; font-weight:600;">API Key</label>
|
||||||
|
<div class="api-key-input">
|
||||||
|
<input type="password" class="form-input" id="uacApiKey" placeholder="sk-...">
|
||||||
|
<button class="api-key-toggle" onclick="toggleApiKey('uacApiKey')">Show</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display:block; font-size:12px; color:#9fb0bd; margin-bottom:6px; font-weight:600;">Model</label>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<select class="form-input" id="uacModel" style="flex:1;">
|
||||||
|
<option value="">— none selected —</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="uacFetchBtn" onclick="uacFetchModels()">Fetch models</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-hint" id="uacModelHint" style="margin-top:6px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display:block; font-size:12px; color:#9fb0bd; margin-bottom:6px; font-weight:600;">Max Context Tokens <span style="color:#6b7a85; font-weight:400;">— optional</span></label>
|
||||||
|
<input type="number" class="form-input" id="uacMaxTokens" placeholder="e.g. 200000" min="0" step="1000">
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10.5px; color:#8a99a5; margin:2px 0 12px; line-height:1.5;">In-browser mission routing currently uses <b>OpenRouter</b> or <b>Venice</b> (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.</div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="uacSave()">Save config</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h2 class="settings-title">🔑 API Keys</h2>
|
<h2 class="settings-title">🔑 API Keys</h2>
|
||||||
|
|
||||||
@@ -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 === 'operators' && typeof window.loadOperativesRoster === 'function') setTimeout(window.loadOperativesRoster, 80);
|
||||||
if (page === 'evidence' && typeof window.hydrateFindings === 'function') setTimeout(window.hydrateFindings, 60);
|
if (page === 'evidence' && typeof window.hydrateFindings === 'function') setTimeout(window.hydrateFindings, 60);
|
||||||
if (page === 'selfimprove' && typeof window.renderSelfImprove === 'function') setTimeout(window.renderSelfImprove, 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)));
|
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';
|
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 <option>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() {
|
function saveApiServerUrl() {
|
||||||
const url = document.getElementById('apiServerUrl').value.trim().replace(/\/+$/, '');
|
const url = document.getElementById('apiServerUrl').value.trim().replace(/\/+$/, '');
|
||||||
if (!url) {
|
if (!url) {
|
||||||
|
|||||||
@@ -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<string, string> }).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<string, string> }).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>; signal?: AbortSignal },
|
||||||
|
) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
|
||||||
|
|
||||||
|
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<ProviderModel[]> {
|
||||||
|
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<string, string> = {};
|
||||||
|
|
||||||
|
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<ResolvedModels> {
|
||||||
|
try {
|
||||||
|
return { source: 'live', models: await listProviderModels(provider, opts) };
|
||||||
|
} catch (e) {
|
||||||
|
return { source: 'static', models: opts.staticFallback, note: (e as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-2
@@ -16,7 +16,8 @@ import { appendFile, mkdir, readFile, writeFile } from 'fs/promises';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
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 { redactString, redactLedgerText, redactSecrets } from './redact.js';
|
||||||
import { LLMBackbone } from './llm/index.js';
|
import { LLMBackbone } from './llm/index.js';
|
||||||
import { TempestCommand } from './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 { 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 { OPERATOR_SYSTEM_PROMPTS, PLINIAN_OPERATOR_DOCTRINE, THE_FIXER_SYSTEM_PROMPT } from './prompts/index.js';
|
||||||
import { createTargetFromUrl, createTargetFromIP } from './target/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 { listOperatorPrompts, setOperatorOverride, resetOperatorOverride, type OperatorOverride } from './operators/index.js';
|
||||||
import { ingestRepoToSourceContext, runWhiteboxAnalysis, resolveRepoSourceForAnalysis, RepoCloneError, RepoPathError } from './recon/whitebox.js';
|
import { ingestRepoToSourceContext, runWhiteboxAnalysis, resolveRepoSourceForAnalysis, RepoCloneError, RepoPathError } from './recon/whitebox.js';
|
||||||
import { redactCredential } from './evidence/index.js';
|
import { redactCredential } from './evidence/index.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
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)
|
// 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
|
// TOOL EXECUTION ENDPOINTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
Reference in new issue
Block a user