Skip to content

Commit 0da5ec0

Browse files
feat: 添加gemini协议适配 (#125)
* feat: 添加gemini协议适配 * Remove unrelated local files from Gemini PR
1 parent 2782529 commit 0da5ec0

24 files changed

Lines changed: 2257 additions & 38 deletions

src/components/ConsoleOAuthFlow.tsx

Lines changed: 262 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ type OAuthStatus =
4848
opusModel: string
4949
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
5050
} // OpenAI Chat Completions API platform
51+
| {
52+
state: 'gemini_api'
53+
baseUrl: string
54+
apiKey: string
55+
haikuModel: string
56+
sonnetModel: string
57+
opusModel: string
58+
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
59+
} // Gemini Generate Content API platform
5160
| { state: 'ready_to_start' } // Flow started, waiting for browser to open
5261
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
5362
| { state: 'creating_api_key' } // Got access token, creating API key
@@ -60,7 +69,6 @@ type OAuthStatus =
6069
}
6170

6271
const PASTE_HERE_MSG = 'Paste code here if prompted > '
63-
6472
export function ConsoleOAuthFlow({
6573
onDone,
6674
startingMessage,
@@ -476,6 +484,16 @@ function OAuthStatusMessage({
476484
),
477485
value: 'openai_chat_api',
478486
},
487+
{
488+
label: (
489+
<Text>
490+
Gemini API ·{' '}
491+
<Text dimColor>Google Gemini native REST/SSE</Text>
492+
{'\n'}
493+
</Text>
494+
),
495+
value: 'gemini_api',
496+
},
479497
{
480498
label: (
481499
<Text>
@@ -543,6 +561,17 @@ function OAuthStatusMessage({
543561
opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '',
544562
activeField: 'base_url',
545563
})
564+
} else if (value === 'gemini_api') {
565+
logEvent('tengu_gemini_api_selected', {})
566+
setOAuthStatus({
567+
state: 'gemini_api',
568+
baseUrl: process.env.GEMINI_BASE_URL ?? '',
569+
apiKey: process.env.GEMINI_API_KEY ?? '',
570+
haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? '',
571+
sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '',
572+
opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '',
573+
activeField: 'base_url',
574+
})
546575
} else if (value === 'platform') {
547576
logEvent('tengu_oauth_platform_selected', {})
548577
setOAuthStatus({ state: 'platform_setup' })
@@ -974,6 +1003,238 @@ function OAuthStatusMessage({
9741003
)
9751004
}
9761005

1006+
case 'gemini_api':
1007+
{
1008+
type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
1009+
const GEMINI_FIELDS: GeminiField[] = [
1010+
'base_url',
1011+
'api_key',
1012+
'haiku_model',
1013+
'sonnet_model',
1014+
'opus_model',
1015+
]
1016+
const gp = oauthStatus as {
1017+
state: 'gemini_api'
1018+
activeField: GeminiField
1019+
baseUrl: string
1020+
apiKey: string
1021+
haikuModel: string
1022+
sonnetModel: string
1023+
opusModel: string
1024+
}
1025+
const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = gp
1026+
const geminiDisplayValues: Record<GeminiField, string> = {
1027+
base_url: baseUrl,
1028+
api_key: apiKey,
1029+
haiku_model: haikuModel,
1030+
sonnet_model: sonnetModel,
1031+
opus_model: opusModel,
1032+
}
1033+
1034+
const [geminiInputValue, setGeminiInputValue] = useState(
1035+
() => geminiDisplayValues[activeField],
1036+
)
1037+
const [geminiInputCursorOffset, setGeminiInputCursorOffset] = useState(
1038+
() => geminiDisplayValues[activeField].length,
1039+
)
1040+
1041+
const buildGeminiState = useCallback(
1042+
(field: GeminiField, value: string, newActive?: GeminiField) => {
1043+
const s = {
1044+
state: 'gemini_api' as const,
1045+
activeField: newActive ?? activeField,
1046+
baseUrl,
1047+
apiKey,
1048+
haikuModel,
1049+
sonnetModel,
1050+
opusModel,
1051+
}
1052+
switch (field) {
1053+
case 'base_url':
1054+
return { ...s, baseUrl: value }
1055+
case 'api_key':
1056+
return { ...s, apiKey: value }
1057+
case 'haiku_model':
1058+
return { ...s, haikuModel: value }
1059+
case 'sonnet_model':
1060+
return { ...s, sonnetModel: value }
1061+
case 'opus_model':
1062+
return { ...s, opusModel: value }
1063+
}
1064+
},
1065+
[activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel],
1066+
)
1067+
1068+
const doGeminiSave = useCallback(() => {
1069+
const finalVals = { ...geminiDisplayValues, [activeField]: geminiInputValue }
1070+
if (!finalVals.haiku_model || !finalVals.sonnet_model || !finalVals.opus_model) {
1071+
setOAuthStatus({
1072+
state: 'error',
1073+
message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.',
1074+
toRetry: {
1075+
state: 'gemini_api',
1076+
baseUrl: finalVals.base_url,
1077+
apiKey: finalVals.api_key,
1078+
haikuModel: finalVals.haiku_model,
1079+
sonnetModel: finalVals.sonnet_model,
1080+
opusModel: finalVals.opus_model,
1081+
activeField,
1082+
},
1083+
})
1084+
return
1085+
}
1086+
1087+
const env: Record<string, string> = {}
1088+
if (finalVals.base_url) env.GEMINI_BASE_URL = finalVals.base_url
1089+
if (finalVals.api_key) env.GEMINI_API_KEY = finalVals.api_key
1090+
if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model
1091+
if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model
1092+
if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model
1093+
const { error } = updateSettingsForSource('userSettings', {
1094+
modelType: 'gemini' as any,
1095+
env,
1096+
} as any)
1097+
if (error) {
1098+
setOAuthStatus({
1099+
state: 'error',
1100+
message: `Failed to save: ${error.message}`,
1101+
toRetry: {
1102+
state: 'gemini_api',
1103+
baseUrl: '',
1104+
apiKey: '',
1105+
haikuModel: '',
1106+
sonnetModel: '',
1107+
opusModel: '',
1108+
activeField: 'base_url',
1109+
},
1110+
})
1111+
} else {
1112+
for (const [k, v] of Object.entries(env)) process.env[k] = v
1113+
setOAuthStatus({ state: 'success' })
1114+
void onDone()
1115+
}
1116+
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus])
1117+
1118+
const handleGeminiEnter = useCallback(() => {
1119+
const idx = GEMINI_FIELDS.indexOf(activeField)
1120+
setOAuthStatus(buildGeminiState(activeField, geminiInputValue))
1121+
if (idx === GEMINI_FIELDS.length - 1) {
1122+
doGeminiSave()
1123+
} else {
1124+
const next = GEMINI_FIELDS[idx + 1]!
1125+
setGeminiInputValue(geminiDisplayValues[next] ?? '')
1126+
setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length)
1127+
}
1128+
}, [
1129+
activeField,
1130+
buildGeminiState,
1131+
doGeminiSave,
1132+
geminiDisplayValues,
1133+
geminiInputValue,
1134+
setOAuthStatus,
1135+
])
1136+
1137+
useKeybinding(
1138+
'tabs:next',
1139+
() => {
1140+
const idx = GEMINI_FIELDS.indexOf(activeField)
1141+
if (idx < GEMINI_FIELDS.length - 1) {
1142+
setOAuthStatus(
1143+
buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx + 1]),
1144+
)
1145+
setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '')
1146+
setGeminiInputCursorOffset(
1147+
(geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '').length,
1148+
)
1149+
}
1150+
},
1151+
{ context: 'Tabs' },
1152+
)
1153+
useKeybinding(
1154+
'tabs:previous',
1155+
() => {
1156+
const idx = GEMINI_FIELDS.indexOf(activeField)
1157+
if (idx > 0) {
1158+
setOAuthStatus(
1159+
buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx - 1]),
1160+
)
1161+
setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '')
1162+
setGeminiInputCursorOffset(
1163+
(geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '').length,
1164+
)
1165+
}
1166+
},
1167+
{ context: 'Tabs' },
1168+
)
1169+
useKeybinding(
1170+
'confirm:no',
1171+
() => {
1172+
setOAuthStatus({ state: 'idle' })
1173+
},
1174+
{ context: 'Confirmation' },
1175+
)
1176+
1177+
const geminiColumns = useTerminalSize().columns - 20
1178+
1179+
const renderGeminiRow = (
1180+
field: GeminiField,
1181+
label: string,
1182+
opts?: { mask?: boolean },
1183+
) => {
1184+
const active = activeField === field
1185+
const val = geminiDisplayValues[field]
1186+
return (
1187+
<Box>
1188+
<Text
1189+
backgroundColor={active ? 'suggestion' : undefined}
1190+
color={active ? 'inverseText' : undefined}
1191+
>
1192+
{` ${label} `}
1193+
</Text>
1194+
<Text> </Text>
1195+
{active ? (
1196+
<TextInput
1197+
value={geminiInputValue}
1198+
onChange={setGeminiInputValue}
1199+
onSubmit={handleGeminiEnter}
1200+
cursorOffset={geminiInputCursorOffset}
1201+
onChangeCursorOffset={setGeminiInputCursorOffset}
1202+
columns={geminiColumns}
1203+
mask={opts?.mask ? '*' : undefined}
1204+
focus={true}
1205+
/>
1206+
) : val ? (
1207+
<Text color="success">
1208+
{opts?.mask
1209+
? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8))
1210+
: val}
1211+
</Text>
1212+
) : null}
1213+
</Box>
1214+
)
1215+
}
1216+
1217+
return (
1218+
<Box flexDirection="column" gap={1}>
1219+
<Text bold>Gemini API Setup</Text>
1220+
<Text dimColor>
1221+
Configure a Gemini Generate Content compatible endpoint. Base URL is
1222+
optional and defaults to Google&apos;s v1beta API.
1223+
</Text>
1224+
<Box flexDirection="column" gap={1}>
1225+
{renderGeminiRow('base_url', 'Base URL ')}
1226+
{renderGeminiRow('api_key', 'API Key ', { mask: true })}
1227+
{renderGeminiRow('haiku_model', 'Haiku ')}
1228+
{renderGeminiRow('sonnet_model', 'Sonnet ')}
1229+
{renderGeminiRow('opus_model', 'Opus ')}
1230+
</Box>
1231+
<Text dimColor>
1232+
Tab to switch · Enter on last field to save · Esc to go back
1233+
</Text>
1234+
</Box>
1235+
)
1236+
}
1237+
9771238
case 'platform_setup':
9781239
return (
9791240
<Box flexDirection="column" gap={1} marginTop={1}>

src/services/api/claude.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -640,24 +640,51 @@ export function assistantMessageToMessageParam(
640640
} else {
641641
return {
642642
role: 'assistant',
643-
content: message.message.content.map((_, i) => ({
644-
..._,
645-
...(i === message.message.content.length - 1 &&
646-
_.type !== 'thinking' &&
647-
_.type !== 'redacted_thinking' &&
648-
(feature('CONNECTOR_TEXT') ? !isConnectorTextBlock(_) : true)
649-
? enablePromptCaching
650-
? { cache_control: getCacheControl({ querySource }) }
651-
: {}
652-
: {}),
653-
})),
643+
content: message.message.content.map((_, i) => {
644+
const contentBlock = stripGeminiProviderMetadata(_)
645+
return {
646+
...contentBlock,
647+
...(i === message.message.content.length - 1 &&
648+
contentBlock.type !== 'thinking' &&
649+
contentBlock.type !== 'redacted_thinking' &&
650+
(feature('CONNECTOR_TEXT')
651+
? !isConnectorTextBlock(contentBlock)
652+
: true)
653+
? enablePromptCaching
654+
? { cache_control: getCacheControl({ querySource }) }
655+
: {}
656+
: {}),
657+
}
658+
}),
654659
}
655660
}
656661
}
657662
return {
658663
role: 'assistant',
659-
content: message.message.content,
664+
content:
665+
typeof message.message.content === 'string'
666+
? message.message.content
667+
: message.message.content.map(stripGeminiProviderMetadata),
668+
}
669+
}
670+
671+
function stripGeminiProviderMetadata<T extends BetaContentBlockParam | string>(
672+
contentBlock: T,
673+
): T {
674+
if (
675+
typeof contentBlock === 'string' ||
676+
!('_geminiThoughtSignature' in contentBlock)
677+
) {
678+
return contentBlock
679+
}
680+
681+
const {
682+
_geminiThoughtSignature: _unusedGeminiThoughtSignature,
683+
...rest
684+
} = contentBlock as T & {
685+
_geminiThoughtSignature?: string
660686
}
687+
return rest as T
661688
}
662689

663690
export type Options = {
@@ -1310,6 +1337,19 @@ async function* queryModel(
13101337
return
13111338
}
13121339

1340+
if (getAPIProvider() === 'gemini') {
1341+
const { queryModelGemini } = await import('./gemini/index.js')
1342+
yield* queryModelGemini(
1343+
messagesForAPI,
1344+
systemPrompt,
1345+
filteredTools,
1346+
signal,
1347+
options,
1348+
thinkingConfig,
1349+
)
1350+
return
1351+
}
1352+
13131353
// Instrumentation: Track message count after normalization
13141354
logEvent('tengu_api_after_normalize', {
13151355
postNormalizedMessageCount: messagesForAPI.length,

0 commit comments

Comments
 (0)