-
Notifications
You must be signed in to change notification settings - Fork 14.4k
feat: 添加gemini协议适配 #125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: 添加gemini协议适配 #125
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,15 @@ type OAuthStatus = | |
| opusModel: string | ||
| activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' | ||
| } // OpenAI Chat Completions API platform | ||
| | { | ||
| state: 'gemini_api' | ||
| baseUrl: string | ||
| apiKey: string | ||
| haikuModel: string | ||
| sonnetModel: string | ||
| opusModel: string | ||
| activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' | ||
| } // Gemini Generate Content API platform | ||
| | { state: 'ready_to_start' } // Flow started, waiting for browser to open | ||
| | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login | ||
| | { state: 'creating_api_key' } // Got access token, creating API key | ||
|
|
@@ -60,7 +69,6 @@ type OAuthStatus = | |
| } | ||
|
|
||
| const PASTE_HERE_MSG = 'Paste code here if prompted > ' | ||
|
|
||
| export function ConsoleOAuthFlow({ | ||
| onDone, | ||
| startingMessage, | ||
|
|
@@ -476,6 +484,16 @@ function OAuthStatusMessage({ | |
| ), | ||
| value: 'openai_chat_api', | ||
| }, | ||
| { | ||
| label: ( | ||
| <Text> | ||
| Gemini API ·{' '} | ||
| <Text dimColor>Google Gemini native REST/SSE</Text> | ||
| {'\n'} | ||
| </Text> | ||
| ), | ||
| value: 'gemini_api', | ||
| }, | ||
| { | ||
| label: ( | ||
| <Text> | ||
|
|
@@ -543,6 +561,17 @@ function OAuthStatusMessage({ | |
| opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', | ||
| activeField: 'base_url', | ||
| }) | ||
| } else if (value === 'gemini_api') { | ||
| logEvent('tengu_gemini_api_selected', {}) | ||
| setOAuthStatus({ | ||
| state: 'gemini_api', | ||
| baseUrl: process.env.GEMINI_BASE_URL ?? '', | ||
| apiKey: process.env.GEMINI_API_KEY ?? '', | ||
| haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? '', | ||
| sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '', | ||
| opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', | ||
| activeField: 'base_url', | ||
| }) | ||
| } else if (value === 'platform') { | ||
| logEvent('tengu_oauth_platform_selected', {}) | ||
| setOAuthStatus({ state: 'platform_setup' }) | ||
|
|
@@ -974,6 +1003,238 @@ function OAuthStatusMessage({ | |
| ) | ||
| } | ||
|
|
||
| case 'gemini_api': | ||
| { | ||
| type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' | ||
| const GEMINI_FIELDS: GeminiField[] = [ | ||
| 'base_url', | ||
| 'api_key', | ||
| 'haiku_model', | ||
| 'sonnet_model', | ||
| 'opus_model', | ||
| ] | ||
| const gp = oauthStatus as { | ||
| state: 'gemini_api' | ||
| activeField: GeminiField | ||
| baseUrl: string | ||
| apiKey: string | ||
| haikuModel: string | ||
| sonnetModel: string | ||
| opusModel: string | ||
| } | ||
| const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = gp | ||
| const geminiDisplayValues: Record<GeminiField, string> = { | ||
| base_url: baseUrl, | ||
| api_key: apiKey, | ||
| haiku_model: haikuModel, | ||
| sonnet_model: sonnetModel, | ||
| opus_model: opusModel, | ||
| } | ||
|
|
||
| const [geminiInputValue, setGeminiInputValue] = useState( | ||
| () => geminiDisplayValues[activeField], | ||
| ) | ||
| const [geminiInputCursorOffset, setGeminiInputCursorOffset] = useState( | ||
| () => geminiDisplayValues[activeField].length, | ||
| ) | ||
|
|
||
| const buildGeminiState = useCallback( | ||
| (field: GeminiField, value: string, newActive?: GeminiField) => { | ||
| const s = { | ||
| state: 'gemini_api' as const, | ||
| activeField: newActive ?? activeField, | ||
| baseUrl, | ||
| apiKey, | ||
| haikuModel, | ||
| sonnetModel, | ||
| opusModel, | ||
| } | ||
| switch (field) { | ||
| case 'base_url': | ||
| return { ...s, baseUrl: value } | ||
| case 'api_key': | ||
| return { ...s, apiKey: value } | ||
| case 'haiku_model': | ||
| return { ...s, haikuModel: value } | ||
| case 'sonnet_model': | ||
| return { ...s, sonnetModel: value } | ||
| case 'opus_model': | ||
| return { ...s, opusModel: value } | ||
| } | ||
| }, | ||
| [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], | ||
| ) | ||
|
|
||
| const doGeminiSave = useCallback(() => { | ||
| const finalVals = { ...geminiDisplayValues, [activeField]: geminiInputValue } | ||
| if (!finalVals.haiku_model || !finalVals.sonnet_model || !finalVals.opus_model) { | ||
| setOAuthStatus({ | ||
| state: 'error', | ||
| message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.', | ||
| toRetry: { | ||
| state: 'gemini_api', | ||
| baseUrl: finalVals.base_url, | ||
| apiKey: finalVals.api_key, | ||
| haikuModel: finalVals.haiku_model, | ||
| sonnetModel: finalVals.sonnet_model, | ||
| opusModel: finalVals.opus_model, | ||
| activeField, | ||
| }, | ||
| }) | ||
| return | ||
| } | ||
|
|
||
| const env: Record<string, string> = {} | ||
| if (finalVals.base_url) env.GEMINI_BASE_URL = finalVals.base_url | ||
| if (finalVals.api_key) env.GEMINI_API_KEY = finalVals.api_key | ||
| if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model | ||
| if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model | ||
| if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model | ||
| const { error } = updateSettingsForSource('userSettings', { | ||
| modelType: 'gemini' as any, | ||
| env, | ||
| } as any) | ||
| if (error) { | ||
| setOAuthStatus({ | ||
| state: 'error', | ||
| message: `Failed to save: ${error.message}`, | ||
| toRetry: { | ||
| state: 'gemini_api', | ||
| baseUrl: '', | ||
| apiKey: '', | ||
| haikuModel: '', | ||
| sonnetModel: '', | ||
| opusModel: '', | ||
| activeField: 'base_url', | ||
| }, | ||
| }) | ||
| } else { | ||
| for (const [k, v] of Object.entries(env)) process.env[k] = v | ||
| setOAuthStatus({ state: 'success' }) | ||
| void onDone() | ||
| } | ||
| }, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus]) | ||
|
|
||
| const handleGeminiEnter = useCallback(() => { | ||
| const idx = GEMINI_FIELDS.indexOf(activeField) | ||
| setOAuthStatus(buildGeminiState(activeField, geminiInputValue)) | ||
| if (idx === GEMINI_FIELDS.length - 1) { | ||
| doGeminiSave() | ||
| } else { | ||
| const next = GEMINI_FIELDS[idx + 1]! | ||
| setGeminiInputValue(geminiDisplayValues[next] ?? '') | ||
| setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length) | ||
| } | ||
|
Comment on lines
+1118
to
+1127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The 🛠️ Suggested fix const handleGeminiEnter = useCallback(() => {
const idx = GEMINI_FIELDS.indexOf(activeField)
- setOAuthStatus(buildGeminiState(activeField, geminiInputValue))
if (idx === GEMINI_FIELDS.length - 1) {
+ setOAuthStatus(buildGeminiState(activeField, geminiInputValue))
doGeminiSave()
} else {
const next = GEMINI_FIELDS[idx + 1]!
+ setOAuthStatus(
+ buildGeminiState(activeField, geminiInputValue, next),
+ )
setGeminiInputValue(geminiDisplayValues[next] ?? '')
setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length)
}
}, [🤖 Prompt for AI Agents |
||
| }, [ | ||
| activeField, | ||
| buildGeminiState, | ||
| doGeminiSave, | ||
| geminiDisplayValues, | ||
| geminiInputValue, | ||
| setOAuthStatus, | ||
| ]) | ||
|
|
||
| useKeybinding( | ||
| 'tabs:next', | ||
| () => { | ||
| const idx = GEMINI_FIELDS.indexOf(activeField) | ||
| if (idx < GEMINI_FIELDS.length - 1) { | ||
| setOAuthStatus( | ||
| buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx + 1]), | ||
| ) | ||
| setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '') | ||
| setGeminiInputCursorOffset( | ||
| (geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '').length, | ||
| ) | ||
| } | ||
| }, | ||
| { context: 'Tabs' }, | ||
| ) | ||
| useKeybinding( | ||
| 'tabs:previous', | ||
| () => { | ||
| const idx = GEMINI_FIELDS.indexOf(activeField) | ||
| if (idx > 0) { | ||
| setOAuthStatus( | ||
| buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx - 1]), | ||
| ) | ||
| setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '') | ||
| setGeminiInputCursorOffset( | ||
| (geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '').length, | ||
| ) | ||
| } | ||
| }, | ||
| { context: 'Tabs' }, | ||
| ) | ||
| useKeybinding( | ||
| 'confirm:no', | ||
| () => { | ||
| setOAuthStatus({ state: 'idle' }) | ||
| }, | ||
| { context: 'Confirmation' }, | ||
| ) | ||
|
|
||
| const geminiColumns = useTerminalSize().columns - 20 | ||
|
|
||
| const renderGeminiRow = ( | ||
| field: GeminiField, | ||
| label: string, | ||
| opts?: { mask?: boolean }, | ||
| ) => { | ||
| const active = activeField === field | ||
| const val = geminiDisplayValues[field] | ||
| return ( | ||
| <Box> | ||
| <Text | ||
| backgroundColor={active ? 'suggestion' : undefined} | ||
| color={active ? 'inverseText' : undefined} | ||
| > | ||
| {` ${label} `} | ||
| </Text> | ||
| <Text> </Text> | ||
| {active ? ( | ||
| <TextInput | ||
| value={geminiInputValue} | ||
| onChange={setGeminiInputValue} | ||
| onSubmit={handleGeminiEnter} | ||
| cursorOffset={geminiInputCursorOffset} | ||
| onChangeCursorOffset={setGeminiInputCursorOffset} | ||
| columns={geminiColumns} | ||
| mask={opts?.mask ? '*' : undefined} | ||
| focus={true} | ||
| /> | ||
| ) : val ? ( | ||
| <Text color="success"> | ||
| {opts?.mask | ||
| ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) | ||
| : val} | ||
| </Text> | ||
| ) : null} | ||
| </Box> | ||
| ) | ||
| } | ||
|
|
||
| return ( | ||
| <Box flexDirection="column" gap={1}> | ||
| <Text bold>Gemini API Setup</Text> | ||
| <Text dimColor> | ||
| Configure a Gemini Generate Content compatible endpoint. Base URL is | ||
| optional and defaults to Google's v1beta API. | ||
| </Text> | ||
| <Box flexDirection="column" gap={1}> | ||
| {renderGeminiRow('base_url', 'Base URL ')} | ||
| {renderGeminiRow('api_key', 'API Key ', { mask: true })} | ||
| {renderGeminiRow('haiku_model', 'Haiku ')} | ||
| {renderGeminiRow('sonnet_model', 'Sonnet ')} | ||
| {renderGeminiRow('opus_model', 'Opus ')} | ||
| </Box> | ||
| <Text dimColor> | ||
| Tab to switch · Enter on last field to save · Esc to go back | ||
| </Text> | ||
| </Box> | ||
| ) | ||
| } | ||
|
|
||
| case 'platform_setup': | ||
| return ( | ||
| <Box flexDirection="column" gap={1} marginTop={1}> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -640,24 +640,51 @@ export function assistantMessageToMessageParam( | |
| } else { | ||
| return { | ||
| role: 'assistant', | ||
| content: message.message.content.map((_, i) => ({ | ||
| ..._, | ||
| ...(i === message.message.content.length - 1 && | ||
| _.type !== 'thinking' && | ||
| _.type !== 'redacted_thinking' && | ||
| (feature('CONNECTOR_TEXT') ? !isConnectorTextBlock(_) : true) | ||
| ? enablePromptCaching | ||
| ? { cache_control: getCacheControl({ querySource }) } | ||
| : {} | ||
| : {}), | ||
| })), | ||
| content: message.message.content.map((_, i) => { | ||
| const contentBlock = stripGeminiProviderMetadata(_) | ||
| return { | ||
| ...contentBlock, | ||
| ...(i === message.message.content.length - 1 && | ||
| contentBlock.type !== 'thinking' && | ||
| contentBlock.type !== 'redacted_thinking' && | ||
| (feature('CONNECTOR_TEXT') | ||
| ? !isConnectorTextBlock(contentBlock) | ||
| : true) | ||
| ? enablePromptCaching | ||
| ? { cache_control: getCacheControl({ querySource }) } | ||
| : {} | ||
| : {}), | ||
| } | ||
| }), | ||
|
Comment on lines
+643
to
+658
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the last cacheable block, not just the last array element. This still only applies |
||
| } | ||
| } | ||
| } | ||
| return { | ||
| role: 'assistant', | ||
| content: message.message.content, | ||
| content: | ||
| typeof message.message.content === 'string' | ||
| ? message.message.content | ||
| : message.message.content.map(stripGeminiProviderMetadata), | ||
| } | ||
| } | ||
|
|
||
| function stripGeminiProviderMetadata<T extends BetaContentBlockParam | string>( | ||
| contentBlock: T, | ||
| ): T { | ||
| if ( | ||
| typeof contentBlock === 'string' || | ||
| !('_geminiThoughtSignature' in contentBlock) | ||
| ) { | ||
| return contentBlock | ||
| } | ||
|
|
||
| const { | ||
| _geminiThoughtSignature: _unusedGeminiThoughtSignature, | ||
| ...rest | ||
| } = contentBlock as T & { | ||
| _geminiThoughtSignature?: string | ||
| } | ||
| return rest as T | ||
| } | ||
|
|
||
| export type Options = { | ||
|
|
@@ -1310,6 +1337,19 @@ async function* queryModel( | |
| return | ||
| } | ||
|
|
||
| if (getAPIProvider() === 'gemini') { | ||
| const { queryModelGemini } = await import('./gemini/index.js') | ||
| yield* queryModelGemini( | ||
| messagesForAPI, | ||
| systemPrompt, | ||
| filteredTools, | ||
| signal, | ||
| options, | ||
| thinkingConfig, | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| // Instrumentation: Track message count after normalization | ||
| logEvent('tengu_api_after_normalize', { | ||
| postNormalizedMessageCount: messagesForAPI.length, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve the valid
GEMINI_MODELconfig path.The Gemini provider already accepts a single
GEMINI_MODEL, but this form neither loads that env var nor saves it. An existingGEMINI_MODEL-only setup will show up here with blank model fields and then fail the new Haiku/Sonnet/Opus validation unless the user rewrites their config into three separate defaults.Also applies to: 1068-1096
🤖 Prompt for AI Agents