fix: resolve 13 trivial-conditional alerts + remove community gemini

Trivial-conditional cleanup (13 alerts auto-closed):
- bashSecurity.ts: remove 3 redundant !inSingleQuote checks
- WebFetchTool/UI.tsx, WebSearchTool/UI.tsx: remove redundant guards
- theme.ts: simplify getStoredTheme() || defaultTheme
- EventStream.tsx: remove dead !replay check
- MCPRemoteServerMenu.tsx: remove redundant isAuthenticating
- managedEnv.ts: remove dead !env guard
- permissionSetup.ts: delete duplicate code block
- marketplaceHelpers.ts: simplify blockedValue || undefined
- toolExecution.ts: remove dead : '' ternary branch
- main.tsx: remove dead dynamicMcpConfig && guard
- attachments.ts: wire up suppressNextDiscovery (set flag before await)

Also: remove abandoned community gemini (src/services/api/gemini/)
and its routing from claude.ts. Upgrade CodeQL to security-and-quality.
This commit is contained in:
James Feng 2026-06-08 16:29:34 +08:00
parent 512144bba2
commit 2b6cf08c7c
25 changed files with 252 additions and 2331 deletions

View File

@ -83,7 +83,7 @@ bun run docs:dev
- **为什么 Vite 必须代码分割**: Bun/JSC 会全量解析单个大 JS 文件的 bytecode 和 JIT单文件 17MB 产物导致 RSS 暴涨至 ~1GBNode/V8 懒解析仅需 ~220MB。代码分割为 600+ 小 chunk 后 Bun 按需加载,`--version` RSS 从 966MB 降至 35MB完整加载从 1GB+ 降至 ~500MB。
- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines运行 `src/entrypoints/cli.tsx`。默认启用全部 feature。
- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform.
- **Monorepo**: Bun workspaces — 15 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`
- **Monorepo**: Bun workspaces — 17 个 workspace packages + 若干辅助目录 in `packages/` resolved via `workspace:*`
- **Lint/Format**: Biome (`biome.json`)。覆盖 `src/`、`scripts/`、`packages/` 全项目(含 `packages/@ant/`)。`bun run lint` / `bun run lint:fix` / `bun run format` / `bun run check` / `bun run check:fix`。42 条规则因 decompiled 代码被关闭,仅保留 `recommended` 基线。
- **Pre-commit**: husky + lint-staged。提交时自动对暂存文件执行 `biome check --fix`TS/JS`biome format --write`JSON
- **CI Lint**: `ci.yml` 在依赖安装后、类型检查前执行 `bunx biome ci .`lint 或格式化不达标则 CI 失败。

View File

@ -146,10 +146,10 @@ function extractQuotedContent(command: string, isJq = false): QuoteExtraction {
}
if (char === '\\' && !inSingleQuote) {
withDoubleQuotes += char
if (!inDoubleQuote) fullyUnquoted += char
if (!inDoubleQuote) unquotedKeepQuoteChars += char
escaped = true
if (!inSingleQuote) withDoubleQuotes += char
if (!inSingleQuote && !inDoubleQuote) fullyUnquoted += char
if (!inSingleQuote && !inDoubleQuote) unquotedKeepQuoteChars += char
continue
}
@ -1574,7 +1574,6 @@ function hasBackslashEscapedWhitespace(command: string): boolean {
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote
continue
}
}
@ -1687,7 +1686,6 @@ function hasBackslashEscapedOperator(command: string): boolean {
}
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote
continue
}
}

View File

@ -15,7 +15,7 @@ export function renderToolUseMessage(
return null;
}
if (verbose) {
return `url: "${url}"${verbose && prompt ? `, prompt: "${prompt}"` : ''}`;
return `url: "${url}"${prompt ? `, prompt: "${prompt}"` : ''}`;
}
return url;
}

View File

@ -41,9 +41,7 @@ export function renderToolUseMessage(
let message = '';
if (query) {
message += `"${query}"`;
}
message += `"${query}"`;
if (verbose) {
if (allowed_domains && allowed_domains.length > 0) {

View File

@ -898,7 +898,7 @@ export function useEventProcessor() {
const text = extractEventText(payload as Record<string, unknown>);
if (text) {
setMessages(prev => [...prev, { kind: 'user', content: text }]);
if (!replay) showLoading();
showLoading();
}
break;
}

View File

@ -62,10 +62,10 @@ export function ThemeProvider({
defaultTheme = 'system',
}: ThemeProviderProps) {
const [theme, setThemeState] = useState<Theme>(
() => getStoredTheme() || defaultTheme,
() => getStoredTheme(),
)
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
const stored = getStoredTheme() || defaultTheme
const stored = getStoredTheme()
return stored === 'system' ? getSystemTheme() : stored
})

View File

@ -1,67 +1,50 @@
import figures from 'figures'
import React, { useEffect, useRef, useState } from 'react'
import figures from 'figures';
import React, { useEffect, useRef, useState } from 'react';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import type { CommandResultDisplay } from '../../commands.js'
import { getOauthConfig } from '../../constants/oauth.js'
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { setClipboard } from '@anthropic/ink'
} from 'src/services/analytics/index.js';
import type { CommandResultDisplay } from '../../commands.js';
import { getOauthConfig } from '../../constants/oauth.js';
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { setClipboard } from '@anthropic/ink';
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow menu navigation
import { Box, color, Link, Text, useInput, useTheme } from '@anthropic/ink'
import { useKeybinding } from '../../keybindings/useKeybinding.js'
import {
AuthenticationCancelledError,
performMCPOAuthFlow,
revokeServerTokens,
} from '../../services/mcp/auth.js'
import { clearServerCache } from '../../services/mcp/client.js'
import {
useMcpReconnect,
useMcpToggleEnabled,
} from '../../services/mcp/MCPConnectionManager.js'
import { Box, color, Link, Text, useInput, useTheme } from '@anthropic/ink';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
import { AuthenticationCancelledError, performMCPOAuthFlow, revokeServerTokens } from '../../services/mcp/auth.js';
import { clearServerCache } from '../../services/mcp/client.js';
import { useMcpReconnect, useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js';
import {
describeMcpConfigFilePath,
excludeCommandsByServer,
excludeResourcesByServer,
excludeToolsByServer,
filterMcpPromptsByServer,
} from '../../services/mcp/utils.js'
import { useAppState, useSetAppState } from '../../state/AppState.js'
import { getOauthAccountInfo } from '../../utils/auth.js'
import { openBrowser } from '../../utils/browser.js'
import { errorMessage } from '../../utils/errors.js'
import { logMCPDebug } from '../../utils/log.js'
import { capitalize } from '../../utils/stringUtils.js'
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'
import { Select } from '../CustomSelect/index.js'
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
import { Spinner } from '../Spinner.js'
import TextInput from '../TextInput.js'
import { CapabilitiesSection } from './CapabilitiesSection.js'
import type {
ClaudeAIServerInfo,
HTTPServerInfo,
SSEServerInfo,
} from './types.js'
import {
handleReconnectError,
handleReconnectResult,
} from './utils/reconnectHelpers.js'
} from '../../services/mcp/utils.js';
import { useAppState, useSetAppState } from '../../state/AppState.js';
import { getOauthAccountInfo } from '../../utils/auth.js';
import { openBrowser } from '../../utils/browser.js';
import { errorMessage } from '../../utils/errors.js';
import { logMCPDebug } from '../../utils/log.js';
import { capitalize } from '../../utils/stringUtils.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { Select } from '../CustomSelect/index.js';
import { Byline, KeyboardShortcutHint } from '@anthropic/ink';
import { Spinner } from '../Spinner.js';
import TextInput from '../TextInput.js';
import { CapabilitiesSection } from './CapabilitiesSection.js';
import type { ClaudeAIServerInfo, HTTPServerInfo, SSEServerInfo } from './types.js';
import { handleReconnectError, handleReconnectResult } from './utils/reconnectHelpers.js';
type Props = {
server: SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo
serverToolsCount: number
onViewTools: () => void
onCancel: () => void
onComplete?: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
borderless?: boolean
}
server: SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo;
serverToolsCount: number;
onViewTools: () => void;
onCancel: () => void;
onComplete?: (result?: string, options?: { display?: CommandResultDisplay }) => void;
borderless?: boolean;
};
export function MCPRemoteServerMenu({
server,
@ -71,37 +54,27 @@ export function MCPRemoteServerMenu({
onComplete,
borderless = false,
}: Props): React.ReactNode {
const [theme] = useTheme()
const exitState = useExitOnCtrlCDWithKeybindings()
const { columns: terminalColumns } = useTerminalSize()
const [isAuthenticating, setIsAuthenticating] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const mcp = useAppState(s => s.mcp)
const setAppState = useSetAppState()
const [authorizationUrl, setAuthorizationUrl] = React.useState<string | null>(
null,
)
const [isReconnecting, setIsReconnecting] = useState(false)
const authAbortControllerRef = useRef<AbortController | null>(null)
const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] =
useState(false)
const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState<string | null>(null)
const [isClaudeAIClearingAuth, setIsClaudeAIClearingAuth] = useState(false)
const [claudeAIClearAuthUrl, setClaudeAIClearAuthUrl] = useState<
string | null
>(null)
const [claudeAIClearAuthBrowserOpened, setClaudeAIClearAuthBrowserOpened] =
useState(false)
const [urlCopied, setUrlCopied] = useState(false)
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
)
const unmountedRef = useRef(false)
const [callbackUrlInput, setCallbackUrlInput] = useState('')
const [callbackUrlCursorOffset, setCallbackUrlCursorOffset] = useState(0)
const [manualCallbackSubmit, setManualCallbackSubmit] = useState<
((url: string) => void) | null
>(null)
const [theme] = useTheme();
const exitState = useExitOnCtrlCDWithKeybindings();
const { columns: terminalColumns } = useTerminalSize();
const [isAuthenticating, setIsAuthenticating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const mcp = useAppState(s => s.mcp);
const setAppState = useSetAppState();
const [authorizationUrl, setAuthorizationUrl] = React.useState<string | null>(null);
const [isReconnecting, setIsReconnecting] = useState(false);
const authAbortControllerRef = useRef<AbortController | null>(null);
const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] = useState(false);
const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState<string | null>(null);
const [isClaudeAIClearingAuth, setIsClaudeAIClearingAuth] = useState(false);
const [claudeAIClearAuthUrl, setClaudeAIClearAuthUrl] = useState<string | null>(null);
const [claudeAIClearAuthBrowserOpened, setClaudeAIClearAuthBrowserOpened] = useState(false);
const [urlCopied, setUrlCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const unmountedRef = useRef(false);
const [callbackUrlInput, setCallbackUrlInput] = useState('');
const [callbackUrlCursorOffset, setCallbackUrlCursorOffset] = useState(0);
const [manualCallbackSubmit, setManualCallbackSubmit] = useState<((url: string) => void) | null>(null);
// If the component unmounts mid-auth (e.g. a parent component's Esc handler
// navigates away before ours fires), abort the OAuth flow so the callback
@ -111,70 +84,63 @@ export function MCPRemoteServerMenu({
// schedule a new timer after unmount.
useEffect(
() => () => {
unmountedRef.current = true
authAbortControllerRef.current?.abort()
unmountedRef.current = true;
authAbortControllerRef.current?.abort();
if (copyTimeoutRef.current !== undefined) {
clearTimeout(copyTimeoutRef.current)
clearTimeout(copyTimeoutRef.current);
}
},
[],
)
);
// A server is effectively authenticated if:
// 1. It has OAuth tokens (server.isAuthenticated), OR
// 2. It's connected and has tools (meaning it's working via some auth mechanism)
const isEffectivelyAuthenticated =
server.isAuthenticated ||
(server.client.type === 'connected' && serverToolsCount > 0)
server.isAuthenticated || (server.client.type === 'connected' && serverToolsCount > 0);
const reconnectMcpServer = useMcpReconnect()
const reconnectMcpServer = useMcpReconnect();
const handleClaudeAIAuthComplete = React.useCallback(async () => {
setIsClaudeAIAuthenticating(false)
setClaudeAIAuthUrl(null)
setIsReconnecting(true)
setIsClaudeAIAuthenticating(false);
setClaudeAIAuthUrl(null);
setIsReconnecting(true);
try {
const result = await reconnectMcpServer(server.name)
const success = result.client.type === 'connected'
logEvent('tengu_claudeai_mcp_auth_completed', { success })
const result = await reconnectMcpServer(server.name);
const success = result.client.type === 'connected';
logEvent('tengu_claudeai_mcp_auth_completed', { success });
if (success) {
onComplete?.(`Authentication successful. Connected to ${server.name}.`)
onComplete?.(`Authentication successful. Connected to ${server.name}.`);
} else if (result.client.type === 'needs-auth') {
onComplete?.(
'Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.',
)
);
} else {
onComplete?.(
'Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.',
)
);
}
} catch (err) {
logEvent('tengu_claudeai_mcp_auth_completed', { success: false })
onComplete?.(handleReconnectError(err, server.name))
logEvent('tengu_claudeai_mcp_auth_completed', { success: false });
onComplete?.(handleReconnectError(err, server.name));
} finally {
setIsReconnecting(false)
setIsReconnecting(false);
}
}, [reconnectMcpServer, server.name, onComplete])
}, [reconnectMcpServer, server.name, onComplete]);
const handleClaudeAIClearAuthComplete = React.useCallback(async () => {
await clearServerCache(server.name, {
...server.config,
scope: server.scope,
})
});
setAppState(prev => {
const newClients = prev.mcp.clients.map(c =>
c.name === server.name ? { ...c, type: 'needs-auth' as const } : c,
)
const newTools = excludeToolsByServer(prev.mcp.tools, server.name)
const newCommands = excludeCommandsByServer(
prev.mcp.commands,
server.name,
)
const newResources = excludeResourcesByServer(
prev.mcp.resources,
server.name,
)
);
const newTools = excludeToolsByServer(prev.mcp.tools, server.name);
const newCommands = excludeCommandsByServer(prev.mcp.commands, server.name);
const newResources = excludeResourcesByServer(prev.mcp.resources, server.name);
return {
...prev,
@ -185,176 +151,155 @@ export function MCPRemoteServerMenu({
commands: newCommands,
resources: newResources,
},
}
})
};
});
logEvent('tengu_claudeai_mcp_clear_auth_completed', {})
onComplete?.(`Disconnected from ${server.name}.`)
setIsClaudeAIClearingAuth(false)
setClaudeAIClearAuthUrl(null)
setClaudeAIClearAuthBrowserOpened(false)
}, [server.name, server.config, server.scope, setAppState, onComplete])
logEvent('tengu_claudeai_mcp_clear_auth_completed', {});
onComplete?.(`Disconnected from ${server.name}.`);
setIsClaudeAIClearingAuth(false);
setClaudeAIClearAuthUrl(null);
setClaudeAIClearAuthBrowserOpened(false);
}, [server.name, server.config, server.scope, setAppState, onComplete]);
// Escape to cancel authentication flow
useKeybinding(
'confirm:no',
() => {
authAbortControllerRef.current?.abort()
authAbortControllerRef.current = null
setIsAuthenticating(false)
setAuthorizationUrl(null)
authAbortControllerRef.current?.abort();
authAbortControllerRef.current = null;
setIsAuthenticating(false);
setAuthorizationUrl(null);
},
{
context: 'Confirmation',
isActive: isAuthenticating,
},
)
);
// Escape to cancel Claude AI authentication
useKeybinding(
'confirm:no',
() => {
setIsClaudeAIAuthenticating(false)
setClaudeAIAuthUrl(null)
setIsClaudeAIAuthenticating(false);
setClaudeAIAuthUrl(null);
},
{
context: 'Confirmation',
isActive: isClaudeAIAuthenticating,
},
)
);
// Escape to cancel Claude AI clear auth
useKeybinding(
'confirm:no',
() => {
setIsClaudeAIClearingAuth(false)
setClaudeAIClearAuthUrl(null)
setClaudeAIClearAuthBrowserOpened(false)
setIsClaudeAIClearingAuth(false);
setClaudeAIClearAuthUrl(null);
setClaudeAIClearAuthBrowserOpened(false);
},
{
context: 'Confirmation',
isActive: isClaudeAIClearingAuth,
},
)
);
// Return key handling for authentication flows and 'c' to copy URL
useInput((input, key) => {
if (key.return && isClaudeAIAuthenticating) {
void handleClaudeAIAuthComplete()
void handleClaudeAIAuthComplete();
}
if (key.return && isClaudeAIClearingAuth) {
if (claudeAIClearAuthBrowserOpened) {
void handleClaudeAIClearAuthComplete()
void handleClaudeAIClearAuthComplete();
} else {
// First Enter: open the browser
const connectorsUrl = `${getOauthConfig().CLAUDE_AI_ORIGIN}/settings/connectors`
setClaudeAIClearAuthUrl(connectorsUrl)
setClaudeAIClearAuthBrowserOpened(true)
void openBrowser(connectorsUrl)
const connectorsUrl = `${getOauthConfig().CLAUDE_AI_ORIGIN}/settings/connectors`;
setClaudeAIClearAuthUrl(connectorsUrl);
setClaudeAIClearAuthBrowserOpened(true);
void openBrowser(connectorsUrl);
}
}
if (input === 'c' && !urlCopied) {
const urlToCopy =
authorizationUrl || claudeAIAuthUrl || claudeAIClearAuthUrl
const urlToCopy = authorizationUrl || claudeAIAuthUrl || claudeAIClearAuthUrl;
if (urlToCopy) {
void setClipboard(urlToCopy).then(raw => {
if (unmountedRef.current) return
if (raw) process.stdout.write(raw)
setUrlCopied(true)
if (unmountedRef.current) return;
if (raw) process.stdout.write(raw);
setUrlCopied(true);
if (copyTimeoutRef.current !== undefined) {
clearTimeout(copyTimeoutRef.current)
clearTimeout(copyTimeoutRef.current);
}
copyTimeoutRef.current = setTimeout(setUrlCopied, 2000, false)
})
copyTimeoutRef.current = setTimeout(setUrlCopied, 2000, false);
});
}
}
})
});
const capitalizedServerName = capitalize(String(server.name))
const capitalizedServerName = capitalize(String(server.name));
// Count MCP prompts for this server (skills are shown in /skills, not here)
const serverCommandsCount = filterMcpPromptsByServer(
mcp.commands,
server.name,
).length
const serverCommandsCount = filterMcpPromptsByServer(mcp.commands, server.name).length;
const toggleMcpServer = useMcpToggleEnabled()
const toggleMcpServer = useMcpToggleEnabled();
const handleClaudeAIAuth = React.useCallback(async () => {
const claudeAiBaseUrl = getOauthConfig().CLAUDE_AI_ORIGIN
const accountInfo = getOauthAccountInfo()
const orgUuid = accountInfo?.organizationUuid
const claudeAiBaseUrl = getOauthConfig().CLAUDE_AI_ORIGIN;
const accountInfo = getOauthAccountInfo();
const orgUuid = accountInfo?.organizationUuid;
let authUrl: string
if (
orgUuid &&
server.config.type === 'claudeai-proxy' &&
server.config.id
) {
let authUrl: string;
if (orgUuid && server.config.type === 'claudeai-proxy' && server.config.id) {
// Use the direct auth URL with org and server IDs
// Replace 'mcprs' prefix with 'mcpsrv' if present
const serverId = server.config.id.startsWith('mcprs')
? 'mcpsrv' + server.config.id.slice(5)
: server.config.id
const productSurface = encodeURIComponent(
process.env.CLAUDE_CODE_ENTRYPOINT || 'cli',
)
authUrl = `${claudeAiBaseUrl}/api/organizations/${orgUuid}/mcp/start-auth/${serverId}?product_surface=${productSurface}`
const serverId = server.config.id.startsWith('mcprs') ? 'mcpsrv' + server.config.id.slice(5) : server.config.id;
const productSurface = encodeURIComponent(process.env.CLAUDE_CODE_ENTRYPOINT || 'cli');
authUrl = `${claudeAiBaseUrl}/api/organizations/${orgUuid}/mcp/start-auth/${serverId}?product_surface=${productSurface}`;
} else {
// Fall back to settings/connectors if we don't have the required IDs
authUrl = `${claudeAiBaseUrl}/settings/connectors`
authUrl = `${claudeAiBaseUrl}/settings/connectors`;
}
setClaudeAIAuthUrl(authUrl)
setIsClaudeAIAuthenticating(true)
logEvent('tengu_claudeai_mcp_auth_started', {})
await openBrowser(authUrl)
}, [server.config])
setClaudeAIAuthUrl(authUrl);
setIsClaudeAIAuthenticating(true);
logEvent('tengu_claudeai_mcp_auth_started', {});
await openBrowser(authUrl);
}, [server.config]);
const handleClaudeAIClearAuth = React.useCallback(() => {
setIsClaudeAIClearingAuth(true)
logEvent('tengu_claudeai_mcp_clear_auth_started', {})
}, [])
setIsClaudeAIClearingAuth(true);
logEvent('tengu_claudeai_mcp_clear_auth_started', {});
}, []);
const handleToggleEnabled = React.useCallback(async () => {
const wasEnabled = server.client.type !== 'disabled'
const wasEnabled = server.client.type !== 'disabled';
try {
await toggleMcpServer(server.name)
await toggleMcpServer(server.name);
if (server.config.type === 'claudeai-proxy') {
logEvent('tengu_claudeai_mcp_toggle', {
new_state: (wasEnabled
? 'disabled'
: 'enabled') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
});
}
// Return to the server list so user can continue managing other servers
onCancel()
onCancel();
} catch (err) {
const action = wasEnabled ? 'disable' : 'enable'
onComplete?.(
`Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`,
)
const action = wasEnabled ? 'disable' : 'enable';
onComplete?.(`Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`);
}
}, [
server.client.type,
server.config.type,
server.name,
toggleMcpServer,
onCancel,
onComplete,
])
}, [server.client.type, server.config.type, server.name, toggleMcpServer, onCancel, onComplete]);
const handleAuthenticate = React.useCallback(async () => {
if (server.config.type === 'claudeai-proxy') return
if (server.config.type === 'claudeai-proxy') return;
setIsAuthenticating(true)
setError(null)
setIsAuthenticating(true);
setError(null);
const controller = new AbortController()
authAbortControllerRef.current = controller
const controller = new AbortController();
authAbortControllerRef.current = controller;
try {
// Revoke existing tokens if re-authenticating, but preserve step-up
@ -362,97 +307,75 @@ export function MCPRemoteServerMenu({
if (server.isAuthenticated && server.config) {
await revokeServerTokens(server.name, server.config, {
preserveStepUpState: true,
})
});
}
if (server.config) {
await performMCPOAuthFlow(
server.name,
server.config,
setAuthorizationUrl,
controller.signal,
{
onWaitingForCallback: submit => {
setManualCallbackSubmit(() => submit)
},
await performMCPOAuthFlow(server.name, server.config, setAuthorizationUrl, controller.signal, {
onWaitingForCallback: submit => {
setManualCallbackSubmit(() => submit);
},
)
});
logEvent('tengu_mcp_auth_config_authenticate', {
wasAuthenticated: server.isAuthenticated,
})
});
const result = await reconnectMcpServer(server.name)
const result = await reconnectMcpServer(server.name);
if (result.client.type === 'connected') {
const message = isEffectivelyAuthenticated
? `Authentication successful. Reconnected to ${server.name}.`
: `Authentication successful. Connected to ${server.name}.`
onComplete?.(message)
: `Authentication successful. Connected to ${server.name}.`;
onComplete?.(message);
} else if (result.client.type === 'needs-auth') {
onComplete?.(
'Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.',
)
);
} else {
// result.client.type === 'failed'
logMCPDebug(server.name, `Reconnection failed after authentication`)
logMCPDebug(server.name, `Reconnection failed after authentication`);
onComplete?.(
'Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.',
)
);
}
}
} catch (err) {
// Don't show error if it was a cancellation
if (
err instanceof Error &&
!(err instanceof AuthenticationCancelledError)
) {
setError(err.message)
if (err instanceof Error && !(err instanceof AuthenticationCancelledError)) {
setError(err.message);
}
} finally {
setIsAuthenticating(false)
authAbortControllerRef.current = null
setManualCallbackSubmit(null)
setCallbackUrlInput('')
setIsAuthenticating(false);
authAbortControllerRef.current = null;
setManualCallbackSubmit(null);
setCallbackUrlInput('');
}
}, [
server.isAuthenticated,
server.config,
server.name,
onComplete,
reconnectMcpServer,
isEffectivelyAuthenticated,
])
}, [server.isAuthenticated, server.config, server.name, onComplete, reconnectMcpServer, isEffectivelyAuthenticated]);
const handleClearAuth = async () => {
if (server.config.type === 'claudeai-proxy') return
if (server.config.type === 'claudeai-proxy') return;
if (server.config) {
// First revoke the authentication tokens and clear all auth state
await revokeServerTokens(server.name, server.config)
logEvent('tengu_mcp_auth_config_clear', {})
await revokeServerTokens(server.name, server.config);
logEvent('tengu_mcp_auth_config_clear', {});
// Disconnect the client and clear the cache
await clearServerCache(server.name, {
...server.config,
scope: server.scope,
})
});
// Update app state to remove the disconnected server's tools, commands, and resources
setAppState(prev => {
const newClients = prev.mcp.clients.map(c =>
// 'failed' is a misnomer here, but we don't really differentiate between "not connected" and "failed" at the moment
c.name === server.name ? { ...c, type: 'failed' as const } : c,
)
const newTools = excludeToolsByServer(prev.mcp.tools, server.name)
const newCommands = excludeCommandsByServer(
prev.mcp.commands,
server.name,
)
const newResources = excludeResourcesByServer(
prev.mcp.resources,
server.name,
)
);
const newTools = excludeToolsByServer(prev.mcp.tools, server.name);
const newCommands = excludeCommandsByServer(prev.mcp.commands, server.name);
const newResources = excludeResourcesByServer(prev.mcp.resources, server.name);
return {
...prev,
@ -463,12 +386,12 @@ export function MCPRemoteServerMenu({
commands: newCommands,
resources: newResources,
},
}
})
};
});
onComplete?.(`Authentication cleared for ${server.name}.`)
onComplete?.(`Authentication cleared for ${server.name}.`);
}
}
};
if (isAuthenticating) {
// XAA: silent exchange (cached id_token → no browser), so don't claim
@ -477,7 +400,7 @@ export function MCPRemoteServerMenu({
const authCopy =
server.config.type !== 'claudeai-proxy' && server.config.oauth?.xaa
? ' Authenticating via your identity provider'
: ' A browser window will open for authentication'
: ' A browser window will open for authentication';
return (
<Box flexDirection="column" gap={1} padding={1}>
<Text color="claude">Authenticating with {server.name}</Text>
@ -488,10 +411,7 @@ export function MCPRemoteServerMenu({
{authorizationUrl && (
<Box flexDirection="column">
<Box>
<Text dimColor>
If your browser doesn&apos;t open automatically, copy this URL
manually{' '}
</Text>
<Text dimColor>If your browser doesn&apos;t open automatically, copy this URL manually </Text>
{urlCopied ? (
<Text color="success">(Copied!)</Text>
) : (
@ -503,11 +423,10 @@ export function MCPRemoteServerMenu({
<Link url={authorizationUrl} />
</Box>
)}
{isAuthenticating && authorizationUrl && manualCallbackSubmit && (
{authorizationUrl && manualCallbackSubmit && (
<Box flexDirection="column" marginTop={1}>
<Text dimColor>
If the redirect page shows a connection error, paste the URL from
your browser&apos;s address bar:
If the redirect page shows a connection error, paste the URL from your browser&apos;s address bar:
</Text>
<Box>
<Text dimColor>URL {'>'} </Text>
@ -515,8 +434,8 @@ export function MCPRemoteServerMenu({
value={callbackUrlInput}
onChange={setCallbackUrlInput}
onSubmit={(value: string) => {
manualCallbackSubmit(value.trim())
setCallbackUrlInput('')
manualCallbackSubmit(value.trim());
setCallbackUrlInput('');
}}
cursorOffset={callbackUrlCursorOffset}
onChangeCursorOffset={setCallbackUrlCursorOffset}
@ -526,13 +445,10 @@ export function MCPRemoteServerMenu({
</Box>
)}
<Box marginLeft={3}>
<Text dimColor>
Return here after authenticating in your browser. Press Esc to go
back.
</Text>
<Text dimColor>Return here after authenticating in your browser. Press Esc to go back.</Text>
</Box>
</Box>
)
);
}
if (isClaudeAIAuthenticating) {
@ -546,10 +462,7 @@ export function MCPRemoteServerMenu({
{claudeAIAuthUrl && (
<Box flexDirection="column">
<Box>
<Text dimColor>
If your browser doesn&apos;t open automatically, copy this URL
manually{' '}
</Text>
<Text dimColor>If your browser doesn&apos;t open automatically, copy this URL manually </Text>
{urlCopied ? (
<Text color="success">(Copied!)</Text>
) : (
@ -566,16 +479,11 @@ export function MCPRemoteServerMenu({
Press <Text bold>Enter</Text> after authenticating in your browser.
</Text>
<Text dimColor italic>
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="back"
/>
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />
</Text>
</Box>
</Box>
)
);
}
if (isClaudeAIClearingAuth) {
@ -584,17 +492,11 @@ export function MCPRemoteServerMenu({
<Text color="claude">Clear authentication for {server.name}</Text>
{claudeAIClearAuthBrowserOpened ? (
<>
<Text>
Find the MCP server in the browser and click
&quot;Disconnect&quot;.
</Text>
<Text>Find the MCP server in the browser and click &quot;Disconnect&quot;.</Text>
{claudeAIClearAuthUrl && (
<Box flexDirection="column">
<Box>
<Text dimColor>
If your browser didn&apos;t open automatically, copy this
URL manually{' '}
</Text>
<Text dimColor>If your browser didn&apos;t open automatically, copy this URL manually </Text>
{urlCopied ? (
<Text color="success">(Copied!)</Text>
) : (
@ -623,8 +525,7 @@ export function MCPRemoteServerMenu({
) : (
<>
<Text>
This will open claude.ai in the browser. Find the MCP server in
the list and click &quot;Disconnect&quot;.
This will open claude.ai in the browser. Find the MCP server in the list and click &quot;Disconnect&quot;.
</Text>
<Box marginLeft={3} flexDirection="column">
<Text color="permission">
@ -642,7 +543,7 @@ export function MCPRemoteServerMenu({
</>
)}
</Box>
)
);
}
if (isReconnecting) {
@ -657,24 +558,24 @@ export function MCPRemoteServerMenu({
</Box>
<Text dimColor>This may take a few moments.</Text>
</Box>
)
);
}
const menuOptions = []
const menuOptions = [];
// If server is disabled, show Enable first as the primary action
if (server.client.type === 'disabled') {
menuOptions.push({
label: 'Enable',
value: 'toggle-enabled',
})
});
}
if (server.client.type === 'connected' && serverToolsCount > 0) {
menuOptions.push({
label: 'View tools',
value: 'tools',
})
});
}
if (server.config.type === 'claudeai-proxy') {
@ -682,30 +583,30 @@ export function MCPRemoteServerMenu({
menuOptions.push({
label: 'Clear authentication',
value: 'claudeai-clear-auth',
})
});
} else if (server.client.type !== 'disabled') {
menuOptions.push({
label: 'Authenticate',
value: 'claudeai-auth',
})
});
}
} else {
if (isEffectivelyAuthenticated) {
menuOptions.push({
label: 'Re-authenticate',
value: 'reauth',
})
});
menuOptions.push({
label: 'Clear authentication',
value: 'clear-auth',
})
});
}
if (!isEffectivelyAuthenticated) {
menuOptions.push({
label: 'Authenticate',
value: 'auth',
})
});
}
}
@ -714,12 +615,12 @@ export function MCPRemoteServerMenu({
menuOptions.push({
label: 'Reconnect',
value: 'reconnectMcpServer',
})
});
}
menuOptions.push({
label: 'Disable',
value: 'toggle-enabled',
})
});
}
// If there are no other options, add a back option so Select handles escape
@ -727,16 +628,12 @@ export function MCPRemoteServerMenu({
menuOptions.push({
label: 'Back',
value: 'back',
})
});
}
return (
<Box flexDirection="column">
<Box
flexDirection="column"
paddingX={1}
borderStyle={borderless ? undefined : 'round'}
>
<Box flexDirection="column" paddingX={1} borderStyle={borderless ? undefined : 'round'}>
<Box marginBottom={1}>
<Text bold>{capitalizedServerName} MCP Server</Text>
</Box>
@ -754,10 +651,7 @@ export function MCPRemoteServerMenu({
<Text> connecting</Text>
</>
) : server.client.type === 'needs-auth' ? (
<Text>
{color('warning', theme)(figures.triangleUpOutline)} needs
authentication
</Text>
<Text>{color('warning', theme)(figures.triangleUpOutline)} needs authentication</Text>
) : (
<Text>{color('error', theme)(figures.cross)} failed</Text>
)}
@ -767,13 +661,9 @@ export function MCPRemoteServerMenu({
<Box>
<Text bold>Auth: </Text>
{isEffectivelyAuthenticated ? (
<Text>
{color('success', theme)(figures.tick)} authenticated
</Text>
<Text>{color('success', theme)(figures.tick)} authenticated</Text>
) : (
<Text>
{color('error', theme)(figures.cross)} not authenticated
</Text>
<Text>{color('error', theme)(figures.cross)} not authenticated</Text>
)}
</Box>
)}
@ -817,52 +707,49 @@ export function MCPRemoteServerMenu({
onChange={async value => {
switch (value) {
case 'tools':
onViewTools()
break
onViewTools();
break;
case 'auth':
case 'reauth':
await handleAuthenticate()
break
await handleAuthenticate();
break;
case 'clear-auth':
await handleClearAuth()
break
await handleClearAuth();
break;
case 'claudeai-auth':
await handleClaudeAIAuth()
break
await handleClaudeAIAuth();
break;
case 'claudeai-clear-auth':
handleClaudeAIClearAuth()
break
handleClaudeAIClearAuth();
break;
case 'reconnectMcpServer':
setIsReconnecting(true)
setIsReconnecting(true);
try {
const result = await reconnectMcpServer(server.name)
const result = await reconnectMcpServer(server.name);
if (server.config.type === 'claudeai-proxy') {
logEvent('tengu_claudeai_mcp_reconnect', {
success: result.client.type === 'connected',
})
});
}
const { message } = handleReconnectResult(
result,
server.name,
)
onComplete?.(message)
const { message } = handleReconnectResult(result, server.name);
onComplete?.(message);
} catch (err) {
if (server.config.type === 'claudeai-proxy') {
logEvent('tengu_claudeai_mcp_reconnect', {
success: false,
})
});
}
onComplete?.(handleReconnectError(err, server.name))
onComplete?.(handleReconnectError(err, server.name));
} finally {
setIsReconnecting(false)
setIsReconnecting(false);
}
break
break;
case 'toggle-enabled':
await handleToggleEnabled()
break
await handleToggleEnabled();
break;
case 'back':
onCancel()
break
onCancel();
break;
}
}}
onCancel={onCancel}
@ -879,16 +766,11 @@ export function MCPRemoteServerMenu({
<Byline>
<KeyboardShortcutHint shortcut="↑↓" action="navigate" />
<KeyboardShortcutHint shortcut="Enter" action="select" />
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="back"
/>
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="back" />
</Byline>
)}
</Text>
</Box>
</Box>
)
);
}

View File

@ -2049,7 +2049,7 @@ async function run(): Promise<CommanderCommand> {
}
// For --mcp-config, allow if all servers are internal types (sdk)
if (dynamicMcpConfig && !areMcpConfigsAllowedWithEnterpriseMcpConfig(dynamicMcpConfig)) {
if (!areMcpConfigsAllowedWithEnterpriseMcpConfig(dynamicMcpConfig)) {
process.stderr.write(
chalk.red('You cannot dynamically configure MCP servers when an enterprise MCP config is present'),
);

View File

@ -1364,19 +1364,6 @@ async function* queryModel(
return
}
if (getAPIProvider() === 'gemini') {
const { queryModelGemini } = await import('./gemini/index.js')
yield* queryModelGemini(
messagesForAPI,
systemPrompt,
filteredTools,
signal,
options,
thinkingConfig,
)
return
}
if (getAPIProvider() === 'grok') {
const { queryModelGrok } = await import('./grok/index.js')
yield* queryModelGrok(

View File

@ -1,267 +0,0 @@
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' }],
},
])
})
test('converts base64 image to inlineData', () => {
const result = anthropicMessagesToGemini(
[makeUserMsg([
{ type: 'text', text: 'describe this' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'iVBORw0KGgo=',
},
},
])],
[] as any,
)
expect(result.contents).toEqual([
{
role: 'user',
parts: [
{ text: 'describe this' },
{ inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgo=' } },
],
},
])
})
test('converts url image to text fallback', () => {
const result = anthropicMessagesToGemini(
[makeUserMsg([
{
type: 'image',
source: {
type: 'url',
url: 'https://example.com/img.png',
},
},
])],
[] as any,
)
expect(result.contents).toEqual([
{
role: 'user',
parts: [{ text: '[image: https://example.com/img.png]' }],
},
])
})
test('defaults to image/png when media_type is missing', () => {
const result = anthropicMessagesToGemini(
[makeUserMsg([
{
type: 'image',
source: {
type: 'base64',
data: 'ABC123',
},
},
])],
[] as any,
)
expect(result.contents[0].parts[0]).toEqual({
inlineData: { mimeType: 'image/png', data: 'ABC123' },
})
})
})

View File

@ -1,130 +0,0 @@
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'],
})
})
})

View File

@ -1,100 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { resolveGeminiModel } from '../modelMapping.js'
describe('resolveGeminiModel', () => {
const originalEnv = {
GEMINI_MODEL: process.env.GEMINI_MODEL,
GEMINI_DEFAULT_HAIKU_MODEL: process.env.GEMINI_DEFAULT_HAIKU_MODEL,
GEMINI_DEFAULT_SONNET_MODEL: process.env.GEMINI_DEFAULT_SONNET_MODEL,
GEMINI_DEFAULT_OPUS_MODEL: process.env.GEMINI_DEFAULT_OPUS_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.GEMINI_DEFAULT_HAIKU_MODEL
delete process.env.GEMINI_DEFAULT_SONNET_MODEL
delete process.env.GEMINI_DEFAULT_OPUS_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('GEMINI_DEFAULT_*_MODEL takes precedence over ANTHROPIC_DEFAULT_*', () => {
process.env.GEMINI_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash-priority'
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash-fallback'
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe(
'gemini-2.5-flash-priority',
)
})
test('resolves sonnet model from GEMINI_DEFAULT_SONNET_MODEL', () => {
process.env.GEMINI_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-flash')
})
test('resolves haiku model from GEMINI_DEFAULT_HAIKU_MODEL', () => {
process.env.GEMINI_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 GEMINI_DEFAULT_OPUS_MODEL', () => {
process.env.GEMINI_DEFAULT_OPUS_MODEL = 'gemini-2.5-pro'
expect(resolveGeminiModel('claude-opus-4-6')).toBe('gemini-2.5-pro')
})
test('falls back to ANTHROPIC_DEFAULT_* when GEMINI_DEFAULT_* not set', () => {
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-flash')
})
test('resolves haiku from ANTHROPIC_DEFAULT_HAIKU_MODEL as fallback', () => {
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 from ANTHROPIC_DEFAULT_OPUS_MODEL as fallback', () => {
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'gemini-2.5-pro'
expect(resolveGeminiModel('claude-opus-4-6')).toBe('gemini-2.5-pro')
})
test('uses backward compatible 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.GEMINI_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 no Gemini model configuration is available', () => {
expect(() => resolveGeminiModel('claude-sonnet-4-6')).toThrow(
'Gemini provider requires GEMINI_MODEL or GEMINI_DEFAULT_SONNET_MODEL (or ANTHROPIC_DEFAULT_SONNET_MODEL for backward compatibility) to be configured.',
)
})
})

View File

@ -1,175 +0,0 @@
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)
})
})

View File

@ -1,97 +0,0 @@
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()
}
}

View File

@ -1,298 +0,0 @@
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 as unknown as string | Record<string, unknown>, 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),
},
},
]
}
// 将 Anthropic image 块转换为 Gemini inlineData
if (block.type === 'image') {
const source = block.source as Record<string, unknown> | undefined
if (source?.type === 'base64' && typeof source.data === 'string') {
const mediaType = (source.media_type as string) || 'image/png'
return [
{
inlineData: {
mimeType: mediaType,
data: source.data,
},
},
]
}
// url 类型的图片Gemini 不直接支持,转为文本描述
if (source?.type === 'url' && typeof source.url === 'string') {
return createTextGeminiParts(`[image: ${source.url}]`)
}
}
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 as unknown as Record<string, unknown>),
),
)
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 as unknown as Record<string, unknown>) && {
thoughtSignature: getGeminiThoughtSignature(block as unknown as Record<string, unknown>),
}),
})
}
}
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 as Record<string, unknown>), is_error: true } : result as Record<string, unknown>
}
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
}

View File

@ -1,285 +0,0 @@
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 => {
const toolType = (tool as unknown as { type?: string }).type
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
})
.map(tool => {
const anyTool = tool as unknown 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
}
}

View File

@ -1,226 +0,0 @@
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 { 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 { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
import type { ThinkingConfig } from '../../../utils/thinking.js'
import type { Options } from '../claude.js'
import { recordLLMObservation } from '../../../services/langfuse/tracing.js'
import {
convertMessagesToLangfuse,
convertOutputToLangfuse,
convertToolsToLangfuse,
} from '../../../services/langfuse/convert.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 unknown 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()
const collectedMessages: AssistantMessage[] = []
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(),
}
collectedMessages.push(message)
yield message
break
}
case 'message_delta':
case 'message_stop':
break
}
yield {
type: 'stream_event',
event,
...(event.type === 'message_start' ? { ttftMs } : undefined),
} as StreamEvent
}
// Record LLM observation in Langfuse (no-op if not configured).
recordLLMObservation(options.langfuseTrace ?? null, {
model: geminiModel,
provider: 'gemini',
input: convertMessagesToLangfuse(messagesForAPI, systemPrompt),
output: convertOutputToLangfuse(collectedMessages),
usage: {
input_tokens: 0,
output_tokens: 0,
},
startTime: new Date(start),
endTime: new Date(),
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
thinking:
thinkingConfig.type !== 'disabled'
? {
type: thinkingConfig.type,
...(thinkingConfig.type === 'enabled' && {
budgetTokens: thinkingConfig.budgetTokens,
}),
}
: undefined,
})
} 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))) as unknown as SDKAssistantMessageError,
})
}
}

View File

@ -1,37 +0,0 @@
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
}
// First, try Gemini-specific DEFAULT variables (separated from Anthropic)
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
const geminiModel = process.env[geminiEnvVar]
if (geminiModel) {
return geminiModel
}
// Fallback to Anthropic DEFAULT variables for backward compatibility
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 ${geminiEnvVar} (or ${sharedEnvVar} for backward compatibility) to be configured.`,
)
}

View File

@ -1,243 +0,0 @@
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 unknown 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'
}
}

View File

@ -1,86 +0,0 @@
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 GeminiInlineData = {
mimeType: string
data: string
}
export type GeminiPart = {
text?: string
thought?: boolean
thoughtSignature?: string
functionCall?: GeminiFunctionCall
functionResponse?: GeminiFunctionResponse
inlineData?: GeminiInlineData
}
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
}

View File

@ -470,7 +470,7 @@ export async function* runToolUse(
} catch (error) {
logError(error)
const errorMessage = error instanceof Error ? error.message : String(error)
const toolInfo = tool ? ` (${tool.name})` : ''
const toolInfo = ` (${tool.name})`
const detailedError = `Error calling tool${toolInfo}: ${errorMessage}`
yield {

View File

@ -820,6 +820,7 @@ export async function getAttachments(
suppressNextDiscovery = false
return []
}
suppressNextDiscovery = true
const result =
await skillSearchModules.prefetch.getTurnZeroSkillDiscovery(
input,

View File

@ -71,7 +71,7 @@ let ccdSpawnEnvKeys: Set<string> | null | undefined
function withoutCcdSpawnEnvKeys(
env: Record<string, string> | undefined,
): Record<string, string> {
if (!env || !ccdSpawnEnvKeys) return env || {}
if (!ccdSpawnEnvKeys) return env
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (!ccdSpawnEnvKeys.has(key)) out[key] = value

View File

@ -799,10 +799,6 @@ export function initialPermissionModeFromCLI({
result = { mode: 'default', notification }
}
if (!result) {
result = { mode: 'default', notification }
}
if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') {
autoModeStateModule?.setAutoModeActive(true)
}
@ -1310,8 +1306,11 @@ export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null
*/
export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in'
const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState =
feature('TRANSCRIPT_CLASSIFIER') ? 'enabled' : 'disabled'
const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = feature(
'TRANSCRIPT_CLASSIFIER',
)
? 'enabled'
: 'disabled'
function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState {
if (value === 'enabled' || value === 'disabled' || value === 'opt-in') {

View File

@ -377,7 +377,7 @@ function blockedConstraintMatches(
return true
}
// If blocklist specifies a constraint, source must match exactly
return (blockedValue || undefined) === (sourceValue || undefined)
return blockedValue === (sourceValue || undefined)
}
/**