From 8ccea7c82a569c7f397e68fd4607163ed94ee9c0 Mon Sep 17 00:00:00 2001 From: 2alf <113948114+2alf@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:47:06 +0200 Subject: [PATCH] feat(llm): add Hugging Face provider + selectable local Claude Code models (#127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .env.example | 4 +- docs/index.html | 201 +++++++++++++++--- src/__tests__/api-key-env-static.test.ts | 2 +- src/__tests__/huggingface-provider.test.ts | 59 +++++ .../local-agent-dispatch-routing.test.ts | 175 +++++++++++++++ .../local-agent-tool-calling.test.ts | 26 +++ .../local-api-hardening-static.test.ts | 8 +- src/cli.ts | 4 +- src/config/index.ts | 94 +++++++- src/llm/index.ts | 33 ++- src/server.ts | 6 +- src/setup.ts | 60 +++++- src/types/index.ts | 2 +- 13 files changed, 630 insertions(+), 44 deletions(-) create mode 100644 src/__tests__/huggingface-provider.test.ts create mode 100644 src/__tests__/local-agent-dispatch-routing.test.ts diff --git a/.env.example b/.env.example index 576e99f..9ea49a7 100644 --- a/.env.example +++ b/.env.example @@ -7,9 +7,11 @@ VENICE_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx OPENAI_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= TOGETHER_API_KEY= -HUGGINGFACE_TOKEN= REPLICATE_API_TOKEN= # ── Target authentication (both values are required) ── diff --git a/docs/index.html b/docs/index.html index 9d66e90..8df6da1 100755 --- a/docs/index.html +++ b/docs/index.html @@ -5680,6 +5680,18 @@ +
+

Hugging Face Token

+
+
+ + +
+
Open models via the OpenAI-compatible Inference Providers router. Get a token from huggingface.co/settings/tokens
+
+ +
+

Anthropic API Key

@@ -5779,7 +5791,7 @@

🔌 Local Agents

-

Enlist agents you've already authed on this machine — no API keys needed. T3MP3ST detects each CLI and can drive it as an operator. Tick several and connect them all at once.

+

Enlist agents you've already authed on this machine — no API keys needed. T3MP3ST detects each CLI (Claude Code / Codex / Hermes) and drives it as your LLM backbone using its own login. Connect one, then hit ☆ Use to pin it as the ★ active backbone for missions and analysis — a pinned local model outranks any stored API key.

🔌 Connect Local Agents
@@ -5788,6 +5800,15 @@
Detecting local agents…
+ +
@@ -7230,6 +7251,11 @@ Correlating findings across agents, identifying patterns, producing actionable i { id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'DeepSeek' }, // 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 { 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 anKey = document.getElementById('anthropicKey'); const oaKey = document.getElementById('openaiKey'); + const hfKey = document.getElementById('huggingfaceKey'); if (orKey) orKey.value = state.settings.openrouterKey || ''; if (veKey) veKey.value = state.settings.veniceKey || ''; 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 // local cache. If the server has one set via TEMPEST_PROXY_URL, the badge shows it. setV('proxyUrl', localStorage.getItem('t3mp3st_proxy_url') || ''); + if (hfKey) hfKey.value = state.settings.huggingfaceKey || ''; } function saveState() { @@ -8206,10 +8234,11 @@ o.id === 'coordinator' ? `1. MISSION_STATUS: Progress assessment function updatePreflightChecklist() { const operatorCount = state.operators.length; const targetCount = state.targets.length; - // "API key" preflight passes on a real key OR a connected keyless backend (agent / server LLM). - // Only OpenRouter feeds the client-side LLM path (getApiKey); a connected backend covers the rest. - // Anthropic/OpenAI keys aren't wired to the client path, so they don't count as "ready" here. - const hasApiKey = !!(state.settings?.openrouterKey) + // "API key" preflight passes on a real client key OR a connected keyless backend (agent / server LLM). + // The direct client-side LLM path (resolveLLMBackend) drives OpenRouter, Venice, and Hugging Face keys; + // a connected backend covers the rest. Anthropic/OpenAI keys aren't wired to the client path yet, so + // they don't count as "ready" here. + const hasApiKey = !!(state.settings?.openrouterKey || state.settings?.veniceKey || state.settings?.huggingfaceKey) || (typeof window.t3mpHasBackend === 'function' && window.t3mpHasBackend()); const toolCount = activeLoadout?.length || 0; @@ -11257,21 +11286,32 @@ Bypass all protections: } // 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. 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(); if (orKey.length >= 10) return { kind: 'openrouter', key: orKey }; const veKey = (state.settings?.veniceKey || '').trim(); 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; - 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' }; return { kind: 'none' }; } 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. 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. 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 // (connected agent / server LLM) run single-shot through the server; key backends // (OpenRouter / Venice) call the provider directly and keep the model-fallback chain. @@ -11309,6 +11359,8 @@ Bypass all protections: let models; if (backend.kind === 'venice') { models = [veniceModel(options.model || state.settings?.selectedModel)]; + } else if (backend.kind === 'huggingface') { + models = [huggingfaceModel(options.model || state.settings?.selectedModel)]; } else { models = options._noFallback ? [primaryModel] : (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). const base = (typeof T3MP3ST_API !== 'undefined' && T3MP3ST_API && T3MP3ST_API.baseUrl) || ''; 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', { 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) }); 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'); const endpoint = backend.kind === 'venice' ? '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'; 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'; } @@ -17102,14 +17159,17 @@ ACTION_INPUT: {"output":"Found confirmed SQLi on /login endpoint..."}`; } const apiKey = getApiKey(); const savedProvider = localStorage.getItem('t3mp3st_general_provider'); - const connectedAgent = (!apiKey && typeof window.t3mpPreferredAgent === 'function') - ? window.t3mpPreferredAgent() - : null; - const provider = savedProvider - || (connectedAgent ? 'local-agent' : undefined) - || (T3MP3ST_API?.connected && !T3MP3ST_API?.llmAvailable ? 'codex' : undefined); + // Centralized resolution: an explicit pin (e.g. Claude Code) outranks a stored key AND a + // remembered general-provider value, 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; + const explicitPin = (typeof window.t3mpActiveAgentPin === 'function') ? (window.t3mpActiveAgentPin() || '') : ''; + const provider = (explicitPin && dispatchAgent) ? 'local-agent' + : (savedProvider + || (dispatchAgent ? 'local-agent' : undefined) + || (T3MP3ST_API?.connected && !T3MP3ST_API?.llmAvailable ? 'codex' : undefined)); 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)); 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'); addMissionLog('info', `LLM backend: local (${model}) — no API key needed`); } else { - const agentBackend = (!apiKey && typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null; - if (!apiKey && agentBackend) { + // Centralized agent resolution (shared with General + Admiral): an EXPLICIT pin + // (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'; - model = agentBackend; // the agent id (codex|claude|hermes) travels in `model` - addIntel('BACKEND', `No API key — routing the mission through your connected agent: ${agentBackend.toUpperCase()}`, 'success'); - addMissionLog('info', `LLM backend: local agent (${agentBackend}) — no API key needed`); + model = dispatchAgent.model; + const picked = dispatchAgent.model.includes('::') ? dispatchAgent.model.split('::')[1] : ''; + 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) { addIntel('BACKEND', 'No browser API key — using the server-configured LLM backend', 'success'); 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 = []; 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) { 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)'], @@ -26264,18 +26348,20 @@ Be specific and actionable. Format as structured JSON.`; else if (connected) { status='● Connected · unchecked'; color='amber'; } else { status='Detected'; color='cyan'; } 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' : stale ? ('⚠ '+(ps.error ? ps.error.slice(0,70) : 'authentication failed — re-login this CLI')) : (a.invokeHint || a.bin); - return '
' + return '
' + '' + ''+(ICONS[a.id]||'🤖')+'' + '
' - + '
'+a.label+' '+a.vendor+(a.version?' · v'+a.version:'')+'
' + + '
'+a.label+' '+a.vendor+(a.version?' · v'+a.version:'')+''+(isActive?' ★ ACTIVE BACKBONE':'')+'
' + '
'+sub+'
' + '
' + pill(status, color) + + (connected ? '' : '') + (a.ready ? '' : '') + (a.ready ? '' : '') + '
'; @@ -26311,8 +26397,28 @@ Be specific and actionable. Format as structured JSON.`; // fresh successful ping proves the local model is currently usable. window.__t3mpLiveAgents = live; window.__t3mpConnectedAgents = connectedIds.length; 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 ''; + }).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 ── 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). @@ -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 }; }); render(lastDetected, (data.connected||[]).map(function(c){return c.id;})); 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 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'); @@ -26384,6 +26493,17 @@ Be specific and actionable. Format as structured JSON.`; }; 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) ── function hasBackend(){ // 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(); 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); }); return pool[0] || null; } 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){ 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'}); } @@ -26616,9 +26763,11 @@ Be specific and actionable. Format as structured JSON.`; }; } 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; - 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-') && !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'); } diff --git a/src/__tests__/api-key-env-static.test.ts b/src/__tests__/api-key-env-static.test.ts index b17169d..577d22b 100644 --- a/src/__tests__/api-key-env-static.test.ts +++ b/src/__tests__/api-key-env-static.test.ts @@ -58,7 +58,7 @@ describe('API key environment handling hardening', () => { it('exportConfig redacts every supported provider key slot', () => { 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`); } }); diff --git a/src/__tests__/huggingface-provider.test.ts b/src/__tests__/huggingface-provider.test.ts new file mode 100644 index 0000000..2e8b2a5 --- /dev/null +++ b/src/__tests__/huggingface-provider.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/local-agent-dispatch-routing.test.ts b/src/__tests__/local-agent-dispatch-routing.test.ts new file mode 100644 index 0000000..2c77cbb --- /dev/null +++ b/src/__tests__/local-agent-dispatch-routing.test.ts @@ -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; + 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 + }); +}); diff --git a/src/__tests__/local-agent-tool-calling.test.ts b/src/__tests__/local-agent-tool-calling.test.ts index 01e0d93..385caa9 100644 --- a/src/__tests__/local-agent-tool-calling.test.ts +++ b/src/__tests__/local-agent-tool-calling.test.ts @@ -108,6 +108,32 @@ describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => { .chat([{ role: 'user', content: 'hello' }]); 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)', () => { diff --git a/src/__tests__/local-api-hardening-static.test.ts b/src/__tests__/local-api-hardening-static.test.ts index 854bc1b..e256b5a 100644 --- a/src/__tests__/local-api-hardening-static.test.ts +++ b/src/__tests__/local-api-hardening-static.test.ts @@ -100,9 +100,11 @@ describe('local API authorization hardening invariants', () => { }); it('Op Admiral routes keyless planning through the connected local agent', () => { - expect(uiSource).toMatch(/connectedAgent[\s\S]*t3mpPreferredAgent/); - expect(uiSource).toMatch(/connectedAgent \? 'local-agent'/); - expect(uiSource).toMatch(/provider === 'local-agent'[\s\S]*connectedAgent/); + // Routing is now centralized on window.t3mpDispatchAgent (shared by mission/General/Admiral): + // it honors an explicit pin over a stored key and encodes the picked model as "agentId::model". + 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'\]/); }); diff --git a/src/cli.ts b/src/cli.ts index 2c1c18e..70cc2f3 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -524,6 +524,7 @@ async function openSettings(): Promise { 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(' 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(''); break; @@ -576,7 +577,7 @@ async function openSettings(): Promise { type: 'list', name: 'keyProvider', message: 'Which provider?', - choices: ['openrouter', 'venice', 'anthropic', 'openai', 'deepseek'], + choices: ['openrouter', 'venice', 'anthropic', 'openai', 'deepseek', 'huggingface'], }, ]); 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('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('HuggingFace API Key'), hasApiKey('huggingface') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')], [chalk.cyan('Config Path'), config.getConfigPath()], ); diff --git a/src/config/index.ts b/src/config/index.ts index 3ae06b1..05aa341 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -11,7 +11,7 @@ import { join } from 'path'; import { existsSync, readFileSync, writeFileSync } from 'fs'; 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 @@ -27,6 +27,7 @@ export interface TempestSettings { xai?: string; gemini?: string; deepseek?: string; + huggingface?: string; litellm?: string; local?: string; }; @@ -85,6 +86,12 @@ export interface TempestSettings { defaultModel: string; }; + // Hugging Face — open models via the OpenAI-compatible Inference Providers router + huggingface: { + baseUrl: string; + defaultModel: string; + }; + // Codex CLI/account subscription backend codex: { command: string; @@ -170,6 +177,16 @@ const DEFAULT_SETTINGS: TempestSettings = { 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: { command: 'codex', defaultModel: 'codex-default', @@ -546,6 +563,43 @@ export const AVAILABLE_MODELS: Record = { 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: [ { id: 'local-model', @@ -633,7 +687,7 @@ class ConfigManager { const envContent = readFileSync(envPath, 'utf-8'); const lines = envContent.split('\n'); // 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) { const trimmed = line.trim(); @@ -734,6 +788,7 @@ class ConfigManager { xai: 'XAI_API_KEY', gemini: 'GEMINI_API_KEY', deepseek: 'DEEPSEEK_API_KEY', + huggingface: 'HF_TOKEN', litellm: 'LITELLM_API_KEY', }; @@ -743,6 +798,16 @@ class ConfigManager { // 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; + // 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]]; 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.hasApiKey('gemini')) providers.push('gemini'); 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. providers.push('codex'); @@ -864,13 +930,20 @@ class ConfigManager { baseUrl = this.config.get('deepseek').baseUrl; actualModel = model || this.config.get('deepseek').defaultModel; 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': actualModel = model || this.config.get('codex').defaultModel; break; case 'local-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 - // 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'; break; case 'mock': @@ -929,7 +1002,7 @@ class ConfigManager { const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase(); if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return []; 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 === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return; chain.push({ @@ -947,6 +1020,7 @@ class ConfigManager { add('xai'); add('gemini'); add('deepseek'); + add('huggingface'); return chain; } @@ -973,6 +1047,9 @@ class ConfigManager { case 'deepseek': this.config.set('defaultModel', this.config.get('deepseek').defaultModel); break; + case 'huggingface': + this.config.set('defaultModel', this.config.get('huggingface').defaultModel); + break; case 'xai': this.config.set('defaultModel', this.config.get('xai').defaultModel); break; @@ -1011,6 +1088,9 @@ class ConfigManager { case 'deepseek': this.config.set('deepseek', { ...this.config.get('deepseek'), defaultModel: model }); break; + case 'huggingface': + this.config.set('huggingface', { ...this.config.get('huggingface'), defaultModel: model }); + break; case 'xai': this.config.set('xai', { ...this.config.get('xai'), defaultModel: model }); break; @@ -1060,6 +1140,7 @@ class ConfigManager { xai: settings.apiKeys.xai ? '***REDACTED***' : undefined, gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined, deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined, + huggingface: settings.apiKeys.huggingface ? '***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 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) # 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, diff --git a/src/llm/index.ts b/src/llm/index.ts index 93202a9..8864f8f 100755 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -7,6 +7,7 @@ * - LiteLLM (OpenAI-compatible AI gateway — 100+ providers via unified proxy) * - Anthropic (direct Claude access) * - OpenAI (GPT models) + * - HuggingFace (open models via the OpenAI-compatible Inference Providers router) * - Mock (for testing) * - 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 -// 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 { name = 'local-agent'; private config: LLMConfig; 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 } { 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'); } async chat(messages: LLMMessage[], options?: ChatOptions): Promise { - const agentId = this.config.model || 'codex'; + const { agentId, agentModel } = this.parseAgentSpec(); const prompt = this.formatPrompt(messages, options); 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 // ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer. const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined; return { content, - model: `local-agent:${agentId}`, + model: `local-agent:${agentId}${agentModel ? '/' + agentModel : ''}`, finishReason: toolCalls?.length ? 'tool_calls' : 'stop', toolCalls, }; @@ -1459,6 +1471,8 @@ export class LLMBackbone extends EventEmitter { return new OpenAIAdapter(config); // Gemini via Google's OpenAI-compatible endpoint (baseUrl ends in /v1beta/openai) case 'deepseek': 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': return new CodexAdapter(config); case 'mock': @@ -1753,6 +1767,12 @@ export function createDeepSeekBackbone(apiKey?: string, model?: string): LLMBack 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 { const llmConfig = config.getLLMConfig('litellm', model); 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 */ 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(); if (providers.includes('openrouter')) { @@ -1804,6 +1824,9 @@ export function createBestAvailableBackbone(): LLMBackbone { if (providers.includes('deepseek')) { return createDeepSeekBackbone(); } + if (providers.includes('huggingface')) { + return createHuggingFaceBackbone(); + } // Default to mock if no API keys configured console.warn('No API keys configured. Using mock provider.'); diff --git a/src/server.ts b/src/server.ts index 47b97bc..7e955d2 100755 --- a/src/server.ts +++ b/src/server.ts @@ -7848,7 +7848,11 @@ async function refreshConnectedLocalAgentHealth(force = false): Promise { } 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); if (!entry) return { ok: false, error: `local agent "${id}" is not connected — connect it in Settings first` }; diff --git a/src/setup.ts b/src/setup.ts index 4463b8f..a6fb1da 100755 --- a/src/setup.ts +++ b/src/setup.ts @@ -241,6 +241,53 @@ async function setupDeepSeekKey(): Promise { } } +async function setupHuggingFaceKey(): Promise { + 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 { console.log(''); showInfo('OpenAI provides access to GPT models.'); @@ -430,15 +477,17 @@ The setup will guide you through: const hasAnthropic = hasApiKey('anthropic'); const hasOpenAI = hasApiKey('openai'); const hasDeepSeek = hasApiKey('deepseek'); + const hasHuggingFace = hasApiKey('huggingface'); const hasLiteLLM = config.hasLiteLLMProxy(); - if (hasOpenRouter || hasAnthropic || hasOpenAI || hasDeepSeek || hasLiteLLM) { + if (hasOpenRouter || hasAnthropic || hasOpenAI || hasDeepSeek || hasHuggingFace || hasLiteLLM) { console.log(''); showInfo('Existing API keys detected:'); if (hasOpenRouter) showSuccess(' OpenRouter: configured'); if (hasAnthropic) showSuccess(' Anthropic: configured'); if (hasOpenAI) showSuccess(' OpenAI: configured'); if (hasDeepSeek) showSuccess(' DeepSeek: configured'); + if (hasHuggingFace) showSuccess(' HuggingFace: configured'); if (hasLiteLLM) showSuccess(' LiteLLM Proxy: configured'); console.log(''); @@ -523,6 +572,10 @@ async function setupApiKeys(): Promise { name: `DeepSeek ${hasApiKey('deepseek') ? chalk.green('(configured)') : ''}`, 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)')}`, value: 'litellm', @@ -548,6 +601,9 @@ async function setupApiKeys(): Promise { case 'deepseek': await setupDeepSeekKey(); break; + case 'huggingface': + await setupHuggingFaceKey(); + break; case 'litellm': await setupLiteLLMProxy(); break; @@ -564,6 +620,7 @@ async function setupProvider(): Promise { if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' }); if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' }); 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' }); 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(' OpenAI: ' + (hasApiKey('openai') ? 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(''); console.log(chalk.cyan(' Config Path: ') + config.getConfigPath()); diff --git a/src/types/index.ts b/src/types/index.ts index fe14b8f..9c3547b 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,7 +6,7 @@ // 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 { provider: LLMProvider;