feat(llm): add native NanoGPT provider support (#131)
Align NanoGPT integration with the published OpenAI-compatible API contract, including model discovery, configuration/setup surfaces, and secret-redaction safeguards.\n\nFixes #129.
This commit is contained in:
1 parent
8ccea7c82a
commit
8df967dd39
13 files changed
+264
-9
No files matched your search
@@ -5620,6 +5620,7 @@
|
|||||||
<option value="openrouter">OpenRouter · OpenAI-compatible</option>
|
<option value="openrouter">OpenRouter · OpenAI-compatible</option>
|
||||||
<option value="venice">Venice · OpenAI-compatible</option>
|
<option value="venice">Venice · OpenAI-compatible</option>
|
||||||
<option value="openai">OpenAI · OpenAI-compatible</option>
|
<option value="openai">OpenAI · OpenAI-compatible</option>
|
||||||
|
<option value="nanogpt">NanoGPT · OpenAI-compatible</option>
|
||||||
<option value="anthropic">Claude · Anthropic Messages API</option>
|
<option value="anthropic">Claude · Anthropic Messages API</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -9242,6 +9243,7 @@ o.id === 'coordinator' ? `1. MISSION_STATUS: Progress assessment
|
|||||||
openrouter: 'https://openrouter.ai/api/v1',
|
openrouter: 'https://openrouter.ai/api/v1',
|
||||||
venice: 'https://api.venice.ai/api/v1',
|
venice: 'https://api.venice.ai/api/v1',
|
||||||
openai: 'https://api.openai.com/v1',
|
openai: 'https://api.openai.com/v1',
|
||||||
|
nanogpt: 'https://nano-gpt.com/api/v1',
|
||||||
anthropic: 'https://api.anthropic.com',
|
anthropic: 'https://api.anthropic.com',
|
||||||
};
|
};
|
||||||
function uacProvider() { return document.getElementById('uacProvider')?.value || 'openrouter'; }
|
function uacProvider() { return document.getElementById('uacProvider')?.value || 'openrouter'; }
|
||||||
|
|||||||
+12
-1
@@ -22,9 +22,10 @@ echo "Choose your LLM provider:"
|
|||||||
echo " 1) OpenRouter (recommended - access to all models)"
|
echo " 1) OpenRouter (recommended - access to all models)"
|
||||||
echo " 2) Anthropic (Claude direct)"
|
echo " 2) Anthropic (Claude direct)"
|
||||||
echo " 3) OpenAI (GPT models)"
|
echo " 3) OpenAI (GPT models)"
|
||||||
|
echo " 4) NanoGPT (direct OpenAI-compatible API)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
read -r -p "Enter choice [1-3]: " choice
|
read -r -p "Enter choice [1-4]: " choice
|
||||||
|
|
||||||
case $choice in
|
case $choice in
|
||||||
1)
|
1)
|
||||||
@@ -57,6 +58,16 @@ case $choice in
|
|||||||
printf 'LLM_PROVIDER=openai\n'
|
printf 'LLM_PROVIDER=openai\n'
|
||||||
} > "$ENV_FILE"
|
} > "$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"
|
echo "Invalid choice"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { join } from 'path';
|
|||||||
const configSource = readFileSync(join(process.cwd(), 'src/config/index.ts'), 'utf8');
|
const configSource = readFileSync(join(process.cwd(), 'src/config/index.ts'), 'utf8');
|
||||||
const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8');
|
const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8');
|
||||||
const setupScript = readFileSync(join(process.cwd(), 'scripts/setup-api.sh'), '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 {
|
function sourceBlock(startMarker: string, endMarker: string): string {
|
||||||
const start = configSource.indexOf(startMarker);
|
const start = configSource.indexOf(startMarker);
|
||||||
@@ -58,11 +61,24 @@ describe('API key environment handling hardening', () => {
|
|||||||
it('exportConfig redacts every supported provider key slot', () => {
|
it('exportConfig redacts every supported provider key slot', () => {
|
||||||
const block = exportConfigBlock();
|
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`);
|
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('<option value="nanogpt">NanoGPT · OpenAI-compatible</option>');
|
||||||
|
expect(uiSource).toContain("nanogpt: 'https://nano-gpt.com/api/v1'");
|
||||||
|
});
|
||||||
|
|
||||||
it('ConfigManager uses env keys in-memory and does not persist imported env keys', () => {
|
it('ConfigManager uses env keys in-memory and does not persist imported env keys', () => {
|
||||||
const block = configLoadEnvBlock();
|
const block = configLoadEnvBlock();
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,6 +43,21 @@ describe('listProviderModels — OpenAI-compatible providers', () => {
|
|||||||
expect(models.map((m) => m.id)).toEqual(['llama-3.3-70b']);
|
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');
|
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<string, string> }).headers.Authorization)
|
||||||
|
.toBe('Bearer sk-nano-test-key');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('listProviderModels — Anthropic wire', () => {
|
describe('listProviderModels — Anthropic wire', () => {
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ describe('redactString — URL userinfo (basic-auth) scrub', () => {
|
|||||||
expect(redactString('token=abc123def456ghi')).toBe('token=[redacted]');
|
expect(redactString('token=abc123def456ghi')).toBe('token=[redacted]');
|
||||||
expect(redactString('Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')).toContain('Bearer [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', () => {
|
describe('redactSecrets — approval-audit record shape', () => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const PROVIDER_ENV_TO_STRIP = [
|
|||||||
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL',
|
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL',
|
||||||
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_ORGANIZATION',
|
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_ORGANIZATION',
|
||||||
'OPENROUTER_API_KEY',
|
'OPENROUTER_API_KEY',
|
||||||
|
'NANOGPT_API_KEY',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+49
-3
@@ -11,7 +11,7 @@ import { join } from 'path';
|
|||||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||||
import type { LLMProvider, LLMConfig, FallbackEntry, OpsecLevel } from '../types/index.js';
|
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
|
// CONFIGURATION SCHEMA
|
||||||
@@ -28,6 +28,7 @@ export interface TempestSettings {
|
|||||||
gemini?: string;
|
gemini?: string;
|
||||||
deepseek?: string;
|
deepseek?: string;
|
||||||
huggingface?: string;
|
huggingface?: string;
|
||||||
|
nanogpt?: string;
|
||||||
litellm?: string;
|
litellm?: string;
|
||||||
local?: string;
|
local?: string;
|
||||||
};
|
};
|
||||||
@@ -92,6 +93,12 @@ export interface TempestSettings {
|
|||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// NanoGPT — OpenAI-compatible text API
|
||||||
|
nanogpt: {
|
||||||
|
baseUrl: string;
|
||||||
|
defaultModel: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Codex CLI/account subscription backend
|
// Codex CLI/account subscription backend
|
||||||
codex: {
|
codex: {
|
||||||
command: string;
|
command: string;
|
||||||
@@ -187,6 +194,13 @@ const DEFAULT_SETTINGS: TempestSettings = {
|
|||||||
defaultModel: 'meta-llama/Llama-3.3-70B-Instruct',
|
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: {
|
codex: {
|
||||||
command: 'codex',
|
command: 'codex',
|
||||||
defaultModel: 'codex-default',
|
defaultModel: 'codex-default',
|
||||||
@@ -600,6 +614,18 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
|
|||||||
capabilities: ['reasoning', 'code', 'analysis', 'tools'],
|
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: [
|
local: [
|
||||||
{
|
{
|
||||||
id: 'local-model',
|
id: 'local-model',
|
||||||
@@ -687,7 +713,7 @@ class ConfigManager {
|
|||||||
const envContent = readFileSync(envPath, 'utf-8');
|
const envContent = readFileSync(envPath, 'utf-8');
|
||||||
const lines = envContent.split('\n');
|
const lines = envContent.split('\n');
|
||||||
// Validation variables added
|
// 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) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
@@ -789,6 +815,7 @@ class ConfigManager {
|
|||||||
gemini: 'GEMINI_API_KEY',
|
gemini: 'GEMINI_API_KEY',
|
||||||
deepseek: 'DEEPSEEK_API_KEY',
|
deepseek: 'DEEPSEEK_API_KEY',
|
||||||
huggingface: 'HF_TOKEN',
|
huggingface: 'HF_TOKEN',
|
||||||
|
nanogpt: 'NANOGPT_API_KEY',
|
||||||
litellm: 'LITELLM_API_KEY',
|
litellm: 'LITELLM_API_KEY',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -857,6 +884,7 @@ class ConfigManager {
|
|||||||
if (this.hasApiKey('gemini')) providers.push('gemini');
|
if (this.hasApiKey('gemini')) providers.push('gemini');
|
||||||
if (this.hasApiKey('deepseek')) providers.push('deepseek');
|
if (this.hasApiKey('deepseek')) providers.push('deepseek');
|
||||||
if (this.hasApiKey('huggingface')) providers.push('huggingface');
|
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.
|
// Codex uses the local Codex CLI/account auth instead of API-key storage.
|
||||||
providers.push('codex');
|
providers.push('codex');
|
||||||
@@ -936,6 +964,12 @@ class ConfigManager {
|
|||||||
baseUrl = this.config.get('huggingface').baseUrl;
|
baseUrl = this.config.get('huggingface').baseUrl;
|
||||||
actualModel = model || this.config.get('huggingface').defaultModel;
|
actualModel = model || this.config.get('huggingface').defaultModel;
|
||||||
break;
|
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':
|
case 'codex':
|
||||||
actualModel = model || this.config.get('codex').defaultModel;
|
actualModel = model || this.config.get('codex').defaultModel;
|
||||||
break;
|
break;
|
||||||
@@ -1002,7 +1036,7 @@ class ConfigManager {
|
|||||||
const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase();
|
const flag = (process.env.TEMPEST_MODEL_FALLBACK || '').trim().toLowerCase();
|
||||||
if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return [];
|
if (!flag || ['0', 'false', 'off', 'no'].includes(flag)) return [];
|
||||||
const chain: FallbackEntry[] = [];
|
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 === primary) return;
|
||||||
if (p === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return;
|
if (p === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return;
|
||||||
chain.push({
|
chain.push({
|
||||||
@@ -1021,6 +1055,7 @@ class ConfigManager {
|
|||||||
add('gemini');
|
add('gemini');
|
||||||
add('deepseek');
|
add('deepseek');
|
||||||
add('huggingface');
|
add('huggingface');
|
||||||
|
add('nanogpt');
|
||||||
return chain;
|
return chain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1050,6 +1085,9 @@ class ConfigManager {
|
|||||||
case 'huggingface':
|
case 'huggingface':
|
||||||
this.config.set('defaultModel', this.config.get('huggingface').defaultModel);
|
this.config.set('defaultModel', this.config.get('huggingface').defaultModel);
|
||||||
break;
|
break;
|
||||||
|
case 'nanogpt':
|
||||||
|
this.config.set('defaultModel', this.config.get('nanogpt').defaultModel);
|
||||||
|
break;
|
||||||
case 'xai':
|
case 'xai':
|
||||||
this.config.set('defaultModel', this.config.get('xai').defaultModel);
|
this.config.set('defaultModel', this.config.get('xai').defaultModel);
|
||||||
break;
|
break;
|
||||||
@@ -1091,6 +1129,9 @@ class ConfigManager {
|
|||||||
case 'huggingface':
|
case 'huggingface':
|
||||||
this.config.set('huggingface', { ...this.config.get('huggingface'), defaultModel: model });
|
this.config.set('huggingface', { ...this.config.get('huggingface'), defaultModel: model });
|
||||||
break;
|
break;
|
||||||
|
case 'nanogpt':
|
||||||
|
this.config.set('nanogpt', { ...this.config.get('nanogpt'), defaultModel: model });
|
||||||
|
break;
|
||||||
case 'xai':
|
case 'xai':
|
||||||
this.config.set('xai', { ...this.config.get('xai'), defaultModel: model });
|
this.config.set('xai', { ...this.config.get('xai'), defaultModel: model });
|
||||||
break;
|
break;
|
||||||
@@ -1141,6 +1182,7 @@ class ConfigManager {
|
|||||||
gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined,
|
gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined,
|
||||||
deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined,
|
deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined,
|
||||||
huggingface: settings.apiKeys.huggingface ? '***REDACTED***' : undefined,
|
huggingface: settings.apiKeys.huggingface ? '***REDACTED***' : undefined,
|
||||||
|
nanogpt: settings.apiKeys.nanogpt ? '***REDACTED***' : undefined,
|
||||||
litellm: settings.apiKeys.litellm ? '***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.
|
# HUGGINGFACE_API_KEY / HUGGINGFACE_TOKEN are also accepted as aliases.
|
||||||
HF_TOKEN=
|
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)
|
# 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).
|
# 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,
|
# For an OpenAI-compatible server, use a versioned path: LM Studio :1234/v1,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const DEFAULT_MODEL_LIST_TIMEOUT_MS = 15_000;
|
|||||||
|
|
||||||
// Pseudo-providers with no remote model list (CLI-driven or in-process).
|
// Pseudo-providers with no remote model list (CLI-driven or in-process).
|
||||||
const NO_REMOTE_LIST = new Set(['codex', 'mock', 'local-agent']);
|
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 DIRECT_REMOTE_LIST = new Set(['anthropic', 'openrouter']);
|
||||||
|
|
||||||
const stripTrailingSlash = (u: string): string => u.replace(/\/+$/, '');
|
const stripTrailingSlash = (u: string): string => u.replace(/\/+$/, '');
|
||||||
|
|||||||
+31
-2
@@ -582,7 +582,7 @@ class AnthropicAdapter implements LLMProviderAdapter {
|
|||||||
|
|
||||||
class OpenAIAdapter implements LLMProviderAdapter {
|
class OpenAIAdapter implements LLMProviderAdapter {
|
||||||
name = 'openai';
|
name = 'openai';
|
||||||
private config: LLMConfig;
|
protected config: LLMConfig;
|
||||||
|
|
||||||
constructor(config: LLMConfig) {
|
constructor(config: LLMConfig) {
|
||||||
this.config = config;
|
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)
|
// MOCK ADAPTER (for testing)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1473,6 +1491,8 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
|
|||||||
return new OpenAIAdapter(config); // DeepSeek native API is OpenAI-compatible
|
return new OpenAIAdapter(config); // DeepSeek native API is OpenAI-compatible
|
||||||
case 'huggingface':
|
case 'huggingface':
|
||||||
return new OpenAIAdapter(config); // HF Inference Providers router is OpenAI-compatible (baseUrl ends in /v1)
|
return new OpenAIAdapter(config); // HF Inference Providers router is OpenAI-compatible (baseUrl ends in /v1)
|
||||||
|
case 'nanogpt':
|
||||||
|
return new NanoGPTAdapter(config);
|
||||||
case 'codex':
|
case 'codex':
|
||||||
return new CodexAdapter(config);
|
return new CodexAdapter(config);
|
||||||
case 'mock':
|
case 'mock':
|
||||||
@@ -1773,6 +1793,12 @@ export function createHuggingFaceBackbone(apiKey?: string, model?: string): LLMB
|
|||||||
return new LLMBackbone(llmConfig);
|
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 {
|
export function createLiteLLMBackbone(apiKey?: string, model?: string, baseUrl?: string): LLMBackbone {
|
||||||
const llmConfig = config.getLLMConfig('litellm', model);
|
const llmConfig = config.getLLMConfig('litellm', model);
|
||||||
if (apiKey) llmConfig.apiKey = apiKey;
|
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
|
* Create the best available backbone based on configured API keys
|
||||||
*/
|
*/
|
||||||
export function createBestAvailableBackbone(): LLMBackbone {
|
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();
|
const providers = config.getConfiguredProviders();
|
||||||
|
|
||||||
if (providers.includes('openrouter')) {
|
if (providers.includes('openrouter')) {
|
||||||
@@ -1827,6 +1853,9 @@ export function createBestAvailableBackbone(): LLMBackbone {
|
|||||||
if (providers.includes('huggingface')) {
|
if (providers.includes('huggingface')) {
|
||||||
return createHuggingFaceBackbone();
|
return createHuggingFaceBackbone();
|
||||||
}
|
}
|
||||||
|
if (providers.includes('nanogpt')) {
|
||||||
|
return createNanoGPTBackbone();
|
||||||
|
}
|
||||||
|
|
||||||
// Default to mock if no API keys configured
|
// Default to mock if no API keys configured
|
||||||
console.warn('No API keys configured. Using mock provider.');
|
console.warn('No API keys configured. Using mock provider.');
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const SECRET_PATTERNS: Record<string, { pattern: RegExp; severity: string
|
|||||||
github_token: { pattern: /ghp_[A-Za-z0-9]{36}/g, severity: 'critical', provider: 'GitHub' },
|
github_token: { pattern: /ghp_[A-Za-z0-9]{36}/g, severity: 'critical', provider: 'GitHub' },
|
||||||
github_oauth: { pattern: /gho_[A-Za-z0-9]{36}/g, severity: 'critical', provider: 'GitHub' },
|
github_oauth: { pattern: /gho_[A-Za-z0-9]{36}/g, severity: 'critical', provider: 'GitHub' },
|
||||||
anthropic_api_key: { pattern: /sk-ant-api\d{2}-[A-Za-z0-9_\-]{16,}/g, severity: 'critical', provider: 'Anthropic' },
|
anthropic_api_key: { pattern: /sk-ant-api\d{2}-[A-Za-z0-9_\-]{16,}/g, severity: 'critical', provider: 'Anthropic' },
|
||||||
|
nanogpt_api_key: { pattern: /sk-nano-[A-Za-z0-9_\-]{16,}/g, severity: 'critical', provider: 'NanoGPT' },
|
||||||
openai_api_key: { pattern: /sk-[A-Za-z0-9_\-]{20,}/g, severity: 'critical', provider: 'OpenAI' },
|
openai_api_key: { pattern: /sk-[A-Za-z0-9_\-]{20,}/g, severity: 'critical', provider: 'OpenAI' },
|
||||||
gitlab_token: { pattern: /glpat-[A-Za-z0-9\-_]{20}/g, severity: 'critical', provider: 'GitLab' },
|
gitlab_token: { pattern: /glpat-[A-Za-z0-9\-_]{20}/g, severity: 'critical', provider: 'GitLab' },
|
||||||
jwt: { pattern: /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*/g, severity: 'high', provider: 'JWT' },
|
jwt: { pattern: /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*/g, severity: 'high', provider: 'JWT' },
|
||||||
|
|||||||
@@ -288,6 +288,53 @@ async function setupHuggingFaceKey(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setupNanoGPTKey(): Promise<boolean> {
|
||||||
|
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<boolean> {
|
async function setupOpenAIKey(): Promise<boolean> {
|
||||||
console.log('');
|
console.log('');
|
||||||
showInfo('OpenAI provides access to GPT models.');
|
showInfo('OpenAI provides access to GPT models.');
|
||||||
@@ -576,6 +623,10 @@ async function setupApiKeys(): Promise<void> {
|
|||||||
name: `HuggingFace ${hasApiKey('huggingface') ? chalk.green('(configured)') : chalk.gray('(open models via HF router)')}`,
|
name: `HuggingFace ${hasApiKey('huggingface') ? chalk.green('(configured)') : chalk.gray('(open models via HF router)')}`,
|
||||||
value: 'huggingface',
|
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)')}`,
|
name: `LiteLLM Proxy ${config.hasLiteLLMProxy() ? chalk.green('(configured)') : chalk.gray('(100+ providers via gateway)')}`,
|
||||||
value: 'litellm',
|
value: 'litellm',
|
||||||
@@ -604,6 +655,9 @@ async function setupApiKeys(): Promise<void> {
|
|||||||
case 'huggingface':
|
case 'huggingface':
|
||||||
await setupHuggingFaceKey();
|
await setupHuggingFaceKey();
|
||||||
break;
|
break;
|
||||||
|
case 'nanogpt':
|
||||||
|
await setupNanoGPTKey();
|
||||||
|
break;
|
||||||
case 'litellm':
|
case 'litellm':
|
||||||
await setupLiteLLMProxy();
|
await setupLiteLLMProxy();
|
||||||
break;
|
break;
|
||||||
@@ -621,6 +675,7 @@ async function setupProvider(): Promise<void> {
|
|||||||
if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' });
|
if (hasApiKey('openai')) configuredProviders.push({ name: 'OpenAI', value: 'openai' });
|
||||||
if (hasApiKey('deepseek')) configuredProviders.push({ name: 'DeepSeek', value: 'deepseek' });
|
if (hasApiKey('deepseek')) configuredProviders.push({ name: 'DeepSeek', value: 'deepseek' });
|
||||||
if (hasApiKey('huggingface')) configuredProviders.push({ name: 'HuggingFace', value: 'huggingface' });
|
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' });
|
if (config.hasLiteLLMProxy()) configuredProviders.push({ name: 'LiteLLM Proxy', value: 'litellm' });
|
||||||
|
|
||||||
const { provider } = await inquirer.prompt([
|
const { provider } = await inquirer.prompt([
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// LLM CONFIGURATION
|
// 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 {
|
export interface LLMConfig {
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
|
|||||||
Reference in new issue
Block a user