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:
1 parent
e52c2fb2b7
commit
8ccea7c82a
13 files changed
+630
-44
No files matched your search
+3
-1
@@ -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
@@ -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'); }
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user