mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-20 15:25:50 +00:00
feat: 添加gemini协议适配 (#125)
* feat: 添加gemini协议适配 * Remove unrelated local files from Gemini PR
This commit is contained in:
@@ -48,6 +48,15 @@ type OAuthStatus =
|
|||||||
opusModel: string
|
opusModel: string
|
||||||
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
|
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
|
||||||
} // OpenAI Chat Completions API platform
|
} // 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: 'ready_to_start' } // Flow started, waiting for browser to open
|
||||||
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
|
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
|
||||||
| { state: 'creating_api_key' } // Got access token, creating API key
|
| { 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 > '
|
const PASTE_HERE_MSG = 'Paste code here if prompted > '
|
||||||
|
|
||||||
export function ConsoleOAuthFlow({
|
export function ConsoleOAuthFlow({
|
||||||
onDone,
|
onDone,
|
||||||
startingMessage,
|
startingMessage,
|
||||||
@@ -476,6 +484,16 @@ function OAuthStatusMessage({
|
|||||||
),
|
),
|
||||||
value: 'openai_chat_api',
|
value: 'openai_chat_api',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Text>
|
||||||
|
Gemini API ·{' '}
|
||||||
|
<Text dimColor>Google Gemini native REST/SSE</Text>
|
||||||
|
{'\n'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
value: 'gemini_api',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<Text>
|
<Text>
|
||||||
@@ -543,6 +561,17 @@ function OAuthStatusMessage({
|
|||||||
opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '',
|
opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '',
|
||||||
activeField: 'base_url',
|
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') {
|
} else if (value === 'platform') {
|
||||||
logEvent('tengu_oauth_platform_selected', {})
|
logEvent('tengu_oauth_platform_selected', {})
|
||||||
setOAuthStatus({ state: 'platform_setup' })
|
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'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':
|
case 'platform_setup':
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||||
|
|||||||
@@ -640,26 +640,53 @@ export function assistantMessageToMessageParam(
|
|||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: message.message.content.map((_, i) => ({
|
content: message.message.content.map((_, i) => {
|
||||||
..._,
|
const contentBlock = stripGeminiProviderMetadata(_)
|
||||||
...(i === message.message.content.length - 1 &&
|
return {
|
||||||
_.type !== 'thinking' &&
|
...contentBlock,
|
||||||
_.type !== 'redacted_thinking' &&
|
...(i === message.message.content.length - 1 &&
|
||||||
(feature('CONNECTOR_TEXT') ? !isConnectorTextBlock(_) : true)
|
contentBlock.type !== 'thinking' &&
|
||||||
? enablePromptCaching
|
contentBlock.type !== 'redacted_thinking' &&
|
||||||
? { cache_control: getCacheControl({ querySource }) }
|
(feature('CONNECTOR_TEXT')
|
||||||
: {}
|
? !isConnectorTextBlock(contentBlock)
|
||||||
: {}),
|
: true)
|
||||||
})),
|
? enablePromptCaching
|
||||||
|
? { cache_control: getCacheControl({ querySource }) }
|
||||||
|
: {}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
role: 'assistant',
|
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 = {
|
export type Options = {
|
||||||
getToolPermissionContext: () => Promise<ToolPermissionContext>
|
getToolPermissionContext: () => Promise<ToolPermissionContext>
|
||||||
model: string
|
model: string
|
||||||
@@ -1310,6 +1337,19 @@ async function* queryModel(
|
|||||||
return
|
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
|
// Instrumentation: Track message count after normalization
|
||||||
logEvent('tengu_api_after_normalize', {
|
logEvent('tengu_api_after_normalize', {
|
||||||
postNormalizedMessageCount: messagesForAPI.length,
|
postNormalizedMessageCount: messagesForAPI.length,
|
||||||
|
|||||||
202
src/services/api/gemini/__tests__/convertMessages.test.ts
Normal file
202
src/services/api/gemini/__tests__/convertMessages.test.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import type {
|
||||||
|
AssistantMessage,
|
||||||
|
UserMessage,
|
||||||
|
} from '../../../../types/message.js'
|
||||||
|
import { anthropicMessagesToGemini } from '../convertMessages.js'
|
||||||
|
|
||||||
|
function makeUserMsg(content: string | any[]): UserMessage {
|
||||||
|
return {
|
||||||
|
type: 'user',
|
||||||
|
uuid: '00000000-0000-0000-0000-000000000000',
|
||||||
|
message: { role: 'user', content },
|
||||||
|
} as UserMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAssistantMsg(content: string | any[]): AssistantMessage {
|
||||||
|
return {
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: '00000000-0000-0000-0000-000000000001',
|
||||||
|
message: { role: 'assistant', content },
|
||||||
|
} as AssistantMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('anthropicMessagesToGemini', () => {
|
||||||
|
test('converts system prompt to systemInstruction', () => {
|
||||||
|
const result = anthropicMessagesToGemini(
|
||||||
|
[makeUserMsg('hello')],
|
||||||
|
['You are helpful.'] as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.systemInstruction).toEqual({
|
||||||
|
parts: [{ text: 'You are helpful.' }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('converts assistant tool_use to functionCall', () => {
|
||||||
|
const result = anthropicMessagesToGemini(
|
||||||
|
[
|
||||||
|
makeAssistantMsg([
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'toolu_123',
|
||||||
|
name: 'bash',
|
||||||
|
input: { command: 'ls' },
|
||||||
|
_geminiThoughtSignature: 'sig-tool',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[] as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
name: 'bash',
|
||||||
|
args: { command: 'ls' },
|
||||||
|
},
|
||||||
|
thoughtSignature: 'sig-tool',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('converts tool_result to functionResponse using prior tool name', () => {
|
||||||
|
const result = anthropicMessagesToGemini(
|
||||||
|
[
|
||||||
|
makeAssistantMsg([
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'toolu_123',
|
||||||
|
name: 'bash',
|
||||||
|
input: { command: 'ls' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
makeUserMsg([
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'toolu_123',
|
||||||
|
content: 'file.txt',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[] as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.contents[1]).toEqual({
|
||||||
|
role: 'user',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: 'bash',
|
||||||
|
response: {
|
||||||
|
result: 'file.txt',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('converts thinking blocks with signatures', () => {
|
||||||
|
const result = anthropicMessagesToGemini(
|
||||||
|
[
|
||||||
|
makeAssistantMsg([
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'internal reasoning',
|
||||||
|
signature: 'sig-thinking',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'visible answer',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[] as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.contents[0]).toEqual({
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
text: 'internal reasoning',
|
||||||
|
thought: true,
|
||||||
|
thoughtSignature: 'sig-thinking',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'visible answer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filters empty assistant text and signature-only thinking parts', () => {
|
||||||
|
const result = anthropicMessagesToGemini(
|
||||||
|
[
|
||||||
|
makeAssistantMsg([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '',
|
||||||
|
_geminiThoughtSignature: 'sig-empty-text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: '',
|
||||||
|
signature: 'sig-empty-thinking',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'toolu_123',
|
||||||
|
name: 'bash',
|
||||||
|
input: { command: 'pwd' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[] as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
name: 'bash',
|
||||||
|
args: { command: 'pwd' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filters empty user text blocks', () => {
|
||||||
|
const result = anthropicMessagesToGemini(
|
||||||
|
[
|
||||||
|
makeUserMsg([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'hello',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[] as any,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
parts: [{ text: 'hello' }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
130
src/services/api/gemini/__tests__/convertTools.test.ts
Normal file
130
src/services/api/gemini/__tests__/convertTools.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
anthropicToolChoiceToGemini,
|
||||||
|
anthropicToolsToGemini,
|
||||||
|
} from '../convertTools.js'
|
||||||
|
|
||||||
|
describe('anthropicToolsToGemini', () => {
|
||||||
|
test('converts basic tool to parametersJsonSchema', () => {
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
name: 'bash',
|
||||||
|
description: 'Run a bash command',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { command: { type: 'string' } },
|
||||||
|
required: ['command'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(anthropicToolsToGemini(tools as any)).toEqual([
|
||||||
|
{
|
||||||
|
functionDeclarations: [
|
||||||
|
{
|
||||||
|
name: 'bash',
|
||||||
|
description: 'Run a bash command',
|
||||||
|
parametersJsonSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { command: { type: 'string' } },
|
||||||
|
propertyOrdering: ['command'],
|
||||||
|
required: ['command'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sanitizes unsupported JSON Schema fields for Gemini', () => {
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
name: 'complex',
|
||||||
|
description: 'Complex schema',
|
||||||
|
input_schema: {
|
||||||
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
propertyNames: { pattern: '^[a-z]+$' },
|
||||||
|
properties: {
|
||||||
|
mode: { const: 'strict' },
|
||||||
|
retries: {
|
||||||
|
type: 'integer',
|
||||||
|
exclusiveMinimum: 0,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: {
|
||||||
|
type: 'string',
|
||||||
|
propertyNames: { pattern: '^[a-z]+$' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['mode'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(anthropicToolsToGemini(tools as any)).toEqual([
|
||||||
|
{
|
||||||
|
functionDeclarations: [
|
||||||
|
{
|
||||||
|
name: 'complex',
|
||||||
|
description: 'Complex schema',
|
||||||
|
parametersJsonSchema: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
mode: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['strict'],
|
||||||
|
},
|
||||||
|
retries: {
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 0,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
propertyOrdering: ['mode', 'retries', 'metadata'],
|
||||||
|
required: ['mode'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns empty array when no tools are provided', () => {
|
||||||
|
expect(anthropicToolsToGemini([])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('anthropicToolChoiceToGemini', () => {
|
||||||
|
test('maps auto', () => {
|
||||||
|
expect(anthropicToolChoiceToGemini({ type: 'auto' })).toEqual({
|
||||||
|
mode: 'AUTO',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps any', () => {
|
||||||
|
expect(anthropicToolChoiceToGemini({ type: 'any' })).toEqual({
|
||||||
|
mode: 'ANY',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps explicit tool choice', () => {
|
||||||
|
expect(
|
||||||
|
anthropicToolChoiceToGemini({ type: 'tool', name: 'bash' }),
|
||||||
|
).toEqual({
|
||||||
|
mode: 'ANY',
|
||||||
|
allowedFunctionNames: ['bash'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
72
src/services/api/gemini/__tests__/modelMapping.test.ts
Normal file
72
src/services/api/gemini/__tests__/modelMapping.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { resolveGeminiModel } from '../modelMapping.js'
|
||||||
|
|
||||||
|
describe('resolveGeminiModel', () => {
|
||||||
|
const originalEnv = {
|
||||||
|
GEMINI_MODEL: process.env.GEMINI_MODEL,
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.GEMINI_MODEL
|
||||||
|
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||||
|
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||||
|
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.assign(process.env, originalEnv)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEMINI_MODEL env var overrides family mappings', () => {
|
||||||
|
process.env.GEMINI_MODEL = 'gemini-2.5-pro'
|
||||||
|
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||||
|
|
||||||
|
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-pro')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves sonnet model from shared family override', () => {
|
||||||
|
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||||
|
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-flash')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves haiku model from shared family override', () => {
|
||||||
|
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'gemini-2.5-flash-lite'
|
||||||
|
expect(resolveGeminiModel('claude-haiku-4-5-20251001')).toBe(
|
||||||
|
'gemini-2.5-flash-lite',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resolves opus model from shared family override', () => {
|
||||||
|
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'gemini-2.5-pro'
|
||||||
|
expect(resolveGeminiModel('claude-opus-4-6')).toBe('gemini-2.5-pro')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses shared family override', () => {
|
||||||
|
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'legacy-gemini-sonnet'
|
||||||
|
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe(
|
||||||
|
'legacy-gemini-sonnet',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('strips [1m] suffix before resolving', () => {
|
||||||
|
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||||
|
expect(resolveGeminiModel('claude-sonnet-4-6[1m]')).toBe(
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes through explicit Gemini model names', () => {
|
||||||
|
expect(resolveGeminiModel('gemini-3.1-flash-lite-preview')).toBe(
|
||||||
|
'gemini-3.1-flash-lite-preview',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('throws when family mapping is missing', () => {
|
||||||
|
expect(() => resolveGeminiModel('claude-sonnet-4-6')).toThrow(
|
||||||
|
'Gemini provider requires GEMINI_MODEL or ANTHROPIC_DEFAULT_SONNET_MODEL to be configured.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
175
src/services/api/gemini/__tests__/streamAdapter.test.ts
Normal file
175
src/services/api/gemini/__tests__/streamAdapter.test.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { adaptGeminiStreamToAnthropic } from '../streamAdapter.js'
|
||||||
|
import type { GeminiStreamChunk } from '../types.js'
|
||||||
|
|
||||||
|
function mockStream(
|
||||||
|
chunks: GeminiStreamChunk[],
|
||||||
|
): AsyncIterable<GeminiStreamChunk> {
|
||||||
|
return {
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
let index = 0
|
||||||
|
return {
|
||||||
|
async next() {
|
||||||
|
if (index >= chunks.length) {
|
||||||
|
return { done: true, value: undefined }
|
||||||
|
}
|
||||||
|
return { done: false, value: chunks[index++] }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectEvents(chunks: GeminiStreamChunk[]) {
|
||||||
|
const events: any[] = []
|
||||||
|
for await (const event of adaptGeminiStreamToAnthropic(
|
||||||
|
mockStream(chunks),
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
)) {
|
||||||
|
events.push(event)
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('adaptGeminiStreamToAnthropic', () => {
|
||||||
|
test('converts text chunks', async () => {
|
||||||
|
const events = await collectEvents([
|
||||||
|
{
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'Hello' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: ' world' }],
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const textDeltas = events.filter(
|
||||||
|
event =>
|
||||||
|
event.type === 'content_block_delta' && event.delta.type === 'text_delta',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(events[0].type).toBe('message_start')
|
||||||
|
expect(textDeltas).toHaveLength(2)
|
||||||
|
expect(textDeltas[0].delta.text).toBe('Hello')
|
||||||
|
expect(textDeltas[1].delta.text).toBe(' world')
|
||||||
|
|
||||||
|
const messageDelta = events.find(event => event.type === 'message_delta')
|
||||||
|
expect(messageDelta.delta.stop_reason).toBe('end_turn')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('converts thinking chunks and signatures', async () => {
|
||||||
|
const events = await collectEvents([
|
||||||
|
{
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'Think', thought: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ thought: true, thoughtSignature: 'sig-123' }],
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const blockStart = events.find(event => event.type === 'content_block_start')
|
||||||
|
expect(blockStart.content_block.type).toBe('thinking')
|
||||||
|
|
||||||
|
const signatureDelta = events.find(
|
||||||
|
event =>
|
||||||
|
event.type === 'content_block_delta' &&
|
||||||
|
event.delta.type === 'signature_delta',
|
||||||
|
)
|
||||||
|
expect(signatureDelta.delta.signature).toBe('sig-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('converts function calls to tool_use blocks', async () => {
|
||||||
|
const events = await collectEvents([
|
||||||
|
{
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: {
|
||||||
|
name: 'bash',
|
||||||
|
args: { command: 'ls' },
|
||||||
|
},
|
||||||
|
thoughtSignature: 'sig-tool',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const blockStart = events.find(event => event.type === 'content_block_start')
|
||||||
|
expect(blockStart.content_block.type).toBe('tool_use')
|
||||||
|
expect(blockStart.content_block.name).toBe('bash')
|
||||||
|
|
||||||
|
const signatureDelta = events.find(
|
||||||
|
event =>
|
||||||
|
event.type === 'content_block_delta' &&
|
||||||
|
event.delta.type === 'signature_delta',
|
||||||
|
)
|
||||||
|
expect(signatureDelta.delta.signature).toBe('sig-tool')
|
||||||
|
|
||||||
|
const inputDelta = events.find(
|
||||||
|
event =>
|
||||||
|
event.type === 'content_block_delta' &&
|
||||||
|
event.delta.type === 'input_json_delta',
|
||||||
|
)
|
||||||
|
expect(inputDelta.delta.partial_json).toBe('{"command":"ls"}')
|
||||||
|
|
||||||
|
const messageDelta = events.find(event => event.type === 'message_delta')
|
||||||
|
expect(messageDelta.delta.stop_reason).toBe('tool_use')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps usage metadata into output tokens', async () => {
|
||||||
|
const events = await collectEvents([
|
||||||
|
{
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: 'Hello' }],
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 10,
|
||||||
|
candidatesTokenCount: 5,
|
||||||
|
thoughtsTokenCount: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const messageStart = events.find(event => event.type === 'message_start')
|
||||||
|
expect(messageStart.message.usage.input_tokens).toBe(10)
|
||||||
|
|
||||||
|
const messageDelta = events.find(event => event.type === 'message_delta')
|
||||||
|
expect(messageDelta.usage.output_tokens).toBe(7)
|
||||||
|
})
|
||||||
|
})
|
||||||
97
src/services/api/gemini/client.ts
Normal file
97
src/services/api/gemini/client.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { parseSSEFrames } from 'src/cli/transports/SSETransport.js'
|
||||||
|
import { errorMessage } from 'src/utils/errors.js'
|
||||||
|
import { getProxyFetchOptions } from 'src/utils/proxy.js'
|
||||||
|
import type {
|
||||||
|
GeminiGenerateContentRequest,
|
||||||
|
GeminiStreamChunk,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
const DEFAULT_GEMINI_BASE_URL =
|
||||||
|
'https://generativelanguage.googleapis.com/v1beta'
|
||||||
|
|
||||||
|
const STREAM_DECODE_OPTS: TextDecodeOptions = { stream: true }
|
||||||
|
|
||||||
|
function getGeminiBaseUrl(): string {
|
||||||
|
return (process.env.GEMINI_BASE_URL || DEFAULT_GEMINI_BASE_URL).replace(
|
||||||
|
/\/+$/,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGeminiModelPath(model: string): string {
|
||||||
|
const normalized = model.replace(/^\/+/, '')
|
||||||
|
return normalized.startsWith('models/') ? normalized : `models/${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamGeminiGenerateContent(params: {
|
||||||
|
model: string
|
||||||
|
body: GeminiGenerateContentRequest
|
||||||
|
signal: AbortSignal
|
||||||
|
fetchOverride?: typeof fetch
|
||||||
|
}): AsyncGenerator<GeminiStreamChunk, void> {
|
||||||
|
const fetchImpl = params.fetchOverride ?? fetch
|
||||||
|
const url = `${getGeminiBaseUrl()}/${getGeminiModelPath(params.model)}:streamGenerateContent?alt=sse`
|
||||||
|
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-goog-api-key': process.env.GEMINI_API_KEY || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(params.body),
|
||||||
|
signal: params.signal,
|
||||||
|
...getProxyFetchOptions({ forAnthropicAPI: false }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text()
|
||||||
|
throw new Error(
|
||||||
|
`Gemini API request failed (${response.status} ${response.statusText}): ${body || 'empty response body'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Gemini API returned no response body')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, STREAM_DECODE_OPTS)
|
||||||
|
const { frames, remaining } = parseSSEFrames(buffer)
|
||||||
|
buffer = remaining
|
||||||
|
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (!frame.data || frame.data === '[DONE]') continue
|
||||||
|
try {
|
||||||
|
yield JSON.parse(frame.data) as GeminiStreamChunk
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse Gemini SSE payload: ${errorMessage(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode()
|
||||||
|
const { frames } = parseSSEFrames(buffer)
|
||||||
|
for (const frame of frames) {
|
||||||
|
if (!frame.data || frame.data === '[DONE]') continue
|
||||||
|
try {
|
||||||
|
yield JSON.parse(frame.data) as GeminiStreamChunk
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse trailing Gemini SSE payload: ${errorMessage(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
278
src/services/api/gemini/convertMessages.ts
Normal file
278
src/services/api/gemini/convertMessages.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import type {
|
||||||
|
BetaToolResultBlockParam,
|
||||||
|
BetaToolUseBlock,
|
||||||
|
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
|
||||||
|
import { safeParseJSON } from '../../../utils/json.js'
|
||||||
|
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
||||||
|
import {
|
||||||
|
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||||
|
type GeminiContent,
|
||||||
|
type GeminiGenerateContentRequest,
|
||||||
|
type GeminiPart,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
export function anthropicMessagesToGemini(
|
||||||
|
messages: (UserMessage | AssistantMessage)[],
|
||||||
|
systemPrompt: SystemPrompt,
|
||||||
|
): Pick<GeminiGenerateContentRequest, 'contents' | 'systemInstruction'> {
|
||||||
|
const contents: GeminiContent[] = []
|
||||||
|
const toolNamesById = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.type === 'assistant') {
|
||||||
|
const content = convertInternalAssistantMessage(msg)
|
||||||
|
if (content.parts.length > 0) {
|
||||||
|
contents.push(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantContent = msg.message.content
|
||||||
|
if (Array.isArray(assistantContent)) {
|
||||||
|
for (const block of assistantContent) {
|
||||||
|
if (typeof block !== 'string' && block.type === 'tool_use') {
|
||||||
|
toolNamesById.set(block.id, block.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'user') {
|
||||||
|
const content = convertInternalUserMessage(msg, toolNamesById)
|
||||||
|
if (content.parts.length > 0) {
|
||||||
|
contents.push(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemText = systemPromptToText(systemPrompt)
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents,
|
||||||
|
...(systemText
|
||||||
|
? {
|
||||||
|
systemInstruction: {
|
||||||
|
parts: [{ text: systemText }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||||
|
if (!systemPrompt || systemPrompt.length === 0) return ''
|
||||||
|
return systemPrompt.filter(Boolean).join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertInternalUserMessage(
|
||||||
|
msg: UserMessage,
|
||||||
|
toolNamesById: ReadonlyMap<string, string>,
|
||||||
|
): GeminiContent {
|
||||||
|
const content = msg.message.content
|
||||||
|
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return {
|
||||||
|
role: 'user',
|
||||||
|
parts: createTextGeminiParts(content),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return { role: 'user', parts: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: 'user',
|
||||||
|
parts: content.flatMap(block =>
|
||||||
|
convertUserContentBlockToGeminiParts(block, toolNamesById),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertUserContentBlockToGeminiParts(
|
||||||
|
block: string | Record<string, unknown>,
|
||||||
|
toolNamesById: ReadonlyMap<string, string>,
|
||||||
|
): GeminiPart[] {
|
||||||
|
if (typeof block === 'string') {
|
||||||
|
return createTextGeminiParts(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'text') {
|
||||||
|
return createTextGeminiParts(block.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'tool_result') {
|
||||||
|
const toolResult = block as unknown as BetaToolResultBlockParam
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: toolNamesById.get(toolResult.tool_use_id) ?? toolResult.tool_use_id,
|
||||||
|
response: toolResultToResponseObject(toolResult),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertInternalAssistantMessage(msg: AssistantMessage): GeminiContent {
|
||||||
|
const content = msg.message.content
|
||||||
|
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return {
|
||||||
|
role: 'model',
|
||||||
|
parts: createTextGeminiParts(content),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return { role: 'model', parts: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: GeminiPart[] = []
|
||||||
|
for (const block of content) {
|
||||||
|
if (typeof block === 'string') {
|
||||||
|
parts.push(...createTextGeminiParts(block))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'text') {
|
||||||
|
parts.push(
|
||||||
|
...createTextGeminiParts(
|
||||||
|
block.text,
|
||||||
|
getGeminiThoughtSignature(block),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'thinking') {
|
||||||
|
const thinkingPart = createThinkingGeminiPart(
|
||||||
|
block.thinking,
|
||||||
|
block.signature,
|
||||||
|
)
|
||||||
|
if (thinkingPart) {
|
||||||
|
parts.push(thinkingPart)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === 'tool_use') {
|
||||||
|
const toolUse = block as unknown as BetaToolUseBlock
|
||||||
|
parts.push({
|
||||||
|
functionCall: {
|
||||||
|
name: toolUse.name,
|
||||||
|
args: normalizeToolUseInput(toolUse.input),
|
||||||
|
},
|
||||||
|
...(getGeminiThoughtSignature(block) && {
|
||||||
|
thoughtSignature: getGeminiThoughtSignature(block),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { role: 'model', parts }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTextGeminiParts(
|
||||||
|
value: unknown,
|
||||||
|
thoughtSignature?: string,
|
||||||
|
): GeminiPart[] {
|
||||||
|
if (typeof value !== 'string' || value.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: value,
|
||||||
|
...(thoughtSignature && { thoughtSignature }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThinkingGeminiPart(
|
||||||
|
value: unknown,
|
||||||
|
thoughtSignature?: string,
|
||||||
|
): GeminiPart | undefined {
|
||||||
|
if (typeof value !== 'string' || value.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: value,
|
||||||
|
thought: true,
|
||||||
|
...(thoughtSignature && { thoughtSignature }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolUseInput(input: unknown): Record<string, unknown> {
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const parsed = safeParseJSON(input)
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>
|
||||||
|
}
|
||||||
|
return parsed === null ? {} : { value: parsed }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||||
|
return input as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
return input === undefined ? {} : { value: input }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResultToResponseObject(
|
||||||
|
block: BetaToolResultBlockParam,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result = normalizeToolResultContent(block.content)
|
||||||
|
if (
|
||||||
|
result &&
|
||||||
|
typeof result === 'object' &&
|
||||||
|
!Array.isArray(result)
|
||||||
|
) {
|
||||||
|
return block.is_error ? { ...result, is_error: true } : result
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
...(block.is_error ? { is_error: true } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolResultContent(content: unknown): unknown {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
const parsed = safeParseJSON(content)
|
||||||
|
return parsed ?? content
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const text = content
|
||||||
|
.map(part => {
|
||||||
|
if (typeof part === 'string') return part
|
||||||
|
if (
|
||||||
|
part &&
|
||||||
|
typeof part === 'object' &&
|
||||||
|
'text' in part &&
|
||||||
|
typeof part.text === 'string'
|
||||||
|
) {
|
||||||
|
return part.text
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const parsed = safeParseJSON(text)
|
||||||
|
return parsed ?? text
|
||||||
|
}
|
||||||
|
|
||||||
|
return content ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGeminiThoughtSignature(block: Record<string, unknown>): string | undefined {
|
||||||
|
const signature = block[GEMINI_THOUGHT_SIGNATURE_FIELD]
|
||||||
|
return typeof signature === 'string' && signature.length > 0
|
||||||
|
? signature
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
284
src/services/api/gemini/convertTools.ts
Normal file
284
src/services/api/gemini/convertTools.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import type {
|
||||||
|
GeminiFunctionCallingConfig,
|
||||||
|
GeminiTool,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
const GEMINI_JSON_SCHEMA_TYPES = new Set([
|
||||||
|
'string',
|
||||||
|
'number',
|
||||||
|
'integer',
|
||||||
|
'boolean',
|
||||||
|
'object',
|
||||||
|
'array',
|
||||||
|
'null',
|
||||||
|
])
|
||||||
|
|
||||||
|
function normalizeGeminiJsonSchemaType(
|
||||||
|
value: unknown,
|
||||||
|
): string | string[] | undefined {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return GEMINI_JSON_SCHEMA_TYPES.has(value) ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const normalized = value.filter(
|
||||||
|
(item): item is string =>
|
||||||
|
typeof item === 'string' && GEMINI_JSON_SCHEMA_TYPES.has(item),
|
||||||
|
)
|
||||||
|
const unique = Array.from(new Set(normalized))
|
||||||
|
if (unique.length === 0) return undefined
|
||||||
|
return unique.length === 1 ? unique[0] : unique
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferGeminiJsonSchemaTypeFromValue(value: unknown): string | undefined {
|
||||||
|
if (value === null) return 'null'
|
||||||
|
if (Array.isArray(value)) return 'array'
|
||||||
|
if (typeof value === 'string') return 'string'
|
||||||
|
if (typeof value === 'boolean') return 'boolean'
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isInteger(value) ? 'integer' : 'number'
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') return 'object'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferGeminiJsonSchemaTypeFromEnum(
|
||||||
|
values: unknown[],
|
||||||
|
): string | string[] | undefined {
|
||||||
|
const inferred = values
|
||||||
|
.map(inferGeminiJsonSchemaTypeFromValue)
|
||||||
|
.filter((value): value is string => value !== undefined)
|
||||||
|
const unique = Array.from(new Set(inferred))
|
||||||
|
if (unique.length === 0) return undefined
|
||||||
|
return unique.length === 1 ? unique[0] : unique
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNullToGeminiJsonSchemaType(
|
||||||
|
value: string | string[] | undefined,
|
||||||
|
): string | string[] | undefined {
|
||||||
|
if (value === undefined) return ['null']
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.includes('null') ? value : [...value, 'null']
|
||||||
|
}
|
||||||
|
return value === 'null' ? value : [value, 'null']
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeGeminiJsonSchemaProperties(
|
||||||
|
value: unknown,
|
||||||
|
): Record<string, Record<string, unknown>> | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedEntries = Object.entries(value as Record<string, unknown>)
|
||||||
|
.map(([key, schema]) => [key, sanitizeGeminiJsonSchema(schema)] as const)
|
||||||
|
.filter(([, schema]) => Object.keys(schema).length > 0)
|
||||||
|
|
||||||
|
if (sanitizedEntries.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(sanitizedEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeGeminiJsonSchemaArray(
|
||||||
|
value: unknown,
|
||||||
|
): Record<string, unknown>[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined
|
||||||
|
|
||||||
|
const sanitized = value
|
||||||
|
.map(item => sanitizeGeminiJsonSchema(item))
|
||||||
|
.filter(item => Object.keys(item).length > 0)
|
||||||
|
|
||||||
|
return sanitized.length > 0 ? sanitized : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeGeminiJsonSchema(
|
||||||
|
schema: unknown,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = schema as Record<string, unknown>
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
let type = normalizeGeminiJsonSchemaType(source.type)
|
||||||
|
|
||||||
|
if (source.const !== undefined) {
|
||||||
|
result.enum = [source.const]
|
||||||
|
type = type ?? inferGeminiJsonSchemaTypeFromValue(source.const)
|
||||||
|
} else if (Array.isArray(source.enum) && source.enum.length > 0) {
|
||||||
|
result.enum = source.enum
|
||||||
|
type = type ?? inferGeminiJsonSchemaTypeFromEnum(source.enum)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
if (source.properties && typeof source.properties === 'object') {
|
||||||
|
type = 'object'
|
||||||
|
} else if (source.items !== undefined || source.prefixItems !== undefined) {
|
||||||
|
type = 'array'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.nullable === true) {
|
||||||
|
type = addNullToGeminiJsonSchemaType(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
result.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof source.title === 'string') {
|
||||||
|
result.title = source.title
|
||||||
|
}
|
||||||
|
if (typeof source.description === 'string') {
|
||||||
|
result.description = source.description
|
||||||
|
}
|
||||||
|
if (typeof source.format === 'string') {
|
||||||
|
result.format = source.format
|
||||||
|
}
|
||||||
|
if (typeof source.pattern === 'string') {
|
||||||
|
result.pattern = source.pattern
|
||||||
|
}
|
||||||
|
if (typeof source.minimum === 'number') {
|
||||||
|
result.minimum = source.minimum
|
||||||
|
} else if (typeof source.exclusiveMinimum === 'number') {
|
||||||
|
result.minimum = source.exclusiveMinimum
|
||||||
|
}
|
||||||
|
if (typeof source.maximum === 'number') {
|
||||||
|
result.maximum = source.maximum
|
||||||
|
} else if (typeof source.exclusiveMaximum === 'number') {
|
||||||
|
result.maximum = source.exclusiveMaximum
|
||||||
|
}
|
||||||
|
if (typeof source.minItems === 'number') {
|
||||||
|
result.minItems = source.minItems
|
||||||
|
}
|
||||||
|
if (typeof source.maxItems === 'number') {
|
||||||
|
result.maxItems = source.maxItems
|
||||||
|
}
|
||||||
|
if (typeof source.minLength === 'number') {
|
||||||
|
result.minLength = source.minLength
|
||||||
|
}
|
||||||
|
if (typeof source.maxLength === 'number') {
|
||||||
|
result.maxLength = source.maxLength
|
||||||
|
}
|
||||||
|
if (typeof source.minProperties === 'number') {
|
||||||
|
result.minProperties = source.minProperties
|
||||||
|
}
|
||||||
|
if (typeof source.maxProperties === 'number') {
|
||||||
|
result.maxProperties = source.maxProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = sanitizeGeminiJsonSchemaProperties(source.properties)
|
||||||
|
if (properties) {
|
||||||
|
result.properties = properties
|
||||||
|
result.propertyOrdering = Object.keys(properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(source.required)) {
|
||||||
|
const required = source.required.filter(
|
||||||
|
(item): item is string => typeof item === 'string',
|
||||||
|
)
|
||||||
|
if (required.length > 0) {
|
||||||
|
result.required = required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof source.additionalProperties === 'boolean') {
|
||||||
|
result.additionalProperties = source.additionalProperties
|
||||||
|
} else {
|
||||||
|
const additionalProperties = sanitizeGeminiJsonSchema(
|
||||||
|
source.additionalProperties,
|
||||||
|
)
|
||||||
|
if (Object.keys(additionalProperties).length > 0) {
|
||||||
|
result.additionalProperties = additionalProperties
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = sanitizeGeminiJsonSchema(source.items)
|
||||||
|
if (Object.keys(items).length > 0) {
|
||||||
|
result.items = items
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixItems = sanitizeGeminiJsonSchemaArray(source.prefixItems)
|
||||||
|
if (prefixItems) {
|
||||||
|
result.prefixItems = prefixItems
|
||||||
|
}
|
||||||
|
|
||||||
|
const anyOf = sanitizeGeminiJsonSchemaArray(source.anyOf ?? source.oneOf)
|
||||||
|
if (anyOf) {
|
||||||
|
result.anyOf = anyOf
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeGeminiFunctionParameters(
|
||||||
|
schema: unknown,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const sanitized = sanitizeGeminiJsonSchema(schema)
|
||||||
|
if (Object.keys(sanitized).length > 0) {
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function anthropicToolsToGemini(tools: BetaToolUnion[]): GeminiTool[] {
|
||||||
|
const functionDeclarations = tools
|
||||||
|
.filter(tool => {
|
||||||
|
return tool.type === 'custom' || !('type' in tool) || tool.type !== 'server'
|
||||||
|
})
|
||||||
|
.map(tool => {
|
||||||
|
const anyTool = tool as Record<string, unknown>
|
||||||
|
const name = (anyTool.name as string) || ''
|
||||||
|
const description = (anyTool.description as string) || ''
|
||||||
|
const inputSchema =
|
||||||
|
(anyTool.input_schema as Record<string, unknown> | undefined) ?? {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parametersJsonSchema: sanitizeGeminiFunctionParameters(inputSchema),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return functionDeclarations.length > 0
|
||||||
|
? [{ functionDeclarations }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function anthropicToolChoiceToGemini(
|
||||||
|
toolChoice: unknown,
|
||||||
|
): GeminiFunctionCallingConfig | undefined {
|
||||||
|
if (!toolChoice || typeof toolChoice !== 'object') return undefined
|
||||||
|
|
||||||
|
const tc = toolChoice as Record<string, unknown>
|
||||||
|
const type = tc.type as string
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'auto':
|
||||||
|
return { mode: 'AUTO' }
|
||||||
|
case 'any':
|
||||||
|
return { mode: 'ANY' }
|
||||||
|
case 'tool':
|
||||||
|
return {
|
||||||
|
mode: 'ANY',
|
||||||
|
allowedFunctionNames:
|
||||||
|
typeof tc.name === 'string' ? [tc.name] : undefined,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/services/api/gemini/index.ts
Normal file
192
src/services/api/gemini/index.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type {
|
||||||
|
AssistantMessage,
|
||||||
|
Message,
|
||||||
|
StreamEvent,
|
||||||
|
SystemAPIErrorMessage,
|
||||||
|
} from '../../../types/message.js'
|
||||||
|
import { getEmptyToolPermissionContext, type Tools } from '../../../Tool.js'
|
||||||
|
import { toolToAPISchema } from '../../../utils/api.js'
|
||||||
|
import { logForDebugging } from '../../../utils/debug.js'
|
||||||
|
import {
|
||||||
|
createAssistantAPIErrorMessage,
|
||||||
|
normalizeContentFromAPI,
|
||||||
|
normalizeMessagesForAPI,
|
||||||
|
} from '../../../utils/messages.js'
|
||||||
|
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
||||||
|
import type { ThinkingConfig } from '../../../utils/thinking.js'
|
||||||
|
import type { Options } from '../claude.js'
|
||||||
|
import { streamGeminiGenerateContent } from './client.js'
|
||||||
|
import { anthropicMessagesToGemini } from './convertMessages.js'
|
||||||
|
import {
|
||||||
|
anthropicToolChoiceToGemini,
|
||||||
|
anthropicToolsToGemini,
|
||||||
|
} from './convertTools.js'
|
||||||
|
import { resolveGeminiModel } from './modelMapping.js'
|
||||||
|
import { adaptGeminiStreamToAnthropic } from './streamAdapter.js'
|
||||||
|
import { GEMINI_THOUGHT_SIGNATURE_FIELD } from './types.js'
|
||||||
|
|
||||||
|
export async function* queryModelGemini(
|
||||||
|
messages: Message[],
|
||||||
|
systemPrompt: SystemPrompt,
|
||||||
|
tools: Tools,
|
||||||
|
signal: AbortSignal,
|
||||||
|
options: Options,
|
||||||
|
thinkingConfig: ThinkingConfig,
|
||||||
|
): AsyncGenerator<
|
||||||
|
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
|
||||||
|
void
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const geminiModel = resolveGeminiModel(options.model)
|
||||||
|
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
|
||||||
|
|
||||||
|
const toolSchemas = await Promise.all(
|
||||||
|
tools.map(tool =>
|
||||||
|
toolToAPISchema(tool, {
|
||||||
|
getToolPermissionContext: options.getToolPermissionContext,
|
||||||
|
tools,
|
||||||
|
agents: options.agents,
|
||||||
|
allowedAgentTypes: options.allowedAgentTypes,
|
||||||
|
model: options.model,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const standardTools = toolSchemas.filter(
|
||||||
|
(t): t is BetaToolUnion & { type: string } => {
|
||||||
|
const anyTool = t as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
anyTool.type !== 'advisor_20260301' &&
|
||||||
|
anyTool.type !== 'computer_20250124'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { contents, systemInstruction } = anthropicMessagesToGemini(
|
||||||
|
messagesForAPI,
|
||||||
|
systemPrompt,
|
||||||
|
)
|
||||||
|
const geminiTools = anthropicToolsToGemini(standardTools)
|
||||||
|
const toolChoice = anthropicToolChoiceToGemini(options.toolChoice)
|
||||||
|
|
||||||
|
const stream = streamGeminiGenerateContent({
|
||||||
|
model: geminiModel,
|
||||||
|
signal,
|
||||||
|
fetchOverride: options.fetchOverride as typeof fetch | undefined,
|
||||||
|
body: {
|
||||||
|
contents,
|
||||||
|
...(systemInstruction && { systemInstruction }),
|
||||||
|
...(geminiTools.length > 0 && { tools: geminiTools }),
|
||||||
|
...(toolChoice && {
|
||||||
|
toolConfig: {
|
||||||
|
functionCallingConfig: toolChoice,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
generationConfig: {
|
||||||
|
...(options.temperatureOverride !== undefined && {
|
||||||
|
temperature: options.temperatureOverride,
|
||||||
|
}),
|
||||||
|
...(thinkingConfig.type !== 'disabled' && {
|
||||||
|
thinkingConfig: {
|
||||||
|
includeThoughts: true,
|
||||||
|
...(thinkingConfig.type === 'enabled' && {
|
||||||
|
thinkingBudget: thinkingConfig.budgetTokens,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
logForDebugging(
|
||||||
|
`[Gemini] Calling model=${geminiModel}, messages=${contents.length}, tools=${geminiTools.length}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const adaptedStream = adaptGeminiStreamToAnthropic(stream, geminiModel)
|
||||||
|
const contentBlocks: Record<number, any> = {}
|
||||||
|
let partialMessage: any = undefined
|
||||||
|
let ttftMs = 0
|
||||||
|
const start = Date.now()
|
||||||
|
|
||||||
|
for await (const event of adaptedStream) {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'message_start':
|
||||||
|
partialMessage = (event as any).message
|
||||||
|
ttftMs = Date.now() - start
|
||||||
|
break
|
||||||
|
case 'content_block_start': {
|
||||||
|
const idx = (event as any).index
|
||||||
|
const cb = (event as any).content_block
|
||||||
|
if (cb.type === 'tool_use') {
|
||||||
|
contentBlocks[idx] = { ...cb, input: '' }
|
||||||
|
} else if (cb.type === 'text') {
|
||||||
|
contentBlocks[idx] = { ...cb, text: '' }
|
||||||
|
} else if (cb.type === 'thinking') {
|
||||||
|
contentBlocks[idx] = { ...cb, thinking: '', signature: '' }
|
||||||
|
} else {
|
||||||
|
contentBlocks[idx] = { ...cb }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'content_block_delta': {
|
||||||
|
const idx = (event as any).index
|
||||||
|
const delta = (event as any).delta
|
||||||
|
const block = contentBlocks[idx]
|
||||||
|
if (!block) break
|
||||||
|
|
||||||
|
if (delta.type === 'text_delta') {
|
||||||
|
block.text = (block.text || '') + delta.text
|
||||||
|
} else if (delta.type === 'input_json_delta') {
|
||||||
|
block.input = (block.input || '') + delta.partial_json
|
||||||
|
} else if (delta.type === 'thinking_delta') {
|
||||||
|
block.thinking = (block.thinking || '') + delta.thinking
|
||||||
|
} else if (delta.type === 'signature_delta') {
|
||||||
|
if (block.type === 'thinking') {
|
||||||
|
block.signature = delta.signature
|
||||||
|
} else {
|
||||||
|
block[GEMINI_THOUGHT_SIGNATURE_FIELD] = delta.signature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'content_block_stop': {
|
||||||
|
const idx = (event as any).index
|
||||||
|
const block = contentBlocks[idx]
|
||||||
|
if (!block || !partialMessage) break
|
||||||
|
|
||||||
|
const message: AssistantMessage = {
|
||||||
|
message: {
|
||||||
|
...partialMessage,
|
||||||
|
content: normalizeContentFromAPI([block], tools, options.agentId),
|
||||||
|
},
|
||||||
|
requestId: undefined,
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: randomUUID(),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
yield message
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'message_delta':
|
||||||
|
case 'message_stop':
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'stream_event',
|
||||||
|
event,
|
||||||
|
...(event.type === 'message_start' ? { ttftMs } : undefined),
|
||||||
|
} as StreamEvent
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
logForDebugging(`[Gemini] Error: ${errorMessage}`, { level: 'error' })
|
||||||
|
yield createAssistantAPIErrorMessage({
|
||||||
|
content: `API Error: ${errorMessage}`,
|
||||||
|
apiError: 'api_error',
|
||||||
|
error: error instanceof Error ? error : new Error(String(error)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/services/api/gemini/modelMapping.ts
Normal file
30
src/services/api/gemini/modelMapping.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||||
|
if (/haiku/i.test(model)) return 'haiku'
|
||||||
|
if (/opus/i.test(model)) return 'opus'
|
||||||
|
if (/sonnet/i.test(model)) return 'sonnet'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGeminiModel(anthropicModel: string): string {
|
||||||
|
if (process.env.GEMINI_MODEL) {
|
||||||
|
return process.env.GEMINI_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanModel = anthropicModel.replace(/\[1m\]$/i, '')
|
||||||
|
const family = getModelFamily(cleanModel)
|
||||||
|
|
||||||
|
if (!family) {
|
||||||
|
return cleanModel
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||||
|
const resolvedModel = process.env[sharedEnvVar]
|
||||||
|
if (resolvedModel) {
|
||||||
|
return resolvedModel
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Gemini provider requires GEMINI_MODEL or ${sharedEnvVar} to be configured.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
244
src/services/api/gemini/streamAdapter.ts
Normal file
244
src/services/api/gemini/streamAdapter.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type { GeminiPart, GeminiStreamChunk } from './types.js'
|
||||||
|
|
||||||
|
export async function* adaptGeminiStreamToAnthropic(
|
||||||
|
stream: AsyncIterable<GeminiStreamChunk>,
|
||||||
|
model: string,
|
||||||
|
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
|
||||||
|
const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||||
|
let started = false
|
||||||
|
let stopped = false
|
||||||
|
let nextContentIndex = 0
|
||||||
|
let openTextLikeBlock:
|
||||||
|
| { index: number; type: 'text' | 'thinking' }
|
||||||
|
| null = null
|
||||||
|
let sawToolUse = false
|
||||||
|
let finishReason: string | undefined
|
||||||
|
let inputTokens = 0
|
||||||
|
let outputTokens = 0
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const usage = chunk.usageMetadata
|
||||||
|
if (usage) {
|
||||||
|
inputTokens = usage.promptTokenCount ?? inputTokens
|
||||||
|
outputTokens =
|
||||||
|
(usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!started) {
|
||||||
|
started = true
|
||||||
|
yield {
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: messageId,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [],
|
||||||
|
model,
|
||||||
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_creation_input_tokens: 0,
|
||||||
|
cache_read_input_tokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = chunk.candidates?.[0]
|
||||||
|
const parts = candidate?.content?.parts ?? []
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.functionCall) {
|
||||||
|
if (openTextLikeBlock) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: openTextLikeBlock.index,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
openTextLikeBlock = null
|
||||||
|
}
|
||||||
|
|
||||||
|
sawToolUse = true
|
||||||
|
const toolIndex = nextContentIndex++
|
||||||
|
const toolId = `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||||
|
yield {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: toolIndex,
|
||||||
|
content_block: {
|
||||||
|
type: 'tool_use',
|
||||||
|
id: toolId,
|
||||||
|
name: part.functionCall.name || '',
|
||||||
|
input: {},
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
|
||||||
|
if (part.thoughtSignature) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: toolIndex,
|
||||||
|
delta: {
|
||||||
|
type: 'signature_delta',
|
||||||
|
signature: part.thoughtSignature,
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.functionCall.args && Object.keys(part.functionCall.args).length > 0) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: toolIndex,
|
||||||
|
delta: {
|
||||||
|
type: 'input_json_delta',
|
||||||
|
partial_json: JSON.stringify(part.functionCall.args),
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: toolIndex,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const textLikeType = getTextLikeBlockType(part)
|
||||||
|
if (textLikeType) {
|
||||||
|
if (!openTextLikeBlock || openTextLikeBlock.type !== textLikeType) {
|
||||||
|
if (openTextLikeBlock) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: openTextLikeBlock.index,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
openTextLikeBlock = {
|
||||||
|
index: nextContentIndex++,
|
||||||
|
type: textLikeType,
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: openTextLikeBlock.index,
|
||||||
|
content_block:
|
||||||
|
textLikeType === 'thinking'
|
||||||
|
? {
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: '',
|
||||||
|
signature: '',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: 'text',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.text) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: openTextLikeBlock.index,
|
||||||
|
delta:
|
||||||
|
textLikeType === 'thinking'
|
||||||
|
? {
|
||||||
|
type: 'thinking_delta',
|
||||||
|
thinking: part.text,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: 'text_delta',
|
||||||
|
text: part.text,
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.thoughtSignature) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: openTextLikeBlock.index,
|
||||||
|
delta: {
|
||||||
|
type: 'signature_delta',
|
||||||
|
signature: part.thoughtSignature,
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.thoughtSignature && openTextLikeBlock) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index: openTextLikeBlock.index,
|
||||||
|
delta: {
|
||||||
|
type: 'signature_delta',
|
||||||
|
signature: part.thoughtSignature,
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate?.finishReason) {
|
||||||
|
finishReason = candidate.finishReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openTextLikeBlock) {
|
||||||
|
yield {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: openTextLikeBlock.index,
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stopped) {
|
||||||
|
yield {
|
||||||
|
type: 'message_delta',
|
||||||
|
delta: {
|
||||||
|
stop_reason: mapGeminiFinishReason(finishReason, sawToolUse),
|
||||||
|
stop_sequence: null,
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
},
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'message_stop',
|
||||||
|
} as BetaRawMessageStreamEvent
|
||||||
|
stopped = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextLikeBlockType(
|
||||||
|
part: GeminiPart,
|
||||||
|
): 'text' | 'thinking' | null {
|
||||||
|
if (typeof part.text !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return part.thought ? 'thinking' : 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGeminiFinishReason(
|
||||||
|
reason: string | undefined,
|
||||||
|
sawToolUse: boolean,
|
||||||
|
): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'MAX_TOKENS':
|
||||||
|
return 'max_tokens'
|
||||||
|
case 'STOP':
|
||||||
|
case 'FINISH_REASON_UNSPECIFIED':
|
||||||
|
case 'SAFETY':
|
||||||
|
case 'RECITATION':
|
||||||
|
case 'BLOCKLIST':
|
||||||
|
case 'PROHIBITED_CONTENT':
|
||||||
|
case 'SPII':
|
||||||
|
case 'MALFORMED_FUNCTION_CALL':
|
||||||
|
default:
|
||||||
|
return sawToolUse ? 'tool_use' : 'end_turn'
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/services/api/gemini/types.ts
Normal file
80
src/services/api/gemini/types.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export const GEMINI_THOUGHT_SIGNATURE_FIELD = '_geminiThoughtSignature'
|
||||||
|
|
||||||
|
export type GeminiFunctionCall = {
|
||||||
|
name?: string
|
||||||
|
args?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiFunctionResponse = {
|
||||||
|
name?: string
|
||||||
|
response?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiPart = {
|
||||||
|
text?: string
|
||||||
|
thought?: boolean
|
||||||
|
thoughtSignature?: string
|
||||||
|
functionCall?: GeminiFunctionCall
|
||||||
|
functionResponse?: GeminiFunctionResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiContent = {
|
||||||
|
role: 'user' | 'model'
|
||||||
|
parts: GeminiPart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiFunctionDeclaration = {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
parameters?: Record<string, unknown>
|
||||||
|
parametersJsonSchema?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiTool = {
|
||||||
|
functionDeclarations: GeminiFunctionDeclaration[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiFunctionCallingConfig = {
|
||||||
|
mode: 'AUTO' | 'ANY' | 'NONE'
|
||||||
|
allowedFunctionNames?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiGenerateContentRequest = {
|
||||||
|
contents: GeminiContent[]
|
||||||
|
systemInstruction?: {
|
||||||
|
parts: Array<{ text: string }>
|
||||||
|
}
|
||||||
|
tools?: GeminiTool[]
|
||||||
|
toolConfig?: {
|
||||||
|
functionCallingConfig: GeminiFunctionCallingConfig
|
||||||
|
}
|
||||||
|
generationConfig?: {
|
||||||
|
temperature?: number
|
||||||
|
thinkingConfig?: {
|
||||||
|
includeThoughts?: boolean
|
||||||
|
thinkingBudget?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiUsageMetadata = {
|
||||||
|
promptTokenCount?: number
|
||||||
|
candidatesTokenCount?: number
|
||||||
|
thoughtsTokenCount?: number
|
||||||
|
totalTokenCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiCandidate = {
|
||||||
|
content?: {
|
||||||
|
role?: string
|
||||||
|
parts?: GeminiPart[]
|
||||||
|
}
|
||||||
|
finishReason?: string
|
||||||
|
index?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiStreamChunk = {
|
||||||
|
candidates?: GeminiCandidate[]
|
||||||
|
usageMetadata?: GeminiUsageMetadata
|
||||||
|
modelVersion?: string
|
||||||
|
}
|
||||||
@@ -143,11 +143,16 @@ export async function countMessagesTokensWithAPI(
|
|||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
return withTokenCountVCR(messages, tools, async () => {
|
return withTokenCountVCR(messages, tools, async () => {
|
||||||
try {
|
try {
|
||||||
|
const provider = getAPIProvider()
|
||||||
|
if (provider === 'gemini') {
|
||||||
|
return roughTokenCountEstimationForAPIRequest(messages, tools)
|
||||||
|
}
|
||||||
|
|
||||||
const model = getMainLoopModel()
|
const model = getMainLoopModel()
|
||||||
const betas = getModelBetas(model)
|
const betas = getModelBetas(model)
|
||||||
const containsThinking = hasThinkingBlocks(messages)
|
const containsThinking = hasThinkingBlocks(messages)
|
||||||
|
|
||||||
if (getAPIProvider() === 'bedrock') {
|
if (provider === 'bedrock') {
|
||||||
// @anthropic-sdk/bedrock-sdk doesn't support countTokens currently
|
// @anthropic-sdk/bedrock-sdk doesn't support countTokens currently
|
||||||
return countTokensWithBedrock({
|
return countTokensWithBedrock({
|
||||||
model: normalizeModelStringForAPI(model),
|
model: normalizeModelStringForAPI(model),
|
||||||
@@ -252,6 +257,11 @@ export async function countTokensViaHaikuFallback(
|
|||||||
messages: Anthropic.Beta.Messages.BetaMessageParam[],
|
messages: Anthropic.Beta.Messages.BetaMessageParam[],
|
||||||
tools: Anthropic.Beta.Messages.BetaToolUnion[],
|
tools: Anthropic.Beta.Messages.BetaToolUnion[],
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
|
const provider = getAPIProvider()
|
||||||
|
if (provider === 'gemini') {
|
||||||
|
return roughTokenCountEstimationForAPIRequest(messages, tools)
|
||||||
|
}
|
||||||
|
|
||||||
// Check if messages contain thinking blocks
|
// Check if messages contain thinking blocks
|
||||||
const containsThinking = hasThinkingBlocks(messages)
|
const containsThinking = hasThinkingBlocks(messages)
|
||||||
|
|
||||||
@@ -388,6 +398,29 @@ function roughTokenCountEstimationForContent(
|
|||||||
return totalTokens
|
return totalTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roughTokenCountEstimationForAPIRequest(
|
||||||
|
messages: Anthropic.Beta.Messages.BetaMessageParam[],
|
||||||
|
tools: Anthropic.Beta.Messages.BetaToolUnion[],
|
||||||
|
): number {
|
||||||
|
let totalTokens = 0
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
totalTokens += roughTokenCountEstimationForContent(
|
||||||
|
message.content as
|
||||||
|
| string
|
||||||
|
| Array<Anthropic.ContentBlock>
|
||||||
|
| Array<Anthropic.ContentBlockParam>
|
||||||
|
| undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tools.length > 0) {
|
||||||
|
totalTokens += roughTokenCountEstimation(jsonStringify(tools))
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalTokens
|
||||||
|
}
|
||||||
|
|
||||||
function roughTokenCountEstimationForBlock(
|
function roughTokenCountEstimationForBlock(
|
||||||
block: string | Anthropic.ContentBlock | Anthropic.ContentBlockParam,
|
block: string | Anthropic.ContentBlock | Anthropic.ContentBlockParam,
|
||||||
): number {
|
): number {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
isNotEmptyMessage,
|
isNotEmptyMessage,
|
||||||
deriveUUID,
|
deriveUUID,
|
||||||
normalizeMessages,
|
normalizeMessages,
|
||||||
|
normalizeMessagesForAPI,
|
||||||
isClassifierDenial,
|
isClassifierDenial,
|
||||||
buildYoloRejectionMessage,
|
buildYoloRejectionMessage,
|
||||||
buildClassifierUnavailableMessage,
|
buildClassifierUnavailableMessage,
|
||||||
@@ -486,3 +487,23 @@ describe("normalizeMessages", () => {
|
|||||||
expect(normalized.length).toBe(1);
|
expect(normalized.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("normalizeMessagesForAPI", () => {
|
||||||
|
test("preserves Gemini thought signature metadata on tool_use blocks", () => {
|
||||||
|
const assistant = makeAssistantMsg([
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "tool-1",
|
||||||
|
name: "Bash",
|
||||||
|
input: { command: "pwd" },
|
||||||
|
_geminiThoughtSignature: "sig-123",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const normalized = normalizeMessagesForAPI([assistant]);
|
||||||
|
const block = (normalized[0] as AssistantMessage).message.content[0] as any;
|
||||||
|
|
||||||
|
expect(block.type).toBe("tool_use");
|
||||||
|
expect(block._geminiThoughtSignature).toBe("sig-123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ export function isAnthropicAuthEnabled(): boolean {
|
|||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
|
||||||
(settings as any).modelType === 'openai' ||
|
(settings as any).modelType === 'openai' ||
|
||||||
!!process.env.OPENAI_BASE_URL
|
(settings as any).modelType === 'gemini' ||
|
||||||
|
!!process.env.OPENAI_BASE_URL ||
|
||||||
|
!!process.env.GEMINI_BASE_URL
|
||||||
const apiKeyHelper = settings.apiKeyHelper
|
const apiKeyHelper = settings.apiKeyHelper
|
||||||
const hasExternalAuthToken =
|
const hasExternalAuthToken =
|
||||||
process.env.ANTHROPIC_AUTH_TOKEN ||
|
process.env.ANTHROPIC_AUTH_TOKEN ||
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
|
|||||||
'CLAUDE_CODE_USE_BEDROCK',
|
'CLAUDE_CODE_USE_BEDROCK',
|
||||||
'CLAUDE_CODE_USE_VERTEX',
|
'CLAUDE_CODE_USE_VERTEX',
|
||||||
'CLAUDE_CODE_USE_FOUNDRY',
|
'CLAUDE_CODE_USE_FOUNDRY',
|
||||||
|
'CLAUDE_CODE_USE_GEMINI',
|
||||||
// Endpoint config (base URLs, project/resource identifiers)
|
// Endpoint config (base URLs, project/resource identifiers)
|
||||||
'ANTHROPIC_BASE_URL',
|
'ANTHROPIC_BASE_URL',
|
||||||
'ANTHROPIC_BEDROCK_BASE_URL',
|
'ANTHROPIC_BEDROCK_BASE_URL',
|
||||||
@@ -25,6 +26,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
|
|||||||
'ANTHROPIC_FOUNDRY_BASE_URL',
|
'ANTHROPIC_FOUNDRY_BASE_URL',
|
||||||
'ANTHROPIC_FOUNDRY_RESOURCE',
|
'ANTHROPIC_FOUNDRY_RESOURCE',
|
||||||
'ANTHROPIC_VERTEX_PROJECT_ID',
|
'ANTHROPIC_VERTEX_PROJECT_ID',
|
||||||
|
'GEMINI_BASE_URL',
|
||||||
// Region routing (per-model VERTEX_REGION_CLAUDE_* handled by prefix below)
|
// Region routing (per-model VERTEX_REGION_CLAUDE_* handled by prefix below)
|
||||||
'CLOUD_ML_REGION',
|
'CLOUD_ML_REGION',
|
||||||
// Auth
|
// Auth
|
||||||
@@ -36,6 +38,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
|
|||||||
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
|
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
|
||||||
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
|
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
|
||||||
'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
|
'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
|
||||||
|
'GEMINI_API_KEY',
|
||||||
// Model defaults — often set to provider-specific ID formats
|
// Model defaults — often set to provider-specific ID formats
|
||||||
'ANTHROPIC_MODEL',
|
'ANTHROPIC_MODEL',
|
||||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
@@ -53,6 +56,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
|
|||||||
'ANTHROPIC_SMALL_FAST_MODEL',
|
'ANTHROPIC_SMALL_FAST_MODEL',
|
||||||
'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
|
'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
|
||||||
'CLAUDE_CODE_SUBAGENT_MODEL',
|
'CLAUDE_CODE_SUBAGENT_MODEL',
|
||||||
|
'GEMINI_MODEL',
|
||||||
])
|
])
|
||||||
|
|
||||||
const PROVIDER_MANAGED_ENV_PREFIXES = [
|
const PROVIDER_MANAGED_ENV_PREFIXES = [
|
||||||
@@ -147,7 +151,9 @@ export const SAFE_ENV_VARS = new Set([
|
|||||||
'CLAUDE_CODE_SUBAGENT_MODEL',
|
'CLAUDE_CODE_SUBAGENT_MODEL',
|
||||||
'CLAUDE_CODE_USE_BEDROCK',
|
'CLAUDE_CODE_USE_BEDROCK',
|
||||||
'CLAUDE_CODE_USE_FOUNDRY',
|
'CLAUDE_CODE_USE_FOUNDRY',
|
||||||
|
'CLAUDE_CODE_USE_GEMINI',
|
||||||
'CLAUDE_CODE_USE_VERTEX',
|
'CLAUDE_CODE_USE_VERTEX',
|
||||||
|
'GEMINI_MODEL',
|
||||||
'DISABLE_AUTOUPDATER',
|
'DISABLE_AUTOUPDATER',
|
||||||
'DISABLE_BUG_COMMAND',
|
'DISABLE_BUG_COMMAND',
|
||||||
'DISABLE_COST_WARNINGS',
|
'DISABLE_COST_WARNINGS',
|
||||||
|
|||||||
@@ -2249,10 +2249,13 @@ export function normalizeMessagesForAPI(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When tool search is NOT enabled, explicitly construct tool_use
|
// When tool search is NOT enabled, strip tool-search-only fields
|
||||||
// block with only standard API fields to avoid sending fields like
|
// like 'caller', but preserve other provider metadata attached to
|
||||||
// 'caller' that may be stored in sessions from tool search runs
|
// the block (for example Gemini thought signatures on tool_use).
|
||||||
|
const { caller: _caller, ...toolUseRest } = block as ToolUseBlock &
|
||||||
|
Record<string, unknown> & { caller?: unknown }
|
||||||
return {
|
return {
|
||||||
|
...toolUseRest,
|
||||||
type: 'tool_use' as const,
|
type: 'tool_use' as const,
|
||||||
id: toolUseBlk.id,
|
id: toolUseBlk.id,
|
||||||
name: canonicalName,
|
name: canonicalName,
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from "../providers";
|
|
||||||
|
let mockedModelType: "gemini" | undefined;
|
||||||
|
|
||||||
|
mock.module("../../settings/settings.js", () => ({
|
||||||
|
getInitialSettings: () =>
|
||||||
|
mockedModelType ? { modelType: mockedModelType } : {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { getAPIProvider, isFirstPartyAnthropicBaseUrl } =
|
||||||
|
await import("../providers");
|
||||||
|
|
||||||
describe("getAPIProvider", () => {
|
describe("getAPIProvider", () => {
|
||||||
const envKeys = [
|
const envKeys = [
|
||||||
|
"CLAUDE_CODE_USE_GEMINI",
|
||||||
"CLAUDE_CODE_USE_BEDROCK",
|
"CLAUDE_CODE_USE_BEDROCK",
|
||||||
"CLAUDE_CODE_USE_VERTEX",
|
"CLAUDE_CODE_USE_VERTEX",
|
||||||
"CLAUDE_CODE_USE_FOUNDRY",
|
"CLAUDE_CODE_USE_FOUNDRY",
|
||||||
@@ -10,10 +20,15 @@ describe("getAPIProvider", () => {
|
|||||||
const savedEnv: Record<string, string | undefined> = {};
|
const savedEnv: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const key of envKeys) savedEnv[key] = process.env[key];
|
mockedModelType = undefined;
|
||||||
|
for (const key of envKeys) {
|
||||||
|
savedEnv[key] = process.env[key];
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
mockedModelType = undefined;
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
if (savedEnv[key] !== undefined) {
|
if (savedEnv[key] !== undefined) {
|
||||||
process.env[key] = savedEnv[key];
|
process.env[key] = savedEnv[key];
|
||||||
@@ -24,12 +39,25 @@ describe("getAPIProvider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('returns "firstParty" by default', () => {
|
test('returns "firstParty" by default', () => {
|
||||||
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
|
||||||
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
|
||||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY;
|
|
||||||
expect(getAPIProvider()).toBe("firstParty");
|
expect(getAPIProvider()).toBe("firstParty");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns "gemini" when modelType is gemini', () => {
|
||||||
|
mockedModelType = "gemini";
|
||||||
|
expect(getAPIProvider()).toBe("gemini");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modelType takes precedence over environment variables", () => {
|
||||||
|
mockedModelType = "gemini";
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
|
expect(getAPIProvider()).toBe("gemini");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns "gemini" when CLAUDE_CODE_USE_GEMINI is set', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_GEMINI = "1";
|
||||||
|
expect(getAPIProvider()).toBe("gemini");
|
||||||
|
});
|
||||||
|
|
||||||
test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', () => {
|
test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', () => {
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
expect(getAPIProvider()).toBe("bedrock");
|
expect(getAPIProvider()).toBe("bedrock");
|
||||||
@@ -45,6 +73,12 @@ describe("getAPIProvider", () => {
|
|||||||
expect(getAPIProvider()).toBe("foundry");
|
expect(getAPIProvider()).toBe("foundry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("bedrock takes precedence over gemini", () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
|
process.env.CLAUDE_CODE_USE_GEMINI = "1";
|
||||||
|
expect(getAPIProvider()).toBe("bedrock");
|
||||||
|
});
|
||||||
|
|
||||||
test("bedrock takes precedence over vertex", () => {
|
test("bedrock takes precedence over vertex", () => {
|
||||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = {
|
|||||||
vertex: 'claude-3-7-sonnet@20250219',
|
vertex: 'claude-3-7-sonnet@20250219',
|
||||||
foundry: 'claude-3-7-sonnet',
|
foundry: 'claude-3-7-sonnet',
|
||||||
openai: 'claude-3-7-sonnet-20250219',
|
openai: 'claude-3-7-sonnet-20250219',
|
||||||
|
gemini: 'claude-3-7-sonnet-20250219',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_3_5_V2_SONNET_CONFIG = {
|
export const CLAUDE_3_5_V2_SONNET_CONFIG = {
|
||||||
@@ -20,6 +21,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = {
|
|||||||
vertex: 'claude-3-5-sonnet-v2@20241022',
|
vertex: 'claude-3-5-sonnet-v2@20241022',
|
||||||
foundry: 'claude-3-5-sonnet',
|
foundry: 'claude-3-5-sonnet',
|
||||||
openai: 'claude-3-5-sonnet-20241022',
|
openai: 'claude-3-5-sonnet-20241022',
|
||||||
|
gemini: 'claude-3-5-sonnet-20241022',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_3_5_HAIKU_CONFIG = {
|
export const CLAUDE_3_5_HAIKU_CONFIG = {
|
||||||
@@ -28,6 +30,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = {
|
|||||||
vertex: 'claude-3-5-haiku@20241022',
|
vertex: 'claude-3-5-haiku@20241022',
|
||||||
foundry: 'claude-3-5-haiku',
|
foundry: 'claude-3-5-haiku',
|
||||||
openai: 'claude-3-5-haiku-20241022',
|
openai: 'claude-3-5-haiku-20241022',
|
||||||
|
gemini: 'claude-3-5-haiku-20241022',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_HAIKU_4_5_CONFIG = {
|
export const CLAUDE_HAIKU_4_5_CONFIG = {
|
||||||
@@ -36,6 +39,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = {
|
|||||||
vertex: 'claude-haiku-4-5@20251001',
|
vertex: 'claude-haiku-4-5@20251001',
|
||||||
foundry: 'claude-haiku-4-5',
|
foundry: 'claude-haiku-4-5',
|
||||||
openai: 'claude-haiku-4-5-20251001',
|
openai: 'claude-haiku-4-5-20251001',
|
||||||
|
gemini: 'claude-haiku-4-5-20251001',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_SONNET_4_CONFIG = {
|
export const CLAUDE_SONNET_4_CONFIG = {
|
||||||
@@ -44,6 +48,7 @@ export const CLAUDE_SONNET_4_CONFIG = {
|
|||||||
vertex: 'claude-sonnet-4@20250514',
|
vertex: 'claude-sonnet-4@20250514',
|
||||||
foundry: 'claude-sonnet-4',
|
foundry: 'claude-sonnet-4',
|
||||||
openai: 'claude-sonnet-4-20250514',
|
openai: 'claude-sonnet-4-20250514',
|
||||||
|
gemini: 'claude-sonnet-4-20250514',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_SONNET_4_5_CONFIG = {
|
export const CLAUDE_SONNET_4_5_CONFIG = {
|
||||||
@@ -52,6 +57,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = {
|
|||||||
vertex: 'claude-sonnet-4-5@20250929',
|
vertex: 'claude-sonnet-4-5@20250929',
|
||||||
foundry: 'claude-sonnet-4-5',
|
foundry: 'claude-sonnet-4-5',
|
||||||
openai: 'claude-sonnet-4-5-20250929',
|
openai: 'claude-sonnet-4-5-20250929',
|
||||||
|
gemini: 'claude-sonnet-4-5-20250929',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_OPUS_4_CONFIG = {
|
export const CLAUDE_OPUS_4_CONFIG = {
|
||||||
@@ -60,6 +66,7 @@ export const CLAUDE_OPUS_4_CONFIG = {
|
|||||||
vertex: 'claude-opus-4@20250514',
|
vertex: 'claude-opus-4@20250514',
|
||||||
foundry: 'claude-opus-4',
|
foundry: 'claude-opus-4',
|
||||||
openai: 'claude-opus-4-20250514',
|
openai: 'claude-opus-4-20250514',
|
||||||
|
gemini: 'claude-opus-4-20250514',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_OPUS_4_1_CONFIG = {
|
export const CLAUDE_OPUS_4_1_CONFIG = {
|
||||||
@@ -68,6 +75,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = {
|
|||||||
vertex: 'claude-opus-4-1@20250805',
|
vertex: 'claude-opus-4-1@20250805',
|
||||||
foundry: 'claude-opus-4-1',
|
foundry: 'claude-opus-4-1',
|
||||||
openai: 'claude-opus-4-1-20250805',
|
openai: 'claude-opus-4-1-20250805',
|
||||||
|
gemini: 'claude-opus-4-1-20250805',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_OPUS_4_5_CONFIG = {
|
export const CLAUDE_OPUS_4_5_CONFIG = {
|
||||||
@@ -76,6 +84,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = {
|
|||||||
vertex: 'claude-opus-4-5@20251101',
|
vertex: 'claude-opus-4-5@20251101',
|
||||||
foundry: 'claude-opus-4-5',
|
foundry: 'claude-opus-4-5',
|
||||||
openai: 'claude-opus-4-5-20251101',
|
openai: 'claude-opus-4-5-20251101',
|
||||||
|
gemini: 'claude-opus-4-5-20251101',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_OPUS_4_6_CONFIG = {
|
export const CLAUDE_OPUS_4_6_CONFIG = {
|
||||||
@@ -84,6 +93,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = {
|
|||||||
vertex: 'claude-opus-4-6',
|
vertex: 'claude-opus-4-6',
|
||||||
foundry: 'claude-opus-4-6',
|
foundry: 'claude-opus-4-6',
|
||||||
openai: 'claude-opus-4-6',
|
openai: 'claude-opus-4-6',
|
||||||
|
gemini: 'claude-opus-4-6',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
export const CLAUDE_SONNET_4_6_CONFIG = {
|
export const CLAUDE_SONNET_4_6_CONFIG = {
|
||||||
@@ -92,6 +102,7 @@ export const CLAUDE_SONNET_4_6_CONFIG = {
|
|||||||
vertex: 'claude-sonnet-4-6',
|
vertex: 'claude-sonnet-4-6',
|
||||||
foundry: 'claude-sonnet-4-6',
|
foundry: 'claude-sonnet-4-6',
|
||||||
openai: 'claude-sonnet-4-6',
|
openai: 'claude-sonnet-4-6',
|
||||||
|
gemini: 'claude-sonnet-4-6',
|
||||||
} as const satisfies ModelConfig
|
} as const satisfies ModelConfig
|
||||||
|
|
||||||
// @[MODEL LAUNCH]: Register the new config here.
|
// @[MODEL LAUNCH]: Register the new config here.
|
||||||
|
|||||||
@@ -2,23 +2,32 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from
|
|||||||
import { getInitialSettings } from '../settings/settings.js'
|
import { getInitialSettings } from '../settings/settings.js'
|
||||||
import { isEnvTruthy } from '../envUtils.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 {
|
export function getAPIProvider(): APIProvider {
|
||||||
// 1. Check settings.json modelType field (highest priority)
|
// 1. Check settings.json modelType field (highest priority)
|
||||||
const modelType = getInitialSettings().modelType
|
const modelType = getInitialSettings().modelType
|
||||||
if (modelType === 'openai') return 'openai'
|
if (modelType === 'openai') return 'openai'
|
||||||
|
if (modelType === 'gemini') return 'gemini'
|
||||||
|
|
||||||
// 2. Check environment variables (backward compatibility)
|
// 2. Check environment variables (backward compatibility)
|
||||||
return isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
return isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)
|
||||||
? 'openai'
|
? 'bedrock'
|
||||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)
|
: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)
|
||||||
? 'bedrock'
|
? 'vertex'
|
||||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)
|
: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
|
||||||
? 'vertex'
|
? 'foundry'
|
||||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
|
: isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
||||||
? 'foundry'
|
? 'openai'
|
||||||
: 'firstParty'
|
: isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
||||||
|
? 'gemini'
|
||||||
|
: 'firstParty'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
|
export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
|
||||||
|
|||||||
@@ -474,3 +474,10 @@ describe("formatZodError", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("gemini settings", () => {
|
||||||
|
test("accepts gemini modelType", () => {
|
||||||
|
const result = SettingsSchema().safeParse({ modelType: "gemini" });
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -373,11 +373,11 @@ export const SettingsSchema = lazySchema(() =>
|
|||||||
.optional()
|
.optional()
|
||||||
.describe('Tool usage permissions configuration'),
|
.describe('Tool usage permissions configuration'),
|
||||||
modelType: z
|
modelType: z
|
||||||
.enum(['anthropic', 'openai'])
|
.enum(['anthropic', 'openai', 'gemini'])
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API (/v1/chat/completions). ' +
|
'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API (/v1/chat/completions), and "gemini" uses the Gemini Generate Content API. ' +
|
||||||
'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL in env.',
|
'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL in env. When set to "gemini", configure GEMINI_API_KEY, optional GEMINI_BASE_URL, and either GEMINI_MODEL or ANTHROPIC_DEFAULT_*_MODEL family env vars.',
|
||||||
),
|
),
|
||||||
model: z
|
model: z
|
||||||
.string()
|
.string()
|
||||||
@@ -1153,3 +1153,4 @@ export type PluginConfig = {
|
|||||||
[serverName: string]: UserConfigValues
|
[serverName: string]: UserConfigValues
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -339,8 +339,8 @@ export function buildAPIProviderProperties(): Property[] {
|
|||||||
bedrock: 'AWS Bedrock',
|
bedrock: 'AWS Bedrock',
|
||||||
vertex: 'Google Vertex AI',
|
vertex: 'Google Vertex AI',
|
||||||
foundry: 'Microsoft Foundry',
|
foundry: 'Microsoft Foundry',
|
||||||
|
gemini: 'Gemini API'
|
||||||
}[apiProvider]
|
}[apiProvider]
|
||||||
|
|
||||||
properties.push({
|
properties.push({
|
||||||
label: 'API provider',
|
label: 'API provider',
|
||||||
value: providerLabel,
|
value: providerLabel,
|
||||||
@@ -423,6 +423,13 @@ export function buildAPIProviderProperties(): Property[] {
|
|||||||
value: 'Microsoft Foundry auth skipped',
|
value: 'Microsoft Foundry auth skipped',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} else if (apiProvider === 'gemini') {
|
||||||
|
const geminiBaseUrl =
|
||||||
|
process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta'
|
||||||
|
properties.push({
|
||||||
|
label: 'Gemini base URL',
|
||||||
|
value: geminiBaseUrl,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyUrl = getProxyUrl()
|
const proxyUrl = getProxyUrl()
|
||||||
|
|||||||
Reference in New Issue
Block a user