diff --git a/docs/index.html b/docs/index.html index 8df6da1..7ce7abe 100755 --- a/docs/index.html +++ b/docs/index.html @@ -5620,6 +5620,7 @@ + @@ -9242,6 +9243,7 @@ o.id === 'coordinator' ? `1. MISSION_STATUS: Progress assessment openrouter: 'https://openrouter.ai/api/v1', venice: 'https://api.venice.ai/api/v1', openai: 'https://api.openai.com/v1', + nanogpt: 'https://nano-gpt.com/api/v1', anthropic: 'https://api.anthropic.com', }; function uacProvider() { return document.getElementById('uacProvider')?.value || 'openrouter'; } diff --git a/scripts/setup-api.sh b/scripts/setup-api.sh index bd5c043..e6eaf11 100755 --- a/scripts/setup-api.sh +++ b/scripts/setup-api.sh @@ -22,9 +22,10 @@ echo "Choose your LLM provider:" echo " 1) OpenRouter (recommended - access to all models)" echo " 2) Anthropic (Claude direct)" echo " 3) OpenAI (GPT models)" +echo " 4) NanoGPT (direct OpenAI-compatible API)" echo "" -read -r -p "Enter choice [1-3]: " choice +read -r -p "Enter choice [1-4]: " choice case $choice in 1) @@ -57,6 +58,16 @@ case $choice in printf 'LLM_PROVIDER=openai\n' } > "$ENV_FILE" ;; + 4) + echo "" + echo "Get your NanoGPT API key at: https://nano-gpt.com/api" + read -rsp "Enter your NanoGPT API key: " api_key + echo "" + { + printf 'NANOGPT_API_KEY=%s\n' "$api_key" + printf 'LLM_PROVIDER=nanogpt\n' + } > "$ENV_FILE" + ;; *) echo "Invalid choice" exit 1 diff --git a/src/__tests__/api-key-env-static.test.ts b/src/__tests__/api-key-env-static.test.ts index 577d22b..7004777 100644 --- a/src/__tests__/api-key-env-static.test.ts +++ b/src/__tests__/api-key-env-static.test.ts @@ -5,6 +5,9 @@ import { join } from 'path'; const configSource = readFileSync(join(process.cwd(), 'src/config/index.ts'), 'utf8'); const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8'); const setupScript = readFileSync(join(process.cwd(), 'scripts/setup-api.sh'), 'utf8'); +const localAgentsSource = readFileSync(join(process.cwd(), 'src/agent/local-agents.ts'), 'utf8'); +const setupSource = readFileSync(join(process.cwd(), 'src/setup.ts'), 'utf8'); +const uiSource = readFileSync(join(process.cwd(), 'docs/index.html'), 'utf8'); function sourceBlock(startMarker: string, endMarker: string): string { const start = configSource.indexOf(startMarker); @@ -58,11 +61,24 @@ 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', 'huggingface', 'litellm']) { + for (const provider of ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'deepseek', 'huggingface', 'nanogpt', 'litellm']) { expect(block).toContain(`${provider}: settings.apiKeys.${provider} ? '***REDACTED***' : undefined`); } }); + it('documents NanoGPT and strips its key before spawning local agent CLIs', () => { + const block = envTemplateBlock(); + + expect(block).toContain('NANOGPT_API_KEY='); + expect(setupScript).toContain("printf 'NANOGPT_API_KEY=%s\\n'"); + expect(setupScript).toContain('LLM_PROVIDER=nanogpt'); + expect(localAgentsSource).toContain("'NANOGPT_API_KEY'"); + expect(setupSource).toContain("provider: 'nanogpt'"); + expect(setupSource).toContain("setApiKey('nanogpt', apiKey)"); + expect(uiSource).toContain(''); + expect(uiSource).toContain("nanogpt: 'https://nano-gpt.com/api/v1'"); + }); + it('ConfigManager uses env keys in-memory and does not persist imported env keys', () => { const block = configLoadEnvBlock(); diff --git a/src/__tests__/nanogpt-provider.test.ts b/src/__tests__/nanogpt-provider.test.ts new file mode 100644 index 0000000..849871d --- /dev/null +++ b/src/__tests__/nanogpt-provider.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AVAILABLE_MODELS, config } from '../config/index.js'; +import { createNanoGPTBackbone } from '../llm/index.js'; + +const KEY = 'sk-nano-12345678-1234-1234-1234-123456789abc'; + +describe('NanoGPT provider wiring', () => { + const originalFetch = global.fetch; + + afterEach(() => { + delete process.env.NANOGPT_API_KEY; + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('uses the canonical base URL, documented key name, and fail-open model', () => { + process.env.NANOGPT_API_KEY = KEY; + + const cfg = config.getLLMConfig('nanogpt'); + expect(cfg.provider).toBe('nanogpt'); + expect(cfg.baseUrl).toBe('https://nano-gpt.com/api/v1'); + expect(cfg.model).toBe('minimax/minimax-m2.7'); + expect(cfg.apiKey).toBe(KEY); + expect(`${cfg.baseUrl}/chat/completions`) + .toBe('https://nano-gpt.com/api/v1/chat/completions'); + }); + + it('does not recognize the obsolete RBE_API_KEY identifier', () => { + process.env.RBE_API_KEY = KEY; + try { + expect(config.getLLMConfig('nanogpt').apiKey).toBeUndefined(); + } finally { + delete process.env.RBE_API_KEY; + } + }); + + it('routes chat directly to NanoGPT with Bearer authentication', async () => { + const fetchSpy = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({ + ok: true, + json: async () => ({ + id: 'chatcmpl-test', + model: 'minimax/minimax-m2.7', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + }), + })); + global.fetch = fetchSpy as unknown as typeof fetch; + + const response = await createNanoGPTBackbone(KEY).chat([ + { role: 'user', content: 'hello' }, + ]); + + expect(response.content).toBe('ok'); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe('https://nano-gpt.com/api/v1/chat/completions'); + expect((init as RequestInit).headers).toMatchObject({ + Authorization: `Bearer ${KEY}`, + 'Content-Type': 'application/json', + }); + }); + + it('requires a NanoGPT key and surfaces configured provider state', () => { + const bb = createNanoGPTBackbone(); + expect(bb.getProvider()).toBe('nanogpt'); + expect(bb.validateConfig()).toEqual({ + valid: false, + error: expect.stringContaining('NANOGPT_API_KEY'), + }); + + process.env.NANOGPT_API_KEY = KEY; + expect(config.getConfiguredProviders()).toContain('nanogpt'); + expect(AVAILABLE_MODELS.nanogpt.map((model) => model.id)) + .toEqual(['minimax/minimax-m2.7']); + }); +}); diff --git a/src/__tests__/provider-models.test.ts b/src/__tests__/provider-models.test.ts index 5c223f0..04e9d0f 100644 --- a/src/__tests__/provider-models.test.ts +++ b/src/__tests__/provider-models.test.ts @@ -43,6 +43,21 @@ describe('listProviderModels — OpenAI-compatible providers', () => { expect(models.map((m) => m.id)).toEqual(['llama-3.3-70b']); expect(f.mock.calls[0][0]).toBe('https://api.venice.ai/api/v1/models'); }); + + it('uses NanoGPT canonical /api/v1/models with Bearer authentication', async () => { + const f = fakeFetch({ data: [{ id: 'minimax/minimax-m2.7' }] }); + const models = await listProviderModels('nanogpt', { + baseUrl: 'https://nano-gpt.com/api/v1', + apiKey: 'sk-nano-test-key', + fetchImpl: f, + }); + + expect(models.map((m) => m.id)).toEqual(['minimax/minimax-m2.7']); + const [url, init] = f.mock.calls[0]; + expect(url).toBe('https://nano-gpt.com/api/v1/models'); + expect((init as { headers: Record }).headers.Authorization) + .toBe('Bearer sk-nano-test-key'); + }); }); describe('listProviderModels — Anthropic wire', () => { diff --git a/src/__tests__/redact.test.ts b/src/__tests__/redact.test.ts index d52cbdf..90f4db3 100644 --- a/src/__tests__/redact.test.ts +++ b/src/__tests__/redact.test.ts @@ -30,6 +30,11 @@ describe('redactString — URL userinfo (basic-auth) scrub', () => { expect(redactString('token=abc123def456ghi')).toBe('token=[redacted]'); expect(redactString('Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')).toContain('Bearer [redacted]'); }); + + it('redacts NanoGPT sk-nano keys', () => { + expect(redactString('NanoGPT key: sk-nano-12345678-1234-1234-1234-123456789abc')) + .toBe('NanoGPT key: [redacted]'); + }); }); describe('redactSecrets — approval-audit record shape', () => { diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index 9254ed3..74e5782 100644 --- a/src/agent/local-agents.ts +++ b/src/agent/local-agents.ts @@ -25,6 +25,7 @@ const PROVIDER_ENV_TO_STRIP = [ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_ORGANIZATION', 'OPENROUTER_API_KEY', + 'NANOGPT_API_KEY', ]; /** diff --git a/src/config/index.ts b/src/config/index.ts index 05aa341..25881e7 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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' | 'huggingface' | 'local'; +type ApiKeyProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'nanogpt' | 'local'; // ============================================================================= // CONFIGURATION SCHEMA @@ -28,6 +28,7 @@ export interface TempestSettings { gemini?: string; deepseek?: string; huggingface?: string; + nanogpt?: string; litellm?: string; local?: string; }; @@ -92,6 +93,12 @@ export interface TempestSettings { defaultModel: string; }; + // NanoGPT — OpenAI-compatible text API + nanogpt: { + baseUrl: string; + defaultModel: string; + }; + // Codex CLI/account subscription backend codex: { command: string; @@ -187,6 +194,13 @@ const DEFAULT_SETTINGS: TempestSettings = { defaultModel: 'meta-llama/Llama-3.3-70B-Instruct', }, + // NanoGPT's canonical OpenAI-compatible base. The OpenAIAdapter appends + // /chat/completions and provider-models appends /models. + nanogpt: { + baseUrl: 'https://nano-gpt.com/api/v1', + defaultModel: 'minimax/minimax-m2.7', + }, + codex: { command: 'codex', defaultModel: 'codex-default', @@ -600,6 +614,18 @@ export const AVAILABLE_MODELS: Record = { capabilities: ['reasoning', 'code', 'analysis', 'tools'], }, ], + // NanoGPT's catalog is dynamic; this documented model is only a fail-open + // fallback for the UI when GET /api/v1/models is unavailable. + nanogpt: [ + { + id: 'minimax/minimax-m2.7', + name: 'MiniMax M2.7 (NanoGPT)', + provider: 'NanoGPT', + contextWindow: 204800, + maxOutput: 131072, + capabilities: ['reasoning', 'code', 'analysis', 'tools'], + }, + ], local: [ { id: 'local-model', @@ -687,7 +713,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', 'huggingface', 'local']; + const VALID_PROVIDERS = ['openrouter', 'venice', 'anthropic', 'openai', 'xai', 'gemini', 'litellm', 'deepseek', 'huggingface', 'nanogpt', 'local']; for (const line of lines) { const trimmed = line.trim(); @@ -789,6 +815,7 @@ class ConfigManager { gemini: 'GEMINI_API_KEY', deepseek: 'DEEPSEEK_API_KEY', huggingface: 'HF_TOKEN', + nanogpt: 'NANOGPT_API_KEY', litellm: 'LITELLM_API_KEY', }; @@ -857,6 +884,7 @@ class ConfigManager { if (this.hasApiKey('gemini')) providers.push('gemini'); if (this.hasApiKey('deepseek')) providers.push('deepseek'); if (this.hasApiKey('huggingface')) providers.push('huggingface'); + if (this.hasApiKey('nanogpt')) providers.push('nanogpt'); // Codex uses the local Codex CLI/account auth instead of API-key storage. providers.push('codex'); @@ -936,6 +964,12 @@ class ConfigManager { baseUrl = this.config.get('huggingface').baseUrl; actualModel = model || this.config.get('huggingface').defaultModel; break; + case 'nanogpt': + // NanoGPT's canonical /api/v1 surface is OpenAI-compatible. + apiKey = this.getApiKey('nanogpt'); + baseUrl = this.config.get('nanogpt').baseUrl; + actualModel = model || this.config.get('nanogpt').defaultModel; + break; case 'codex': actualModel = model || this.config.get('codex').defaultModel; break; @@ -1002,7 +1036,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' | 'huggingface') => { + const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'nanogpt') => { if (p === primary) return; if (p === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return; chain.push({ @@ -1021,6 +1055,7 @@ class ConfigManager { add('gemini'); add('deepseek'); add('huggingface'); + add('nanogpt'); return chain; } @@ -1050,6 +1085,9 @@ class ConfigManager { case 'huggingface': this.config.set('defaultModel', this.config.get('huggingface').defaultModel); break; + case 'nanogpt': + this.config.set('defaultModel', this.config.get('nanogpt').defaultModel); + break; case 'xai': this.config.set('defaultModel', this.config.get('xai').defaultModel); break; @@ -1091,6 +1129,9 @@ class ConfigManager { case 'huggingface': this.config.set('huggingface', { ...this.config.get('huggingface'), defaultModel: model }); break; + case 'nanogpt': + this.config.set('nanogpt', { ...this.config.get('nanogpt'), defaultModel: model }); + break; case 'xai': this.config.set('xai', { ...this.config.get('xai'), defaultModel: model }); break; @@ -1141,6 +1182,7 @@ class ConfigManager { gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined, deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined, huggingface: settings.apiKeys.huggingface ? '***REDACTED***' : undefined, + nanogpt: settings.apiKeys.nanogpt ? '***REDACTED***' : undefined, litellm: settings.apiKeys.litellm ? '***REDACTED***' : undefined, }, }; @@ -1194,6 +1236,10 @@ DEEPSEEK_API_KEY= # HUGGINGFACE_API_KEY / HUGGINGFACE_TOKEN are also accepted as aliases. HF_TOKEN= +# NanoGPT API Key (direct OpenAI-compatible API) +# Docs and key management: https://docs.nano-gpt.com/authentication +NANOGPT_API_KEY= + # 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, diff --git a/src/config/provider-models.ts b/src/config/provider-models.ts index d40c47f..51145e9 100644 --- a/src/config/provider-models.ts +++ b/src/config/provider-models.ts @@ -23,7 +23,7 @@ const DEFAULT_MODEL_LIST_TIMEOUT_MS = 15_000; // Pseudo-providers with no remote model list (CLI-driven or in-process). const NO_REMOTE_LIST = new Set(['codex', 'mock', 'local-agent']); -const OPENAI_COMPATIBLE_REMOTE_LIST = new Set(['openai', 'venice', 'xai', 'gemini', 'local']); +const OPENAI_COMPATIBLE_REMOTE_LIST = new Set(['openai', 'venice', 'xai', 'gemini', 'nanogpt', 'local']); const DIRECT_REMOTE_LIST = new Set(['anthropic', 'openrouter']); const stripTrailingSlash = (u: string): string => u.replace(/\/+$/, ''); diff --git a/src/llm/index.ts b/src/llm/index.ts index 8864f8f..4aa28c2 100755 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -582,7 +582,7 @@ class AnthropicAdapter implements LLMProviderAdapter { class OpenAIAdapter implements LLMProviderAdapter { name = 'openai'; - private config: LLMConfig; + protected config: LLMConfig; constructor(config: LLMConfig) { this.config = config; @@ -683,6 +683,24 @@ class OpenAIAdapter implements LLMProviderAdapter { } } +// ============================================================================= +// NANOGPT ADAPTER +// ============================================================================= + +class NanoGPTAdapter extends OpenAIAdapter { + name = 'nanogpt'; + + validateConfig(): { valid: boolean; error?: string } { + if (!this.config.apiKey) { + return { + valid: false, + error: 'NanoGPT API key is required. Set NANOGPT_API_KEY. Docs: https://docs.nano-gpt.com/authentication', + }; + } + return { valid: true }; + } +} + // ============================================================================= // MOCK ADAPTER (for testing) // ============================================================================= @@ -1473,6 +1491,8 @@ export class LLMBackbone extends EventEmitter { 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 'nanogpt': + return new NanoGPTAdapter(config); case 'codex': return new CodexAdapter(config); case 'mock': @@ -1773,6 +1793,12 @@ export function createHuggingFaceBackbone(apiKey?: string, model?: string): LLMB return new LLMBackbone(llmConfig); } +export function createNanoGPTBackbone(apiKey?: string, model?: string): LLMBackbone { + const llmConfig = config.getLLMConfig('nanogpt', 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; @@ -1803,7 +1829,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 > HuggingFace > Local > Mock + // Priority: OpenRouter > Venice > LiteLLM > Anthropic > OpenAI > DeepSeek > HuggingFace > NanoGPT > Local > Mock const providers = config.getConfiguredProviders(); if (providers.includes('openrouter')) { @@ -1827,6 +1853,9 @@ export function createBestAvailableBackbone(): LLMBackbone { if (providers.includes('huggingface')) { return createHuggingFaceBackbone(); } + if (providers.includes('nanogpt')) { + return createNanoGPTBackbone(); + } // Default to mock if no API keys configured console.warn('No API keys configured. Using mock provider.'); diff --git a/src/redact.ts b/src/redact.ts index 327f4e3..ecaa2d2 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -13,6 +13,7 @@ export const SECRET_PATTERNS: Record { } } +async function setupNanoGPTKey(): Promise { + console.log(''); + showInfo('NanoGPT provides a direct OpenAI-compatible API with a live model catalog.'); + showInfo('Get your API key at: ' + chalk.underline('https://nano-gpt.com/api')); + console.log(''); + + const { apiKey } = await inquirer.prompt([ + { + type: 'password', + name: 'apiKey', + message: 'Enter your NanoGPT API key:', + mask: '*', + validate: (input: string) => { + if (!input || input.length < 10) { + return 'Please enter a valid API key'; + } + return true; + }, + }, + ]); + + const spinner = ora('Testing API key...').start(); + + try { + const llm = new LLMBackbone({ + provider: 'nanogpt', + model: 'minimax/minimax-m2.7', + baseUrl: 'https://nano-gpt.com/api/v1', + apiKey, + maxTokens: 10, + temperature: 0, + }); + + await llm.prompt('Hello', undefined, { maxTokens: 10 }); + spinner.succeed('API key is valid!'); + + setApiKey('nanogpt', apiKey); + showSuccess('NanoGPT API key saved successfully!'); + + return true; + } catch (error) { + spinner.fail('API key validation failed'); + showError(`Error: ${error instanceof Error ? error.message : String(error)}`); + return false; + } +} + async function setupOpenAIKey(): Promise { console.log(''); showInfo('OpenAI provides access to GPT models.'); @@ -576,6 +623,10 @@ async function setupApiKeys(): Promise { name: `HuggingFace ${hasApiKey('huggingface') ? chalk.green('(configured)') : chalk.gray('(open models via HF router)')}`, value: 'huggingface', }, + { + name: `NanoGPT ${hasApiKey('nanogpt') ? chalk.green('(configured)') : chalk.gray('(direct OpenAI-compatible API)')}`, + value: 'nanogpt', + }, { name: `LiteLLM Proxy ${config.hasLiteLLMProxy() ? chalk.green('(configured)') : chalk.gray('(100+ providers via gateway)')}`, value: 'litellm', @@ -604,6 +655,9 @@ async function setupApiKeys(): Promise { case 'huggingface': await setupHuggingFaceKey(); break; + case 'nanogpt': + await setupNanoGPTKey(); + break; case 'litellm': await setupLiteLLMProxy(); break; @@ -621,6 +675,7 @@ async function setupProvider(): Promise { 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 (hasApiKey('nanogpt')) configuredProviders.push({ name: 'NanoGPT', value: 'nanogpt' }); if (config.hasLiteLLMProxy()) configuredProviders.push({ name: 'LiteLLM Proxy', value: 'litellm' }); const { provider } = await inquirer.prompt([ diff --git a/src/types/index.ts b/src/types/index.ts index 9c3547b..0f0dd8e 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,7 +6,7 @@ // LLM CONFIGURATION // ============================================================================= -export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'codex' | 'mock' | 'local' | 'local-agent'; +export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek' | 'huggingface' | 'nanogpt' | 'codex' | 'mock' | 'local' | 'local-agent'; export interface LLMConfig { provider: LLMProvider;