diff --git a/bin/openclaude b/bin/openclaude index a5688523..2ecc153f 100755 --- a/bin/openclaude +++ b/bin/openclaude @@ -9,14 +9,13 @@ import { existsSync } from 'fs' import { join, dirname } from 'path' -import { fileURLToPath } from 'url' -import { getDistImportSpecifier } from './import-specifier.mjs' +import { fileURLToPath, pathToFileURL } from 'url' const __dirname = dirname(fileURLToPath(import.meta.url)) const distPath = join(__dirname, '..', 'dist', 'cli.mjs') if (existsSync(distPath)) { - await import(getDistImportSpecifier(__dirname)) + await import(pathToFileURL(distPath).href) } else { console.error(` openclaude: dist/cli.mjs not found. diff --git a/package.json b/package.json index 60cab439..7c595fbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gitlawb/openclaude", - "version": "0.1.2", + "version": "0.1.4", "description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models", "type": "module", "bin": { @@ -18,6 +18,7 @@ "dev:profile:fast": "bun run scripts/provider-launch.ts auto --fast --bare", "dev:codex": "bun run scripts/provider-launch.ts codex", "dev:openai": "bun run scripts/provider-launch.ts openai", + "dev:gemini": "bun run scripts/provider-launch.ts gemini", "dev:ollama": "bun run scripts/provider-launch.ts ollama", "dev:ollama:fast": "bun run scripts/provider-launch.ts ollama --fast --bare", "profile:init": "bun run scripts/provider-bootstrap.ts", diff --git a/scripts/build.ts b/scripts/build.ts index dbbbcedb..014fc402 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -65,6 +65,39 @@ const result = await Bun.build({ { name: 'bun-bundle-shim', setup(build) { + const internalFeatureStubModules = new Map([ + [ + '../daemon/workerRegistry.js', + 'export async function runDaemonWorker() { throw new Error("Daemon worker is unavailable in the open build."); }', + ], + [ + '../daemon/main.js', + 'export async function daemonMain() { throw new Error("Daemon mode is unavailable in the open build."); }', + ], + [ + '../cli/bg.js', + ` +export async function psHandler() { throw new Error("Background sessions are unavailable in the open build."); } +export async function logsHandler() { throw new Error("Background sessions are unavailable in the open build."); } +export async function attachHandler() { throw new Error("Background sessions are unavailable in the open build."); } +export async function killHandler() { throw new Error("Background sessions are unavailable in the open build."); } +export async function handleBgFlag() { throw new Error("Background sessions are unavailable in the open build."); } +`, + ], + [ + '../cli/handlers/templateJobs.js', + 'export async function templatesMain() { throw new Error("Template jobs are unavailable in the open build."); }', + ], + [ + '../environment-runner/main.js', + 'export async function environmentRunnerMain() { throw new Error("Environment runner is unavailable in the open build."); }', + ], + [ + '../self-hosted-runner/main.js', + 'export async function selfHostedRunnerMain() { throw new Error("Self-hosted runner is unavailable in the open build."); }', + ], + ] as const) + // Resolve `import { feature } from 'bun:bundle'` to a shim build.onResolve({ filter: /^bun:bundle$/ }, () => ({ path: 'bun:bundle', @@ -78,6 +111,26 @@ const result = await Bun.build({ }), ) + build.onResolve( + { filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ }, + args => { + if (!internalFeatureStubModules.has(args.path)) return null + return { + path: args.path, + namespace: 'internal-feature-stub', + } + }, + ) + build.onLoad( + { filter: /.*/, namespace: 'internal-feature-stub' }, + args => ({ + contents: + internalFeatureStubModules.get(args.path) ?? + 'export {}', + loader: 'js', + }), + ) + // Resolve react/compiler-runtime to the standalone package build.onResolve({ filter: /^react\/compiler-runtime$/ }, () => ({ path: 'react/compiler-runtime', diff --git a/scripts/provider-bootstrap.ts b/scripts/provider-bootstrap.ts index d54f49da..82ebbbb6 100644 --- a/scripts/provider-bootstrap.ts +++ b/scripts/provider-bootstrap.ts @@ -11,6 +11,7 @@ import { } from '../src/utils/providerRecommendation.ts' import { buildCodexProfileEnv, + buildGeminiProfileEnv, buildOllamaProfileEnv, buildOpenAIProfileEnv, createProfileFile, @@ -33,7 +34,7 @@ function parseArg(name: string): string | null { function parseProviderArg(): ProviderProfile | 'auto' { const p = parseArg('--provider')?.toLowerCase() - if (p === 'openai' || p === 'ollama' || p === 'codex') return p + if (p === 'openai' || p === 'ollama' || p === 'codex' || p === 'gemini') return p return 'auto' } @@ -72,7 +73,22 @@ async function main(): Promise { } let env: ProfileFile['env'] - if (selected === 'ollama') { + if (selected === 'gemini') { + const builtEnv = buildGeminiProfileEnv({ + model: argModel || null, + baseUrl: argBaseUrl || null, + apiKey: argApiKey || null, + processEnv: process.env, + }) + + if (!builtEnv) { + console.error('Gemini profile requires an API key. Use --api-key or set GEMINI_API_KEY.') + console.error('Get a free key at: https://aistudio.google.com/apikey') + process.exit(1) + } + + env = builtEnv + } else if (selected === 'ollama') { resolvedOllamaModel ??= await resolveOllamaModel(argModel, argBaseUrl, goal) if (!resolvedOllamaModel) { console.error('No viable Ollama chat model was discovered. Pull a chat model first or pass --model explicitly.') @@ -136,7 +152,7 @@ async function main(): Promise { console.log(`Saved profile: ${selected}`) console.log(`Goal: ${goal}`) - console.log(`Model: ${profile.env.OPENAI_MODEL}`) + console.log(`Model: ${profile.env.GEMINI_MODEL || profile.env.OPENAI_MODEL || getGoalDefaultOpenAIModel(goal)}`) console.log(`Path: ${outputPath}`) console.log('Next: bun run dev:profile') } diff --git a/scripts/provider-launch.ts b/scripts/provider-launch.ts index 46a87414..d8516be0 100644 --- a/scripts/provider-launch.ts +++ b/scripts/provider-launch.ts @@ -48,7 +48,7 @@ function parseLaunchOptions(argv: string[]): LaunchOptions { continue } - if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex') && requestedProfile === 'auto') { + if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini') && requestedProfile === 'auto') { requestedProfile = lower as ProviderProfile | 'auto' continue } @@ -79,7 +79,7 @@ function loadPersistedProfile(): ProfileFile | null { if (!existsSync(path)) return null try { const parsed = JSON.parse(readFileSync(path, 'utf8')) as ProfileFile - if (parsed.profile === 'openai' || parsed.profile === 'ollama' || parsed.profile === 'codex') { + if (parsed.profile === 'openai' || parsed.profile === 'ollama' || parsed.profile === 'codex' || parsed.profile === 'gemini') { return parsed } return null @@ -126,22 +126,26 @@ function quoteArg(arg: string): string { } function printSummary(profile: ProviderProfile, env: NodeJS.ProcessEnv): void { - const keySet = profile === 'codex' - ? Boolean(resolveCodexApiCredentials(env).apiKey) - : Boolean(env.OPENAI_API_KEY) console.log(`Launching profile: ${profile}`) - console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`) - console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`) - console.log( - `${profile === 'codex' ? 'CODEX_API_KEY_SET' : 'OPENAI_API_KEY_SET'}=${keySet}`, - ) + if (profile === 'gemini') { + console.log(`GEMINI_MODEL=${env.GEMINI_MODEL}`) + console.log(`GEMINI_API_KEY_SET=${Boolean(env.GEMINI_API_KEY)}`) + } else if (profile === 'codex') { + console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`) + console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`) + console.log(`CODEX_API_KEY_SET=${Boolean(resolveCodexApiCredentials(env).apiKey)}`) + } else { + console.log(`OPENAI_BASE_URL=${env.OPENAI_BASE_URL}`) + console.log(`OPENAI_MODEL=${env.OPENAI_MODEL}`) + console.log(`OPENAI_API_KEY_SET=${Boolean(env.OPENAI_API_KEY)}`) + } } async function main(): Promise { const options = parseLaunchOptions(process.argv.slice(2)) const requestedProfile = options.requestedProfile if (!requestedProfile) { - console.error('Usage: bun run scripts/provider-launch.ts [openai|ollama|codex|auto] [--fast] [--goal ] [-- ]') + console.error('Usage: bun run scripts/provider-launch.ts [openai|ollama|codex|gemini|auto] [--fast] [--goal ] [-- ]') process.exit(1) } @@ -184,6 +188,11 @@ async function main(): Promise { applyFastFlags(env) } + if (profile === 'gemini' && !env.GEMINI_API_KEY) { + console.error('GEMINI_API_KEY is required for gemini profile. Run: bun run profile:init -- --provider gemini --api-key ') + process.exit(1) + } + if (profile === 'openai' && (!env.OPENAI_API_KEY || env.OPENAI_API_KEY === 'SUA_CHAVE')) { console.error('OPENAI_API_KEY is required for openai profile and cannot be SUA_CHAVE. Run: bun run profile:init -- --provider openai --api-key ') process.exit(1) diff --git a/scripts/system-check.ts b/scripts/system-check.ts index 98022f6a..e129685a 100644 --- a/scripts/system-check.ts +++ b/scripts/system-check.ts @@ -92,14 +92,49 @@ function isLocalBaseUrl(baseUrl: string): boolean { return isProviderLocalUrl(baseUrl) } +const GEMINI_DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai' + function currentBaseUrl(): string { + if (isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) { + return process.env.GEMINI_BASE_URL ?? GEMINI_DEFAULT_BASE_URL + } return process.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1' } +function checkGeminiEnv(): CheckResult[] { + const results: CheckResult[] = [] + const model = process.env.GEMINI_MODEL + const key = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY + const baseUrl = process.env.GEMINI_BASE_URL ?? GEMINI_DEFAULT_BASE_URL + + results.push(pass('Provider mode', 'Google Gemini provider enabled.')) + + if (!model) { + results.push(pass('GEMINI_MODEL', 'Not set. Default gemini-2.0-flash will be used.')) + } else { + results.push(pass('GEMINI_MODEL', model)) + } + + results.push(pass('GEMINI_BASE_URL', baseUrl)) + + if (!key) { + results.push(fail('GEMINI_API_KEY', 'Missing. Set GEMINI_API_KEY or GOOGLE_API_KEY.')) + } else { + results.push(pass('GEMINI_API_KEY', 'Configured.')) + } + + return results +} + function checkOpenAIEnv(): CheckResult[] { const results: CheckResult[] = [] + const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI) const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) + if (useGemini) { + return checkGeminiEnv() + } + if (!useOpenAI) { results.push(pass('Provider mode', 'Anthropic login flow enabled (CLAUDE_CODE_USE_OPENAI is off).')) return results @@ -160,13 +195,20 @@ function checkOpenAIEnv(): CheckResult[] { } async function checkBaseUrlReachability(): Promise { - if (!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) { + const useGemini = isTruthy(process.env.CLAUDE_CODE_USE_GEMINI) + const useOpenAI = isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) + + if (!useGemini && !useOpenAI) { return pass('Provider reachability', 'Skipped (OpenAI-compatible mode disabled).') } + const geminiBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai' + const resolvedBaseUrl = useGemini + ? (process.env.GEMINI_BASE_URL ?? geminiBaseUrl) + : undefined const request = resolveProviderRequest({ model: process.env.OPENAI_MODEL, - baseUrl: process.env.OPENAI_BASE_URL, + baseUrl: resolvedBaseUrl ?? process.env.OPENAI_BASE_URL, }) const endpoint = request.transport === 'codex_responses' ? `${request.baseUrl}/responses` @@ -203,6 +245,8 @@ async function checkBaseUrlReachability(): Promise { store: false, stream: true, }) + } else if (useGemini && (process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY)) { + headers.Authorization = `Bearer ${process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY}` } else if (process.env.OPENAI_API_KEY) { headers.Authorization = `Bearer ${process.env.OPENAI_API_KEY}` } @@ -228,7 +272,7 @@ async function checkBaseUrlReachability(): Promise { } function checkOllamaProcessorMode(): CheckResult { - if (!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) { + if (!isTruthy(process.env.CLAUDE_CODE_USE_OPENAI) || isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) { return pass('Ollama processor mode', 'Skipped (OpenAI-compatible mode disabled).') } @@ -267,6 +311,14 @@ function checkOllamaProcessorMode(): CheckResult { } function serializeSafeEnvSummary(): Record { + if (isTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) { + return { + CLAUDE_CODE_USE_GEMINI: true, + GEMINI_MODEL: process.env.GEMINI_MODEL ?? '(unset, default: gemini-2.0-flash)', + GEMINI_BASE_URL: process.env.GEMINI_BASE_URL ?? 'https://generativelanguage.googleapis.com/v1beta/openai', + GEMINI_API_KEY_SET: Boolean(process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY), + } + } const request = resolveProviderRequest({ model: process.env.OPENAI_MODEL, baseUrl: process.env.OPENAI_BASE_URL, diff --git a/src/ink/dom.ts b/src/ink/dom.ts index f5b672ba..05c51c1f 100644 --- a/src/ink/dom.ts +++ b/src/ink/dom.ts @@ -265,13 +265,14 @@ export const setAttribute = ( markDirty(node) } -export const setStyle = (node: DOMNode, style: Styles): void => { +export const setStyle = (node: DOMNode, style: Styles | undefined): void => { + const nextStyle = style ?? {} // Compare style properties to avoid marking dirty unnecessarily. // React creates new style objects on every render even when unchanged. - if (stylesEqual(node.style, style)) { + if (stylesEqual(node.style, nextStyle)) { return } - node.style = style + node.style = nextStyle markDirty(node) } diff --git a/src/ink/reconciler.ts b/src/ink/reconciler.ts index c987b9fd..ba5e1395 100644 --- a/src/ink/reconciler.ts +++ b/src/ink/reconciler.ts @@ -59,6 +59,12 @@ $ npm install --save-dev react-devtools-core type AnyObject = Record +type UpdatePayload = { + props?: AnyObject + style?: AnyObject + nextStyle?: Styles | undefined +} + const diff = (before: AnyObject, after: AnyObject): AnyObject | undefined => { if (before === after) { return @@ -232,7 +238,7 @@ const reconciler = createReconciler< unknown, DOMElement, HostContext, - boolean, + UpdatePayload | null, NodeJS.Timeout, -1, null @@ -403,8 +409,19 @@ const reconciler = createReconciler< _type: ElementNames, oldProps: Props, newProps: Props, - ): boolean { - return oldProps !== newProps + ): UpdatePayload | null { + const props = diff(oldProps, newProps) + const style = diff(oldProps['style'] as Styles, newProps['style'] as Styles) + + if (!props && !style) { + return null + } + + return { + props, + style, + nextStyle: newProps['style'] as Styles | undefined, + } }, commitMount(node: DOMElement): void { getFocusManager(node).handleAutoFocus(node) @@ -432,13 +449,16 @@ const reconciler = createReconciler< }, commitUpdate( node: DOMElement, - _updatePayload: boolean, + updatePayload: UpdatePayload | null, _type: ElementNames, - oldProps: Props, - newProps: Props, + _oldProps: Props, + _newProps: Props, ): void { - const props = diff(oldProps, newProps) - const style = diff(oldProps['style'] as Styles, newProps['style'] as Styles) + if (!updatePayload) { + return + } + + const { props, style, nextStyle } = updatePayload if (props) { for (const [key, value] of Object.entries(props)) { @@ -462,7 +482,7 @@ const reconciler = createReconciler< } if (style && node.yogaNode) { - applyStyles(node.yogaNode, style, newProps['style'] as Styles) + applyStyles(node.yogaNode, style, nextStyle) } }, commitTextUpdate(node: TextNode, _oldText: string, newText: string): void { diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 07b38ba1..f7a5f6b7 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -292,6 +292,7 @@ async function* openaiStreamToAnthropic( let hasEmittedContentStart = false let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null let hasEmittedFinalUsage = false + let hasProcessedFinishReason = false // Emit message_start yield { @@ -422,8 +423,11 @@ async function* openaiStreamToAnthropic( } } - // Finish - if (choice.finish_reason) { + // Finish — guard ensures we only process finish_reason once even if + // multiple chunks arrive with finish_reason set (some providers do this) + if (choice.finish_reason && !hasProcessedFinishReason) { + hasProcessedFinishReason = true + // Close any open content blocks if (hasEmittedContentStart) { yield { @@ -741,6 +745,22 @@ export function createOpenAIShimClient(options: { maxRetries?: number timeout?: number }): unknown { + // When Gemini provider is active, map Gemini env vars to OpenAI-compatible ones + // so the existing providerConfig.ts infrastructure picks them up correctly. + if ( + process.env.CLAUDE_CODE_USE_GEMINI === '1' || + process.env.CLAUDE_CODE_USE_GEMINI === 'true' + ) { + process.env.OPENAI_BASE_URL ??= + process.env.GEMINI_BASE_URL ?? + 'https://generativelanguage.googleapis.com/v1beta/openai' + process.env.OPENAI_API_KEY ??= + process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY ?? '' + if (process.env.GEMINI_MODEL && !process.env.OPENAI_MODEL) { + process.env.OPENAI_MODEL = process.env.GEMINI_MODEL + } + } + const beta = new OpenAIShimBeta({ ...(options.defaultHeaders ?? {}), }) diff --git a/src/utils/context.ts b/src/utils/context.ts index d9714de9..f13b2b0a 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -4,6 +4,7 @@ import { getGlobalConfig } from './config.js' import { isEnvTruthy } from './envUtils.js' import { getCanonicalName } from './model/model.js' import { getModelCapability } from './model/modelCapabilities.js' +import { getOpenAIContextWindow, getOpenAIMaxOutputTokens } from './model/openaiContextWindows.js' // Model context window size (200k tokens for all models right now) export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000 @@ -71,6 +72,19 @@ export function getContextWindowForModel( return 1_000_000 } + // OpenAI-compatible provider — use known context windows for the model + if ( + process.env.CLAUDE_CODE_USE_OPENAI === '1' || + process.env.CLAUDE_CODE_USE_OPENAI === 'true' || + process.env.CLAUDE_CODE_USE_GEMINI === '1' || + process.env.CLAUDE_CODE_USE_GEMINI === 'true' + ) { + const openaiWindow = getOpenAIContextWindow(model) + if (openaiWindow !== undefined) { + return openaiWindow + } + } + const cap = getModelCapability(model) if (cap?.max_input_tokens && cap.max_input_tokens >= 100_000) { if ( @@ -162,6 +176,19 @@ export function getModelMaxOutputTokens(model: string): { } } + // OpenAI-compatible provider — use known output limits to avoid 400 errors + if ( + process.env.CLAUDE_CODE_USE_OPENAI === '1' || + process.env.CLAUDE_CODE_USE_OPENAI === 'true' || + process.env.CLAUDE_CODE_USE_GEMINI === '1' || + process.env.CLAUDE_CODE_USE_GEMINI === 'true' + ) { + const openaiMax = getOpenAIMaxOutputTokens(model) + if (openaiMax !== undefined) { + return { default: openaiMax, upperLimit: openaiMax } + } + } + const m = getCanonicalName(model) if (m.includes('opus-4-6')) { diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 0ffd0b5f..3c5f1e79 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -14,6 +14,17 @@ export const OPENAI_MODEL_DEFAULTS = { haiku: 'gpt-4o-mini', // fast & cheap } as const +// --------------------------------------------------------------------------- +// Gemini model mappings +// Maps Claude model tiers to Google Gemini equivalents. +// Override with GEMINI_MODEL env var. +// --------------------------------------------------------------------------- +export const GEMINI_MODEL_DEFAULTS = { + opus: 'gemini-2.5-pro-preview-03-25', // most capable + sonnet: 'gemini-2.0-flash', // balanced + haiku: 'gemini-2.0-flash-lite', // fast & cheap +} as const + // @[MODEL LAUNCH]: Add a new CLAUDE_*_CONFIG constant here. Double check the correct model strings // here since the pattern may change. @@ -23,6 +34,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = { vertex: 'claude-3-7-sonnet@20250219', foundry: 'claude-3-7-sonnet', openai: 'gpt-4o-mini', + gemini: 'gemini-2.0-flash', } as const satisfies ModelConfig export const CLAUDE_3_5_V2_SONNET_CONFIG = { @@ -31,6 +43,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = { vertex: 'claude-3-5-sonnet-v2@20241022', foundry: 'claude-3-5-sonnet', openai: 'gpt-4o-mini', + gemini: 'gemini-2.0-flash', } as const satisfies ModelConfig export const CLAUDE_3_5_HAIKU_CONFIG = { @@ -39,6 +52,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = { vertex: 'claude-3-5-haiku@20241022', foundry: 'claude-3-5-haiku', openai: 'gpt-4o-mini', + gemini: 'gemini-2.0-flash-lite', } as const satisfies ModelConfig export const CLAUDE_HAIKU_4_5_CONFIG = { @@ -47,6 +61,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = { vertex: 'claude-haiku-4-5@20251001', foundry: 'claude-haiku-4-5', openai: 'gpt-4o-mini', + gemini: 'gemini-2.0-flash-lite', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_CONFIG = { @@ -55,6 +70,7 @@ export const CLAUDE_SONNET_4_CONFIG = { vertex: 'claude-sonnet-4@20250514', foundry: 'claude-sonnet-4', openai: 'gpt-4o-mini', + gemini: 'gemini-2.0-flash', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_5_CONFIG = { @@ -63,6 +79,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = { vertex: 'claude-sonnet-4-5@20250929', foundry: 'claude-sonnet-4-5', openai: 'gpt-4o', + gemini: 'gemini-2.0-flash', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_CONFIG = { @@ -71,6 +88,7 @@ export const CLAUDE_OPUS_4_CONFIG = { vertex: 'claude-opus-4@20250514', foundry: 'claude-opus-4', openai: 'gpt-4o', + gemini: 'gemini-2.5-pro-preview-03-25', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_1_CONFIG = { @@ -79,6 +97,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = { vertex: 'claude-opus-4-1@20250805', foundry: 'claude-opus-4-1', openai: 'gpt-4o', + gemini: 'gemini-2.5-pro-preview-03-25', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_5_CONFIG = { @@ -87,6 +106,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = { vertex: 'claude-opus-4-5@20251101', foundry: 'claude-opus-4-5', openai: 'gpt-4o', + gemini: 'gemini-2.5-pro-preview-03-25', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_6_CONFIG = { @@ -95,6 +115,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = { vertex: 'claude-opus-4-6', foundry: 'claude-opus-4-6', openai: 'gpt-4o', + gemini: 'gemini-2.5-pro-preview-03-25', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_6_CONFIG = { @@ -103,6 +124,7 @@ export const CLAUDE_SONNET_4_6_CONFIG = { vertex: 'claude-sonnet-4-6', foundry: 'claude-sonnet-4-6', openai: 'gpt-4o', + gemini: 'gemini-2.0-flash', } as const satisfies ModelConfig // @[MODEL LAUNCH]: Register the new config here. diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 0cfcfece..6c81a8ef 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -35,6 +35,10 @@ export type ModelSetting = ModelName | ModelAlias | null export function getSmallFastModel(): ModelName { if (process.env.ANTHROPIC_SMALL_FAST_MODEL) return process.env.ANTHROPIC_SMALL_FAST_MODEL + // For Gemini provider, use a fast model + if (getAPIProvider() === 'gemini') { + return process.env.GEMINI_MODEL || 'gemini-2.0-flash-lite' + } // For OpenAI provider, use OPENAI_MODEL or a sensible default if (getAPIProvider() === 'openai') { return process.env.OPENAI_MODEL || 'gpt-4o-mini' @@ -71,7 +75,7 @@ export function getUserSpecifiedModelSetting(): ModelSetting | undefined { specifiedModel = modelOverride } else { const settings = getSettings_DEPRECATED() || {} - specifiedModel = process.env.ANTHROPIC_MODEL || process.env.OPENAI_MODEL || settings.model || undefined + specifiedModel = process.env.ANTHROPIC_MODEL || process.env.GEMINI_MODEL || process.env.OPENAI_MODEL || settings.model || undefined } // Ignore the user-specified model if it's not in the availableModels allowlist. @@ -111,6 +115,10 @@ export function getDefaultOpusModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) { return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL } + // Gemini provider + if (getAPIProvider() === 'gemini') { + return process.env.GEMINI_MODEL || 'gemini-2.5-pro-preview-03-25' + } // OpenAI provider: use user-specified model or default if (getAPIProvider() === 'openai') { return process.env.OPENAI_MODEL || 'gpt-4o' @@ -129,6 +137,10 @@ export function getDefaultSonnetModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) { return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL } + // Gemini provider + if (getAPIProvider() === 'gemini') { + return process.env.GEMINI_MODEL || 'gemini-2.0-flash' + } // OpenAI provider if (getAPIProvider() === 'openai') { return process.env.OPENAI_MODEL || 'gpt-4o' @@ -145,6 +157,10 @@ export function getDefaultHaikuModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) { return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL } + // Gemini provider + if (getAPIProvider() === 'gemini') { + return process.env.GEMINI_MODEL || 'gemini-2.0-flash-lite' + } // OpenAI provider if (getAPIProvider() === 'openai') { return process.env.OPENAI_MODEL || 'gpt-4o-mini' @@ -193,6 +209,10 @@ export function getRuntimeMainLoopModel(params: { * @returns The default model setting to use */ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias { + // Gemini provider: always use the configured Gemini model + if (getAPIProvider() === 'gemini') { + return process.env.GEMINI_MODEL || 'gemini-2.0-flash' + } // OpenAI provider: always use the configured OpenAI model if (getAPIProvider() === 'openai') { return process.env.OPENAI_MODEL || 'gpt-4o' @@ -381,8 +401,8 @@ export function renderModelSetting(setting: ModelName | ModelAlias): string { * if the model is not recognized as a public model. */ export function getPublicModelDisplayName(model: ModelName): string | null { - // For OpenAI provider, show the actual model name (e.g. 'gpt-4o') not a Claude alias - if (getAPIProvider() === 'openai') { + // For OpenAI/Gemini providers, show the actual model name not a Claude alias + if (getAPIProvider() === 'openai' || getAPIProvider() === 'gemini') { return null } switch (model) { diff --git a/src/utils/model/openaiContextWindows.ts b/src/utils/model/openaiContextWindows.ts new file mode 100644 index 00000000..fd6fb15a --- /dev/null +++ b/src/utils/model/openaiContextWindows.ts @@ -0,0 +1,132 @@ +/** + * openaiContextWindows.ts + * Context window sizes for OpenAI-compatible models used via the shim. + * Fixes: auto-compact and warnings using wrong 200k default for OpenAI models. + * + * When CLAUDE_CODE_USE_OPENAI=1, getContextWindowForModel() falls through to + * MODEL_CONTEXT_WINDOW_DEFAULT (200k). This causes the warning and blocking + * thresholds to be set at 200k even for models like gpt-4o (128k) or llama3 (8k), + * meaning users get no warning before hitting a hard API error. + * + * Prices in tokens as of April 2026 — update as needed. + */ + +const OPENAI_CONTEXT_WINDOWS: Record = { + // OpenAI + 'gpt-4o': 128_000, + 'gpt-4o-mini': 128_000, + 'gpt-4.1': 1_047_576, + 'gpt-4.1-mini': 1_047_576, + 'gpt-4.1-nano': 1_047_576, + 'gpt-4-turbo': 128_000, + 'gpt-4': 8_192, + 'o3-mini': 200_000, + 'o4-mini': 200_000, + 'o3': 200_000, + + // DeepSeek + 'deepseek-chat': 64_000, + 'deepseek-reasoner': 64_000, + + // Groq (fast inference) + 'llama-3.3-70b-versatile': 128_000, + 'llama-3.1-8b-instant': 128_000, + 'mixtral-8x7b-32768': 32_768, + + // Mistral + 'mistral-large-latest': 131_072, + 'mistral-small-latest': 131_072, + + // Google (via OpenRouter) + 'google/gemini-2.0-flash':1_048_576, + 'google/gemini-2.5-pro': 1_048_576, + + // Ollama local models + 'llama3.3:70b': 8_192, + 'llama3.1:8b': 8_192, + 'llama3.2:3b': 8_192, + 'qwen2.5-coder:32b': 32_768, + 'qwen2.5-coder:7b': 32_768, + 'deepseek-coder-v2:16b': 163_840, + 'deepseek-r1:14b': 65_536, + 'mistral:7b': 32_768, + 'phi4:14b': 16_384, + 'gemma2:27b': 8_192, + 'codellama:13b': 16_384, +} + +/** + * Max output (completion) tokens per model. + * This is separate from the context window (input limit). + * Fixes: 400 error "max_tokens is too large" when default 32k exceeds model limit. + */ +const OPENAI_MAX_OUTPUT_TOKENS: Record = { + // OpenAI + 'gpt-4o': 16_384, + 'gpt-4o-mini': 16_384, + 'gpt-4.1': 32_768, + 'gpt-4.1-mini': 32_768, + 'gpt-4.1-nano': 32_768, + 'gpt-4-turbo': 4_096, + 'gpt-4': 4_096, + 'o3-mini': 100_000, + 'o4-mini': 100_000, + 'o3': 100_000, + + // DeepSeek + 'deepseek-chat': 8_192, + 'deepseek-reasoner': 32_768, + + // Groq + 'llama-3.3-70b-versatile': 32_768, + 'llama-3.1-8b-instant': 8_192, + 'mixtral-8x7b-32768': 32_768, + + // Mistral + 'mistral-large-latest': 32_768, + 'mistral-small-latest': 32_768, + + // Google (via OpenRouter) + 'google/gemini-2.0-flash': 8_192, + 'google/gemini-2.5-pro': 32_768, + + // Ollama local models (conservative safe defaults) + 'llama3.3:70b': 4_096, + 'llama3.1:8b': 4_096, + 'llama3.2:3b': 4_096, + 'qwen2.5-coder:32b': 8_192, + 'qwen2.5-coder:7b': 8_192, + 'deepseek-coder-v2:16b': 8_192, + 'deepseek-r1:14b': 8_192, + 'mistral:7b': 4_096, + 'phi4:14b': 4_096, + 'gemma2:27b': 4_096, + 'codellama:13b': 4_096, +} + +function lookupByModel(table: Record, model: string): T | undefined { + if (table[model] !== undefined) return table[model] + for (const key of Object.keys(table)) { + if (model.startsWith(key)) return table[key] + } + return undefined +} + +/** + * Look up the context window for an OpenAI-compatible model. + * Returns undefined if the model is not in the table. + * + * Falls back to prefix matching so dated variants like + * "gpt-4o-2024-11-20" resolve to the base "gpt-4o" entry. + */ +export function getOpenAIContextWindow(model: string): number | undefined { + return lookupByModel(OPENAI_CONTEXT_WINDOWS, model) +} + +/** + * Look up the max output tokens for an OpenAI-compatible model. + * Returns undefined if the model is not in the table. + */ +export function getOpenAIMaxOutputTokens(model: string): number | undefined { + return lookupByModel(OPENAI_MAX_OUTPUT_TOKENS, model) +} diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index 48acd520..e0be1a34 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -1,18 +1,20 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js' import { isEnvTruthy } from '../envUtils.js' -export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' | 'openai' +export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' | 'openai' | 'gemini' export function getAPIProvider(): APIProvider { - return isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) - ? 'openai' - : isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) - ? 'bedrock' - : isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) - ? 'vertex' - : isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) - ? 'foundry' - : 'firstParty' + return isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) + ? 'gemini' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) + ? 'openai' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) + ? 'bedrock' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) + ? 'vertex' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) + ? 'foundry' + : 'firstParty' } export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index e0050751..e90746c6 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -6,6 +6,7 @@ import test from 'node:test' import { buildCodexProfileEnv, + buildGeminiProfileEnv, buildLaunchEnv, buildOllamaProfileEnv, buildOpenAIProfileEnv, @@ -127,6 +128,60 @@ test('openai launch ignores codex persisted transport hints', async () => { assert.equal(env.OPENAI_API_KEY, 'sk-live') }) +test('matching persisted gemini env is reused for gemini launch', async () => { + const env = await buildLaunchEnv({ + profile: 'gemini', + persisted: profile('gemini', { + GEMINI_MODEL: 'gemini-2.5-flash', + GEMINI_API_KEY: 'gem-persisted', + GEMINI_BASE_URL: 'https://example.test/v1beta/openai', + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1') + assert.equal(env.CLAUDE_CODE_USE_OPENAI, undefined) + assert.equal(env.GEMINI_MODEL, 'gemini-2.5-flash') + assert.equal(env.GEMINI_API_KEY, 'gem-persisted') + assert.equal(env.GEMINI_BASE_URL, 'https://example.test/v1beta/openai') +}) + +test('gemini launch ignores mismatched persisted openai env and strips other provider secrets', async () => { + const env = await buildLaunchEnv({ + profile: 'gemini', + persisted: profile('openai', { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + OPENAI_API_KEY: 'sk-persisted', + }), + goal: 'balanced', + processEnv: { + GEMINI_API_KEY: 'gem-live', + GOOGLE_API_KEY: 'google-live', + OPENAI_API_KEY: 'sk-live', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o-mini', + CODEX_API_KEY: 'codex-live', + CHATGPT_ACCOUNT_ID: 'acct_live', + CLAUDE_CODE_USE_OPENAI: '1', + }, + }) + + assert.equal(env.CLAUDE_CODE_USE_GEMINI, '1') + assert.equal(env.CLAUDE_CODE_USE_OPENAI, undefined) + assert.equal(env.GEMINI_MODEL, 'gemini-2.0-flash') + assert.equal(env.GEMINI_API_KEY, 'gem-live') + assert.equal( + env.GEMINI_BASE_URL, + 'https://generativelanguage.googleapis.com/v1beta/openai', + ) + assert.equal(env.GOOGLE_API_KEY, undefined) + assert.equal(env.OPENAI_API_KEY, undefined) + assert.equal(env.CODEX_API_KEY, undefined) + assert.equal(env.CHATGPT_ACCOUNT_ID, undefined) +}) + test('matching persisted codex env is reused for codex launch', async () => { const env = await buildLaunchEnv({ profile: 'codex', @@ -283,6 +338,27 @@ test('codex profiles require a chatgpt account id', () => { assert.equal(env, null) }) +test('gemini profiles accept google api key fallback', () => { + const env = buildGeminiProfileEnv({ + processEnv: { + GOOGLE_API_KEY: 'gem-live', + }, + }) + + assert.deepEqual(env, { + GEMINI_MODEL: 'gemini-2.0-flash', + GEMINI_API_KEY: 'gem-live', + }) +}) + +test('gemini profiles require a key', () => { + const env = buildGeminiProfileEnv({ + processEnv: {}, + }) + + assert.equal(env, null) +}) + test('openai profiles ignore codex shell transport hints', () => { const env = buildOpenAIProfileEnv({ goal: 'balanced', diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index 466c9666..866c19c5 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -10,7 +10,10 @@ import { type RecommendationGoal, } from './providerRecommendation.ts' -export type ProviderProfile = 'openai' | 'ollama' | 'codex' +const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/openai' +const DEFAULT_GEMINI_MODEL = 'gemini-2.0-flash' + +export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' export type ProfileEnv = { OPENAI_BASE_URL?: string @@ -19,6 +22,9 @@ export type ProfileEnv = { CODEX_API_KEY?: string CHATGPT_ACCOUNT_ID?: string CODEX_ACCOUNT_ID?: string + GEMINI_API_KEY?: string + GEMINI_MODEL?: string + GEMINI_BASE_URL?: string } export type ProfileFile = { @@ -47,6 +53,36 @@ export function buildOllamaProfileEnv( } } +export function buildGeminiProfileEnv(options: { + model?: string | null + baseUrl?: string | null + apiKey?: string | null + processEnv?: NodeJS.ProcessEnv +}): ProfileEnv | null { + const processEnv = options.processEnv ?? process.env + const key = sanitizeApiKey( + options.apiKey ?? + processEnv.GEMINI_API_KEY ?? + processEnv.GOOGLE_API_KEY, + ) + if (!key) { + return null + } + + const env: ProfileEnv = { + GEMINI_MODEL: + options.model || processEnv.GEMINI_MODEL || DEFAULT_GEMINI_MODEL, + GEMINI_API_KEY: key, + } + + const baseUrl = options.baseUrl || processEnv.GEMINI_BASE_URL + if (baseUrl) { + env.GEMINI_BASE_URL = baseUrl + } + + return env +} + export function buildOpenAIProfileEnv(options: { goal: RecommendationGoal model?: string | null @@ -142,11 +178,57 @@ export async function buildLaunchEnv(options: { ? options.persisted.env ?? {} : {} + const shellGeminiKey = sanitizeApiKey( + processEnv.GEMINI_API_KEY ?? processEnv.GOOGLE_API_KEY, + ) + const persistedGeminiKey = sanitizeApiKey(persistedEnv.GEMINI_API_KEY) + + if (options.profile === 'gemini') { + const env: NodeJS.ProcessEnv = { + ...processEnv, + CLAUDE_CODE_USE_GEMINI: '1', + } + + delete env.CLAUDE_CODE_USE_OPENAI + + env.GEMINI_MODEL = + processEnv.GEMINI_MODEL || + persistedEnv.GEMINI_MODEL || + DEFAULT_GEMINI_MODEL + env.GEMINI_BASE_URL = + processEnv.GEMINI_BASE_URL || + persistedEnv.GEMINI_BASE_URL || + DEFAULT_GEMINI_BASE_URL + + const geminiKey = shellGeminiKey || persistedGeminiKey + if (geminiKey) { + env.GEMINI_API_KEY = geminiKey + } else { + delete env.GEMINI_API_KEY + } + + delete env.GOOGLE_API_KEY + delete env.OPENAI_BASE_URL + delete env.OPENAI_MODEL + delete env.OPENAI_API_KEY + delete env.CODEX_API_KEY + delete env.CHATGPT_ACCOUNT_ID + delete env.CODEX_ACCOUNT_ID + + return env + } + const env: NodeJS.ProcessEnv = { ...processEnv, CLAUDE_CODE_USE_OPENAI: '1', } + delete env.CLAUDE_CODE_USE_GEMINI + delete env.GEMINI_API_KEY + delete env.GEMINI_MODEL + delete env.GEMINI_BASE_URL + delete env.GOOGLE_API_KEY + if (options.profile === 'ollama') { const getOllamaBaseUrl = options.getOllamaChatBaseUrl ?? (() => 'http://localhost:11434/v1')