feat: 添加gemini协议适配 (#125)

* feat: 添加gemini协议适配

* Remove unrelated local files from Gemini PR
This commit is contained in:
SaltedFish555
2026-04-06 09:55:20 +08:00
committed by GitHub
parent 27825293bb
commit 0da5ec09e8
24 changed files with 2257 additions and 38 deletions

View File

@@ -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)
}
}, [
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&apos;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}>