feat(llm): native Google Gemini provider via OpenAI-compatible endpoint
Add a first-class `gemini` provider so operators can drive missions with a direct GEMINI_API_KEY, instead of only reaching Gemini through OpenRouter. Gemini is wired exactly like xAI: dispatched through OpenAIAdapter against Google's OpenAI-compatible surface. The base URL is https://generativelanguage.googleapis.com/v1beta/openai — the `/openai` segment is required because OpenAIAdapter posts to `${baseUrl}/chat/completions`; without it requests hit `/v1beta/chat/completions`, which Google does not serve. Wires the provider through the type union, adapter factory, config (interface, defaults, model catalog, key env map, getLLMConfig, fallback chain, redaction, .env template), and adds regression tests pinning the `/openai` base-URL segment.
This commit is contained in:
4 files changed
+111
-9
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+65
-8
@@ -23,6 +23,7 @@ export interface TempestSettings {
|
|||||||
anthropic?: string;
|
anthropic?: string;
|
||||||
openai?: string;
|
openai?: string;
|
||||||
xai?: string;
|
xai?: string;
|
||||||
|
gemini?: string;
|
||||||
local?: string;
|
local?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,6 +63,12 @@ export interface TempestSettings {
|
|||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Google Gemini — native Gemini API via its OpenAI-compatible endpoint
|
||||||
|
gemini: {
|
||||||
|
baseUrl: string;
|
||||||
|
defaultModel: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Codex CLI/account subscription backend
|
// Codex CLI/account subscription backend
|
||||||
codex: {
|
codex: {
|
||||||
command: string;
|
command: string;
|
||||||
@@ -125,6 +132,14 @@ const DEFAULT_SETTINGS: TempestSettings = {
|
|||||||
defaultModel: 'grok-build-0.1',
|
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: {
|
codex: {
|
||||||
command: 'codex',
|
command: 'codex',
|
||||||
defaultModel: 'codex-default',
|
defaultModel: 'codex-default',
|
||||||
@@ -423,6 +438,34 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
|
|||||||
capabilities: ['reasoning', 'code', 'analysis', 'agents', 'tools'],
|
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: [
|
local: [
|
||||||
{
|
{
|
||||||
id: 'local-model',
|
id: 'local-model',
|
||||||
@@ -548,7 +591,7 @@ class ConfigManager {
|
|||||||
/**
|
/**
|
||||||
* Set an API key for a provider
|
* 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');
|
const apiKeys = this.config.get('apiKeys');
|
||||||
apiKeys[provider] = key;
|
apiKeys[provider] = key;
|
||||||
this.config.set('apiKeys', apiKeys);
|
this.config.set('apiKeys', apiKeys);
|
||||||
@@ -557,7 +600,7 @@ class ConfigManager {
|
|||||||
/**
|
/**
|
||||||
* Get an API key for a provider
|
* 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)
|
// First check environment variables (highest priority)
|
||||||
// local provider: a self-hosted/OpenAI-compatible server MAY require a bearer
|
// 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.
|
// (Zhipu/z.ai, Together, etc.) — accept TEMPEST_LOCAL_API_KEY or provider-specific vars.
|
||||||
@@ -572,6 +615,7 @@ class ConfigManager {
|
|||||||
anthropic: 'ANTHROPIC_API_KEY',
|
anthropic: 'ANTHROPIC_API_KEY',
|
||||||
openai: 'OPENAI_API_KEY',
|
openai: 'OPENAI_API_KEY',
|
||||||
xai: 'XAI_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
|
// 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
|
* 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);
|
const key = this.getApiKey(provider);
|
||||||
return !!key && key.length > 10;
|
return !!key && key.length > 10;
|
||||||
}
|
}
|
||||||
@@ -598,7 +642,7 @@ class ConfigManager {
|
|||||||
/**
|
/**
|
||||||
* Remove an API key
|
* 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');
|
const apiKeys = this.config.get('apiKeys');
|
||||||
delete apiKeys[provider];
|
delete apiKeys[provider];
|
||||||
this.config.set('apiKeys', apiKeys);
|
this.config.set('apiKeys', apiKeys);
|
||||||
@@ -615,6 +659,7 @@ class ConfigManager {
|
|||||||
if (this.hasApiKey('anthropic')) providers.push('anthropic');
|
if (this.hasApiKey('anthropic')) providers.push('anthropic');
|
||||||
if (this.hasApiKey('openai')) providers.push('openai');
|
if (this.hasApiKey('openai')) providers.push('openai');
|
||||||
if (this.hasApiKey('xai')) providers.push('xai');
|
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.
|
// Codex uses the local Codex CLI/account auth instead of API-key storage.
|
||||||
providers.push('codex');
|
providers.push('codex');
|
||||||
@@ -662,6 +707,12 @@ class ConfigManager {
|
|||||||
baseUrl = this.config.get('xai').baseUrl;
|
baseUrl = this.config.get('xai').baseUrl;
|
||||||
actualModel = model || this.config.get('xai').defaultModel;
|
actualModel = model || this.config.get('xai').defaultModel;
|
||||||
break;
|
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':
|
case 'codex':
|
||||||
actualModel = model || this.config.get('codex').defaultModel;
|
actualModel = model || this.config.get('codex').defaultModel;
|
||||||
break;
|
break;
|
||||||
@@ -710,7 +761,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') => {
|
const add = (p: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => {
|
||||||
if (p === primary || !this.hasApiKey(p)) return;
|
if (p === primary || !this.hasApiKey(p)) return;
|
||||||
chain.push({
|
chain.push({
|
||||||
provider: p,
|
provider: p,
|
||||||
@@ -724,6 +775,7 @@ class ConfigManager {
|
|||||||
add('anthropic');
|
add('anthropic');
|
||||||
add('openai');
|
add('openai');
|
||||||
add('xai');
|
add('xai');
|
||||||
|
add('gemini');
|
||||||
return chain;
|
return chain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,6 +860,7 @@ class ConfigManager {
|
|||||||
anthropic: settings.apiKeys.anthropic ? '***REDACTED***' : undefined,
|
anthropic: settings.apiKeys.anthropic ? '***REDACTED***' : undefined,
|
||||||
openai: settings.apiKeys.openai ? '***REDACTED***' : undefined,
|
openai: settings.apiKeys.openai ? '***REDACTED***' : undefined,
|
||||||
xai: settings.apiKeys.xai ? '***REDACTED***' : undefined,
|
xai: settings.apiKeys.xai ? '***REDACTED***' : undefined,
|
||||||
|
gemini: settings.apiKeys.gemini ? '***REDACTED***' : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
writeFileSync(filePath, JSON.stringify(safeSettings, null, 2));
|
writeFileSync(filePath, JSON.stringify(safeSettings, null, 2));
|
||||||
@@ -841,6 +894,10 @@ OPENAI_API_KEY=
|
|||||||
# Get your key at: https://console.x.ai/
|
# Get your key at: https://console.x.ai/
|
||||||
XAI_API_KEY=
|
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)
|
# 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,
|
||||||
@@ -863,8 +920,8 @@ TEMPEST_LOCAL_API_KEY=
|
|||||||
export const config = new ConfigManager();
|
export const config = new ConfigManager();
|
||||||
|
|
||||||
// Helper functions for quick access
|
// Helper functions for quick access
|
||||||
export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai') => config.getApiKey(provider);
|
export const getApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai' | 'gemini') => config.getApiKey(provider);
|
||||||
export const setApiKey = (provider: 'openrouter' | 'venice' | 'anthropic' | 'openai' | 'xai', key: string) => config.setApiKey(provider, key);
|
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') => config.hasApiKey(provider);
|
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 getLLMConfig = (provider?: LLMProvider, model?: string) => config.getLLMConfig(provider, model);
|
||||||
export const getConfiguredProviders = () => config.getConfiguredProviders();
|
export const getConfiguredProviders = () => config.getConfiguredProviders();
|
||||||
@@ -1207,6 +1207,8 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
|
|||||||
return new OpenAIAdapter(config);
|
return new OpenAIAdapter(config);
|
||||||
case 'xai':
|
case 'xai':
|
||||||
return new OpenAIAdapter(config); // xAI (Grok Build / grok-*) is OpenAI-compatible
|
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':
|
case 'codex':
|
||||||
return new CodexAdapter(config);
|
return new CodexAdapter(config);
|
||||||
case 'mock':
|
case 'mock':
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
// LLM CONFIGURATION
|
// 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 {
|
export interface LLMConfig {
|
||||||
provider: LLMProvider;
|
provider: LLMProvider;
|
||||||
|
|||||||
Reference in new issue
Block a user