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
+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.');
|
||||
|
||||
Reference in new issue
Block a user