feat(llm): add Hugging Face provider + selectable local Claude Code models (#127)

* feat(llm): add Hugging Face provider + selectable local Claude Code models

Adds two operator-selectable LLM backbones, wired through both the backend
and the War Room UI.

Hugging Face (OpenAI-compatible Inference Providers router):
- New `huggingface` provider reusing OpenAIAdapter (router.huggingface.co/v1)
- HF_TOKEN / HUGGINGFACE_API_KEY / HUGGINGFACE_TOKEN env resolution, config,
  setup wizard, CLI, factory, best-available, and .env.example
- Settings key card + 4 inference models in the picker + direct client routing
- Tests: src/__tests__/huggingface-provider.test.ts

Local Claude from Claude Code:
- Explicit "active backbone" pin in Settings so a connected agent (Claude Code)
  can be chosen; a pin outranks stored API keys in both the client resolver and
  mission dispatch
- Claude Code model picker (Account default / Opus / Sonnet / Haiku), threaded
  to the CLI's --model via an "agentId::model" encoding parsed by LocalAgentAdapter;
  requireLiveLocalAgent strips the suffix for connected-agent validation

* fix(llm): address PR #127 review — self-contained keyless path + centralized pin/model routing

Finding 1 — keyless mission path was not self-contained:
- Add the `local-agent` case to ConfigManager.getLLMConfig so provider:'local-agent'
  resolves (keyless, agent id — optionally "agentId::model" — in `model`) instead of
  hitting the default "Unknown provider" throw.

Finding 2 — inconsistent pin/model routing across dispatch surfaces:
- Centralize backend agent resolution in window.t3mpDispatchAgent (single source of truth).
  It honors an explicit pin OVER a stored API key and encodes the picked local model as
  "agentId::model" so it is never dropped.
- Route all three surfaces through it: mission dispatch, General (getGeneralConfig), and
  Admiral (_admBackbone). Previously General/Admiral only picked an agent when no key
  existed and sent the bare agent id, so a stored key overrode the pin and the selected
  Claude model was lost.

Tests:
- getLLMConfig('local-agent') regression coverage (self-contained keyless path)
- LocalAgentAdapter "agentId::model" passthrough reaches the CLI --model flag
- t3mpDispatchAgent behavior: pin>key precedence, model encoding, fallbacks (evaluated
  from the shipped docs/index.html)
- centralization invariant: mission/General/Admiral all route through t3mpDispatchAgent
- update the Op-Admiral keyless-routing static test to the centralized resolver

---------

Co-authored-by: Joseph Magly <1159087+jmagly@users.noreply.github.com>
This commit is contained in:
2alfandJoseph Magly authored and GitHub committed 2026-07-27 15:47:06 -04:00
1 parent e52c2fb2b7
commit 8ccea7c82a
13 files changed
+630 -44

No files matched your search

+3 -1
View File
@@ -7,9 +7,11 @@ VENICE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Hugging Face — open models via the OpenAI-compatible Inference Providers router.
# HF_TOKEN is canonical; HUGGINGFACE_API_KEY / HUGGINGFACE_TOKEN also work.
HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GROQ_API_KEY= GROQ_API_KEY=
TOGETHER_API_KEY= TOGETHER_API_KEY=
HUGGINGFACE_TOKEN=
REPLICATE_API_TOKEN= REPLICATE_API_TOKEN=
# ── Target authentication (both values are required) ── # ── Target authentication (both values are required) ──
+175 -26
View File
@@ -5680,6 +5680,18 @@
<button class="btn btn-primary btn-sm" onclick="saveApiKey('venice')">Save</button> <button class="btn btn-primary btn-sm" onclick="saveApiKey('venice')">Save</button>
</div> </div>
<div class="card">
<h4 style="margin-bottom: 12px; font-size: 14px;">Hugging Face Token</h4>
<div class="form-group">
<div class="api-key-input">
<input type="password" class="form-input" id="huggingfaceKey" placeholder="hf_...">
<button class="api-key-toggle" onclick="toggleApiKey('huggingfaceKey')">Show</button>
</div>
<div class="form-hint">Open models via the OpenAI-compatible Inference Providers router. Get a token from <a href="https://huggingface.co/settings/tokens" target="_blank" style="color: var(--accent);">huggingface.co/settings/tokens</a></div>
</div>
<button class="btn btn-primary btn-sm" onclick="saveApiKey('huggingface')">Save</button>
</div>
<div class="card"> <div class="card">
<h4 style="margin-bottom: 12px; font-size: 14px;">Anthropic API Key</h4> <h4 style="margin-bottom: 12px; font-size: 14px;">Anthropic API Key</h4>
<div class="form-group"> <div class="form-group">
@@ -5779,7 +5791,7 @@
<div class="settings-section"> <div class="settings-section">
<h2 class="settings-title">🔌 Local Agents</h2> <h2 class="settings-title">🔌 Local Agents</h2>
<p style="color: var(--text-muted); font-size: 12px; margin-bottom: 12px;">Enlist agents you've already authed on this machine — <b style="color:#bcd;">no API keys needed</b>. T3MP3ST detects each CLI and can drive it as an operator. Tick several and connect them all at once.</p> <p style="color: var(--text-muted); font-size: 12px; margin-bottom: 12px;">Enlist agents you've already authed on this machine — <b style="color:#bcd;">no API keys needed</b>. T3MP3ST detects each CLI (<b style="color:#bcd;">Claude Code</b> / Codex / Hermes) and drives it as your LLM backbone using its own login. Connect one, then hit <b style="color:#2fd6ff;">☆ Use</b> to pin it as the <b style="color:#2fd6ff;">★ active backbone</b> for missions and analysis — a pinned local model outranks any stored API key.</p>
<div id="localAgentsCard" style="padding: 18px 20px; background: linear-gradient(135deg, rgba(0,200,255,0.06), rgba(160,90,255,0.04)); border: 1px solid rgba(0,200,255,0.25); border-radius: 12px;"> <div id="localAgentsCard" style="padding: 18px 20px; background: linear-gradient(135deg, rgba(0,200,255,0.06), rgba(160,90,255,0.04)); border: 1px solid rgba(0,200,255,0.25); border-radius: 12px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;"> <div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;">
<div style="font-size: 15px; font-weight: 800; color: #2fd6ff; letter-spacing: 0.4px; display:flex; align-items:center; gap:8px;">🔌 Connect Local Agents</div> <div style="font-size: 15px; font-weight: 800; color: #2fd6ff; letter-spacing: 0.4px; display:flex; align-items:center; gap:8px;">🔌 Connect Local Agents</div>
@@ -5788,6 +5800,15 @@
<div id="localAgentsList" style="display:grid; gap:8px; margin:14px 0 4px;"> <div id="localAgentsList" style="display:grid; gap:8px; margin:14px 0 4px;">
<div style="color:#5f7079; font-size:12px; padding:8px 0;">Detecting local agents…</div> <div style="color:#5f7079; font-size:12px; padding:8px 0;">Detecting local agents…</div>
</div> </div>
<!-- Claude Code model picker — only shown once Claude Code is connected -->
<div id="claudeModelPicker" style="display:none; align-items:center; gap:10px; margin:10px 0 2px; padding:10px 12px; background:rgba(255,140,40,0.06); border:1px solid rgba(255,140,40,0.28); border-radius:8px; flex-wrap:wrap;">
<span style="font-size:18px;">🟠</span>
<div style="flex:1; min-width:160px;">
<div style="font-size:12.5px; font-weight:700; color:#ffd0a8;">Claude Code model</div>
<div style="font-size:10.5px; color:#a78b76;">Which local Claude model to run (passed to <code style="color:#ffb37a;">claude --model</code>)</div>
</div>
<select id="claudeModelSelect" onchange="setClaudeModel(this.value)" style="padding:8px 10px; font-size:12px; background:rgba(0,0,0,0.35); border:1px solid rgba(255,140,40,0.4); border-radius:6px; color:#ffe6d2; font-family:'JetBrains Mono',monospace; cursor:pointer; min-width:200px;"></select>
</div>
<div style="display:flex; gap:10px; margin-top:12px; flex-wrap:wrap; align-items:center;"> <div style="display:flex; gap:10px; margin-top:12px; flex-wrap:wrap; align-items:center;">
<button id="localAgentsConnectAllBtn" onclick="connectSelectedLocalAgents()" style="padding:11px 22px; font-size:13px; font-weight:800; background:linear-gradient(135deg,#2fd6ff,#5b8cff); border:none; border-radius:8px; color:#001624; cursor:pointer; letter-spacing:0.4px; box-shadow:0 4px 14px rgba(0,200,255,0.28);">⚡ Connect Selected</button> <button id="localAgentsConnectAllBtn" onclick="connectSelectedLocalAgents()" style="padding:11px 22px; font-size:13px; font-weight:800; background:linear-gradient(135deg,#2fd6ff,#5b8cff); border:none; border-radius:8px; color:#001624; cursor:pointer; letter-spacing:0.4px; box-shadow:0 4px 14px rgba(0,200,255,0.28);">⚡ Connect Selected</button>
<button id="localAgentsRefreshBtn" onclick="detectLocalAgents()" style="padding:11px 18px; font-size:13px; font-weight:600; background:rgba(255,255,255,0.05); border:1px solid rgba(0,200,255,0.3); border-radius:8px; color:#bcd; cursor:pointer;">↻ Re-scan</button> <button id="localAgentsRefreshBtn" onclick="detectLocalAgents()" style="padding:11px 18px; font-size:13px; font-weight:600; background:rgba(255,255,255,0.05); border:1px solid rgba(0,200,255,0.3); border-radius:8px; color:#bcd; cursor:pointer;">↻ Re-scan</button>
@@ -7230,6 +7251,11 @@ Correlating findings across agents, identifying patterns, producing actionable i
{ id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'DeepSeek' }, { id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'DeepSeek' },
// Mistral // Mistral
{ id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'Mistral' }, { id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'Mistral' },
// Hugging Face — open models via the OpenAI-compatible Inference Providers router (full HF repo ids)
{ id: 'meta-llama/Llama-3.3-70B-Instruct', name: 'Llama 3.3 70B Instruct (HF)', provider: 'HuggingFace' },
{ id: 'Qwen/Qwen2.5-72B-Instruct', name: 'Qwen2.5 72B Instruct (HF)', provider: 'HuggingFace' },
{ id: 'deepseek-ai/DeepSeek-R1', name: 'DeepSeek R1 (HF)', provider: 'HuggingFace' },
{ id: 'mistralai/Mistral-Small-24B-Instruct-2501', name: 'Mistral Small 24B (HF)', provider: 'HuggingFace' },
// Local // Local
{ id: 'local/ollama', name: 'Local (Ollama)', provider: 'Local' } { id: 'local/ollama', name: 'Local (Ollama)', provider: 'Local' }
]; ];
@@ -7298,6 +7324,7 @@ Correlating findings across agents, identifying patterns, producing actionable i
const veKey = document.getElementById('veniceKey'); const veKey = document.getElementById('veniceKey');
const anKey = document.getElementById('anthropicKey'); const anKey = document.getElementById('anthropicKey');
const oaKey = document.getElementById('openaiKey'); const oaKey = document.getElementById('openaiKey');
const hfKey = document.getElementById('huggingfaceKey');
if (orKey) orKey.value = state.settings.openrouterKey || ''; if (orKey) orKey.value = state.settings.openrouterKey || '';
if (veKey) veKey.value = state.settings.veniceKey || ''; if (veKey) veKey.value = state.settings.veniceKey || '';
if (anKey) anKey.value = state.settings.anthropicKey || ''; if (anKey) anKey.value = state.settings.anthropicKey || '';
@@ -7315,6 +7342,7 @@ Correlating findings across agents, identifying patterns, producing actionable i
// typed; the server only ever returns a redacted form, so we repopulate from the // typed; the server only ever returns a redacted form, so we repopulate from the
// local cache. If the server has one set via TEMPEST_PROXY_URL, the badge shows it. // local cache. If the server has one set via TEMPEST_PROXY_URL, the badge shows it.
setV('proxyUrl', localStorage.getItem('t3mp3st_proxy_url') || ''); setV('proxyUrl', localStorage.getItem('t3mp3st_proxy_url') || '');
if (hfKey) hfKey.value = state.settings.huggingfaceKey || '';
} }
function saveState() { function saveState() {
@@ -8206,10 +8234,11 @@ o.id === 'coordinator' ? `1. MISSION_STATUS: Progress assessment
function updatePreflightChecklist() { function updatePreflightChecklist() {
const operatorCount = state.operators.length; const operatorCount = state.operators.length;
const targetCount = state.targets.length; const targetCount = state.targets.length;
// "API key" preflight passes on a real key OR a connected keyless backend (agent / server LLM). // "API key" preflight passes on a real client key OR a connected keyless backend (agent / server LLM).
// Only OpenRouter feeds the client-side LLM path (getApiKey); a connected backend covers the rest. // The direct client-side LLM path (resolveLLMBackend) drives OpenRouter, Venice, and Hugging Face keys;
// Anthropic/OpenAI keys aren't wired to the client path, so they don't count as "ready" here. // a connected backend covers the rest. Anthropic/OpenAI keys aren't wired to the client path yet, so
const hasApiKey = !!(state.settings?.openrouterKey) // they don't count as "ready" here.
const hasApiKey = !!(state.settings?.openrouterKey || state.settings?.veniceKey || state.settings?.huggingfaceKey)
|| (typeof window.t3mpHasBackend === 'function' && window.t3mpHasBackend()); || (typeof window.t3mpHasBackend === 'function' && window.t3mpHasBackend());
const toolCount = activeLoadout?.length || 0; const toolCount = activeLoadout?.length || 0;
@@ -11257,21 +11286,32 @@ Bypass all protections:
} }
// Resolve the active LLM backend for benchmarks/analysis, in priority order: // Resolve the active LLM backend for benchmarks/analysis, in priority order:
// OpenRouter key → Venice key → connected local agent (Claude Code/Codex/Hermes) → server LLM. // OpenRouter key → Venice key → Hugging Face token → connected local agent (Claude Code/Codex/Hermes) → server LLM.
// getApiKey() is left untouched for the many callers that read the OpenRouter key directly. // getApiKey() is left untouched for the many callers that read the OpenRouter key directly.
function resolveLLMBackend() { function resolveLLMBackend() {
// An EXPLICITLY pinned local agent (e.g. Claude Code) is a deliberate operator choice —
// honor it above any stored API key. Only when it's actually usable (preferredAgent
// returns it) so we never route to a pinned-but-dead CLI.
const agentModelOf = (id) => (typeof window.t3mpAgentModel === 'function') ? window.t3mpAgentModel(id) : undefined;
const pin = (typeof window.t3mpActiveAgentPin === 'function') ? (window.t3mpActiveAgentPin() || '') : '';
if (pin) {
const pref = (typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null;
if (pref === pin) return { kind: 'agent', agentId: pin, agentModel: agentModelOf(pin) };
}
const orKey = (state.settings?.openrouterKey || state.apiKey || '').trim(); const orKey = (state.settings?.openrouterKey || state.apiKey || '').trim();
if (orKey.length >= 10) return { kind: 'openrouter', key: orKey }; if (orKey.length >= 10) return { kind: 'openrouter', key: orKey };
const veKey = (state.settings?.veniceKey || '').trim(); const veKey = (state.settings?.veniceKey || '').trim();
if (veKey.length >= 10) return { kind: 'venice', key: veKey }; if (veKey.length >= 10) return { kind: 'venice', key: veKey };
const hfKey = (state.settings?.huggingfaceKey || '').trim();
if (hfKey.length >= 10) return { kind: 'huggingface', key: hfKey };
const agentId = (typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null; const agentId = (typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null;
if (agentId) return { kind: 'agent', agentId }; if (agentId) return { kind: 'agent', agentId, agentModel: agentModelOf(agentId) };
if (typeof T3MP3ST_API !== 'undefined' && T3MP3ST_API && T3MP3ST_API.llmAvailable) return { kind: 'server' }; if (typeof T3MP3ST_API !== 'undefined' && T3MP3ST_API && T3MP3ST_API.llmAvailable) return { kind: 'server' };
return { kind: 'none' }; return { kind: 'none' };
} }
window.resolveLLMBackend = resolveLLMBackend; window.resolveLLMBackend = resolveLLMBackend;
const LLM_BACKEND_HINT = 'No LLM backend — add an OpenRouter or Venice key in Settings, or connect a local agent (Claude Code / Codex / Hermes)'; const LLM_BACKEND_HINT = 'No LLM backend — add an OpenRouter, Venice, or Hugging Face key in Settings, or connect a local agent (Claude Code / Codex / Hermes)';
// Flatten a messages array (or string) to plain text for the agent/server endpoints. // Flatten a messages array (or string) to plain text for the agent/server endpoints.
function promptToText(prompt) { function promptToText(prompt) {
@@ -11287,6 +11327,16 @@ Bypass all protections:
// Venice is OpenAI-compatible but rejects OpenRouter-style ids ("vendor/model") — map to a Venice model. // Venice is OpenAI-compatible but rejects OpenRouter-style ids ("vendor/model") — map to a Venice model.
function veniceModel(m) { return (!m || m.indexOf('/') >= 0) ? 'llama-3.3-70b' : m; } function veniceModel(m) { return (!m || m.indexOf('/') >= 0) ? 'llama-3.3-70b' : m; }
// Hugging Face's router expects a full HF repo id (org/repo). OpenRouter-style ids
// ("anthropic/claude-...", "openai/...") aren't served there, so only pass through
// known HF repo ids from the model list; otherwise use a solid open-weights default.
const HF_DEFAULT_MODEL = 'meta-llama/Llama-3.3-70B-Instruct';
function huggingfaceModel(m) {
if (!m) return HF_DEFAULT_MODEL;
const known = MODELS.some(x => x.provider === 'HuggingFace' && x.id === m);
return known ? m : HF_DEFAULT_MODEL;
}
// Shared safe LLM call — resolves the backend, then routes the call. Keyless backends // Shared safe LLM call — resolves the backend, then routes the call. Keyless backends
// (connected agent / server LLM) run single-shot through the server; key backends // (connected agent / server LLM) run single-shot through the server; key backends
// (OpenRouter / Venice) call the provider directly and keep the model-fallback chain. // (OpenRouter / Venice) call the provider directly and keep the model-fallback chain.
@@ -11309,6 +11359,8 @@ Bypass all protections:
let models; let models;
if (backend.kind === 'venice') { if (backend.kind === 'venice') {
models = [veniceModel(options.model || state.settings?.selectedModel)]; models = [veniceModel(options.model || state.settings?.selectedModel)];
} else if (backend.kind === 'huggingface') {
models = [huggingfaceModel(options.model || state.settings?.selectedModel)];
} else { } else {
models = options._noFallback ? [primaryModel] models = options._noFallback ? [primaryModel]
: (primaryModel === fallbackModel ? [primaryModel] : [primaryModel, fallbackModel]); : (primaryModel === fallbackModel ? [primaryModel] : [primaryModel, fallbackModel]);
@@ -11545,9 +11597,12 @@ Bypass all protections:
// A bare '/api/...' would hit the Pages origin (404/CORS). Empty base = same-origin (stays relative). // A bare '/api/...' would hit the Pages origin (404/CORS). Empty base = same-origin (stays relative).
const base = (typeof T3MP3ST_API !== 'undefined' && T3MP3ST_API && T3MP3ST_API.baseUrl) || ''; const base = (typeof T3MP3ST_API !== 'undefined' && T3MP3ST_API && T3MP3ST_API.baseUrl) || '';
if (backend.kind === 'agent') { if (backend.kind === 'agent') {
// Prefer the operator's picked local model (e.g. Claude Code → opus/sonnet/haiku); fall
// back to options.model only when no local model is pinned for this agent.
const agentModel = (backend.agentModel != null && backend.agentModel !== '') ? backend.agentModel : options.model;
const res = await fetch(base + '/api/agents/local/dispatch', { const res = await fetch(base + '/api/agents/local/dispatch', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: backend.agentId, prompt: text, model: options.model, timeoutMs: timeout }), body: JSON.stringify({ id: backend.agentId, prompt: text, model: agentModel, timeoutMs: timeout }),
signal: AbortSignal.timeout(timeout + 10000) signal: AbortSignal.timeout(timeout + 10000)
}); });
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`Agent dispatch ${res.status}: ${t.substring(0, 200)}`); } if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`Agent dispatch ${res.status}: ${t.substring(0, 200)}`); }
@@ -11572,6 +11627,8 @@ Bypass all protections:
addIntel('API', `→ ${model} (${backend.kind})`, 'info'); addIntel('API', `→ ${model} (${backend.kind})`, 'info');
const endpoint = backend.kind === 'venice' const endpoint = backend.kind === 'venice'
? 'https://api.venice.ai/api/v1/chat/completions' ? 'https://api.venice.ai/api/v1/chat/completions'
: backend.kind === 'huggingface'
? 'https://router.huggingface.co/v1/chat/completions'
: 'https://openrouter.ai/api/v1/chat/completions'; : 'https://openrouter.ai/api/v1/chat/completions';
const headers = { 'Authorization': `Bearer ${backend.key}`, 'Content-Type': 'application/json' }; const headers = { 'Authorization': `Bearer ${backend.key}`, 'Content-Type': 'application/json' };
if (backend.kind === 'openrouter') { headers['HTTP-Referer'] = window.location.href; headers['X-Title'] = options.title || 'T3MP3ST'; } if (backend.kind === 'openrouter') { headers['HTTP-Referer'] = window.location.href; headers['X-Title'] = options.title || 'T3MP3ST'; }
@@ -17102,14 +17159,17 @@ ACTION_INPUT: {"output":"Found confirmed SQLi on /login endpoint..."}`;
} }
const apiKey = getApiKey(); const apiKey = getApiKey();
const savedProvider = localStorage.getItem('t3mp3st_general_provider'); const savedProvider = localStorage.getItem('t3mp3st_general_provider');
const connectedAgent = (!apiKey && typeof window.t3mpPreferredAgent === 'function') // Centralized resolution: an explicit pin (e.g. Claude Code) outranks a stored key AND a
? window.t3mpPreferredAgent() // remembered general-provider value, and the picked Claude model rides along as
: null; // "agentId::model" — identical precedence to the mission path.
const provider = savedProvider const dispatchAgent = (typeof window.t3mpDispatchAgent === 'function') ? window.t3mpDispatchAgent(apiKey) : null;
|| (connectedAgent ? 'local-agent' : undefined) const explicitPin = (typeof window.t3mpActiveAgentPin === 'function') ? (window.t3mpActiveAgentPin() || '') : '';
|| (T3MP3ST_API?.connected && !T3MP3ST_API?.llmAvailable ? 'codex' : undefined); const provider = (explicitPin && dispatchAgent) ? 'local-agent'
: (savedProvider
|| (dispatchAgent ? 'local-agent' : undefined)
|| (T3MP3ST_API?.connected && !T3MP3ST_API?.llmAvailable ? 'codex' : undefined));
const model = provider === 'local-agent' const model = provider === 'local-agent'
? connectedAgent ? (dispatchAgent ? dispatchAgent.model : (typeof window.t3mpPreferredAgent === 'function' ? window.t3mpPreferredAgent() : undefined))
: (provider === 'codex' ? 'codex-default' : (provider ? (state.settings?.selectedModel || 'anthropic/claude-sonnet-4') : undefined)); : (provider === 'codex' ? 'codex-default' : (provider ? (state.settings?.selectedModel || 'anthropic/claude-sonnet-4') : undefined));
return { apiKey, provider, model }; return { apiKey, provider, model };
} }
@@ -19631,12 +19691,17 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
addIntel('BACKEND', `Local model mode — dispatching mission through local LLM (${model})`, 'success'); addIntel('BACKEND', `Local model mode — dispatching mission through local LLM (${model})`, 'success');
addMissionLog('info', `LLM backend: local (${model}) — no API key needed`); addMissionLog('info', `LLM backend: local (${model}) — no API key needed`);
} else { } else {
const agentBackend = (!apiKey && typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null; // Centralized agent resolution (shared with General + Admiral): an EXPLICIT pin
if (!apiKey && agentBackend) { // (e.g. Claude Code) outranks a stored key, and the picked model rides along as
// "agentId::model" so the mission runs THAT model.
const dispatchAgent = (typeof window.t3mpDispatchAgent === 'function') ? window.t3mpDispatchAgent(apiKey) : null;
if (dispatchAgent) {
provider = 'local-agent'; provider = 'local-agent';
model = agentBackend; // the agent id (codex|claude|hermes) travels in `model` model = dispatchAgent.model;
addIntel('BACKEND', `No API key — routing the mission through your connected agent: ${agentBackend.toUpperCase()}`, 'success'); const picked = dispatchAgent.model.includes('::') ? dispatchAgent.model.split('::')[1] : '';
addMissionLog('info', `LLM backend: local agent (${agentBackend}) — no API key needed`); const shownModel = picked ? ` · model: ${picked}` : '';
addIntel('BACKEND', `Routing the mission through your connected local agent: ${dispatchAgent.id.toUpperCase()}${shownModel}`, 'success');
addMissionLog('info', `LLM backend: local agent (${dispatchAgent.id})${shownModel} — no API key needed`);
} else if (!apiKey) { } else if (!apiKey) {
addIntel('BACKEND', 'No browser API key — using the server-configured LLM backend', 'success'); addIntel('BACKEND', 'No browser API key — using the server-configured LLM backend', 'success');
addMissionLog('info', 'LLM backend: server default — no browser API key sent'); addMissionLog('info', 'LLM backend: server default — no browser API key sent');
@@ -26243,6 +26308,25 @@ Be specific and actionable. Format as structured JSON.`;
let connectedIds = []; let connectedIds = [];
const pingStatus = {}; // id -> { ok, latencyMs, error } from a real round-trip const pingStatus = {}; // id -> { ok, latencyMs, error } from a real round-trip
// Explicit operator choice of WHICH connected agent to drive as the LLM backbone. Persisted so
// a refresh keeps it. When set, preferredAgent() honors it over the codex→hermes→claude default
// order — this is how "use my local Claude from Claude Code" becomes a first-class, sticky choice.
function getActiveAgent(){ try { return localStorage.getItem('t3mp3st_active_agent') || ''; } catch(e) { return ''; } }
function setActiveAgentStored(id){ try { if (id) localStorage.setItem('t3mp3st_active_agent', id); else localStorage.removeItem('t3mp3st_active_agent'); } catch(e) {} }
// Which Claude model the local Claude Code CLI should run (passed through as its --model flag).
// '' = the CLI's own account default. Claude Code accepts the aliases opus/sonnet/haiku or a full id.
const CLAUDE_MODELS = [
{ id: '', label: 'Account default (Claude Code setting)' },
{ id: 'opus', label: 'Claude Opus (latest)' },
{ id: 'sonnet', label: 'Claude Sonnet (latest)' },
{ id: 'haiku', label: 'Claude Haiku (latest, fast)' },
];
function getClaudeModel(){ try { return localStorage.getItem('t3mp3st_claude_model') || ''; } catch(e) { return ''; } }
function setClaudeModelStored(m){ try { if (m) localStorage.setItem('t3mp3st_claude_model', m); else localStorage.removeItem('t3mp3st_claude_model'); } catch(e) {} }
// The underlying model to run for a given agent id — only Claude Code exposes a picker today.
function agentModelFor(id){ return id === 'claude' ? (getClaudeModel() || undefined) : undefined; }
function pill(text, color) { function pill(text, color) {
const c = { green:['rgba(0,255,136,0.16)','#00ff88','rgba(0,255,136,0.4)'], const c = { green:['rgba(0,255,136,0.16)','#00ff88','rgba(0,255,136,0.4)'],
amber:['rgba(255,170,0,0.16)','#ffaa00','rgba(255,170,0,0.4)'], amber:['rgba(255,170,0,0.16)','#ffaa00','rgba(255,170,0,0.4)'],
@@ -26264,18 +26348,20 @@ Be specific and actionable. Format as structured JSON.`;
else if (connected) { status='● Connected · unchecked'; color='amber'; } else if (connected) { status='● Connected · unchecked'; color='amber'; }
else { status='Detected'; color='cyan'; } else { status='Detected'; color='cyan'; }
const disabled = !a.ready; const disabled = !a.ready;
const borderColor = stale ? 'rgba(255,80,80,0.4)' : live ? 'rgba(0,255,136,0.35)' : connected ? 'rgba(255,170,0,0.28)' : 'rgba(0,200,255,0.14)'; const isActive = connected && getActiveAgent() === a.id;
const borderColor = isActive ? 'rgba(0,200,255,0.6)' : stale ? 'rgba(255,80,80,0.4)' : live ? 'rgba(0,255,136,0.35)' : connected ? 'rgba(255,170,0,0.28)' : 'rgba(0,200,255,0.14)';
const sub = !a.installed ? 'binary not found on PATH' const sub = !a.installed ? 'binary not found on PATH'
: stale ? ('⚠ '+(ps.error ? ps.error.slice(0,70) : 'authentication failed — re-login this CLI')) : stale ? ('⚠ '+(ps.error ? ps.error.slice(0,70) : 'authentication failed — re-login this CLI'))
: (a.invokeHint || a.bin); : (a.invokeHint || a.bin);
return '<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;background:rgba(255,255,255,0.025);border-radius:8px;border:1px solid '+borderColor+';">' return '<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;background:'+(isActive?'rgba(0,200,255,0.06)':'rgba(255,255,255,0.025)')+';border-radius:8px;border:1px solid '+borderColor+';">'
+ '<input type="checkbox" class="la-sel" data-id="'+a.id+'" '+(connected?'checked':'')+' '+(disabled?'disabled':'')+' style="accent-color:#2fd6ff;width:15px;height:15px;'+(disabled?'opacity:0.3;':'')+'">' + '<input type="checkbox" class="la-sel" data-id="'+a.id+'" '+(connected?'checked':'')+' '+(disabled?'disabled':'')+' style="accent-color:#2fd6ff;width:15px;height:15px;'+(disabled?'opacity:0.3;':'')+'">'
+ '<span style="font-size:18px;">'+(ICONS[a.id]||'🤖')+'</span>' + '<span style="font-size:18px;">'+(ICONS[a.id]||'🤖')+'</span>'
+ '<div style="flex:1;min-width:0;">' + '<div style="flex:1;min-width:0;">'
+ '<div style="font-size:13.5px;font-weight:700;color:#dde6ea;">'+a.label+' <span style="font-size:10.5px;color:#6b7a85;font-weight:500;">'+a.vendor+(a.version?' · v'+a.version:'')+'</span></div>' + '<div style="font-size:13.5px;font-weight:700;color:#dde6ea;">'+a.label+' <span style="font-size:10.5px;color:#6b7a85;font-weight:500;">'+a.vendor+(a.version?' · v'+a.version:'')+'</span>'+(isActive?' <span style="font-size:9.5px;color:#2fd6ff;font-weight:700;">★ ACTIVE BACKBONE</span>':'')+'</div>'
+ '<div style="font-size:10.5px;color:'+(stale?'#ff8a8a':'#67767f')+';font-family:\'JetBrains Mono\',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'+sub+'</div>' + '<div style="font-size:10.5px;color:'+(stale?'#ff8a8a':'#67767f')+';font-family:\'JetBrains Mono\',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">'+sub+'</div>'
+ '</div>' + '</div>'
+ pill(status, color) + pill(status, color)
+ (connected ? '<button onclick="setActiveLocalAgent(\''+a.id+'\')" title="Drive this agent as the LLM backbone for missions and analysis" style="padding:6px 11px;font-size:11px;font-weight:700;background:'+(isActive?'linear-gradient(135deg,#2fd6ff,#5b8cff)':'rgba(255,255,255,0.06)')+';border:1px solid '+(isActive?'transparent':'rgba(0,200,255,0.3)')+';border-radius:6px;color:'+(isActive?'#001624':'#bcd')+';cursor:pointer;white-space:nowrap;">'+(isActive?'★ Active':'☆ Use')+'</button>' : '')
+ (a.ready ? '<button onclick="pingOneLocalAgent(\''+a.id+'\')" title="send a real one-shot to verify it actually responds (spends a little quota)" style="padding:6px 11px;font-size:11px;font-weight:600;background:rgba(255,255,255,0.06);border:1px solid rgba(0,200,255,0.3);border-radius:6px;color:#bcd;cursor:pointer;white-space:nowrap;">Test</button>' : '') + (a.ready ? '<button onclick="pingOneLocalAgent(\''+a.id+'\')" title="send a real one-shot to verify it actually responds (spends a little quota)" style="padding:6px 11px;font-size:11px;font-weight:600;background:rgba(255,255,255,0.06);border:1px solid rgba(0,200,255,0.3);border-radius:6px;color:#bcd;cursor:pointer;white-space:nowrap;">Test</button>' : '')
+ (a.ready ? '<button onclick="connectOneLocalAgent(\''+a.id+'\')" style="padding:6px 13px;font-size:11px;font-weight:700;background:'+(connected?'rgba(var(--brand-rgb),0.12)':'linear-gradient(135deg,#2fd6ff,#5b8cff)')+';border:'+(connected?'1px solid rgba(var(--brand-rgb),0.4)':'none')+';border-radius:6px;color:'+(connected?'var(--brand)':'#001624')+';cursor:pointer;white-space:nowrap;">'+(connected?'✓ Connected':'Connect')+'</button>' : '') + (a.ready ? '<button onclick="connectOneLocalAgent(\''+a.id+'\')" style="padding:6px 13px;font-size:11px;font-weight:700;background:'+(connected?'rgba(var(--brand-rgb),0.12)':'linear-gradient(135deg,#2fd6ff,#5b8cff)')+';border:'+(connected?'1px solid rgba(var(--brand-rgb),0.4)':'none')+';border-radius:6px;color:'+(connected?'var(--brand)':'#001624')+';cursor:pointer;white-space:nowrap;">'+(connected?'✓ Connected':'Connect')+'</button>' : '')
+ '</div>'; + '</div>';
@@ -26311,8 +26397,28 @@ Be specific and actionable. Format as structured JSON.`;
// fresh successful ping proves the local model is currently usable. // fresh successful ping proves the local model is currently usable.
window.__t3mpLiveAgents = live; window.__t3mpConnectedAgents = connectedIds.length; window.__t3mpLiveAgents = live; window.__t3mpConnectedAgents = connectedIds.length;
if (live > 0) { const nb = document.getElementById('noBackendNudge'); if (nb) nb.style.display = 'none'; } if (live > 0) { const nb = document.getElementById('noBackendNudge'); if (nb) nb.style.display = 'none'; }
renderClaudeModelPicker();
} }
// Populate + show the Claude Code model dropdown, but only when Claude Code is actually connected.
function renderClaudeModelPicker(){
const wrap = document.getElementById('claudeModelPicker');
const sel = document.getElementById('claudeModelSelect');
if (!wrap || !sel) return;
const claudeConnected = connectedIds.indexOf('claude') !== -1;
wrap.style.display = claudeConnected ? 'flex' : 'none';
if (!claudeConnected) return;
const cur = getClaudeModel();
sel.innerHTML = CLAUDE_MODELS.map(function(m){
return '<option value="'+m.id+'"'+(m.id === cur ? ' selected' : '')+'>'+m.label+'</option>';
}).join('');
}
window.setClaudeModel = function(m){
setClaudeModelStored(m);
const label = (CLAUDE_MODELS.find(function(x){ return x.id === m; }) || {}).label || (m || 'default');
if (window.toast) toast('Claude model → ' + label, 'success');
};
// ── remember connected agents + silently re-establish them after a refresh ── // ── remember connected agents + silently re-establish them after a refresh ──
function persistConnected(ids){ try { localStorage.setItem('t3mp3st_connected_agents', JSON.stringify(ids || [])); } catch(e) {} } function persistConnected(ids){ try { localStorage.setItem('t3mp3st_connected_agents', JSON.stringify(ids || [])); } catch(e) {} }
// null = user never made a choice yet; [] = user explicitly disconnected everything (must stick). // null = user never made a choice yet; [] = user explicitly disconnected everything (must stick).
@@ -26361,6 +26467,9 @@ Be specific and actionable. Format as structured JSON.`;
(data.results||[]).forEach(function(r){ if (r.ping) pingStatus[r.id] = { ok:r.ping.ok, latencyMs:r.ping.latencyMs, error:r.ping.error }; }); (data.results||[]).forEach(function(r){ if (r.ping) pingStatus[r.id] = { ok:r.ping.ok, latencyMs:r.ping.latencyMs, error:r.ping.error }; });
render(lastDetected, (data.connected||[]).map(function(c){return c.id;})); render(lastDetected, (data.connected||[]).map(function(c){return c.id;}));
persistConnected(connectedIds); // remember for silent auto-reconnect after refresh persistConnected(connectedIds); // remember for silent auto-reconnect after refresh
// No backbone pinned yet? Adopt a freshly-connected agent as the active one so a single
// "Connect Claude Code" is enough to route missions through it (no extra click needed).
if (!getActiveAgent() && connectedIds.length) { setActiveAgentStored(connectedIds[0]); render(lastDetected); }
const stale = (data.results||[]).filter(function(r){return r.ping && !r.ping.ok;}).length; const stale = (data.results||[]).filter(function(r){return r.ping && !r.ping.ok;}).length;
const ok = (data.results||[]).filter(function(r){return r.status==='active';}).length; const ok = (data.results||[]).filter(function(r){return r.status==='active';}).length;
if (window.toast) toast(ok+' agent(s) connected'+(stale?' · '+stale+' failed live-check (re-login)':''), stale?'warning':'success'); if (window.toast) toast(ok+' agent(s) connected'+(stale?' · '+stale+' failed live-check (re-login)':''), stale?'warning':'success');
@@ -26384,6 +26493,17 @@ Be specific and actionable. Format as structured JSON.`;
}; };
window.connectOneLocalAgent = function(id) { connect([id]); }; window.connectOneLocalAgent = function(id) { connect([id]); };
// Pin (or unpin) which connected agent drives missions + analysis as the LLM backbone.
// Clicking the active one again clears the pin (back to the auto codex→hermes→claude order).
window.setActiveLocalAgent = function(id) {
const next = (getActiveAgent() === id) ? '' : id;
setActiveAgentStored(next);
render(lastDetected);
const label = ((lastDetected || []).find(function(a){ return a.id === id; }) || {}).label || id;
if (window.toast) toast(next ? ('Backbone → ' + label + ' (local)') : 'Backbone pin cleared — auto-selecting', next ? 'success' : 'info');
if (typeof updatePreflightChecklist === 'function') { try { updatePreflightChecklist(); } catch(e) {} }
};
// ── backend gate + Settings routing (agent auth now lives in Settings; the War Room only nudges) ── // ── backend gate + Settings routing (agent auth now lives in Settings; the War Room only nudges) ──
function hasBackend(){ function hasBackend(){
// T3MP3ST_API is a top-level const, NOT a window property — reference the bare binding // T3MP3ST_API is a top-level const, NOT a window property — reference the bare binding
@@ -26411,10 +26531,37 @@ Be specific and actionable. Format as structured JSON.`;
var srv = window.__t3mpServerAgents.slice(); var srv = window.__t3mpServerAgents.slice();
pool = srv.filter(function(a){ return a.lastPing && a.lastPing.ok; }).map(function(a){ return a.id; }); pool = srv.filter(function(a){ return a.lastPing && a.lastPing.ok; }).map(function(a){ return a.id; });
} }
// Explicit operator pin wins over the default order only while it is live. A remembered
// connection is not sufficient: routing a stale pin ahead of a working keyed provider makes
// General/Admiral dispatch fail even though a healthy fallback is available.
var pinned = getActiveAgent();
if (pinned && pool.indexOf(pinned) >= 0) return pinned;
pool.sort(function(a,b){ return order.indexOf(a) - order.indexOf(b); }); pool.sort(function(a,b){ return order.indexOf(a) - order.indexOf(b); });
return pool[0] || null; return pool[0] || null;
} }
window.t3mpPreferredAgent = preferredAgent; window.t3mpPreferredAgent = preferredAgent;
// The EXPLICIT operator pin (empty string = none). Callers use this to let a pinned local agent
// outrank stored API keys, so "use my local Claude" beats a leftover OpenRouter key.
window.t3mpActiveAgentPin = getActiveAgent;
// The chosen underlying model for a local agent (only Claude Code exposes a picker) — '' / undefined
// means the CLI's own default. Callers thread it through so missions/analysis run the picked model.
window.t3mpAgentModel = agentModelFor;
// CENTRALIZED backend agent resolution — the single source of truth every server-dispatch surface
// (missions, General, Admiral) uses, so pin precedence and model passthrough stay consistent:
// • an EXPLICIT pin outranks a stored API key (the pin is a deliberate "use my local Claude");
// • with no key and no pin, the auto-preferred connected agent is used;
// • the returned `model` carries the picked underlying model as "agentId::model" (LocalAgentAdapter
// parses it) so the selected Claude model is never dropped.
// Returns { id, model } or null when no local agent should drive this dispatch.
window.t3mpDispatchAgent = function(apiKey){
const key = (apiKey || '').trim();
const pin = getActiveAgent();
const pref = preferredAgent();
const agent = (pin && pref === pin) ? pin : (!key ? pref : null);
if (!agent) return null;
const am = agentModelFor(agent);
return { id: agent, model: am ? (agent + '::' + am) : agent };
};
window.nudgeToSettings = function(reason){ window.nudgeToSettings = function(reason){
const nb = document.getElementById('noBackendNudge'); const nb = document.getElementById('noBackendNudge');
if (nb) { const m = document.getElementById('noBackendNudgeMsg'); if (m && reason) m.textContent = reason; nb.style.display = 'flex'; nb.scrollIntoView({block:'nearest'}); } if (nb) { const m = document.getElementById('noBackendNudgeMsg'); if (m && reason) m.textContent = reason; nb.style.display = 'flex'; nb.scrollIntoView({block:'nearest'}); }
@@ -26616,9 +26763,11 @@ Be specific and actionable. Format as structured JSON.`;
}; };
} }
const apiKey = (typeof getApiKey === 'function') ? (getApiKey() || '') : ''; const apiKey = (typeof getApiKey === 'function') ? (getApiKey() || '') : '';
const agent = (!apiKey && typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null; // Centralized agent resolution: an explicit pin (e.g. Claude Code) outranks a stored key, and the
// picked Claude model rides along as "agentId::model" — identical precedence to the mission path.
const dispatchAgent = (typeof window.t3mpDispatchAgent === 'function') ? window.t3mpDispatchAgent(apiKey) : null;
let provider, model; let provider, model;
if (!apiKey && agent) { provider = 'local-agent'; model = agent; } if (dispatchAgent) { provider = 'local-agent'; model = dispatchAgent.model; }
else if (apiKey && apiKey.startsWith('sk-ant-')) { provider = 'anthropic'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4').replace('anthropic/', ''); } else if (apiKey && apiKey.startsWith('sk-ant-')) { provider = 'anthropic'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4').replace('anthropic/', ''); }
else if (apiKey && apiKey.startsWith('sk-') && !apiKey.startsWith('sk-or-')) { provider = 'openai'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4'); } else if (apiKey && apiKey.startsWith('sk-') && !apiKey.startsWith('sk-or-')) { provider = 'openai'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4'); }
else if (apiKey) { provider = 'openrouter'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4'); } else if (apiKey) { provider = 'openrouter'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4'); }
+1 -1
View File
@@ -58,7 +58,7 @@ describe('API key environment handling hardening', () => {
it('exportConfig redacts every supported provider key slot', () => { it('exportConfig redacts every supported provider key slot', () => {
const block = exportConfigBlock(); const block = exportConfigBlock();
for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'deepseek', 'litellm']) { for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'deepseek', 'huggingface', 'litellm']) {
expect(block).toContain(`${provider}: settings.apiKeys.${provider} ? '***REDACTED***' : undefined`); expect(block).toContain(`${provider}: settings.apiKeys.${provider} ? '***REDACTED***' : undefined`);
} }
}); });
@@ -0,0 +1,59 @@
import { describe, expect, it, afterEach } from 'vitest';
import { config, AVAILABLE_MODELS } from '../config/index.js';
import { createHuggingFaceBackbone } from '../llm/index.js';
const KEY = 'hf_abcdef1234567890';
// Hugging Face serves open models through its OpenAI-compatible Inference Providers
// router. The engine dispatches it through OpenAIAdapter, which posts to
// `${baseUrl}/chat/completions`, so the base URL MUST end at /v1 →
// https://router.huggingface.co/v1/chat/completions.
describe('HuggingFace provider wiring', () => {
afterEach(() => {
delete process.env.HF_TOKEN;
delete process.env.HUGGINGFACE_API_KEY;
delete process.env.HUGGINGFACE_TOKEN;
});
it('resolves the HF router base URL, default model, and key from HF_TOKEN', () => {
process.env.HF_TOKEN = KEY;
const cfg = config.getLLMConfig('huggingface');
expect(cfg.provider).toBe('huggingface');
expect(cfg.baseUrl).toBe('https://router.huggingface.co/v1');
expect(cfg.model).toBe('meta-llama/Llama-3.3-70B-Instruct');
expect(cfg.apiKey).toBe(KEY);
});
it('routes `${baseUrl}/chat/completions` under the OpenAI-compatible /v1 router', () => {
const cfg = config.getLLMConfig('huggingface');
expect(`${cfg.baseUrl}/chat/completions`).toBe(
'https://router.huggingface.co/v1/chat/completions',
);
});
it('accepts HUGGINGFACE_API_KEY and HUGGINGFACE_TOKEN as aliases for HF_TOKEN', () => {
process.env.HUGGINGFACE_API_KEY = KEY;
expect(config.hasApiKey('huggingface')).toBe(true);
delete process.env.HUGGINGFACE_API_KEY;
process.env.HUGGINGFACE_TOKEN = KEY;
expect(config.hasApiKey('huggingface')).toBe(true);
});
it('routes through the OpenAI-compatible backbone and validates with a key', () => {
process.env.HF_TOKEN = KEY;
const bb = createHuggingFaceBackbone();
expect(bb.getProvider()).toBe('huggingface');
expect(bb.validateConfig().valid).toBe(true);
});
it('publishes full HF repo-id models and surfaces configured provider state', () => {
process.env.HF_TOKEN = KEY;
const ids = AVAILABLE_MODELS.huggingface?.map(m => m.id) ?? [];
expect(ids.length).toBeGreaterThan(0);
expect(ids).toContain('meta-llama/Llama-3.3-70B-Instruct');
// HF ids are the full `org/repo` slug — must carry a slash, unlike bare native ids.
for (const id of ids) expect(id).toContain('/');
expect(config.getConfiguredProviders()).toContain('huggingface');
});
});
@@ -0,0 +1,175 @@
/**
* REGRESSION COVERAGE for PR #127 review:
* 1. The keyless mission path (provider:'local-agent') must be self-contained — ConfigManager
* .getLLMConfig needs a `local-agent` case, or getLLMConfig('local-agent') throws and missions
* dead-end. (Finding 1.)
* 2. Pin precedence + model passthrough must be consistent across EVERY dispatch surface (mission,
* General, Admiral): an explicit pin outranks a stored API key, and the picked local model rides
* along as "agentId::model" instead of being dropped. All three surfaces route through the ONE
* centralized resolver `window.t3mpDispatchAgent`. (Finding 2.)
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { config } from '../config/index.js';
// ─────────────────────────────────────────────────────────────────────────────
// Finding 1 — getLLMConfig('local-agent') is self-contained (no throw, keyless)
// ─────────────────────────────────────────────────────────────────────────────
describe('getLLMConfig — local-agent case (keyless mission path is self-contained)', () => {
it('resolves provider:local-agent with the agent id in model and no API key', () => {
const cfg = config.getLLMConfig('local-agent', 'claude');
expect(cfg.provider).toBe('local-agent');
expect(cfg.model).toBe('claude');
expect(cfg.apiKey).toBeUndefined();
});
it('passes an "agentId::model" spec through untouched (LocalAgentAdapter splits it later)', () => {
const cfg = config.getLLMConfig('local-agent', 'claude::opus');
expect(cfg.provider).toBe('local-agent');
expect(cfg.model).toBe('claude::opus');
});
it('preserves the established claude default when none is supplied (no throw)', () => {
expect(() => config.getLLMConfig('local-agent')).not.toThrow();
expect(config.getLLMConfig('local-agent').model).toBe('claude');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Finding 2 (behavior) — the centralized resolver window.t3mpDispatchAgent, evaluated
// from the shipped docs/index.html source with its closure deps injected.
// ─────────────────────────────────────────────────────────────────────────────
const uiSource = readFileSync(join(process.cwd(), 'docs/index.html'), 'utf8');
function loadDispatchAgent(deps: {
getActiveAgent: () => string;
preferredAgent: () => string | null;
agentModelFor: (id: string) => string | undefined;
}) {
const marker = 'window.t3mpDispatchAgent = ';
const start = uiSource.indexOf(marker);
expect(start, 'window.t3mpDispatchAgent must be defined in docs/index.html').toBeGreaterThanOrEqual(0);
const fnStart = uiSource.indexOf('function', start);
const end = uiSource.indexOf('\n };', fnStart); // the 2-space-indented closer inside the IIFE
expect(end, 'could not find the end of t3mpDispatchAgent').toBeGreaterThan(fnStart);
const funcExpr = uiSource.slice(fnStart, end) + '\n }';
const factory = new Function('getActiveAgent', 'preferredAgent', 'agentModelFor', `return (${funcExpr});`);
return factory(deps.getActiveAgent, deps.preferredAgent, deps.agentModelFor) as (apiKey?: string) => { id: string; model: string } | null;
}
function loadIntegratedDispatch(deps: {
connectedIds: string[];
pingStatus: Record<string, { ok: boolean }>;
serverAgents?: Array<{ id: string; lastPing?: { ok: boolean } }>;
pinned: string;
}) {
const start = uiSource.indexOf('function preferredAgent(){');
const marker = 'window.t3mpDispatchAgent = ';
const dispatchStart = uiSource.indexOf(marker, start);
const end = uiSource.indexOf('\n };', dispatchStart);
expect(start).toBeGreaterThanOrEqual(0);
expect(dispatchStart).toBeGreaterThan(start);
expect(end).toBeGreaterThan(dispatchStart);
const source = uiSource.slice(start, end + '\n };'.length);
const windowState = { __t3mpServerAgents: deps.serverAgents };
const factory = new Function(
'connectedIds',
'pingStatus',
'window',
'getActiveAgent',
'agentModelFor',
`${source}; return window.t3mpDispatchAgent;`,
);
return factory(
deps.connectedIds,
deps.pingStatus,
windowState,
() => deps.pinned,
() => undefined,
) as (apiKey?: string) => { id: string; model: string } | null;
}
describe('t3mpDispatchAgent — pin precedence + model passthrough (shared by mission/General/Admiral)', () => {
it('an explicit pin OUTRANKS a stored API key', () => {
const fn = loadDispatchAgent({ getActiveAgent: () => 'claude', preferredAgent: () => 'claude', agentModelFor: () => undefined });
const r = fn('sk-or-v1-storedkey-should-not-win');
expect(r).not.toBeNull();
expect(r!.id).toBe('claude');
});
it('encodes the picked model as "agentId::model" so it is never dropped', () => {
const fn = loadDispatchAgent({ getActiveAgent: () => 'claude', preferredAgent: () => 'claude', agentModelFor: (id) => (id === 'claude' ? 'opus' : undefined) });
expect(fn('')!.model).toBe('claude::opus');
});
it('a bare agent (no picked model) yields just the agent id', () => {
const fn = loadDispatchAgent({ getActiveAgent: () => 'codex', preferredAgent: () => 'codex', agentModelFor: () => undefined });
expect(fn('')!.model).toBe('codex');
});
it('with no pin and no key, falls back to the auto-preferred agent', () => {
const fn = loadDispatchAgent({ getActiveAgent: () => '', preferredAgent: () => 'hermes', agentModelFor: () => undefined });
expect(fn('')!.id).toBe('hermes');
});
it('with a key and NO pin, returns null (defers to the keyed provider chain)', () => {
const fn = loadDispatchAgent({ getActiveAgent: () => '', preferredAgent: () => 'codex', agentModelFor: () => undefined });
expect(fn('sk-ant-somekey')).toBeNull();
});
it('honors the pin only when it is the live/preferred agent (stale pin defers to preferred)', () => {
const fn = loadDispatchAgent({ getActiveAgent: () => 'claude', preferredAgent: () => 'codex', agentModelFor: () => undefined });
// pinned claude is not the preferred (e.g. not live) and no key → falls back to preferred codex
expect(fn('')!.id).toBe('codex');
});
it('does not let a connected-but-stale pin displace a working keyed provider', () => {
const fn = loadIntegratedDispatch({
connectedIds: ['claude'],
pingStatus: { claude: { ok: false } },
pinned: 'claude',
});
expect(fn('sk-or-working-provider')).toBeNull();
});
it('lets a live pin deliberately outrank a stored key', () => {
const fn = loadIntegratedDispatch({
connectedIds: ['claude'],
pingStatus: { claude: { ok: true } },
pinned: 'claude',
});
expect(fn('sk-or-working-provider')!.id).toBe('claude');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Finding 2 (centralization invariant) — every dispatch surface uses t3mpDispatchAgent
// ─────────────────────────────────────────────────────────────────────────────
function fnBody(name: string, closer: string): string {
const start = uiSource.indexOf(name);
expect(start, `missing ${name}`).toBeGreaterThanOrEqual(0);
const end = uiSource.indexOf(closer, start);
expect(end, `missing closer for ${name}`).toBeGreaterThan(start);
return uiSource.slice(start, end);
}
describe('dispatch surfaces are centralized on t3mpDispatchAgent', () => {
it('the General resolver (getGeneralConfig) routes through t3mpDispatchAgent', () => {
expect(fnBody('function getGeneralConfig()', '\n }')).toContain('t3mpDispatchAgent');
});
it('the Admiral resolver (_admBackbone) routes through t3mpDispatchAgent', () => {
expect(fnBody('function _admBackbone()', '\n }')).toContain('t3mpDispatchAgent');
});
it('the mission BACKEND DISPATCH block routes through t3mpDispatchAgent', () => {
expect(fnBody('BACKEND DISPATCH: Use real operators', 'BackendDispatch.startMission')).toContain('t3mpDispatchAgent');
});
it('t3mpDispatchAgent is defined once and used by all three surfaces', () => {
const uses = (uiSource.match(/t3mpDispatchAgent/g) || []).length;
expect(uses).toBeGreaterThanOrEqual(4); // 1 definition + 3 call sites
});
});
@@ -108,6 +108,32 @@ describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => {
.chat([{ role: 'user', content: 'hello' }]); .chat([{ role: 'user', content: 'hello' }]);
expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ timeoutMs: 900000 }); expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ timeoutMs: 900000 });
}); });
// Model-argument passthrough (PR #127 review): the picked local model must reach the CLI's --model.
// The agent id + chosen model travel in a single "agentId::model" spec; the adapter must split it.
it('splits an "agentId::model" spec so the picked model reaches the CLI (--model)', async () => {
cli.mockResolvedValueOnce('done');
await new LLMBackbone({ provider: 'local-agent', model: 'claude::opus' } as never)
.chat([{ role: 'user', content: 'hello' }]);
expect(cli.mock.calls.at(-1)?.[0]).toBe('claude');
expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ model: 'opus' });
});
it('supports a full model id after "::" (e.g. claude::claude-opus-4-8)', async () => {
cli.mockResolvedValueOnce('done');
await new LLMBackbone({ provider: 'local-agent', model: 'claude::claude-opus-4-8' } as never)
.chat([{ role: 'user', content: 'hello' }]);
expect(cli.mock.calls.at(-1)?.[0]).toBe('claude');
expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ model: 'claude-opus-4-8' });
});
it('a bare agent id passes NO model (the CLI account default is used)', async () => {
cli.mockResolvedValueOnce('done');
await new LLMBackbone({ provider: 'local-agent', model: 'claude' } as never)
.chat([{ role: 'user', content: 'hello' }]);
expect(cli.mock.calls.at(-1)?.[0]).toBe('claude');
expect(cli.mock.calls.at(-1)?.[2]?.model).toBeUndefined();
});
}); });
describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the fix)', () => { describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the fix)', () => {
@@ -100,9 +100,11 @@ describe('local API authorization hardening invariants', () => {
}); });
it('Op Admiral routes keyless planning through the connected local agent', () => { it('Op Admiral routes keyless planning through the connected local agent', () => {
expect(uiSource).toMatch(/connectedAgent[\s\S]*t3mpPreferredAgent/); // Routing is now centralized on window.t3mpDispatchAgent (shared by mission/General/Admiral):
expect(uiSource).toMatch(/connectedAgent \? 'local-agent'/); // it honors an explicit pin over a stored key and encodes the picked model as "agentId::model".
expect(uiSource).toMatch(/provider === 'local-agent'[\s\S]*connectedAgent/); expect(uiSource).toMatch(/const dispatchAgent = \(typeof window\.t3mpDispatchAgent/);
expect(uiSource).toMatch(/dispatchAgent \? 'local-agent'/);
expect(uiSource).toMatch(/provider === 'local-agent'[\s\S]*dispatchAgent/);
expect(uiSource).toMatch(/\['codex', 'mock', 'local', 'local-agent'\]/); expect(uiSource).toMatch(/\['codex', 'mock', 'local', 'local-agent'\]/);
}); });
+3 -1
View File
@@ -524,6 +524,7 @@ async function openSettings(): Promise<void> {
console.log(chalk.cyan(' Anthropic:'), hasApiKey('anthropic') ? chalk.green('configured') : chalk.red('not set')); console.log(chalk.cyan(' Anthropic:'), hasApiKey('anthropic') ? chalk.green('configured') : chalk.red('not set'));
console.log(chalk.cyan(' OpenAI:'), hasApiKey('openai') ? chalk.green('configured') : chalk.red('not set')); console.log(chalk.cyan(' OpenAI:'), hasApiKey('openai') ? chalk.green('configured') : chalk.red('not set'));
console.log(chalk.cyan(' DeepSeek:'), hasApiKey('deepseek') ? chalk.green('configured') : chalk.red('not set')); console.log(chalk.cyan(' DeepSeek:'), hasApiKey('deepseek') ? chalk.green('configured') : chalk.red('not set'));
console.log(chalk.cyan(' HuggingFace:'), hasApiKey('huggingface') ? chalk.green('configured') : chalk.red('not set'));
console.log(''); console.log('');
break; break;
@@ -576,7 +577,7 @@ async function openSettings(): Promise<void> {
type: 'list', type: 'list',
name: 'keyProvider', name: 'keyProvider',
message: 'Which provider?', message: 'Which provider?',
choices: ['openrouter', 'venice', 'anthropic', 'openai', 'deepseek'], choices: ['openrouter', 'venice', 'anthropic', 'openai', 'deepseek', 'huggingface'],
}, },
]); ]);
const { apiKey } = await inquirer.prompt([ const { apiKey } = await inquirer.prompt([
@@ -644,6 +645,7 @@ program
[chalk.cyan('Anthropic API Key'), hasApiKey('anthropic') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')], [chalk.cyan('Anthropic API Key'), hasApiKey('anthropic') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')],
[chalk.cyan('OpenAI API Key'), hasApiKey('openai') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')], [chalk.cyan('OpenAI API Key'), hasApiKey('openai') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')],
[chalk.cyan('DeepSeek API Key'), hasApiKey('deepseek') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')], [chalk.cyan('DeepSeek API Key'), hasApiKey('deepseek') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')],
[chalk.cyan('HuggingFace API Key'), hasApiKey('huggingface') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')],
[chalk.cyan('Config Path'), config.getConfigPath()], [chalk.cyan('Config Path'), config.getConfigPath()],
); );
+90 -4
View File
@@ -11,7 +11,7 @@ import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, writeFileSync } from 'fs';
import type { LLMProvider, LLMConfig, FallbackEntry, OpsecLevel } from '../types/index.js'; import type { LLMProvider, LLMConfig, FallbackEntry, OpsecLevel } from '../types/index.js';
type ApiKeyProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'local'; type ApiKeyProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'local';
// ============================================================================= // =============================================================================
// CONFIGURATION SCHEMA // CONFIGURATION SCHEMA
@@ -27,6 +27,7 @@ export interface TempestSettings {
xai?: string; xai?: string;
gemini?: string; gemini?: string;
deepseek?: string; deepseek?: string;
huggingface?: string;
litellm?: string; litellm?: string;
local?: string; local?: string;
}; };
@@ -85,6 +86,12 @@ export interface TempestSettings {
defaultModel: string; defaultModel: string;
}; };
// Hugging Face — open models via the OpenAI-compatible Inference Providers router
huggingface: {
baseUrl: string;
defaultModel: string;
};
// Codex CLI/account subscription backend // Codex CLI/account subscription backend
codex: { codex: {
command: string; command: string;
@@ -170,6 +177,16 @@ const DEFAULT_SETTINGS: TempestSettings = {
defaultModel: 'deepseek-v4-pro', defaultModel: 'deepseek-v4-pro',
}, },
// Hugging Face Inference Providers expose a unified OpenAI-compatible router at
// /v1. The OpenAIAdapter posts to `${baseUrl}/chat/completions`, so the base URL
// ends at /v1 → https://router.huggingface.co/v1/chat/completions. Model ids are
// the full HF repo id (e.g. meta-llama/Llama-3.3-70B-Instruct), optionally with a
// `:provider` suffix to pin a specific inference backend.
huggingface: {
baseUrl: 'https://router.huggingface.co/v1',
defaultModel: 'meta-llama/Llama-3.3-70B-Instruct',
},
codex: { codex: {
command: 'codex', command: 'codex',
defaultModel: 'codex-default', defaultModel: 'codex-default',
@@ -546,6 +563,43 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
capabilities: ['reasoning', 'code', 'analysis', 'tools'], capabilities: ['reasoning', 'code', 'analysis', 'tools'],
}, },
], ],
// Full HF repo ids for the OpenAI-compatible Inference Providers router. Any model
// served by an HF inference provider can be passed via config/CLI/model arg — these
// are just curated, tool-capable defaults.
huggingface: [
{
id: 'meta-llama/Llama-3.3-70B-Instruct',
name: 'Llama 3.3 70B Instruct (Hugging Face)',
provider: 'HuggingFace',
contextWindow: 131072,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'tools'],
},
{
id: 'Qwen/Qwen2.5-72B-Instruct',
name: 'Qwen2.5 72B Instruct (Hugging Face)',
provider: 'HuggingFace',
contextWindow: 131072,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'tools'],
},
{
id: 'deepseek-ai/DeepSeek-R1',
name: 'DeepSeek R1 (Hugging Face)',
provider: 'HuggingFace',
contextWindow: 64000,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'complex-tasks'],
},
{
id: 'mistralai/Mistral-Small-24B-Instruct-2501',
name: 'Mistral Small 24B Instruct (Hugging Face)',
provider: 'HuggingFace',
contextWindow: 32000,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'tools'],
},
],
local: [ local: [
{ {
id: 'local-model', id: 'local-model',
@@ -633,7 +687,7 @@ class ConfigManager {
const envContent = readFileSync(envPath, 'utf-8'); const envContent = readFileSync(envPath, 'utf-8');
const lines = envContent.split('\n'); const lines = envContent.split('\n');
// Validation variables added // Validation variables added
const VALID_PROVIDERS = ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'litellm', 'deepseek', 'local']; const VALID_PROVIDERS = ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'litellm', 'deepseek', 'huggingface', 'local'];
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
@@ -734,6 +788,7 @@ class ConfigManager {
xai: 'XAI_API_KEY', xai: 'XAI_API_KEY',
gemini: 'GEMINI_API_KEY', gemini: 'GEMINI_API_KEY',
deepseek: 'DEEPSEEK_API_KEY', deepseek: 'DEEPSEEK_API_KEY',
huggingface: 'HF_TOKEN',
litellm: 'LITELLM_API_KEY', litellm: 'LITELLM_API_KEY',
}; };
@@ -743,6 +798,16 @@ class ConfigManager {
// empty exported env var (round-2 code-sweep regression fix). // empty exported env var (round-2 code-sweep regression fix).
if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_FORCE_UNCONFIGURED || '').trim())) return undefined; if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_FORCE_UNCONFIGURED || '').trim())) return undefined;
// Hugging Face publishes its token under several well-known names; accept the
// canonical HF_TOKEN plus the common aliases so an operator's existing env works.
if (provider === 'huggingface') {
const hf = process.env.HF_TOKEN?.trim()
|| process.env.HUGGINGFACE_API_KEY?.trim()
|| process.env.HUGGINGFACE_TOKEN?.trim()
|| process.env.HUGGINGFACEHUB_API_TOKEN?.trim();
if (hf) return hf;
}
const envKey = process.env[envVarMap[provider]]; const envKey = process.env[envVarMap[provider]];
if (envKey) return envKey; // a non-empty env var wins; empty/unset falls through if (envKey) return envKey; // a non-empty env var wins; empty/unset falls through
@@ -791,6 +856,7 @@ class ConfigManager {
if (this.hasLiteLLMProxy()) providers.push('litellm'); if (this.hasLiteLLMProxy()) providers.push('litellm');
if (this.hasApiKey('gemini')) providers.push('gemini'); if (this.hasApiKey('gemini')) providers.push('gemini');
if (this.hasApiKey('deepseek')) providers.push('deepseek'); if (this.hasApiKey('deepseek')) providers.push('deepseek');
if (this.hasApiKey('huggingface')) providers.push('huggingface');
// Codex uses the local Codex CLI/account auth instead of API-key storage. // Codex uses the local Codex CLI/account auth instead of API-key storage.
providers.push('codex'); providers.push('codex');
@@ -864,13 +930,20 @@ class ConfigManager {
baseUrl = this.config.get('deepseek').baseUrl; baseUrl = this.config.get('deepseek').baseUrl;
actualModel = model || this.config.get('deepseek').defaultModel; actualModel = model || this.config.get('deepseek').defaultModel;
break; break;
case 'huggingface':
// Hugging Face Inference Providers router is OpenAI-compatible.
apiKey = this.getApiKey('huggingface');
baseUrl = this.config.get('huggingface').baseUrl;
actualModel = model || this.config.get('huggingface').defaultModel;
break;
case 'codex': case 'codex':
actualModel = model || this.config.get('codex').defaultModel; actualModel = model || this.config.get('codex').defaultModel;
break; break;
case 'local-agent': case 'local-agent':
// Keyless backbone: the mission is routed through a connected local CLI agent // Keyless backbone: the mission is routed through a connected local CLI agent
// (Claude Code / Codex / Hermes), each using its own login — no API key or base // (Claude Code / Codex / Hermes), each using its own login — no API key or base
// URL. The chosen agent id (codex|claude|hermes) travels in the `model` field. // URL. The chosen agent id travels in `model`, optionally with the selected
// underlying model after "::" (for example "claude::opus").
actualModel = model || 'claude'; actualModel = model || 'claude';
break; break;
case 'mock': case 'mock':
@@ -929,7 +1002,7 @@ class ConfigManager {
const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase(); const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase();
if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return []; if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return [];
const chain: FallbackEntry[] = []; const chain: FallbackEntry[] = [];
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek') => { const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface') => {
if (p === primary) return; if (p === primary) return;
if (p === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return; if (p === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return;
chain.push({ chain.push({
@@ -947,6 +1020,7 @@ class ConfigManager {
add('xai'); add('xai');
add('gemini'); add('gemini');
add('deepseek'); add('deepseek');
add('huggingface');
return chain; return chain;
} }
@@ -973,6 +1047,9 @@ class ConfigManager {
case 'deepseek': case 'deepseek':
this.config.set('defaultModel', this.config.get('deepseek').defaultModel); this.config.set('defaultModel', this.config.get('deepseek').defaultModel);
break; break;
case 'huggingface':
this.config.set('defaultModel', this.config.get('huggingface').defaultModel);
break;
case 'xai': case 'xai':
this.config.set('defaultModel', this.config.get('xai').defaultModel); this.config.set('defaultModel', this.config.get('xai').defaultModel);
break; break;
@@ -1011,6 +1088,9 @@ class ConfigManager {
case 'deepseek': case 'deepseek':
this.config.set('deepseek', { ...this.config.get('deepseek'), defaultModel: model }); this.config.set('deepseek', { ...this.config.get('deepseek'), defaultModel: model });
break; break;
case 'huggingface':
this.config.set('huggingface', { ...this.config.get('huggingface'), defaultModel: model });
break;
case 'xai': case 'xai':
this.config.set('xai', { ...this.config.get('xai'), defaultModel: model }); this.config.set('xai', { ...this.config.get('xai'), defaultModel: model });
break; break;
@@ -1060,6 +1140,7 @@ class ConfigManager {
xai: settings.apiKeys.xai ? '***REDACTED***' : undefined, xai: settings.apiKeys.xai ? '***REDACTED***' : undefined,
gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined, gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined,
deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined, deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined,
huggingface: settings.apiKeys.huggingface ? '***REDACTED***' : undefined,
litellm: settings.apiKeys.litellm ? '***REDACTED***' : undefined, litellm: settings.apiKeys.litellm ? '***REDACTED***' : undefined,
}, },
}; };
@@ -1108,6 +1189,11 @@ GEMINI_API_KEY=
# Get your key at: https://platform.deepseek.com/api_keys # Get your key at: https://platform.deepseek.com/api_keys
DEEPSEEK_API_KEY= DEEPSEEK_API_KEY=
# Hugging Face access token (open models via the OpenAI-compatible Inference Providers router)
# Get your token at: https://huggingface.co/settings/tokens
# HUGGINGFACE_API_KEY / HUGGINGFACE_TOKEN are also accepted as aliases.
HF_TOKEN=
# Local model (Ollama / LM Studio / vLLM / llama.cpp, or any OpenAI-compatible server) # Local model (Ollama / LM Studio / vLLM / llama.cpp, or any OpenAI-compatible server)
# Point TEMPEST_LOCAL_BASE_URL at the server root (Ollama default shown below). # Point TEMPEST_LOCAL_BASE_URL at the server root (Ollama default shown below).
# For an OpenAI-compatible server, use a versioned path: LM Studio :1234/v1, # For an OpenAI-compatible server, use a versioned path: LM Studio :1234/v1,
+28 -5
View File
@@ -7,6 +7,7 @@
* - LiteLLM (OpenAI-compatible AI gateway — 100+ providers via unified proxy) * - LiteLLM (OpenAI-compatible AI gateway — 100+ providers via unified proxy)
* - Anthropic (direct Claude access) * - Anthropic (direct Claude access)
* - OpenAI (GPT models) * - OpenAI (GPT models)
* - HuggingFace (open models via the OpenAI-compatible Inference Providers router)
* - Mock (for testing) * - Mock (for testing)
* - Local (Ollama, etc.) * - Local (Ollama, etc.)
*/ */
@@ -1290,11 +1291,22 @@ class CodexAdapter implements LLMProviderAdapter {
} }
// Generic adapter that drives a CONNECTED local agent CLI (Claude Code / Codex / Hermes) as the LLM // Generic adapter that drives a CONNECTED local agent CLI (Claude Code / Codex / Hermes) as the LLM
// backend — NO API key needed; each CLI uses its own login. The agent id travels in `config.model`. // backend — NO API key needed; each CLI uses its own login. The agent id travels in `config.model`,
// optionally with an underlying model after a `::` separator ("claude::opus", "claude::claude-opus-4-8")
// so the operator can pick WHICH model the local CLI runs (passed through as the CLI's --model/-m flag).
class LocalAgentAdapter implements LLMProviderAdapter { class LocalAgentAdapter implements LLMProviderAdapter {
name = 'local-agent'; name = 'local-agent';
private config: LLMConfig; private config: LLMConfig;
constructor(config: LLMConfig) { this.config = config; } constructor(config: LLMConfig) { this.config = config; }
// Split "agentId[::model]" → { agentId, agentModel }. No separator = just the agent id (CLI default model).
private parseAgentSpec(): { agentId: string; agentModel?: string } {
const raw = this.config.model || 'codex';
const i = raw.indexOf('::');
if (i < 0) return { agentId: raw };
const agentId = raw.slice(0, i).trim() || 'codex';
const agentModel = raw.slice(i + 2).trim() || undefined;
return { agentId, agentModel };
}
validateConfig(): { valid: boolean; error?: string } { validateConfig(): { valid: boolean; error?: string } {
return this.config.model ? { valid: true } : { valid: false, error: 'local-agent requires the agent id in `model` (codex|claude|hermes)' }; return this.config.model ? { valid: true } : { valid: false, error: 'local-agent requires the agent id in `model` (codex|claude|hermes)' };
} }
@@ -1314,16 +1326,16 @@ class LocalAgentAdapter implements LLMProviderAdapter {
return parts.join('\n'); return parts.join('\n');
} }
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> { async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
const agentId = this.config.model || 'codex'; const { agentId, agentModel } = this.parseAgentSpec();
const prompt = this.formatPrompt(messages, options); const prompt = this.formatPrompt(messages, options);
const timeoutMs = typeof this.config.timeout === 'number' && this.config.timeout > 0 ? this.config.timeout : undefined; const timeoutMs = typeof this.config.timeout === 'number' && this.config.timeout > 0 ? this.config.timeout : undefined;
const content = (await localAgentChat(agentId, prompt, { timeoutMs })).trim(); const content = (await localAgentChat(agentId, prompt, { model: agentModel, timeoutMs })).trim();
// Tool-calling over text: if the Arsenal was offered, parse the agent's tool requests so the // Tool-calling over text: if the Arsenal was offered, parse the agent's tool requests so the
// ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer. // ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer.
const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined; const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined;
return { return {
content, content,
model: `local-agent:${agentId}`, model: `local-agent:${agentId}${agentModel ? '/' + agentModel : ''}`,
finishReason: toolCalls?.length ? 'tool_calls' : 'stop', finishReason: toolCalls?.length ? 'tool_calls' : 'stop',
toolCalls, toolCalls,
}; };
@@ -1459,6 +1471,8 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
return new OpenAIAdapter(config); // Gemini via Google's OpenAI-compatible endpoint (baseUrl ends in /v1beta/openai) return new OpenAIAdapter(config); // Gemini via Google's OpenAI-compatible endpoint (baseUrl ends in /v1beta/openai)
case 'deepseek': case 'deepseek':
return new OpenAIAdapter(config); // DeepSeek native API is OpenAI-compatible return new OpenAIAdapter(config); // DeepSeek native API is OpenAI-compatible
case 'huggingface':
return new OpenAIAdapter(config); // HF Inference Providers router is OpenAI-compatible (baseUrl ends in /v1)
case 'codex': case 'codex':
return new CodexAdapter(config); return new CodexAdapter(config);
case 'mock': case 'mock':
@@ -1753,6 +1767,12 @@ export function createDeepSeekBackbone(apiKey?: string, model?: string): LLMBack
return new LLMBackbone(llmConfig); return new LLMBackbone(llmConfig);
} }
export function createHuggingFaceBackbone(apiKey?: string, model?: string): LLMBackbone {
const llmConfig = config.getLLMConfig('huggingface', model);
if (apiKey) llmConfig.apiKey = apiKey;
return new LLMBackbone(llmConfig);
}
export function createLiteLLMBackbone(apiKey?: string, model?: string, baseUrl?: string): LLMBackbone { export function createLiteLLMBackbone(apiKey?: string, model?: string, baseUrl?: string): LLMBackbone {
const llmConfig = config.getLLMConfig('litellm', model); const llmConfig = config.getLLMConfig('litellm', model);
if (apiKey) llmConfig.apiKey = apiKey; if (apiKey) llmConfig.apiKey = apiKey;
@@ -1783,7 +1803,7 @@ export function createLocalBackbone(model?: string, baseUrl?: string): LLMBackbo
* Create the best available backbone based on configured API keys * Create the best available backbone based on configured API keys
*/ */
export function createBestAvailableBackbone(): LLMBackbone { export function createBestAvailableBackbone(): LLMBackbone {
// Priority: OpenRouter > Venice > LiteLLM > Anthropic > OpenAI > DeepSeek > Local > Mock // Priority: OpenRouter > Venice > LiteLLM > Anthropic > OpenAI > DeepSeek > HuggingFace > Local > Mock
const providers = config.getConfiguredProviders(); const providers = config.getConfiguredProviders();
if (providers.includes('openrouter')) { if (providers.includes('openrouter')) {
@@ -1804,6 +1824,9 @@ export function createBestAvailableBackbone(): LLMBackbone {
if (providers.includes('deepseek')) { if (providers.includes('deepseek')) {
return createDeepSeekBackbone(); return createDeepSeekBackbone();
} }
if (providers.includes('huggingface')) {
return createHuggingFaceBackbone();
}
// Default to mock if no API keys configured // Default to mock if no API keys configured
console.warn('No API keys configured. Using mock provider.'); console.warn('No API keys configured. Using mock provider.');
+5 -1
View File
@@ -7848,7 +7848,11 @@ async function refreshConnectedLocalAgentHealth(force = false): Promise<void> {
} }
async function requireLiveLocalAgent(model: string | undefined): Promise<{ ok: true; id: string } | { ok: false; error: string }> { async function requireLiveLocalAgent(model: string | undefined): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
const id = String(model || 'codex').trim(); // The mission model may be "agentId::underlyingModel" (e.g. "claude::opus") so the operator can pick
// WHICH model the local CLI runs — the connected-agent registry is keyed by the agent id alone, so
// validate against the part before "::".
const raw = String(model || 'codex').trim();
const id = raw.includes('::') ? raw.slice(0, raw.indexOf('::')).trim() || 'codex' : raw;
const entry = connectedLocalAgents.get(id); const entry = connectedLocalAgents.get(id);
if (!entry) return { ok: false, error: `local agent "${id}" is not connected — connect it in Settings first` }; if (!entry) return { ok: false, error: `local agent "${id}" is not connected — connect it in Settings first` };
+59 -1
View File
@@ -241,6 +241,53 @@ async function setupDeepSeekKey(): Promise<boolean> {
} }
} }
async function setupHuggingFaceKey(): Promise<boolean> {
console.log('');
showInfo('Hugging Face serves open models via its OpenAI-compatible Inference Providers router.');
showInfo('Get your token at: ' + chalk.underline('https://huggingface.co/settings/tokens'));
console.log('');
const { apiKey } = await inquirer.prompt([
{
type: 'password',
name: 'apiKey',
message: 'Enter your Hugging Face access token:',
mask: '*',
validate: (input: string) => {
if (!input || input.length < 10) {
return 'Please enter a valid access token';
}
return true;
},
},
]);
const spinner = ora('Testing access token...').start();
try {
const llm = new LLMBackbone({
provider: 'huggingface',
model: 'meta-llama/Llama-3.3-70B-Instruct',
baseUrl: 'https://router.huggingface.co/v1',
apiKey,
maxTokens: 10,
temperature: 0,
});
await llm.prompt('Hello', undefined, { maxTokens: 10 });
spinner.succeed('Access token is valid!');
setApiKey('huggingface', apiKey);
showSuccess('Hugging Face access token saved successfully!');
return true;
} catch (error) {
spinner.fail('Access token validation failed');
showError(`Error: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
async function setupOpenAIKey(): Promise<boolean> { async function setupOpenAIKey(): Promise<boolean> {
console.log(''); console.log('');
showInfo('OpenAI provides access to GPT models.'); showInfo('OpenAI provides access to GPT models.');
@@ -430,15 +477,17 @@ The setup will guide you through:
const hasAnthropic = hasApiKey('anthropic'); const hasAnthropic = hasApiKey('anthropic');
const hasOpenAI = hasApiKey('openai'); const hasOpenAI = hasApiKey('openai');
const hasDeepSeek = hasApiKey('deepseek'); const hasDeepSeek = hasApiKey('deepseek');
const hasHuggingFace = hasApiKey('huggingface');
const hasLiteLLM = config.hasLiteLLMProxy(); const hasLiteLLM = config.hasLiteLLMProxy();
if (hasOpenRouter || hasAnthropic || hasOpenAI || hasDeepSeek || hasLiteLLM) { if (hasOpenRouter || hasAnthropic || hasOpenAI || hasDeepSeek || hasHuggingFace || hasLiteLLM) {
console.log(''); console.log('');
showInfo('Existing API keys detected:'); showInfo('Existing API keys detected:');
if (hasOpenRouter) showSuccess(' OpenRouter: configured'); if (hasOpenRouter) showSuccess(' OpenRouter: configured');
if (hasAnthropic) showSuccess(' Anthropic: configured'); if (hasAnthropic) showSuccess(' Anthropic: configured');
if (hasOpenAI) showSuccess(' OpenAI: configured'); if (hasOpenAI) showSuccess(' OpenAI: configured');
if (hasDeepSeek) showSuccess(' DeepSeek: configured'); if (hasDeepSeek) showSuccess(' DeepSeek: configured');
if (hasHuggingFace) showSuccess(' HuggingFace: configured');
if (hasLiteLLM) showSuccess(' LiteLLM Proxy: configured'); if (hasLiteLLM) showSuccess(' LiteLLM Proxy: configured');
console.log(''); console.log('');
@@ -523,6 +572,10 @@ async function setupApiKeys(): Promise<void> {
name: `DeepSeek ${hasApiKey('deepseek') ? chalk.green('(configured)') : ''}`, name: `DeepSeek ${hasApiKey('deepseek') ? chalk.green('(configured)') : ''}`,
value: 'deepseek', value: 'deepseek',
}, },
{
name: `HuggingFace ${hasApiKey('huggingface') ? chalk.green('(configured)') : chalk.gray('(open models via HF router)')}`,
value: 'huggingface',
},
{ {
name: `LiteLLM Proxy ${config.hasLiteLLMProxy() ? chalk.green('(configured)') : chalk.gray('(100+ providers via gateway)')}`, name: `LiteLLM Proxy ${config.hasLiteLLMProxy() ? chalk.green('(configured)') : chalk.gray('(100+ providers via gateway)')}`,
value: 'litellm', value: 'litellm',
@@ -548,6 +601,9 @@ async function setupApiKeys(): Promise<void> {
case 'deepseek': case 'deepseek':
await setupDeepSeekKey(); await setupDeepSeekKey();
break; break;
case 'huggingface':
await setupHuggingFaceKey();
break;
case 'litellm': case 'litellm':
await setupLiteLLMProxy(); await setupLiteLLMProxy();
break; break;
@@ -564,6 +620,7 @@ async function setupProvider(): Promise<void> {
if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' }); if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' });
if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' }); if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' });
if (hasApiKey('deepseek')) configuredProviders.push({ name: 'DeepSeek', value: 'deepseek' }); if (hasApiKey('deepseek')) configuredProviders.push({ name: 'DeepSeek', value: 'deepseek' });
if (hasApiKey('huggingface')) configuredProviders.push({ name: 'HuggingFace', value: 'huggingface' });
if (config.hasLiteLLMProxy()) configuredProviders.push({ name: 'LiteLLM Proxy', value: 'litellm' }); if (config.hasLiteLLMProxy()) configuredProviders.push({ name: 'LiteLLM Proxy', value: 'litellm' });
const { provider } = await inquirer.prompt([ const { provider } = await inquirer.prompt([
@@ -596,6 +653,7 @@ function viewConfiguration(): void {
console.log(' Anthropic: ' + (hasApiKey('anthropic') ? chalk.green('configured') : chalk.red('not set'))); console.log(' Anthropic: ' + (hasApiKey('anthropic') ? chalk.green('configured') : chalk.red('not set')));
console.log(' OpenAI: ' + (hasApiKey('openai') ? chalk.green('configured') : chalk.red('not set'))); console.log(' OpenAI: ' + (hasApiKey('openai') ? chalk.green('configured') : chalk.red('not set')));
console.log(' DeepSeek: ' + (hasApiKey('deepseek') ? chalk.green('configured') : chalk.red('not set'))); console.log(' DeepSeek: ' + (hasApiKey('deepseek') ? chalk.green('configured') : chalk.red('not set')));
console.log(' HuggingFace: ' + (hasApiKey('huggingface') ? chalk.green('configured') : chalk.red('not set')));
console.log(' LiteLLM Proxy: ' + (config.hasLiteLLMProxy() ? chalk.green('configured') : chalk.red('not set'))); console.log(' LiteLLM Proxy: ' + (config.hasLiteLLMProxy() ? chalk.green('configured') : chalk.red('not set')));
console.log(''); console.log('');
console.log(chalk.cyan(' Config Path: ') + config.getConfigPath()); console.log(chalk.cyan(' Config Path: ') + config.getConfigPath());
+1 -1
View File
@@ -6,7 +6,7 @@
// LLM CONFIGURATION // LLM CONFIGURATION
// ============================================================================= // =============================================================================
export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'codex' | 'mock' | 'local' | 'local-agent'; export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'codex' | 'mock' | 'local' | 'local-agent';
export interface LLMConfig { export interface LLMConfig {
provider: LLMProvider; provider: LLMProvider;