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

+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.');