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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user