mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Merge branch 'main' into main
This commit is contained in:
@@ -22,12 +22,18 @@ export interface InputBackend {
|
||||
}
|
||||
|
||||
function loadBackend(): InputBackend | null {
|
||||
if (process.platform !== 'darwin') return null
|
||||
try {
|
||||
return require('./backends/darwin.js') as InputBackend
|
||||
if (process.platform === 'darwin') {
|
||||
return require('./backends/darwin.js') as InputBackend
|
||||
} else if (process.platform === 'win32') {
|
||||
return require('./backends/win32.js') as InputBackend
|
||||
} else if (process.platform === 'linux') {
|
||||
return require('./backends/linux.js') as InputBackend
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const backend = loadBackend()
|
||||
|
||||
@@ -18,19 +18,25 @@ export type {
|
||||
|
||||
import type { ResolvePrepareCaptureResult } from './backends/darwin.js'
|
||||
|
||||
function loadDarwin() {
|
||||
if (process.platform !== 'darwin') return null
|
||||
function loadBackend() {
|
||||
try {
|
||||
return require('./backends/darwin.js')
|
||||
if (process.platform === 'darwin') {
|
||||
return require('./backends/darwin.js')
|
||||
} else if (process.platform === 'win32') {
|
||||
return require('./backends/win32.js')
|
||||
} else if (process.platform === 'linux') {
|
||||
return require('./backends/linux.js')
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const darwin = loadDarwin()
|
||||
const backend = loadBackend()
|
||||
|
||||
export class ComputerUseAPI {
|
||||
apps = darwin?.apps ?? {
|
||||
apps = backend?.apps ?? {
|
||||
async prepareDisplay() { return { activated: '', hidden: [] } },
|
||||
async previewHideSet() { return [] },
|
||||
async findWindowDisplays(ids: string[]) { return ids.map((b: string) => ({ bundleId: b, displayIds: [] as number[] })) },
|
||||
@@ -42,12 +48,12 @@ export class ComputerUseAPI {
|
||||
async unhide() {},
|
||||
}
|
||||
|
||||
display = darwin?.display ?? {
|
||||
display = backend?.display ?? {
|
||||
getSize() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
listAll() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
}
|
||||
|
||||
screenshot = darwin?.screenshot ?? {
|
||||
screenshot = backend?.screenshot ?? {
|
||||
async captureExcluding() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
async captureRegion() { throw new Error('@ant/computer-use-swift: macOS only') },
|
||||
}
|
||||
|
||||
@@ -49,6 +49,15 @@ type OAuthStatus =
|
||||
opusModel: string
|
||||
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
|
||||
} // OpenAI Chat Completions API platform
|
||||
| {
|
||||
state: 'gemini_api'
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
haikuModel: string
|
||||
sonnetModel: string
|
||||
opusModel: string
|
||||
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
|
||||
} // Gemini Generate Content API platform
|
||||
| { state: 'ready_to_start' } // Flow started, waiting for browser to open
|
||||
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
|
||||
| { state: 'creating_api_key' } // Got access token, creating API key
|
||||
@@ -61,7 +70,6 @@ type OAuthStatus =
|
||||
}
|
||||
|
||||
const PASTE_HERE_MSG = 'Paste code here if prompted > '
|
||||
|
||||
export function ConsoleOAuthFlow({
|
||||
onDone,
|
||||
startingMessage,
|
||||
@@ -470,6 +478,16 @@ function OAuthStatusMessage({
|
||||
),
|
||||
value: 'openai_chat_api',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
Gemini API ·{' '}
|
||||
<Text dimColor>Google Gemini native REST/SSE</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'gemini_api',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
@@ -537,6 +555,17 @@ function OAuthStatusMessage({
|
||||
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
|
||||
activeField: 'base_url',
|
||||
})
|
||||
} else if (value === 'gemini_api') {
|
||||
logEvent('tengu_gemini_api_selected', {})
|
||||
setOAuthStatus({
|
||||
state: 'gemini_api',
|
||||
baseUrl: process.env.GEMINI_BASE_URL ?? '',
|
||||
apiKey: process.env.GEMINI_API_KEY ?? '',
|
||||
haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? '',
|
||||
sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '',
|
||||
opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '',
|
||||
activeField: 'base_url',
|
||||
})
|
||||
} else if (value === 'platform') {
|
||||
logEvent('tengu_oauth_platform_selected', {})
|
||||
setOAuthStatus({ state: 'platform_setup' })
|
||||
@@ -1014,6 +1043,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':
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
|
||||
@@ -2434,7 +2434,7 @@ async function run(): Promise<CommanderCommand> {
|
||||
// shipped without incident; chicago places itself correctly.
|
||||
if (
|
||||
feature('CHICAGO_MCP') &&
|
||||
getPlatform() === 'macos' &&
|
||||
getPlatform() !== 'unknown' &&
|
||||
!getIsNonInteractiveSession()
|
||||
) {
|
||||
try {
|
||||
|
||||
@@ -640,26 +640,53 @@ export function assistantMessageToMessageParam(
|
||||
} else {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: message.message.content.map((_, i) => ({
|
||||
..._,
|
||||
...(i === message.message.content.length - 1 &&
|
||||
_.type !== 'thinking' &&
|
||||
_.type !== 'redacted_thinking' &&
|
||||
(feature('CONNECTOR_TEXT') ? !isConnectorTextBlock(_) : true)
|
||||
? enablePromptCaching
|
||||
? { cache_control: getCacheControl({ querySource }) }
|
||||
: {}
|
||||
: {}),
|
||||
})),
|
||||
content: message.message.content.map((_, i) => {
|
||||
const contentBlock = stripGeminiProviderMetadata(_)
|
||||
return {
|
||||
...contentBlock,
|
||||
...(i === message.message.content.length - 1 &&
|
||||
contentBlock.type !== 'thinking' &&
|
||||
contentBlock.type !== 'redacted_thinking' &&
|
||||
(feature('CONNECTOR_TEXT')
|
||||
? !isConnectorTextBlock(contentBlock)
|
||||
: true)
|
||||
? enablePromptCaching
|
||||
? { cache_control: getCacheControl({ querySource }) }
|
||||
: {}
|
||||
: {}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: message.message.content,
|
||||
content:
|
||||
typeof message.message.content === 'string'
|
||||
? message.message.content
|
||||
: message.message.content.map(stripGeminiProviderMetadata),
|
||||
}
|
||||
}
|
||||
|
||||
function stripGeminiProviderMetadata<T extends BetaContentBlockParam | string>(
|
||||
contentBlock: T,
|
||||
): T {
|
||||
if (
|
||||
typeof contentBlock === 'string' ||
|
||||
!('_geminiThoughtSignature' in contentBlock)
|
||||
) {
|
||||
return contentBlock
|
||||
}
|
||||
|
||||
const {
|
||||
_geminiThoughtSignature: _unusedGeminiThoughtSignature,
|
||||
...rest
|
||||
} = contentBlock as T & {
|
||||
_geminiThoughtSignature?: string
|
||||
}
|
||||
return rest as T
|
||||
}
|
||||
|
||||
export type Options = {
|
||||
getToolPermissionContext: () => Promise<ToolPermissionContext>
|
||||
model: string
|
||||
@@ -1310,6 +1337,19 @@ async function* queryModel(
|
||||
return
|
||||
}
|
||||
|
||||
if (getAPIProvider() === 'gemini') {
|
||||
const { queryModelGemini } = await import('./gemini/index.js')
|
||||
yield* queryModelGemini(
|
||||
messagesForAPI,
|
||||
systemPrompt,
|
||||
filteredTools,
|
||||
signal,
|
||||
options,
|
||||
thinkingConfig,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Instrumentation: Track message count after normalization
|
||||
logEvent('tengu_api_after_normalize', {
|
||||
postNormalizedMessageCount: messagesForAPI.length,
|
||||
|
||||
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> {
|
||||
return withTokenCountVCR(messages, tools, async () => {
|
||||
try {
|
||||
const provider = getAPIProvider()
|
||||
if (provider === 'gemini') {
|
||||
return roughTokenCountEstimationForAPIRequest(messages, tools)
|
||||
}
|
||||
|
||||
const model = getMainLoopModel()
|
||||
const betas = getModelBetas(model)
|
||||
const containsThinking = hasThinkingBlocks(messages)
|
||||
|
||||
if (getAPIProvider() === 'bedrock') {
|
||||
if (provider === 'bedrock') {
|
||||
// @anthropic-sdk/bedrock-sdk doesn't support countTokens currently
|
||||
return countTokensWithBedrock({
|
||||
model: normalizeModelStringForAPI(model),
|
||||
@@ -252,6 +257,11 @@ export async function countTokensViaHaikuFallback(
|
||||
messages: Anthropic.Beta.Messages.BetaMessageParam[],
|
||||
tools: Anthropic.Beta.Messages.BetaToolUnion[],
|
||||
): Promise<number | null> {
|
||||
const provider = getAPIProvider()
|
||||
if (provider === 'gemini') {
|
||||
return roughTokenCountEstimationForAPIRequest(messages, tools)
|
||||
}
|
||||
|
||||
// Check if messages contain thinking blocks
|
||||
const containsThinking = hasThinkingBlocks(messages)
|
||||
|
||||
@@ -388,6 +398,29 @@ function roughTokenCountEstimationForContent(
|
||||
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(
|
||||
block: string | Anthropic.ContentBlock | Anthropic.ContentBlockParam,
|
||||
): number {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isNotEmptyMessage,
|
||||
deriveUUID,
|
||||
normalizeMessages,
|
||||
normalizeMessagesForAPI,
|
||||
isClassifierDenial,
|
||||
buildYoloRejectionMessage,
|
||||
buildClassifierUnavailableMessage,
|
||||
@@ -486,3 +487,23 @@ describe("normalizeMessages", () => {
|
||||
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_FOUNDRY) ||
|
||||
(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 hasExternalAuthToken =
|
||||
process.env.ANTHROPIC_AUTH_TOKEN ||
|
||||
|
||||
@@ -7,9 +7,6 @@ let cached: ComputerUseAPI | undefined
|
||||
* Non-darwin platforms should use src/utils/computerUse/platforms/ instead.
|
||||
*/
|
||||
export function requireComputerUseSwift(): ComputerUseAPI {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('@ant/computer-use-swift is macOS-only. Use platforms/ for cross-platform.')
|
||||
}
|
||||
if (cached) return cached
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require('@ant/computer-use-swift')
|
||||
|
||||
@@ -22,6 +22,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_USE_GEMINI',
|
||||
// Endpoint config (base URLs, project/resource identifiers)
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_BEDROCK_BASE_URL',
|
||||
@@ -29,6 +30,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
|
||||
'ANTHROPIC_FOUNDRY_BASE_URL',
|
||||
'ANTHROPIC_FOUNDRY_RESOURCE',
|
||||
'ANTHROPIC_VERTEX_PROJECT_ID',
|
||||
'GEMINI_BASE_URL',
|
||||
// Region routing (per-model VERTEX_REGION_CLAUDE_* handled by prefix below)
|
||||
'CLOUD_ML_REGION',
|
||||
// Auth
|
||||
@@ -40,6 +42,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
|
||||
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
|
||||
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
|
||||
'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
|
||||
'GEMINI_API_KEY',
|
||||
// Model defaults — often set to provider-specific ID formats
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
@@ -74,6 +77,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
|
||||
'ANTHROPIC_SMALL_FAST_MODEL',
|
||||
'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION',
|
||||
'CLAUDE_CODE_SUBAGENT_MODEL',
|
||||
'GEMINI_MODEL',
|
||||
])
|
||||
|
||||
const PROVIDER_MANAGED_ENV_PREFIXES = [
|
||||
@@ -181,7 +185,9 @@ export const SAFE_ENV_VARS = new Set([
|
||||
'CLAUDE_CODE_SUBAGENT_MODEL',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_USE_GEMINI',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'GEMINI_MODEL',
|
||||
'DISABLE_AUTOUPDATER',
|
||||
'DISABLE_BUG_COMMAND',
|
||||
'DISABLE_COST_WARNINGS',
|
||||
|
||||
@@ -2249,10 +2249,13 @@ export function normalizeMessagesForAPI(
|
||||
}
|
||||
}
|
||||
|
||||
// When tool search is NOT enabled, explicitly construct tool_use
|
||||
// block with only standard API fields to avoid sending fields like
|
||||
// 'caller' that may be stored in sessions from tool search runs
|
||||
// When tool search is NOT enabled, strip tool-search-only fields
|
||||
// like 'caller', but preserve other provider metadata attached to
|
||||
// the block (for example Gemini thought signatures on tool_use).
|
||||
const { caller: _caller, ...toolUseRest } = block as ToolUseBlock &
|
||||
Record<string, unknown> & { caller?: unknown }
|
||||
return {
|
||||
...toolUseRest,
|
||||
type: 'tool_use' as const,
|
||||
id: toolUseBlk.id,
|
||||
name: canonicalName,
|
||||
|
||||
@@ -11,9 +11,21 @@ const __dirname = path.dirname(__filename);
|
||||
function getSettingsPath(): string {
|
||||
return path.join(homedir(), ".claude", "settings.json");
|
||||
}
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
let mockedModelType: "gemini" | undefined;
|
||||
|
||||
mock.module("../../settings/settings.js", () => ({
|
||||
getInitialSettings: () =>
|
||||
mockedModelType ? { modelType: mockedModelType } : {},
|
||||
}));
|
||||
|
||||
const { getAPIProvider, isFirstPartyAnthropicBaseUrl } =
|
||||
await import("../providers");
|
||||
|
||||
describe("getAPIProvider", () => {
|
||||
const envKeys = [
|
||||
"CLAUDE_CODE_USE_GEMINI",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"CLAUDE_CODE_USE_FOUNDRY",
|
||||
@@ -44,6 +56,7 @@ describe("getAPIProvider", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Save and clear environment variables
|
||||
mockedModelType = undefined;
|
||||
for (const key of envKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
@@ -52,6 +65,7 @@ describe("getAPIProvider", () => {
|
||||
|
||||
afterEach(() => {
|
||||
// Restore environment variables
|
||||
mockedModelType = undefined;
|
||||
for (const key of envKeys) {
|
||||
if (savedEnv[key] !== undefined) {
|
||||
process.env[key] = savedEnv[key];
|
||||
@@ -65,6 +79,22 @@ describe("getAPIProvider", () => {
|
||||
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', () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
expect(getAPIProvider()).toBe("bedrock");
|
||||
@@ -80,6 +110,12 @@ describe("getAPIProvider", () => {
|
||||
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", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "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',
|
||||
foundry: 'claude-3-7-sonnet',
|
||||
openai: 'claude-3-7-sonnet-20250219',
|
||||
gemini: 'claude-3-7-sonnet-20250219',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
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',
|
||||
foundry: 'claude-3-5-sonnet',
|
||||
openai: 'claude-3-5-sonnet-20241022',
|
||||
gemini: 'claude-3-5-sonnet-20241022',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_3_5_HAIKU_CONFIG = {
|
||||
@@ -28,6 +30,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = {
|
||||
vertex: 'claude-3-5-haiku@20241022',
|
||||
foundry: 'claude-3-5-haiku',
|
||||
openai: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'claude-3-5-haiku-20241022',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_HAIKU_4_5_CONFIG = {
|
||||
@@ -36,6 +39,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = {
|
||||
vertex: 'claude-haiku-4-5@20251001',
|
||||
foundry: 'claude-haiku-4-5',
|
||||
openai: 'claude-haiku-4-5-20251001',
|
||||
gemini: 'claude-haiku-4-5-20251001',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_SONNET_4_CONFIG = {
|
||||
@@ -44,6 +48,7 @@ export const CLAUDE_SONNET_4_CONFIG = {
|
||||
vertex: 'claude-sonnet-4@20250514',
|
||||
foundry: 'claude-sonnet-4',
|
||||
openai: 'claude-sonnet-4-20250514',
|
||||
gemini: 'claude-sonnet-4-20250514',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_SONNET_4_5_CONFIG = {
|
||||
@@ -52,6 +57,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = {
|
||||
vertex: 'claude-sonnet-4-5@20250929',
|
||||
foundry: 'claude-sonnet-4-5',
|
||||
openai: 'claude-sonnet-4-5-20250929',
|
||||
gemini: 'claude-sonnet-4-5-20250929',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_OPUS_4_CONFIG = {
|
||||
@@ -60,6 +66,7 @@ export const CLAUDE_OPUS_4_CONFIG = {
|
||||
vertex: 'claude-opus-4@20250514',
|
||||
foundry: 'claude-opus-4',
|
||||
openai: 'claude-opus-4-20250514',
|
||||
gemini: 'claude-opus-4-20250514',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_OPUS_4_1_CONFIG = {
|
||||
@@ -68,6 +75,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = {
|
||||
vertex: 'claude-opus-4-1@20250805',
|
||||
foundry: 'claude-opus-4-1',
|
||||
openai: 'claude-opus-4-1-20250805',
|
||||
gemini: 'claude-opus-4-1-20250805',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_OPUS_4_5_CONFIG = {
|
||||
@@ -76,6 +84,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = {
|
||||
vertex: 'claude-opus-4-5@20251101',
|
||||
foundry: 'claude-opus-4-5',
|
||||
openai: 'claude-opus-4-5-20251101',
|
||||
gemini: 'claude-opus-4-5-20251101',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_OPUS_4_6_CONFIG = {
|
||||
@@ -84,6 +93,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = {
|
||||
vertex: 'claude-opus-4-6',
|
||||
foundry: 'claude-opus-4-6',
|
||||
openai: 'claude-opus-4-6',
|
||||
gemini: 'claude-opus-4-6',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
export const CLAUDE_SONNET_4_6_CONFIG = {
|
||||
@@ -92,6 +102,7 @@ export const CLAUDE_SONNET_4_6_CONFIG = {
|
||||
vertex: 'claude-sonnet-4-6',
|
||||
foundry: 'claude-sonnet-4-6',
|
||||
openai: 'claude-sonnet-4-6',
|
||||
gemini: 'claude-sonnet-4-6',
|
||||
} as const satisfies ModelConfig
|
||||
|
||||
// @[MODEL LAUNCH]: Register the new config here.
|
||||
|
||||
@@ -2,7 +2,13 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from
|
||||
import { getInitialSettings } from '../settings/settings.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 {
|
||||
// Cloud provider env vars have highest priority (they are explicit switches)
|
||||
@@ -13,12 +19,20 @@ export function getAPIProvider(): APIProvider {
|
||||
// Check settings.json modelType field
|
||||
const modelType = getInitialSettings().modelType
|
||||
if (modelType === 'openai') return 'openai'
|
||||
if (modelType === 'gemini') return 'gemini'
|
||||
|
||||
// Backward compatibility: check legacy OpenAI env var
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) return 'openai'
|
||||
|
||||
// Default: Anthropic first-party API
|
||||
return 'firstParty'
|
||||
// 2. Check environment variables (backward compatibility)
|
||||
return isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)
|
||||
? 'bedrock'
|
||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)
|
||||
? 'vertex'
|
||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
|
||||
? 'foundry'
|
||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)
|
||||
? 'openai'
|
||||
: isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
||||
? 'gemini'
|
||||
: 'firstParty'
|
||||
}
|
||||
|
||||
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()
|
||||
.describe('Tool usage permissions configuration'),
|
||||
modelType: z
|
||||
.enum(['anthropic', 'openai'])
|
||||
.enum(['anthropic', 'openai', 'gemini'])
|
||||
.optional()
|
||||
.describe(
|
||||
'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API (/v1/chat/completions). ' +
|
||||
'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL in env.',
|
||||
'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 "gemini", configure GEMINI_API_KEY, optional GEMINI_BASE_URL, and either GEMINI_MODEL or ANTHROPIC_DEFAULT_*_MODEL family env vars.',
|
||||
),
|
||||
model: z
|
||||
.string()
|
||||
@@ -1153,3 +1153,4 @@ export type PluginConfig = {
|
||||
[serverName: string]: UserConfigValues
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -339,8 +339,8 @@ export function buildAPIProviderProperties(): Property[] {
|
||||
bedrock: 'AWS Bedrock',
|
||||
vertex: 'Google Vertex AI',
|
||||
foundry: 'Microsoft Foundry',
|
||||
gemini: 'Gemini API'
|
||||
}[apiProvider]
|
||||
|
||||
properties.push({
|
||||
label: 'API provider',
|
||||
value: providerLabel,
|
||||
@@ -423,6 +423,13 @@ export function buildAPIProviderProperties(): Property[] {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user