Merge remote-tracking branch 'upstream/main' into pr-70

# Conflicts:
#	src/config/index.ts
#	src/llm/index.ts
#	src/server.ts
#	src/setup.ts
#	src/types/index.ts
This commit is contained in:
Joseph Magly committed 2026-07-17 01:39:59 -04:00
commit be3eb7cb44
75 files changed
+7788 -586

No files matched your search

+104 -13
View File
@@ -11,6 +11,8 @@ 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';
// =============================================================================
// CONFIGURATION SCHEMA
// =============================================================================
@@ -25,6 +27,7 @@ export interface TempestSettings {
xai?: string;
gemini?: string;
deepseek?: string;
litellm?: string;
local?: string;
};
@@ -64,6 +67,12 @@ export interface TempestSettings {
defaultModel: string;
};
// LiteLLM AI gateway proxy — routes to 100+ LLM providers via unified API
litellm: {
baseUrl: string;
defaultModel: string;
};
// Google Gemini — native Gemini API via its OpenAI-compatible endpoint
gemini: {
baseUrl: string;
@@ -139,6 +148,11 @@ const DEFAULT_SETTINGS: TempestSettings = {
defaultModel: 'grok-build-0.1',
},
litellm: {
baseUrl: 'http://localhost:4000/v1',
defaultModel: 'gpt-4o',
},
// Gemini's OpenAI-compatible surface lives under /v1beta/openai. The OpenAIAdapter posts to
// `${baseUrl}/chat/completions`, so the /openai path segment is REQUIRED here — without it,
// requests hit /v1beta/chat/completions, which is not a valid Gemini endpoint.
@@ -409,6 +423,16 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'fast'],
},
],
litellm: [
{
id: 'gpt-4o',
name: 'Any model via LiteLLM proxy (default: gpt-4o)',
provider: 'LiteLLM',
contextWindow: 128000,
maxOutput: 4096,
capabilities: ['reasoning', 'code', 'analysis', 'vision', 'tools'],
},
],
codex: [
{
id: 'codex-default',
@@ -567,10 +591,14 @@ class ConfigManager {
join(homedir(), '.env'),
];
let envProvider: string | undefined;
for (const envPath of envPaths) {
if (existsSync(envPath)) {
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'];
for (const line of lines) {
const trimmed = line.trim();
@@ -583,9 +611,18 @@ class ConfigManager {
// empty OPENROUTER_API_KEY= — the .env file no longer clobbers it.
if (key && value && process.env[key] === undefined) {
process.env[key] = value;
// Track LLM_PROVIDER for default provider
if (key === 'LLM_PROVIDER' && VALID_PROVIDERS.includes(value.toLowerCase())) {
envProvider = value.toLowerCase();
}
}
}
}
// Set TEMPEST_DEFAULT_PROVIDER if LLM_PROVIDER found
if (envProvider && !process.env.TEMPEST_DEFAULT_PROVIDER) {
process.env.TEMPEST_DEFAULT_PROVIDER = envProvider;
}
break;
}
}
@@ -621,7 +658,7 @@ class ConfigManager {
/**
* Set an API key for a provider
*/
setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local', key: string): void {
setApiKey(provider: ApiKeyProvider, key: string): void {
const apiKeys = this.config.get('apiKeys');
apiKeys[provider] = key;
this.config.set('apiKeys', apiKeys);
@@ -630,7 +667,7 @@ class ConfigManager {
/**
* Get an API key for a provider
*/
getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local'): string | undefined {
getApiKey(provider: ApiKeyProvider): string | undefined {
// First check environment variables (highest priority)
// local provider: a self-hosted/OpenAI-compatible server MAY require a bearer
// (Zhipu/z.ai, Together, etc.) — accept TEMPEST_LOCAL_API_KEY or provider-specific vars.
@@ -647,6 +684,7 @@ class ConfigManager {
xai: 'XAI_API_KEY',
gemini: 'GEMINI_API_KEY',
deepseek: 'DEEPSEEK_API_KEY',
litellm: 'LITELLM_API_KEY',
};
// Force a fully UNCONFIGURED server (no key from env OR the saved store) — used by
@@ -665,7 +703,7 @@ class ConfigManager {
/**
* Check if a provider has a valid API key configured
*/
hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local'): boolean {
hasApiKey(provider: ApiKeyProvider): boolean {
const key = this.getApiKey(provider);
return !!key && key.length > 10;
}
@@ -673,12 +711,22 @@ class ConfigManager {
/**
* Remove an API key
*/
removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek' | 'local'): void {
removeApiKey(provider: ApiKeyProvider): void {
const apiKeys = this.config.get('apiKeys');
delete apiKeys[provider];
this.config.set('apiKeys', apiKeys);
}
/**
* Check if a LiteLLM proxy is configured (base URL set via env or config)
*/
hasLiteLLMProxy(): boolean {
const envUrl = process.env.LITELLM_BASE_URL?.trim();
if (envUrl) return true;
const storedUrl = this.config.get('litellm')?.baseUrl;
return !!storedUrl && storedUrl !== 'http://localhost:4000/v1';
}
/**
* Get configured providers (those with API keys)
*/
@@ -690,6 +738,7 @@ class ConfigManager {
if (this.hasApiKey('anthropic')) providers.push('anthropic');
if (this.hasApiKey('openai')) providers.push('openai');
if (this.hasApiKey('xai')) providers.push('xai');
if (this.hasLiteLLMProxy()) providers.push('litellm');
if (this.hasApiKey('gemini')) providers.push('gemini');
if (this.hasApiKey('deepseek')) providers.push('deepseek');
@@ -706,7 +755,16 @@ class ConfigManager {
* Get the LLM configuration for the default or specified provider
*/
getLLMConfig(provider?: LLMProvider, model?: string): LLMConfig {
const actualProvider = provider || this.config.get('defaultProvider');
let actualProvider = provider;
if (!actualProvider) {
const envProvider = process.env.TEMPEST_DEFAULT_PROVIDER?.trim();
if (envProvider) {
actualProvider = envProvider as LLMProvider;
} else {
actualProvider = this.config.get('defaultProvider');
}
}
let apiKey: string | undefined;
let baseUrl: string | undefined;
@@ -739,6 +797,11 @@ class ConfigManager {
baseUrl = this.config.get('xai').baseUrl;
actualModel = model || this.config.get('xai').defaultModel;
break;
case 'litellm':
apiKey = this.getApiKey('litellm');
baseUrl = process.env.LITELLM_BASE_URL?.trim() || this.config.get('litellm').baseUrl;
actualModel = model || process.env.LITELLM_MODEL?.trim() || this.config.get('litellm').defaultModel;
break;
case 'gemini':
// Google Gemini via its OpenAI-compatible endpoint (native tool-calling).
apiKey = this.getApiKey('gemini');
@@ -764,9 +827,10 @@ class ConfigManager {
// Some OpenAI-compatible servers require a real bearer (Zhipu, Together, etc.) —
// TEMPEST_LOCAL_API_KEY (or a provider-specific env like ZAI_API_KEY) provides it.
baseUrl = process.env.TEMPEST_LOCAL_BASE_URL?.trim() || 'http://localhost:11434/api';
// 'local-model' is the placeholder id from AVAILABLE_MODELS — treat it as unset so
// TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag passed in still takes priority.
actualModel = (model && model !== 'local-model' ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3';
// Placeholder ids from AVAILABLE_MODELS / the static UI are not real served model tags.
// Treat them as unset so TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag
// passed in still takes priority.
actualModel = (model && !['local-model', 'local/ollama'].includes(model) ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3';
apiKey = process.env.TEMPEST_LOCAL_API_KEY?.trim() || process.env.ZAI_API_KEY?.trim() || process.env.ZHIPUAI_API_KEY?.trim();
break;
default:
@@ -799,8 +863,9 @@ 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' | 'deepseek') => {
if (p === primary || !this.hasApiKey(p)) return;
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'litellm' | 'deepseek') => {
if (p === primary) return;
if (p === 'litellm' ? !this.hasLiteLLMProxy() : !this.hasApiKey(p)) return;
chain.push({
provider: p,
model: this.config.get(p).defaultModel,
@@ -810,6 +875,7 @@ class ConfigManager {
};
add('openrouter');
add('venice');
add('litellm');
add('anthropic');
add('openai');
add('xai');
@@ -841,6 +907,15 @@ class ConfigManager {
case 'deepseek':
this.config.set('defaultModel', this.config.get('deepseek').defaultModel);
break;
case 'xai':
this.config.set('defaultModel', this.config.get('xai').defaultModel);
break;
case 'gemini':
this.config.set('defaultModel', this.config.get('gemini').defaultModel);
break;
case 'litellm':
this.config.set('defaultModel', this.config.get('litellm').defaultModel);
break;
case 'codex':
this.config.set('defaultModel', this.config.get('codex').defaultModel);
break;
@@ -870,6 +945,15 @@ class ConfigManager {
case 'deepseek':
this.config.set('deepseek', { ...this.config.get('deepseek'), defaultModel: model });
break;
case 'xai':
this.config.set('xai', { ...this.config.get('xai'), defaultModel: model });
break;
case 'gemini':
this.config.set('gemini', { ...this.config.get('gemini'), defaultModel: model });
break;
case 'litellm':
this.config.set('litellm', { ...this.config.get('litellm'), defaultModel: model });
break;
case 'codex':
this.config.set('codex', { ...this.config.get('codex'), defaultModel: model });
break;
@@ -910,6 +994,7 @@ class ConfigManager {
xai: settings.apiKeys.xai ? '***REDACTED***' : undefined,
gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined,
deepseek: settings.apiKeys.deepseek ? '***REDACTED***' : undefined,
litellm: settings.apiKeys.litellm ? '***REDACTED***' : undefined,
},
};
writeFileSync(filePath, JSON.stringify(safeSettings, null, 2));
@@ -943,6 +1028,12 @@ OPENAI_API_KEY=
# Get your key at: https://console.x.ai/
XAI_API_KEY=
# LiteLLM Proxy (AI gateway — 100+ providers via single API)
# Docs: https://docs.litellm.ai/docs/proxy/quick_start
LITELLM_BASE_URL=http://localhost:4000/v1
LITELLM_API_KEY=
LITELLM_MODEL=gpt-4o
# Google Gemini API Key (direct Gemini API via its OpenAI-compatible endpoint)
# Get your key at: https://aistudio.google.com/apikey
GEMINI_API_KEY=
@@ -973,8 +1064,8 @@ TEMPEST_LOCAL_API_KEY=
export const config = new ConfigManager();
// Helper functions for quick access
export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek') => config.getApiKey(provider);
export const setApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek', key: string) => config.setApiKey(provider, key);
export const hasApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'deepseek') => config.hasApiKey(provider);
export const getApiKey = (provider: ApiKeyProvider) => config.getApiKey(provider);
export const setApiKey = (provider: ApiKeyProvider, key: string) => config.setApiKey(provider, key);
export const hasApiKey = (provider: ApiKeyProvider) => config.hasApiKey(provider);
export const getLLMConfig = (provider?: LLMProvider, model?: string) => config.getLLMConfig(provider, model);
export const getConfiguredProviders = () => config.getConfiguredProviders();