Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 262 additions & 1 deletion src/components/ConsoleOAuthFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,7 +69,6 @@ type OAuthStatus =
}

const PASTE_HERE_MSG = 'Paste code here if prompted > '

export function ConsoleOAuthFlow({
onDone,
startingMessage,
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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',
})
Comment on lines +564 to +574
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve the valid GEMINI_MODEL config path.

The Gemini provider already accepts a single GEMINI_MODEL, but this form neither loads that env var nor saves it. An existing GEMINI_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
Verify each finding against the current code and only fix it if needed.

In `@src/components/ConsoleOAuthFlow.tsx` around lines 564 - 574, When
initializing the 'gemini_api' branch in setOAuthStatus, preserve an existing
single GEMINI_MODEL by using process.env.GEMINI_MODEL as a fallback for
haikuModel, sonnetModel and opusModel (i.e. set each of
haikuModel/sonnetModel/opusModel to their specific ANTHROPIC_DEFAULT_* env var
if present, otherwise to process.env.GEMINI_MODEL), so an existing
GEMINI_MODEL-only config is loaded and won't fail the new Haiku/Sonnet/Opus
validation; apply the same fallback logic to the other similar section mentioned
(the block around the later handling of gemini_api).

} else if (value === 'platform') {
logEvent('tengu_oauth_platform_selected', {})
setOAuthStatus({ state: 'platform_setup' })
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enter before the last field can desynchronize the form state.

The else branch swaps geminiInputValue to the next field's value without moving activeField. After that, the UI is still editing the old field while holding the next field's content, so the next Tab/save can persist the wrong value under baseUrl, apiKey, or one of the model fields.

🛠️ 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
Verify each finding against the current code and only fix it if needed.

In `@src/components/ConsoleOAuthFlow.tsx` around lines 1118 - 1127, The Enter
handler for Gemini fields moves the input value to the next field but never
updates activeField, causing the UI to remain focused on the old field while
holding the next field's content; fix handleGeminiEnter by, in the else branch
(where next is computed), also calling the setter to update the active field
(e.g., setActiveField(next)) so the form focus/state and geminiInputValue remain
synchronized (also keep existing updates to geminiInputCursorOffset and
setOAuthStatus/buildGeminiState as-is).

}, [
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}>
Expand Down
64 changes: 52 additions & 12 deletions src/services/api/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the last cacheable block, not just the last array element.

This still only applies cache_control when i === message.message.content.length - 1. If the tail block is thinking, redacted_thinking, or connector text, no block gets marked at all and prompt caching silently drops out for that assistant message.

}
}
}
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 = {
Expand Down Expand Up @@ -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,
Expand Down
Loading