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 @@
+
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…
+
+
+ 🟠
+
+
Claude Code model
+
Which local Claude model to run (passed to claude --model)
+
+
+
@@ -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 '