diff --git a/src/__tests__/gemini-provider.test.ts b/src/__tests__/gemini-provider.test.ts new file mode 100644 index 0000000..0f42efc --- /dev/null +++ b/src/__tests__/gemini-provider.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { config, AVAILABLE_MODELS } from '../config/index.js'; + +// Regression coverage for #52: native Google Gemini support. +// The engine dispatches Gemini through OpenAIAdapter, which posts to +// `${baseUrl}/chat/completions`. Gemini's OpenAI-compatible surface lives under +// /v1beta/openai, so the base URL MUST carry that path segment — the single most +// important thing to get right, and the exact detail an earlier attempt missed. +describe('Gemini native provider wiring (#52)', () => { + it('routes gemini through the OpenAI-compatible endpoint including the /openai segment', () => { + const cfg = config.getLLMConfig('gemini'); + expect(cfg.provider).toBe('gemini'); + expect(cfg.baseUrl).toBe('https://generativelanguage.googleapis.com/v1beta/openai'); + // Guard the regression directly: `${baseUrl}/chat/completions` must resolve under /v1beta/openai. + expect(`${cfg.baseUrl}/chat/completions`).toBe( + 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', + ); + }); + + it('defaults to a bare native Gemini model id (no google/ prefix)', () => { + const cfg = config.getLLMConfig('gemini'); + expect(cfg.model).toBe('gemini-2.5-flash'); + expect(cfg.model.startsWith('google/')).toBe(false); + }); + + it('publishes a non-empty native Gemini model catalog with bare ids', () => { + expect(AVAILABLE_MODELS.gemini?.length ?? 0).toBeGreaterThan(0); + for (const m of AVAILABLE_MODELS.gemini) { + expect(m.id.startsWith('google/')).toBe(false); + } + }); + + it('maps the gemini provider to the GEMINI_API_KEY environment variable', () => { + const prev = process.env.GEMINI_API_KEY; + process.env.GEMINI_API_KEY = 'test-gemini-key-0123456789'; + try { + expect(config.hasApiKey('gemini')).toBe(true); + } finally { + if (prev === undefined) delete process.env.GEMINI_API_KEY; + else process.env.GEMINI_API_KEY = prev; + } + }); +}); diff --git a/src/config/index.ts b/src/config/index.ts index e4fefc5..b1b9ea3 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -23,6 +23,7 @@ export interface TempestSettings { anthropic?: string; openai?: string; xai?: string; + gemini?: string; local?: string; }; @@ -62,6 +63,12 @@ export interface TempestSettings { defaultModel: string; }; + // Google Gemini — native Gemini API via its OpenAI-compatible endpoint + gemini: { + baseUrl: string; + defaultModel: string; + }; + // Codex CLI/account subscription backend codex: { command: string; @@ -125,6 +132,14 @@ const DEFAULT_SETTINGS: TempestSettings = { defaultModel: 'grok-build-0.1', }, + // 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. + gemini: { + baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', + defaultModel: 'gemini-2.5-flash', + }, + codex: { command: 'codex', defaultModel: 'codex-default', @@ -423,6 +438,34 @@ export const AVAILABLE_MODELS: Record = { capabilities: ['reasoning', 'code', 'analysis', 'agents', 'tools'], }, ], + // Native Gemini model ids (bare, no `google/` prefix) for Google's OpenAI-compatible endpoint. + // Distinct from the `google/gemini-*` ids under `openrouter`, which route through OpenRouter. + gemini: [ + { + id: 'gemini-2.5-flash', + name: 'Gemini 2.5 Flash (native)', + provider: 'Google', + contextWindow: 1000000, + maxOutput: 8192, + capabilities: ['reasoning', 'code', 'analysis', 'vision', 'fast', 'tools'], + }, + { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro (native)', + provider: 'Google', + contextWindow: 1000000, + maxOutput: 8192, + capabilities: ['reasoning', 'code', 'analysis', 'vision', 'multimodal', 'tools'], + }, + { + id: 'gemini-2.0-flash', + name: 'Gemini 2.0 Flash (native)', + provider: 'Google', + contextWindow: 1000000, + maxOutput: 8192, + capabilities: ['reasoning', 'code', 'analysis', 'vision', 'fast', 'tools'], + }, + ], local: [ { id: 'local-model', @@ -548,7 +591,7 @@ class ConfigManager { /** * Set an API key for a provider */ - setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'local', key: string): void { + setApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local', key: string): void { const apiKeys = this.config.get('apiKeys'); apiKeys[provider] = key; this.config.set('apiKeys', apiKeys); @@ -557,7 +600,7 @@ class ConfigManager { /** * Get an API key for a provider */ - getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'local'): string | undefined { + getApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local'): 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. @@ -572,6 +615,7 @@ class ConfigManager { anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', xai: 'XAI_API_KEY', + gemini: 'GEMINI_API_KEY', }; // Force a fully UNCONFIGURED server (no key from env OR the saved store) — used by @@ -590,7 +634,7 @@ class ConfigManager { /** * Check if a provider has a valid API key configured */ - hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'local'): boolean { + hasApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local'): boolean { const key = this.getApiKey(provider); return !!key && key.length > 10; } @@ -598,7 +642,7 @@ class ConfigManager { /** * Remove an API key */ - removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'local'): void { + removeApiKey(provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'local'): void { const apiKeys = this.config.get('apiKeys'); delete apiKeys[provider]; this.config.set('apiKeys', apiKeys); @@ -615,6 +659,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.hasApiKey('gemini')) providers.push('gemini'); // Codex uses the local Codex CLI/account auth instead of API-key storage. providers.push('codex'); @@ -662,6 +707,12 @@ class ConfigManager { baseUrl = this.config.get('xai').baseUrl; actualModel = model || this.config.get('xai').defaultModel; break; + case 'gemini': + // Google Gemini via its OpenAI-compatible endpoint (native tool-calling). + apiKey = this.getApiKey('gemini'); + baseUrl = this.config.get('gemini').baseUrl; + actualModel = model || this.config.get('gemini').defaultModel; + break; case 'codex': actualModel = model || this.config.get('codex').defaultModel; break; @@ -710,7 +761,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') => { + const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => { if (p === primary || !this.hasApiKey(p)) return; chain.push({ provider: p, @@ -724,6 +775,7 @@ class ConfigManager { add('anthropic'); add('openai'); add('xai'); + add('gemini'); return chain; } @@ -808,6 +860,7 @@ class ConfigManager { anthropic: settings.apiKeys.anthropic ? '***REDACTED***' : undefined, openai: settings.apiKeys.openai ? '***REDACTED***' : undefined, xai: settings.apiKeys.xai ? '***REDACTED***' : undefined, + gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined, }, }; writeFileSync(filePath, JSON.stringify(safeSettings, null, 2)); @@ -841,6 +894,10 @@ OPENAI_API_KEY= # Get your key at: https://console.x.ai/ XAI_API_KEY= +# Google Gemini API Key (direct Gemini API via its OpenAI-compatible endpoint) +# Get your key at: https://aistudio.google.com/apikey +GEMINI_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, @@ -863,8 +920,8 @@ TEMPEST_LOCAL_API_KEY= export const config = new ConfigManager(); // Helper functions for quick access -export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai') => config.getApiKey(provider); -export const setApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai', key: string) => config.setApiKey(provider, key); -export const hasApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai') => config.hasApiKey(provider); +export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => config.getApiKey(provider); +export const setApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini', key: string) => config.setApiKey(provider, key); +export const hasApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => config.hasApiKey(provider); export const getLLMConfig = (provider?: LLMProvider, model?: string) => config.getLLMConfig(provider, model); export const getConfiguredProviders = () => config.getConfiguredProviders(); diff --git a/src/llm/index.ts b/src/llm/index.ts index 0b426f3..b29aa30 100755 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -1207,6 +1207,8 @@ export class LLMBackbone extends EventEmitter { return new OpenAIAdapter(config); case 'xai': return new OpenAIAdapter(config); // xAI (Grok Build / grok-*) is OpenAI-compatible + case 'gemini': + return new OpenAIAdapter(config); // Gemini via Google's OpenAI-compatible endpoint (baseUrl ends in /v1beta/openai) case 'codex': return new CodexAdapter(config); case 'mock': diff --git a/src/types/index.ts b/src/types/index.ts index e8cfdea..e19cfa7 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' | 'codex' | 'mock' | 'local' | 'local-agent'; +export type LLMProvider = 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini' | 'codex' | 'mock' | 'local' | 'local-agent'; export interface LLMConfig { provider: LLMProvider;