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

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

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

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

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

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

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

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

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

---------

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

No files matched your search

+1 -1
View File
@@ -58,7 +58,7 @@ describe('API key environment handling hardening', () => {
it('exportConfig redacts every supported provider key slot', () => {
const block = exportConfigBlock();
for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'deepseek', 'litellm']) {
for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'deepseek', 'huggingface', 'litellm']) {
expect(block).toContain(`${provider}: settings.apiKeys.${provider} ? '***REDACTED***' : undefined`);
}
});
@@ -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' }]);
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)', () => {
@@ -100,9 +100,11 @@ describe('local API authorization hardening invariants', () => {
});
it('Op Admiral routes keyless planning through the connected local agent', () => {
expect(uiSource).toMatch(/connectedAgent[\s\S]*t3mpPreferredAgent/);
expect(uiSource).toMatch(/connectedAgent \? 'local-agent'/);
expect(uiSource).toMatch(/provider === 'local-agent'[\s\S]*connectedAgent/);
// Routing is now centralized on window.t3mpDispatchAgent (shared by mission/General/Admiral):
// it honors an explicit pin over a stored key and encodes the picked model as "agentId::model".
expect(uiSource).toMatch(/const dispatchAgent = \(typeof window\.t3mpDispatchAgent/);
expect(uiSource).toMatch(/dispatchAgent \? 'local-agent'/);
expect(uiSource).toMatch(/provider === 'local-agent'[\s\S]*dispatchAgent/);
expect(uiSource).toMatch(/\['codex', 'mock', 'local', 'local-agent'\]/);
});
+3 -1
View File
@@ -524,6 +524,7 @@ async function openSettings(): Promise<void> {
console.log(chalk.cyan(' Anthropic:'), hasApiKey('anthropic') ? chalk.green('configured') : chalk.red('not set'));
console.log(chalk.cyan(' OpenAI:'), hasApiKey('openai') ? chalk.green('configured') : chalk.red('not set'));
console.log(chalk.cyan(' DeepSeek:'), hasApiKey('deepseek') ? chalk.green('configured') : chalk.red('not set'));
console.log(chalk.cyan(' HuggingFace:'), hasApiKey('huggingface') ? chalk.green('configured') : chalk.red('not set'));
console.log('');
break;
@@ -576,7 +577,7 @@ async function openSettings(): Promise<void> {
type: 'list',
name: 'keyProvider',
message: 'Which provider?',
choices: ['openrouter', 'venice', 'anthropic', 'openai', 'deepseek'],
choices: ['openrouter', 'venice', 'anthropic', 'openai', 'deepseek', 'huggingface'],
},
]);
const { apiKey } = await inquirer.prompt([
@@ -644,6 +645,7 @@ program
[chalk.cyan('Anthropic API Key'), hasApiKey('anthropic') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')],
[chalk.cyan('OpenAI API Key'), hasApiKey('openai') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')],
[chalk.cyan('DeepSeek API Key'), hasApiKey('deepseek') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')],
[chalk.cyan('HuggingFace API Key'), hasApiKey('huggingface') ? chalk.green('✓ Configured') : chalk.red('✗ Not set')],
[chalk.cyan('Config Path'), config.getConfigPath()],
);
+90 -4
View File
@@ -11,7 +11,7 @@ import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import type { LLMProvider, LLMConfig, FallbackEntry, OpsecLevel } from '../types/index.js';
type ApiKeyProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'local';
type ApiKeyProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'local';
// =============================================================================
// CONFIGURATION SCHEMA
@@ -27,6 +27,7 @@ export interface TempestSettings {
xai?: string;
gemini?: string;
deepseek?: string;
huggingface?: string;
litellm?: string;
local?: string;
};
@@ -85,6 +86,12 @@ export interface TempestSettings {
defaultModel: string;
};
// Hugging Face — open models via the OpenAI-compatible Inference Providers router
huggingface: {
baseUrl: string;
defaultModel: string;
};
// Codex CLI/account subscription backend
codex: {
command: string;
@@ -170,6 +177,16 @@ const DEFAULT_SETTINGS: TempestSettings = {
defaultModel: 'deepseek-v4-pro',
},
// Hugging Face Inference Providers expose a unified OpenAI-compatible router at
// /v1. The OpenAIAdapter posts to `${baseUrl}/chat/completions`, so the base URL
// ends at /v1 → https://router.huggingface.co/v1/chat/completions. Model ids are
// the full HF repo id (e.g. meta-llama/Llama-3.3-70B-Instruct), optionally with a
// `:provider` suffix to pin a specific inference backend.
huggingface: {
baseUrl: 'https://router.huggingface.co/v1',
defaultModel: 'meta-llama/Llama-3.3-70B-Instruct',
},
codex: {
command: 'codex',
defaultModel: 'codex-default',
@@ -546,6 +563,43 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
capabilities: ['reasoning', 'code', 'analysis', 'tools'],
},
],
// Full HF repo ids for the OpenAI-compatible Inference Providers router. Any model
// served by an HF inference provider can be passed via config/CLI/model arg — these
// are just curated, tool-capable defaults.
huggingface: [
{
id: 'meta-llama/Llama-3.3-70B-Instruct',
name: 'Llama 3.3 70B Instruct (Hugging Face)',
provider: 'HuggingFace',
contextWindow: 131072,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'tools'],
},
{
id: 'Qwen/Qwen2.5-72B-Instruct',
name: 'Qwen2.5 72B Instruct (Hugging Face)',
provider: 'HuggingFace',
contextWindow: 131072,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'tools'],
},
{
id: 'deepseek-ai/DeepSeek-R1',
name: 'DeepSeek R1 (Hugging Face)',
provider: 'HuggingFace',
contextWindow: 64000,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'complex-tasks'],
},
{
id: 'mistralai/Mistral-Small-24B-Instruct-2501',
name: 'Mistral Small 24B Instruct (Hugging Face)',
provider: 'HuggingFace',
contextWindow: 32000,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'tools'],
},
],
local: [
{
id: 'local-model',
@@ -633,7 +687,7 @@ class ConfigManager {
const envContent = readFileSync(envPath, 'utf-8');
const lines = envContent.split('\n');
// Validation variables added
const VALID_PROVIDERS = ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'litellm', 'deepseek', 'local'];
const VALID_PROVIDERS = ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'litellm', 'deepseek', 'huggingface', 'local'];
for (const line of lines) {
const trimmed = line.trim();
@@ -734,6 +788,7 @@ class ConfigManager {
xai: 'XAI_API_KEY',
gemini: 'GEMINI_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
huggingface: 'HF_TOKEN',
litellm: 'LITELLM_API_KEY',
};
@@ -743,6 +798,16 @@ class ConfigManager {
// empty exported env var (round-2 code-sweep regression fix).
if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_FORCE_UNCONFIGURED || '').trim())) return undefined;
// Hugging Face publishes its token under several well-known names; accept the
// canonical HF_TOKEN plus the common aliases so an operator's existing env works.
if (provider === 'huggingface') {
const hf = process.env.HF_TOKEN?.trim()
|| process.env.HUGGINGFACE_API_KEY?.trim()
|| process.env.HUGGINGFACE_TOKEN?.trim()
|| process.env.HUGGINGFACEHUB_API_TOKEN?.trim();
if (hf) return hf;
}
const envKey = process.env[envVarMap[provider]];
if (envKey) return envKey; // a non-empty env var wins; empty/unset falls through
@@ -791,6 +856,7 @@ class ConfigManager {
if (this.hasLiteLLMProxy()) providers.push('litellm');
if (this.hasApiKey('gemini')) providers.push('gemini');
if (this.hasApiKey('deepseek')) providers.push('deepseek');
if (this.hasApiKey('huggingface')) providers.push('huggingface');
// Codex uses the local Codex CLI/account auth instead of API-key storage.
providers.push('codex');
@@ -864,13 +930,20 @@ class ConfigManager {
baseUrl = this.config.get('deepseek').baseUrl;
actualModel = model || this.config.get('deepseek').defaultModel;
break;
case 'huggingface':
// Hugging Face Inference Providers router is OpenAI-compatible.
apiKey = this.getApiKey('huggingface');
baseUrl = this.config.get('huggingface').baseUrl;
actualModel = model || this.config.get('huggingface').defaultModel;
break;
case 'codex':
actualModel = model || this.config.get('codex').defaultModel;
break;
case 'local-agent':
// Keyless backbone: the mission is routed through a connected local CLI agent
// (Claude Code / Codex / Hermes), each using its own login — no API key or base
// URL. The chosen agent id (codex|claude|hermes) travels in the `model` field.
// URL. The chosen agent id travels in `model`, optionally with the selected
// underlying model after "::" (for example "claude::opus").
actualModel = model || 'claude';
break;
case 'mock':
@@ -929,7 +1002,7 @@ class ConfigManager {
const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase();
if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return [];
const chain: FallbackEntry[] = [];
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek') => {
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface') => {
if (p === primary) return;
if (p === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return;
chain.push({
@@ -947,6 +1020,7 @@ class ConfigManager {
add('xai');
add('gemini');
add('deepseek');
add('huggingface');
return chain;
}
@@ -973,6 +1047,9 @@ class ConfigManager {
case 'deepseek':
this.config.set('defaultModel', this.config.get('deepseek').defaultModel);
break;
case 'huggingface':
this.config.set('defaultModel', this.config.get('huggingface').defaultModel);
break;
case 'xai':
this.config.set('defaultModel', this.config.get('xai').defaultModel);
break;
@@ -1011,6 +1088,9 @@ class ConfigManager {
case 'deepseek':
this.config.set('deepseek', { ...this.config.get('deepseek'), defaultModel: model });
break;
case 'huggingface':
this.config.set('huggingface', { ...this.config.get('huggingface'), defaultModel: model });
break;
case 'xai':
this.config.set('xai', { ...this.config.get('xai'), defaultModel: model });
break;
@@ -1060,6 +1140,7 @@ class ConfigManager {
xai: settings.apiKeys.xai ? '***REDACTED***' : undefined,
gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined,
deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined,
huggingface: settings.apiKeys.huggingface ? '***REDACTED***' : undefined,
litellm: settings.apiKeys.litellm ? '***REDACTED***' : undefined,
},
};
@@ -1108,6 +1189,11 @@ GEMINI_API_KEY=
# Get your key at: https://platform.deepseek.com/api_keys
DEEPSEEK_API_KEY=
# Hugging Face access token (open models via the OpenAI-compatible Inference Providers router)
# Get your token at: https://huggingface.co/settings/tokens
# HUGGINGFACE_API_KEY / HUGGINGFACE_TOKEN are also accepted as aliases.
HF_TOKEN=
# Local model (Ollama / LM Studio / vLLM / llama.cpp, or any OpenAI-compatible server)
# Point TEMPEST_LOCAL_BASE_URL at the server root (Ollama default shown below).
# For an OpenAI-compatible server, use a versioned path: LM Studio :1234/v1,
+28 -5
View File
@@ -7,6 +7,7 @@
* - LiteLLM (OpenAI-compatible AI gateway — 100+ providers via unified proxy)
* - Anthropic (direct Claude access)
* - OpenAI (GPT models)
* - HuggingFace (open models via the OpenAI-compatible Inference Providers router)
* - Mock (for testing)
* - Local (Ollama, etc.)
*/
@@ -1290,11 +1291,22 @@ class CodexAdapter implements LLMProviderAdapter {
}
// Generic adapter that drives a CONNECTED local agent CLI (Claude Code / Codex / Hermes) as the LLM
// backend — NO API key needed; each CLI uses its own login. The agent id travels in `config.model`.
// backend — NO API key needed; each CLI uses its own login. The agent id travels in `config.model`,
// optionally with an underlying model after a `::` separator ("claude::opus", "claude::claude-opus-4-8")
// so the operator can pick WHICH model the local CLI runs (passed through as the CLI's --model/-m flag).
class LocalAgentAdapter implements LLMProviderAdapter {
name = 'local-agent';
private config: LLMConfig;
constructor(config: LLMConfig) { this.config = config; }
// Split "agentId[::model]" → { agentId, agentModel }. No separator = just the agent id (CLI default model).
private parseAgentSpec(): { agentId: string; agentModel?: string } {
const raw = this.config.model || 'codex';
const i = raw.indexOf('::');
if (i < 0) return { agentId: raw };
const agentId = raw.slice(0, i).trim() || 'codex';
const agentModel = raw.slice(i + 2).trim() || undefined;
return { agentId, agentModel };
}
validateConfig(): { valid: boolean; error?: string } {
return this.config.model ? { valid: true } : { valid: false, error: 'local-agent requires the agent id in `model` (codex|claude|hermes)' };
}
@@ -1314,16 +1326,16 @@ class LocalAgentAdapter implements LLMProviderAdapter {
return parts.join('\n');
}
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
const agentId = this.config.model || 'codex';
const { agentId, agentModel } = this.parseAgentSpec();
const prompt = this.formatPrompt(messages, options);
const timeoutMs = typeof this.config.timeout === 'number' && this.config.timeout > 0 ? this.config.timeout : undefined;
const content = (await localAgentChat(agentId, prompt, { timeoutMs })).trim();
const content = (await localAgentChat(agentId, prompt, { model: agentModel, timeoutMs })).trim();
// Tool-calling over text: if the Arsenal was offered, parse the agent's tool requests so the
// ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer.
const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined;
return {
content,
model: `local-agent:${agentId}`,
model: `local-agent:${agentId}${agentModel ? '/' + agentModel : ''}`,
finishReason: toolCalls?.length ? 'tool_calls' : 'stop',
toolCalls,
};
@@ -1459,6 +1471,8 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
return new OpenAIAdapter(config); // Gemini via Google's OpenAI-compatible endpoint (baseUrl ends in /v1beta/openai)
case 'deepseek':
return new OpenAIAdapter(config); // DeepSeek native API is OpenAI-compatible
case 'huggingface':
return new OpenAIAdapter(config); // HF Inference Providers router is OpenAI-compatible (baseUrl ends in /v1)
case 'codex':
return new CodexAdapter(config);
case 'mock':
@@ -1753,6 +1767,12 @@ export function createDeepSeekBackbone(apiKey?: string, model?: string): LLMBack
return new LLMBackbone(llmConfig);
}
export function createHuggingFaceBackbone(apiKey?: string, model?: string): LLMBackbone {
const llmConfig = config.getLLMConfig('huggingface', model);
if (apiKey) llmConfig.apiKey = apiKey;
return new LLMBackbone(llmConfig);
}
export function createLiteLLMBackbone(apiKey?: string, model?: string, baseUrl?: string): LLMBackbone {
const llmConfig = config.getLLMConfig('litellm', model);
if (apiKey) llmConfig.apiKey = apiKey;
@@ -1783,7 +1803,7 @@ export function createLocalBackbone(model?: string, baseUrl?: string): LLMBackbo
* Create the best available backbone based on configured API keys
*/
export function createBestAvailableBackbone(): LLMBackbone {
// Priority: OpenRouter > Venice > LiteLLM > Anthropic > OpenAI > DeepSeek > Local > Mock
// Priority: OpenRouter > Venice > LiteLLM > Anthropic > OpenAI > DeepSeek > HuggingFace > Local > Mock
const providers = config.getConfiguredProviders();
if (providers.includes('openrouter')) {
@@ -1804,6 +1824,9 @@ export function createBestAvailableBackbone(): LLMBackbone {
if (providers.includes('deepseek')) {
return createDeepSeekBackbone();
}
if (providers.includes('huggingface')) {
return createHuggingFaceBackbone();
}
// Default to mock if no API keys configured
console.warn('No API keys configured. Using mock provider.');
+5 -1
View File
@@ -7848,7 +7848,11 @@ async function refreshConnectedLocalAgentHealth(force = false): Promise<void> {
}
async function requireLiveLocalAgent(model: string | undefined): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
const id = String(model || 'codex').trim();
// The mission model may be "agentId::underlyingModel" (e.g. "claude::opus") so the operator can pick
// WHICH model the local CLI runs — the connected-agent registry is keyed by the agent id alone, so
// validate against the part before "::".
const raw = String(model || 'codex').trim();
const id = raw.includes('::') ? raw.slice(0, raw.indexOf('::')).trim() || 'codex' : raw;
const entry = connectedLocalAgents.get(id);
if (!entry) return { ok: false, error: `local agent "${id}" is not connected — connect it in Settings first` };
+59 -1
View File
@@ -241,6 +241,53 @@ async function setupDeepSeekKey(): Promise<boolean> {
}
}
async function setupHuggingFaceKey(): Promise<boolean> {
console.log('');
showInfo('Hugging Face serves open models via its OpenAI-compatible Inference Providers router.');
showInfo('Get your token at: ' + chalk.underline('https://huggingface.co/settings/tokens'));
console.log('');
const { apiKey } = await inquirer.prompt([
{
type: 'password',
name: 'apiKey',
message: 'Enter your Hugging Face access token:',
mask: '*',
validate: (input: string) => {
if (!input || input.length < 10) {
return 'Please enter a valid access token';
}
return true;
},
},
]);
const spinner = ora('Testing access token...').start();
try {
const llm = new LLMBackbone({
provider: 'huggingface',
model: 'meta-llama/Llama-3.3-70B-Instruct',
baseUrl: 'https://router.huggingface.co/v1',
apiKey,
maxTokens: 10,
temperature: 0,
});
await llm.prompt('Hello', undefined, { maxTokens: 10 });
spinner.succeed('Access token is valid!');
setApiKey('huggingface', apiKey);
showSuccess('Hugging Face access token saved successfully!');
return true;
} catch (error) {
spinner.fail('Access token validation failed');
showError(`Error: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
async function setupOpenAIKey(): Promise<boolean> {
console.log('');
showInfo('OpenAI provides access to GPT models.');
@@ -430,15 +477,17 @@ The setup will guide you through:
const hasAnthropic = hasApiKey('anthropic');
const hasOpenAI = hasApiKey('openai');
const hasDeepSeek = hasApiKey('deepseek');
const hasHuggingFace = hasApiKey('huggingface');
const hasLiteLLM = config.hasLiteLLMProxy();
if (hasOpenRouter || hasAnthropic || hasOpenAI || hasDeepSeek || hasLiteLLM) {
if (hasOpenRouter || hasAnthropic || hasOpenAI || hasDeepSeek || hasHuggingFace || hasLiteLLM) {
console.log('');
showInfo('Existing API keys detected:');
if (hasOpenRouter) showSuccess(' OpenRouter: configured');
if (hasAnthropic) showSuccess(' Anthropic: configured');
if (hasOpenAI) showSuccess(' OpenAI: configured');
if (hasDeepSeek) showSuccess(' DeepSeek: configured');
if (hasHuggingFace) showSuccess(' HuggingFace: configured');
if (hasLiteLLM) showSuccess(' LiteLLM Proxy: configured');
console.log('');
@@ -523,6 +572,10 @@ async function setupApiKeys(): Promise<void> {
name: `DeepSeek ${hasApiKey('deepseek') ? chalk.green('(configured)') : ''}`,
value: 'deepseek',
},
{
name: `HuggingFace ${hasApiKey('huggingface') ? chalk.green('(configured)') : chalk.gray('(open models via HF router)')}`,
value: 'huggingface',
},
{
name: `LiteLLM Proxy ${config.hasLiteLLMProxy() ? chalk.green('(configured)') : chalk.gray('(100+ providers via gateway)')}`,
value: 'litellm',
@@ -548,6 +601,9 @@ async function setupApiKeys(): Promise<void> {
case 'deepseek':
await setupDeepSeekKey();
break;
case 'huggingface':
await setupHuggingFaceKey();
break;
case 'litellm':
await setupLiteLLMProxy();
break;
@@ -564,6 +620,7 @@ async function setupProvider(): Promise<void> {
if (hasApiKey('anthropic')) configuredProviders.push({ name: 'Anthropic', value: 'anthropic' });
if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' });
if (hasApiKey('deepseek')) configuredProviders.push({ name: 'DeepSeek', value: 'deepseek' });
if (hasApiKey('huggingface')) configuredProviders.push({ name: 'HuggingFace', value: 'huggingface' });
if (config.hasLiteLLMProxy()) configuredProviders.push({ name: 'LiteLLM Proxy', value: 'litellm' });
const { provider } = await inquirer.prompt([
@@ -596,6 +653,7 @@ function viewConfiguration(): void {
console.log(' Anthropic: ' + (hasApiKey('anthropic') ? chalk.green('configured') : chalk.red('not set')));
console.log(' OpenAI: ' + (hasApiKey('openai') ? chalk.green('configured') : chalk.red('not set')));
console.log(' DeepSeek: ' + (hasApiKey('deepseek') ? chalk.green('configured') : chalk.red('not set')));
console.log(' HuggingFace: ' + (hasApiKey('huggingface') ? chalk.green('configured') : chalk.red('not set')));
console.log(' LiteLLM Proxy: ' + (config.hasLiteLLMProxy() ? chalk.green('configured') : chalk.red('not set')));
console.log('');
console.log(chalk.cyan(' Config Path: ') + config.getConfigPath());
+1 -1
View File
@@ -6,7 +6,7 @@
// LLM CONFIGURATION
// =============================================================================
export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'codex' | 'mock' | 'local' | 'local-agent';
export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'codex' | 'mock' | 'local' | 'local-agent';
export interface LLMConfig {
provider: LLMProvider;