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:
parent
512144bba2
commit
2b6cf08c7c
|
|
@ -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 失败。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,9 +41,7 @@ export function renderToolUseMessage(
|
|||
|
||||
let message = '';
|
||||
|
||||
if (query) {
|
||||
message += `"${query}"`;
|
||||
}
|
||||
message += `"${query}"`;
|
||||
|
||||
if (verbose) {
|
||||
if (allowed_domains && allowed_domains.length > 0) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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't open automatically, copy this URL
|
||||
manually{' '}
|
||||
</Text>
|
||||
<Text dimColor>If your browser doesn'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's address bar:
|
||||
If the redirect page shows a connection error, paste the URL from your browser'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't open automatically, copy this URL
|
||||
manually{' '}
|
||||
</Text>
|
||||
<Text dimColor>If your browser doesn'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
|
||||
"Disconnect".
|
||||
</Text>
|
||||
<Text>Find the MCP server in the browser and click "Disconnect".</Text>
|
||||
{claudeAIClearAuthUrl && (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
If your browser didn't open automatically, copy this
|
||||
URL manually{' '}
|
||||
</Text>
|
||||
<Text dimColor>If your browser didn'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 "Disconnect".
|
||||
This will open claude.ai in the browser. Find the MCP server in the list and click "Disconnect".
|
||||
</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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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.`,
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -820,6 +820,7 @@ export async function getAttachments(
|
|||
suppressNextDiscovery = false
|
||||
return []
|
||||
}
|
||||
suppressNextDiscovery = true
|
||||
const result =
|
||||
await skillSearchModules.prefetch.getTurnZeroSkillDiscovery(
|
||||
input,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user