From 2b6cf08c7cfd71d48cb942f4802cfdc8d0621655 Mon Sep 17 00:00:00 2001 From: James Feng <47167674+GhostDragon124@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:29:34 +0800 Subject: [PATCH] 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. --- CLAUDE.md | 2 +- .../src/tools/BashTool/bashSecurity.ts | 8 +- .../src/tools/WebFetchTool/UI.tsx | 2 +- .../src/tools/WebSearchTool/UI.tsx | 4 +- .../web/src/components/EventStream.tsx | 2 +- .../web/src/lib/theme.ts | 4 +- src/components/mcp/MCPRemoteServerMenu.tsx | 584 +++++++----------- src/main.tsx | 2 +- src/services/api/claude.ts | 13 - .../gemini/__tests__/convertMessages.test.ts | 267 -------- .../api/gemini/__tests__/convertTools.test.ts | 130 ---- .../api/gemini/__tests__/modelMapping.test.ts | 100 --- .../gemini/__tests__/streamAdapter.test.ts | 175 ------ src/services/api/gemini/client.ts | 97 --- src/services/api/gemini/convertMessages.ts | 298 --------- src/services/api/gemini/convertTools.ts | 285 --------- src/services/api/gemini/index.ts | 226 ------- src/services/api/gemini/modelMapping.ts | 37 -- src/services/api/gemini/streamAdapter.ts | 243 -------- src/services/api/gemini/types.ts | 86 --- src/services/tools/toolExecution.ts | 2 +- src/utils/attachments.ts | 1 + src/utils/managedEnv.ts | 2 +- src/utils/permissions/permissionSetup.ts | 11 +- src/utils/plugins/marketplaceHelpers.ts | 2 +- 25 files changed, 252 insertions(+), 2331 deletions(-) delete mode 100644 src/services/api/gemini/__tests__/convertMessages.test.ts delete mode 100644 src/services/api/gemini/__tests__/convertTools.test.ts delete mode 100644 src/services/api/gemini/__tests__/modelMapping.test.ts delete mode 100644 src/services/api/gemini/__tests__/streamAdapter.test.ts delete mode 100644 src/services/api/gemini/client.ts delete mode 100644 src/services/api/gemini/convertMessages.ts delete mode 100644 src/services/api/gemini/convertTools.ts delete mode 100644 src/services/api/gemini/index.ts delete mode 100644 src/services/api/gemini/modelMapping.ts delete mode 100644 src/services/api/gemini/streamAdapter.ts delete mode 100644 src/services/api/gemini/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index be02817f9..869d6d1b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ bun run docs:dev - **为什么 Vite 必须代码分割**: Bun/JSC 会全量解析单个大 JS 文件的 bytecode 和 JIT,单文件 17MB 产物导致 RSS 暴涨至 ~1GB(Node/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 失败。 diff --git a/packages/builtin-tools/src/tools/BashTool/bashSecurity.ts b/packages/builtin-tools/src/tools/BashTool/bashSecurity.ts index 13f949b7a..527234405 100644 --- a/packages/builtin-tools/src/tools/BashTool/bashSecurity.ts +++ b/packages/builtin-tools/src/tools/BashTool/bashSecurity.ts @@ -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 } } diff --git a/packages/builtin-tools/src/tools/WebFetchTool/UI.tsx b/packages/builtin-tools/src/tools/WebFetchTool/UI.tsx index a92804279..dc55629d5 100644 --- a/packages/builtin-tools/src/tools/WebFetchTool/UI.tsx +++ b/packages/builtin-tools/src/tools/WebFetchTool/UI.tsx @@ -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; } diff --git a/packages/builtin-tools/src/tools/WebSearchTool/UI.tsx b/packages/builtin-tools/src/tools/WebSearchTool/UI.tsx index 3c868bfa3..87a4713b6 100644 --- a/packages/builtin-tools/src/tools/WebSearchTool/UI.tsx +++ b/packages/builtin-tools/src/tools/WebSearchTool/UI.tsx @@ -41,9 +41,7 @@ export function renderToolUseMessage( let message = ''; - if (query) { - message += `"${query}"`; - } + message += `"${query}"`; if (verbose) { if (allowed_domains && allowed_domains.length > 0) { diff --git a/packages/remote-control-server/web/src/components/EventStream.tsx b/packages/remote-control-server/web/src/components/EventStream.tsx index 0ecc16d17..c2cfc08b9 100644 --- a/packages/remote-control-server/web/src/components/EventStream.tsx +++ b/packages/remote-control-server/web/src/components/EventStream.tsx @@ -898,7 +898,7 @@ export function useEventProcessor() { const text = extractEventText(payload as Record); if (text) { setMessages(prev => [...prev, { kind: 'user', content: text }]); - if (!replay) showLoading(); + showLoading(); } break; } diff --git a/packages/remote-control-server/web/src/lib/theme.ts b/packages/remote-control-server/web/src/lib/theme.ts index d8914a550..79f69afc1 100644 --- a/packages/remote-control-server/web/src/lib/theme.ts +++ b/packages/remote-control-server/web/src/lib/theme.ts @@ -62,10 +62,10 @@ export function ThemeProvider({ defaultTheme = 'system', }: ThemeProviderProps) { const [theme, setThemeState] = useState( - () => getStoredTheme() || defaultTheme, + () => getStoredTheme(), ) const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => { - const stored = getStoredTheme() || defaultTheme + const stored = getStoredTheme() return stored === 'system' ? getSystemTheme() : stored }) diff --git a/src/components/mcp/MCPRemoteServerMenu.tsx b/src/components/mcp/MCPRemoteServerMenu.tsx index 41665860e..a4589f3ab 100644 --- a/src/components/mcp/MCPRemoteServerMenu.tsx +++ b/src/components/mcp/MCPRemoteServerMenu.tsx @@ -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(null) - const mcp = useAppState(s => s.mcp) - const setAppState = useSetAppState() - const [authorizationUrl, setAuthorizationUrl] = React.useState( - null, - ) - const [isReconnecting, setIsReconnecting] = useState(false) - const authAbortControllerRef = useRef(null) - const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] = - useState(false) - const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState(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 | 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(null); + const mcp = useAppState(s => s.mcp); + const setAppState = useSetAppState(); + const [authorizationUrl, setAuthorizationUrl] = React.useState(null); + const [isReconnecting, setIsReconnecting] = useState(false); + const authAbortControllerRef = useRef(null); + const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] = useState(false); + const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState(null); + const [isClaudeAIClearingAuth, setIsClaudeAIClearingAuth] = useState(false); + const [claudeAIClearAuthUrl, setClaudeAIClearAuthUrl] = useState(null); + const [claudeAIClearAuthBrowserOpened, setClaudeAIClearAuthBrowserOpened] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); + const copyTimeoutRef = useRef | 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 ( Authenticating with {server.name}… @@ -488,10 +411,7 @@ export function MCPRemoteServerMenu({ {authorizationUrl && ( - - If your browser doesn't open automatically, copy this URL - manually{' '} - + If your browser doesn't open automatically, copy this URL manually {urlCopied ? ( (Copied!) ) : ( @@ -503,11 +423,10 @@ export function MCPRemoteServerMenu({ )} - {isAuthenticating && authorizationUrl && manualCallbackSubmit && ( + {authorizationUrl && manualCallbackSubmit && ( - If the redirect page shows a connection error, paste the URL from - your browser's address bar: + If the redirect page shows a connection error, paste the URL from your browser's address bar: URL {'>'} @@ -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({ )} - - Return here after authenticating in your browser. Press Esc to go - back. - + Return here after authenticating in your browser. Press Esc to go back. - ) + ); } if (isClaudeAIAuthenticating) { @@ -546,10 +462,7 @@ export function MCPRemoteServerMenu({ {claudeAIAuthUrl && ( - - If your browser doesn't open automatically, copy this URL - manually{' '} - + If your browser doesn't open automatically, copy this URL manually {urlCopied ? ( (Copied!) ) : ( @@ -566,16 +479,11 @@ export function MCPRemoteServerMenu({ Press Enter after authenticating in your browser. - + - ) + ); } if (isClaudeAIClearingAuth) { @@ -584,17 +492,11 @@ export function MCPRemoteServerMenu({ Clear authentication for {server.name} {claudeAIClearAuthBrowserOpened ? ( <> - - Find the MCP server in the browser and click - "Disconnect". - + Find the MCP server in the browser and click "Disconnect". {claudeAIClearAuthUrl && ( - - If your browser didn't open automatically, copy this - URL manually{' '} - + If your browser didn't open automatically, copy this URL manually {urlCopied ? ( (Copied!) ) : ( @@ -623,8 +525,7 @@ export function MCPRemoteServerMenu({ ) : ( <> - This will open claude.ai in the browser. Find the MCP server in - the list and click "Disconnect". + This will open claude.ai in the browser. Find the MCP server in the list and click "Disconnect". @@ -642,7 +543,7 @@ export function MCPRemoteServerMenu({ )} - ) + ); } if (isReconnecting) { @@ -657,24 +558,24 @@ export function MCPRemoteServerMenu({ This may take a few moments. - ) + ); } - 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 ( - + {capitalizedServerName} MCP Server @@ -754,10 +651,7 @@ export function MCPRemoteServerMenu({ connecting… ) : server.client.type === 'needs-auth' ? ( - - {color('warning', theme)(figures.triangleUpOutline)} needs - authentication - + {color('warning', theme)(figures.triangleUpOutline)} needs authentication ) : ( {color('error', theme)(figures.cross)} failed )} @@ -767,13 +661,9 @@ export function MCPRemoteServerMenu({ Auth: {isEffectivelyAuthenticated ? ( - - {color('success', theme)(figures.tick)} authenticated - + {color('success', theme)(figures.tick)} authenticated ) : ( - - {color('error', theme)(figures.cross)} not authenticated - + {color('error', theme)(figures.cross)} not authenticated )} )} @@ -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({ - + )} - ) + ); } diff --git a/src/main.tsx b/src/main.tsx index 68af40acc..c1d160542 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2049,7 +2049,7 @@ async function run(): Promise { } // 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'), ); diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 665c68d2e..e63fa88e1 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -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( diff --git a/src/services/api/gemini/__tests__/convertMessages.test.ts b/src/services/api/gemini/__tests__/convertMessages.test.ts deleted file mode 100644 index 63a9cf60a..000000000 --- a/src/services/api/gemini/__tests__/convertMessages.test.ts +++ /dev/null @@ -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' }, - }) - }) -}) diff --git a/src/services/api/gemini/__tests__/convertTools.test.ts b/src/services/api/gemini/__tests__/convertTools.test.ts deleted file mode 100644 index 999f362cd..000000000 --- a/src/services/api/gemini/__tests__/convertTools.test.ts +++ /dev/null @@ -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'], - }) - }) -}) diff --git a/src/services/api/gemini/__tests__/modelMapping.test.ts b/src/services/api/gemini/__tests__/modelMapping.test.ts deleted file mode 100644 index 37a285ab8..000000000 --- a/src/services/api/gemini/__tests__/modelMapping.test.ts +++ /dev/null @@ -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.', - ) - }) -}) diff --git a/src/services/api/gemini/__tests__/streamAdapter.test.ts b/src/services/api/gemini/__tests__/streamAdapter.test.ts deleted file mode 100644 index d7b42229f..000000000 --- a/src/services/api/gemini/__tests__/streamAdapter.test.ts +++ /dev/null @@ -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 { - 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) - }) -}) diff --git a/src/services/api/gemini/client.ts b/src/services/api/gemini/client.ts deleted file mode 100644 index 2c8b68f8e..000000000 --- a/src/services/api/gemini/client.ts +++ /dev/null @@ -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 { - 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() - } -} diff --git a/src/services/api/gemini/convertMessages.ts b/src/services/api/gemini/convertMessages.ts deleted file mode 100644 index 0bdf22223..000000000 --- a/src/services/api/gemini/convertMessages.ts +++ /dev/null @@ -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 { - const contents: GeminiContent[] = [] - const toolNamesById = new Map() - - 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, -): 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, toolNamesById), - ), - } -} - -function convertUserContentBlockToGeminiParts( - block: string | Record, - toolNamesById: ReadonlyMap, -): 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 | 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), - ), - ) - 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) && { - thoughtSignature: getGeminiThoughtSignature(block as unknown as Record), - }), - }) - } - } - - 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 { - if (typeof input === 'string') { - const parsed = safeParseJSON(input) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record - } - return parsed === null ? {} : { value: parsed } - } - - if (input && typeof input === 'object' && !Array.isArray(input)) { - return input as Record - } - - return input === undefined ? {} : { value: input } -} - -function toolResultToResponseObject( - block: BetaToolResultBlockParam, -): Record { - const result = normalizeToolResultContent(block.content) - if ( - result && - typeof result === 'object' && - !Array.isArray(result) - ) { - return block.is_error ? { ...(result as Record), is_error: true } : result as Record - } - - 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 | undefined { - const signature = block[GEMINI_THOUGHT_SIGNATURE_FIELD] - return typeof signature === 'string' && signature.length > 0 - ? signature - : undefined -} diff --git a/src/services/api/gemini/convertTools.ts b/src/services/api/gemini/convertTools.ts deleted file mode 100644 index 7f6fc82c5..000000000 --- a/src/services/api/gemini/convertTools.ts +++ /dev/null @@ -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> | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined - } - - const sanitizedEntries = Object.entries(value as Record) - .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[] | 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 { - if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { - return {} - } - - const source = schema as Record - const result: Record = {} - - 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 { - 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 - const name = (anyTool.name as string) || '' - const description = (anyTool.description as string) || '' - const inputSchema = - (anyTool.input_schema as Record | 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 - 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 - } -} diff --git a/src/services/api/gemini/index.ts b/src/services/api/gemini/index.ts deleted file mode 100644 index 4d9eca056..000000000 --- a/src/services/api/gemini/index.ts +++ /dev/null @@ -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 - 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 = {} - 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, - }) - } -} diff --git a/src/services/api/gemini/modelMapping.ts b/src/services/api/gemini/modelMapping.ts deleted file mode 100644 index 1d372e026..000000000 --- a/src/services/api/gemini/modelMapping.ts +++ /dev/null @@ -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.`, - ) -} diff --git a/src/services/api/gemini/streamAdapter.ts b/src/services/api/gemini/streamAdapter.ts deleted file mode 100644 index d40980e04..000000000 --- a/src/services/api/gemini/streamAdapter.ts +++ /dev/null @@ -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, - model: string, -): AsyncGenerator { - 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' - } -} diff --git a/src/services/api/gemini/types.ts b/src/services/api/gemini/types.ts deleted file mode 100644 index e8718fecd..000000000 --- a/src/services/api/gemini/types.ts +++ /dev/null @@ -1,86 +0,0 @@ -export const GEMINI_THOUGHT_SIGNATURE_FIELD = '_geminiThoughtSignature' - -export type GeminiFunctionCall = { - name?: string - args?: Record -} - -export type GeminiFunctionResponse = { - name?: string - response?: Record -} - -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 - parametersJsonSchema?: Record -} - -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 -} diff --git a/src/services/tools/toolExecution.ts b/src/services/tools/toolExecution.ts index f748cb2cd..b4bfba9db 100644 --- a/src/services/tools/toolExecution.ts +++ b/src/services/tools/toolExecution.ts @@ -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 { diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index ae332fe61..d46236618 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -820,6 +820,7 @@ export async function getAttachments( suppressNextDiscovery = false return [] } + suppressNextDiscovery = true const result = await skillSearchModules.prefetch.getTurnZeroSkillDiscovery( input, diff --git a/src/utils/managedEnv.ts b/src/utils/managedEnv.ts index 324b2d31e..65935b53f 100644 --- a/src/utils/managedEnv.ts +++ b/src/utils/managedEnv.ts @@ -71,7 +71,7 @@ let ccdSpawnEnvKeys: Set | null | undefined function withoutCcdSpawnEnvKeys( env: Record | undefined, ): Record { - if (!env || !ccdSpawnEnvKeys) return env || {} + if (!ccdSpawnEnvKeys) return env const out: Record = {} for (const [key, value] of Object.entries(env)) { if (!ccdSpawnEnvKeys.has(key)) out[key] = value diff --git a/src/utils/permissions/permissionSetup.ts b/src/utils/permissions/permissionSetup.ts index 0ddfd419b..67c54419b 100644 --- a/src/utils/permissions/permissionSetup.ts +++ b/src/utils/permissions/permissionSetup.ts @@ -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') { diff --git a/src/utils/plugins/marketplaceHelpers.ts b/src/utils/plugins/marketplaceHelpers.ts index 1deb29390..03c631270 100644 --- a/src/utils/plugins/marketplaceHelpers.ts +++ b/src/utils/plugins/marketplaceHelpers.ts @@ -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) } /**