Merge pull request #57 from jmagly/fix/gemini-native-provider
feat(llm): native Google Gemini provider via OpenAI-compatible endpoint
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