From 5f9ef8d8e4bf75f71dd1e7c400e86e46fea9f7e6 Mon Sep 17 00:00:00 2001 From: SaltedFish555 <3262749586@qq.com> Date: Sun, 5 Apr 2026 00:11:45 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0gemini=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/environments/environment.toml | 11 + AGENTS.md | 179 +++++++++++ fixtures/token-count-7ef7321f9e68.json | 3 + fixtures/token-count-fafe44226655.json | 3 + src/components/ConsoleOAuthFlow.tsx | 140 +++++++++ src/services/api/claude.ts | 64 +++- .../gemini/__tests__/convertMessages.test.ts | 202 +++++++++++++ .../api/gemini/__tests__/convertTools.test.ts | 130 ++++++++ .../api/gemini/__tests__/modelMapping.test.ts | 72 +++++ .../gemini/__tests__/streamAdapter.test.ts | 175 +++++++++++ src/services/api/gemini/client.ts | 97 ++++++ src/services/api/gemini/convertMessages.ts | 278 +++++++++++++++++ src/services/api/gemini/convertTools.ts | 284 ++++++++++++++++++ src/services/api/gemini/index.ts | 192 ++++++++++++ src/services/api/gemini/modelMapping.ts | 30 ++ src/services/api/gemini/streamAdapter.ts | 244 +++++++++++++++ src/services/api/gemini/types.ts | 80 +++++ src/services/tokenEstimation.ts | 35 ++- src/utils/__tests__/messages.test.ts | 21 ++ src/utils/auth.ts | 4 +- src/utils/managedEnvConstants.ts | 6 + src/utils/messages.ts | 9 +- src/utils/model/__tests__/providers.test.ts | 46 ++- src/utils/model/configs.ts | 11 + src/utils/model/providers.ts | 29 +- src/utils/settings/__tests__/config.test.ts | 7 + src/utils/settings/types.ts | 7 +- src/utils/status.tsx | 9 +- 28 files changed, 2331 insertions(+), 37 deletions(-) create mode 100644 .codex/environments/environment.toml create mode 100644 AGENTS.md create mode 100644 fixtures/token-count-7ef7321f9e68.json create mode 100644 fixtures/token-count-fafe44226655.json create mode 100644 src/services/api/gemini/__tests__/convertMessages.test.ts create mode 100644 src/services/api/gemini/__tests__/convertTools.test.ts create mode 100644 src/services/api/gemini/__tests__/modelMapping.test.ts create mode 100644 src/services/api/gemini/__tests__/streamAdapter.test.ts create mode 100644 src/services/api/gemini/client.ts create mode 100644 src/services/api/gemini/convertMessages.ts create mode 100644 src/services/api/gemini/convertTools.ts create mode 100644 src/services/api/gemini/index.ts create mode 100644 src/services/api/gemini/modelMapping.ts create mode 100644 src/services/api/gemini/streamAdapter.ts create mode 100644 src/services/api/gemini/types.ts diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 000000000..1b8f087ab --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "claude-code" + +[setup] +script = "" + +[[actions]] +name = "运行" +icon = "run" +command = "bun run dev" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..c804ded04 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,179 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +This is a **reverse-engineered / decompiled** version of Anthropic's official Codex CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. The codebase has ~1341 tsc errors from decompilation (mostly `unknown`/`never`/`{}` types) — these do **not** block Bun runtime execution. + +## Commands + +```bash +# Install dependencies +bun install + +# Dev mode (runs cli.tsx with MACRO defines injected via -d flags) +bun run dev + +# Dev mode with debugger (set BUN_INSPECT=9229 to pick port) +bun run dev:inspect + +# Pipe mode +echo "say hello" | bun run src/entrypoints/cli.tsx -p + +# Build (code splitting, outputs dist/cli.js + ~450 chunk files) +bun run build + +# Test +bun test # run all tests +bun test src/utils/__tests__/hash.test.ts # run single file +bun test --coverage # with coverage report + +# Lint & Format (Biome) +bun run lint # check only +bun run lint:fix # auto-fix +bun run format # format all src/ + +# Health check +bun run health + +# Check unused exports +bun run check:unused + +# Docs dev server (Mintlify) +bun run docs:dev +``` + +详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`。 + +## Architecture + +### Runtime & Build + +- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs. +- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。默认启用 `AGENT_TRIGGERS_REMOTE` feature。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。 +- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用 `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE` 四个 feature。 +- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform. +- **Monorepo**: Bun workspaces — internal packages live in `packages/` resolved via `workspace:*`. +- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。 +- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。 + +### Entry & Bootstrap + +1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径: + - `--version` / `-v` — 零模块加载 + - `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT) + - `--Codex-in-chrome-mcp` / `--chrome-native-host` + - `--daemon-worker=` — feature-gated (DAEMON) + - `remote-control` / `rc` / `bridge` — feature-gated (BRIDGE_MODE) + - `daemon` — feature-gated (DAEMON) + - `ps` / `logs` / `attach` / `kill` / `--bg` — feature-gated (BG_SESSIONS) + - `--tmux` + `--worktree` 组合 + - 默认路径:加载 `main.tsx` 启动完整 CLI +2. **`src/main.tsx`** (~4680 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。 +3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。 + +### Core Loop + +- **`src/query.ts`** — The main API query function. Sends messages to Codex API, handles streaming responses, processes tool calls, and manages the conversation turn loop. +- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen. +- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts. + +### API Layer + +- **`src/services/api/Codex.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events. +- Supports multiple providers: Anthropic direct, AWS Bedrock, Google Vertex, Azure. +- Provider selection in `src/utils/model/providers.ts`. + +### Tool System + +- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`). +- **`src/tools.ts`** — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`. +- **`src/tools//`** — 61 个 tool 目录(如 BashTool, FileEditTool, GrepTool, AgentTool, WebFetchTool, LSPTool, MCPTool 等)。每个 tool 包含 `name`、`description`、`inputSchema`、`call()` 及可选的 React 渲染组件。 +- **`src/tools/shared/`** — Tool 共享工具函数。 + +### UI Layer (Ink) + +- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection. +- **`src/ink/`** — Custom Ink framework (forked/internal): custom reconciler, hooks (`useInput`, `useTerminalSize`, `useSearchHighlight`), virtual list rendering. +- **`src/components/`** — 大量 React 组件(170+ 项),渲染于终端 Ink 环境中。关键组件: + - `App.tsx` — Root provider (AppState, Stats, FpsMetrics) + - `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering + - `PromptInput/` — User input handling + - `permissions/` — Tool permission approval UI + - `design-system/` — 复用 UI 组件(Dialog, FuzzyPicker, ProgressBar, ThemeProvider 等) +- Components use React Compiler runtime (`react/compiler-runtime`) — decompiled output has `_c()` memoization calls throughout. + +### State Management + +- **`src/state/AppState.tsx`** — Central app state type and context provider. Contains messages, tools, permissions, MCP connections, etc. +- **`src/state/AppStateStore.ts`** — Default state and store factory. +- **`src/state/store.ts`** — Zustand-style store for AppState (`createStore`). +- **`src/state/selectors.ts`** — State selectors. +- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts, model overrides, client type, permission mode). + +### Bridge / Remote Control + +- **`src/bridge/`** (~35 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。 +- CLI 快速路径: `Codex remote-control` / `Codex rc` / `Codex bridge`。 + +### Daemon Mode + +- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。 + +### Context & System Prompt + +- **`src/context.ts`** — Builds system/user context for the API call (git status, date, AGENTS.md contents, memory files). +- **`src/utils/claudemd.ts`** — Discovers and loads AGENTS.md files from project hierarchy. + +### Feature Flag System + +Feature flags control which functionality is enabled at runtime: + +- **在代码中使用**: 统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`。**不要**在 `cli.tsx` 或其他文件里自己定义 `feature` 函数或覆盖这个 import。 +- **启用方式**: 通过环境变量 `FEATURE_=1`。例如 `FEATURE_BUDDY=1 bun run dev` 启用 BUDDY 功能。 +- **Dev 默认 features**: `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE`(见 `scripts/dev.ts`)。 +- **Build 默认 features**: `AGENT_TRIGGERS_REMOTE`(见 `build.ts`)。 +- **常见 flag**: `BUDDY`, `DAEMON`, `BRIDGE_MODE`, `BG_SESSIONS`, `PROACTIVE`, `KAIROS`, `VOICE_MODE`, `FORK_SUBAGENT`, `SSH_REMOTE`, `DIRECT_CONNECT`, `TEMPLATES`, `CHICAGO_MCP`, `BYOC_ENVIRONMENT_RUNNER`, `SELF_HOSTED_RUNNER`, `COORDINATOR_MODE`, `UDS_INBOX`, `LODESTONE`, `ABLATION_BASELINE` 等。 +- **类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。 + +**新增功能的正确做法**: 保留 `import { feature } from 'bun:bundle'` + `feature('FLAG_NAME')` 的标准模式,在运行时通过环境变量或配置控制,不要绕过 feature flag 直接 import。 + +### Stubbed/Deleted Modules + +| Module | Status | +|--------|--------| +| Computer Use (`@ant/*`) | Stub packages in `packages/@ant/` | +| `*-napi` packages (audio, image, url, modifiers) | Stubs in `packages/` (except `color-diff-napi` which is fully implemented) | +| Analytics / GrowthBook / Sentry | Empty implementations | +| Magic Docs / Voice Mode / LSP Server | Removed | +| Plugins / Marketplace | Removed | +| MCP OAuth | Simplified | + +### Key Type Files + +- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers. +- **`src/types/internal-modules.d.ts`** — Type declarations for `bun:bundle`, `bun:ffi`, `@anthropic-ai/mcpb`. +- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.). +- **`src/types/permissions.ts`** — Permission mode and result types. + +## Testing + +- **框架**: `bun:test`(内置断言 + mock) +- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `.test.ts` +- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain) +- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/) +- **命名**: `describe("functionName")` + `test("behavior description")`,英文 +- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入) +- **当前状态**: ~1623 tests / 114 files (110 unit + 4 integration) / 0 fail(详见 `docs/testing-spec.md`) + +## Working with This Codebase + +- **Don't try to fix all tsc errors** — they're from decompilation and don't affect runtime. +- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。 +- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal. +- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。 +- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid. +- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。 +- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。 +- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。 diff --git a/fixtures/token-count-7ef7321f9e68.json b/fixtures/token-count-7ef7321f9e68.json new file mode 100644 index 000000000..0b8d94697 --- /dev/null +++ b/fixtures/token-count-7ef7321f9e68.json @@ -0,0 +1,3 @@ +{ + "tokenCount": 3 +} \ No newline at end of file diff --git a/fixtures/token-count-fafe44226655.json b/fixtures/token-count-fafe44226655.json new file mode 100644 index 000000000..0b8d94697 --- /dev/null +++ b/fixtures/token-count-fafe44226655.json @@ -0,0 +1,3 @@ +{ + "tokenCount": 3 +} \ No newline at end of file diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 03b6e0e7a..020fab290 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -47,6 +47,15 @@ type OAuthStatus = { opusModel: string; activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; } // OpenAI Chat Completions API platform +| { + state: 'gemini_api'; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; +} // Gemini Generate Content API platform | { state: 'ready_to_start'; } // Flow started, waiting for browser to open @@ -430,6 +439,9 @@ function OAuthStatusMessage(t0) { }, { label: OpenAI Compatible ·{" "}Ollama, DeepSeek, vLLM, One API, etc.{"\n"}, value: "openai_chat_api" + }, { + label: Gemini API ·{" "}Google Gemini native REST/SSE{"\n"}, + value: "gemini_api" }, t4, t5, { label: 3rd-party platform ·{" "}Amazon Bedrock, Microsoft Foundry, or Vertex AI{"\n"}, value: "platform" @@ -463,6 +475,17 @@ function OAuthStatusMessage(t0) { opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? "", activeField: "base_url" }); + } else if (value_0 === "gemini_api") { + logEvent("tengu_gemini_api_selected", {}); + setOAuthStatus({ + state: "gemini_api", + baseUrl: process.env.GEMINI_BASE_URL ?? "", + apiKey: process.env.GEMINI_API_KEY ?? "", + haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? "", + sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? "", + opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? "", + activeField: "base_url" + }); } else if (value_0 === "platform") { logEvent("tengu_oauth_platform_selected", {}); setOAuthStatus({ @@ -765,6 +788,123 @@ function OAuthStatusMessage(t0) { Tab to switch · Enter on last field to save · Esc to go back ; } + case "gemini_api": + { + type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; + const GEMINI_FIELDS: GeminiField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; + const gp = oauthStatus as { state: 'gemini_api'; activeField: GeminiField; baseUrl: string; apiKey: string; haikuModel: string; sonnetModel: string; opusModel: string }; + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = gp; + const geminiDisplayValues: Record = { base_url: baseUrl, api_key: apiKey, haiku_model: haikuModel, sonnet_model: sonnetModel, opus_model: opusModel }; + + const [geminiInputValue, setGeminiInputValue] = useState(() => geminiDisplayValues[activeField]); + const [geminiInputCursorOffset, setGeminiInputCursorOffset] = useState(() => geminiDisplayValues[activeField].length); + + const buildGeminiState = useCallback((field: GeminiField, value: string, newActive?: GeminiField) => { + const s = { state: 'gemini_api' as const, activeField: newActive ?? activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel }; + switch (field) { + case 'base_url': return { ...s, baseUrl: value }; + case 'api_key': return { ...s, apiKey: value }; + case 'haiku_model': return { ...s, haikuModel: value }; + case 'sonnet_model': return { ...s, sonnetModel: value }; + case 'opus_model': return { ...s, opusModel: value }; + } + }, [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel]); + + const doGeminiSave = useCallback(() => { + const finalVals = { ...geminiDisplayValues, [activeField]: geminiInputValue }; + if (!finalVals.haiku_model || !finalVals.sonnet_model || !finalVals.opus_model) { + setOAuthStatus({ + state: 'error', + message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.', + toRetry: { + state: 'gemini_api', + baseUrl: finalVals.base_url, + apiKey: finalVals.api_key, + haikuModel: finalVals.haiku_model, + sonnetModel: finalVals.sonnet_model, + opusModel: finalVals.opus_model, + activeField + } + }); + return; + } + const env: Record = {}; + if (finalVals.base_url) env.GEMINI_BASE_URL = finalVals.base_url; + if (finalVals.api_key) env.GEMINI_API_KEY = finalVals.api_key; + if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; + if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; + if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model; + const { error } = updateSettingsForSource('userSettings', { modelType: 'gemini' as any, env } as any); + if (error) { + setOAuthStatus({ state: 'error', message: `Failed to save: ${error.message}`, toRetry: { state: 'gemini_api', baseUrl: '', apiKey: '', haikuModel: '', sonnetModel: '', opusModel: '', activeField: 'base_url' } }); + } else { + for (const [k, v] of Object.entries(env)) process.env[k] = v; + setOAuthStatus({ state: 'success' }); + void onDone(); + } + }, [activeField, geminiInputValue, geminiDisplayValues, setOAuthStatus, onDone]); + + const handleGeminiEnter = useCallback(() => { + const idx = GEMINI_FIELDS.indexOf(activeField); + setOAuthStatus(buildGeminiState(activeField, geminiInputValue)); + if (idx === GEMINI_FIELDS.length - 1) { + doGeminiSave(); + } else { + const next = GEMINI_FIELDS[idx + 1]!; + setGeminiInputValue(geminiDisplayValues[next] ?? ''); + setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length); + } + }, [activeField, geminiInputValue, buildGeminiState, doGeminiSave, geminiDisplayValues, setOAuthStatus]); + + useKeybinding('tabs:next', () => { + const idx = GEMINI_FIELDS.indexOf(activeField); + if (idx < GEMINI_FIELDS.length - 1) { + setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx + 1])); + setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? ''); + setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '').length); + } + }, { context: 'Tabs' }); + useKeybinding('tabs:previous', () => { + const idx = GEMINI_FIELDS.indexOf(activeField); + if (idx > 0) { + setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx - 1])); + setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? ''); + setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '').length); + } + }, { context: 'Tabs' }); + useKeybinding('confirm:no', () => { + setOAuthStatus({ state: 'idle' }); + }, { context: 'Confirmation' }); + + const geminiColumns = useTerminalSize().columns - 20; + + const renderGeminiRow = (field: GeminiField, label: string, opts?: { mask?: boolean }) => { + const active = activeField === field; + const val = geminiDisplayValues[field]; + return + {` ${label} `} + + {active + ? + : (val + ? {opts?.mask ? val.slice(0, 8) + '·'.repeat(Math.max(0, val.length - 8)) : val} + : null)} + ; + }; + + return + Gemini API Setup + Configure a Gemini Generate Content compatible endpoint. Base URL is optional and defaults to Google's v1beta API. + + {renderGeminiRow('base_url', 'Base URL ')} + {renderGeminiRow('api_key', 'API Key ', { mask: true })} + {renderGeminiRow('haiku_model', 'Haiku ')} + {renderGeminiRow('sonnet_model', 'Sonnet ')} + {renderGeminiRow('opus_model', 'Opus ')} + + Tab to switch · Enter on last field to save · Esc to go back + ; + } case "waiting_for_login": { let t1; diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index bc6f380f2..f2b19d3c3 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -640,24 +640,51 @@ export function assistantMessageToMessageParam( } else { return { role: 'assistant', - content: message.message.content.map((_, i) => ({ - ..._, - ...(i === message.message.content.length - 1 && - _.type !== 'thinking' && - _.type !== 'redacted_thinking' && - (feature('CONNECTOR_TEXT') ? !isConnectorTextBlock(_) : true) - ? enablePromptCaching - ? { cache_control: getCacheControl({ querySource }) } - : {} - : {}), - })), + content: message.message.content.map((_, i) => { + const contentBlock = stripGeminiProviderMetadata(_) + return { + ...contentBlock, + ...(i === message.message.content.length - 1 && + contentBlock.type !== 'thinking' && + contentBlock.type !== 'redacted_thinking' && + (feature('CONNECTOR_TEXT') + ? !isConnectorTextBlock(contentBlock) + : true) + ? enablePromptCaching + ? { cache_control: getCacheControl({ querySource }) } + : {} + : {}), + } + }), } } } return { role: 'assistant', - content: message.message.content, + content: + typeof message.message.content === 'string' + ? message.message.content + : message.message.content.map(stripGeminiProviderMetadata), + } +} + +function stripGeminiProviderMetadata( + contentBlock: T, +): T { + if ( + typeof contentBlock === 'string' || + !('_geminiThoughtSignature' in contentBlock) + ) { + return contentBlock + } + + const { + _geminiThoughtSignature: _unusedGeminiThoughtSignature, + ...rest + } = contentBlock as T & { + _geminiThoughtSignature?: string } + return rest as T } export type Options = { @@ -1310,6 +1337,19 @@ async function* queryModel( return } + if (getAPIProvider() === 'gemini') { + const { queryModelGemini } = await import('./gemini/index.js') + yield* queryModelGemini( + messagesForAPI, + systemPrompt, + filteredTools, + signal, + options, + thinkingConfig, + ) + return + } + // Instrumentation: Track message count after normalization logEvent('tengu_api_after_normalize', { postNormalizedMessageCount: messagesForAPI.length, diff --git a/src/services/api/gemini/__tests__/convertMessages.test.ts b/src/services/api/gemini/__tests__/convertMessages.test.ts new file mode 100644 index 000000000..11d49ca37 --- /dev/null +++ b/src/services/api/gemini/__tests__/convertMessages.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from 'bun:test' +import type { + AssistantMessage, + UserMessage, +} from '../../../../types/message.js' +import { anthropicMessagesToGemini } from '../convertMessages.js' + +function makeUserMsg(content: string | any[]): UserMessage { + return { + type: 'user', + uuid: '00000000-0000-0000-0000-000000000000', + message: { role: 'user', content }, + } as UserMessage +} + +function makeAssistantMsg(content: string | any[]): AssistantMessage { + return { + type: 'assistant', + uuid: '00000000-0000-0000-0000-000000000001', + message: { role: 'assistant', content }, + } as AssistantMessage +} + +describe('anthropicMessagesToGemini', () => { + test('converts system prompt to systemInstruction', () => { + const result = anthropicMessagesToGemini( + [makeUserMsg('hello')], + ['You are helpful.'] as any, + ) + + expect(result.systemInstruction).toEqual({ + parts: [{ text: 'You are helpful.' }], + }) + }) + + test('converts assistant tool_use to functionCall', () => { + const result = anthropicMessagesToGemini( + [ + makeAssistantMsg([ + { + type: 'tool_use', + id: 'toolu_123', + name: 'bash', + input: { command: 'ls' }, + _geminiThoughtSignature: 'sig-tool', + }, + ]), + ], + [] as any, + ) + + expect(result.contents).toEqual([ + { + role: 'model', + parts: [ + { + functionCall: { + name: 'bash', + args: { command: 'ls' }, + }, + thoughtSignature: 'sig-tool', + }, + ], + }, + ]) + }) + + test('converts tool_result to functionResponse using prior tool name', () => { + const result = anthropicMessagesToGemini( + [ + makeAssistantMsg([ + { + type: 'tool_use', + id: 'toolu_123', + name: 'bash', + input: { command: 'ls' }, + }, + ]), + makeUserMsg([ + { + type: 'tool_result', + tool_use_id: 'toolu_123', + content: 'file.txt', + }, + ]), + ], + [] as any, + ) + + expect(result.contents[1]).toEqual({ + role: 'user', + parts: [ + { + functionResponse: { + name: 'bash', + response: { + result: 'file.txt', + }, + }, + }, + ], + }) + }) + + test('converts thinking blocks with signatures', () => { + const result = anthropicMessagesToGemini( + [ + makeAssistantMsg([ + { + type: 'thinking', + thinking: 'internal reasoning', + signature: 'sig-thinking', + }, + { + type: 'text', + text: 'visible answer', + }, + ]), + ], + [] as any, + ) + + expect(result.contents[0]).toEqual({ + role: 'model', + parts: [ + { + text: 'internal reasoning', + thought: true, + thoughtSignature: 'sig-thinking', + }, + { + text: 'visible answer', + }, + ], + }) + }) + + test('filters empty assistant text and signature-only thinking parts', () => { + const result = anthropicMessagesToGemini( + [ + makeAssistantMsg([ + { + type: 'text', + text: '', + _geminiThoughtSignature: 'sig-empty-text', + }, + { + type: 'thinking', + thinking: '', + signature: 'sig-empty-thinking', + }, + { + type: 'tool_use', + id: 'toolu_123', + name: 'bash', + input: { command: 'pwd' }, + }, + ]), + ], + [] as any, + ) + + expect(result.contents).toEqual([ + { + role: 'model', + parts: [ + { + functionCall: { + name: 'bash', + args: { command: 'pwd' }, + }, + }, + ], + }, + ]) + }) + + test('filters empty user text blocks', () => { + const result = anthropicMessagesToGemini( + [ + makeUserMsg([ + { + type: 'text', + text: '', + }, + { + type: 'text', + text: 'hello', + }, + ]), + ], + [] as any, + ) + + expect(result.contents).toEqual([ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ]) + }) +}) diff --git a/src/services/api/gemini/__tests__/convertTools.test.ts b/src/services/api/gemini/__tests__/convertTools.test.ts new file mode 100644 index 000000000..999f362cd --- /dev/null +++ b/src/services/api/gemini/__tests__/convertTools.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'bun:test' +import { + anthropicToolChoiceToGemini, + anthropicToolsToGemini, +} from '../convertTools.js' + +describe('anthropicToolsToGemini', () => { + test('converts basic tool to parametersJsonSchema', () => { + const tools = [ + { + type: 'custom', + name: 'bash', + description: 'Run a bash command', + input_schema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + }, + }, + ] + + expect(anthropicToolsToGemini(tools as any)).toEqual([ + { + functionDeclarations: [ + { + name: 'bash', + description: 'Run a bash command', + parametersJsonSchema: { + type: 'object', + properties: { command: { type: 'string' } }, + propertyOrdering: ['command'], + required: ['command'], + }, + }, + ], + }, + ]) + }) + + test('sanitizes unsupported JSON Schema fields for Gemini', () => { + const tools = [ + { + type: 'custom', + name: 'complex', + description: 'Complex schema', + input_schema: { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + additionalProperties: false, + propertyNames: { pattern: '^[a-z]+$' }, + properties: { + mode: { const: 'strict' }, + retries: { + type: 'integer', + exclusiveMinimum: 0, + }, + metadata: { + type: 'object', + additionalProperties: { + type: 'string', + propertyNames: { pattern: '^[a-z]+$' }, + }, + }, + }, + required: ['mode'], + }, + }, + ] + + expect(anthropicToolsToGemini(tools as any)).toEqual([ + { + functionDeclarations: [ + { + name: 'complex', + description: 'Complex schema', + parametersJsonSchema: { + type: 'object', + additionalProperties: false, + properties: { + mode: { + type: 'string', + enum: ['strict'], + }, + retries: { + type: 'integer', + minimum: 0, + }, + metadata: { + type: 'object', + additionalProperties: { + type: 'string', + }, + }, + }, + propertyOrdering: ['mode', 'retries', 'metadata'], + required: ['mode'], + }, + }, + ], + }, + ]) + }) + + test('returns empty array when no tools are provided', () => { + expect(anthropicToolsToGemini([])).toEqual([]) + }) +}) + +describe('anthropicToolChoiceToGemini', () => { + test('maps auto', () => { + expect(anthropicToolChoiceToGemini({ type: 'auto' })).toEqual({ + mode: 'AUTO', + }) + }) + + test('maps any', () => { + expect(anthropicToolChoiceToGemini({ type: 'any' })).toEqual({ + mode: 'ANY', + }) + }) + + test('maps explicit tool choice', () => { + expect( + anthropicToolChoiceToGemini({ type: 'tool', name: 'bash' }), + ).toEqual({ + mode: 'ANY', + allowedFunctionNames: ['bash'], + }) + }) +}) diff --git a/src/services/api/gemini/__tests__/modelMapping.test.ts b/src/services/api/gemini/__tests__/modelMapping.test.ts new file mode 100644 index 000000000..18846b23c --- /dev/null +++ b/src/services/api/gemini/__tests__/modelMapping.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { resolveGeminiModel } from '../modelMapping.js' + +describe('resolveGeminiModel', () => { + const originalEnv = { + GEMINI_MODEL: process.env.GEMINI_MODEL, + ANTHROPIC_DEFAULT_HAIKU_MODEL: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, + ANTHROPIC_DEFAULT_SONNET_MODEL: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL, + ANTHROPIC_DEFAULT_OPUS_MODEL: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL, + } + + beforeEach(() => { + delete process.env.GEMINI_MODEL + delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + }) + + afterEach(() => { + Object.assign(process.env, originalEnv) + }) + + test('GEMINI_MODEL env var overrides family mappings', () => { + process.env.GEMINI_MODEL = 'gemini-2.5-pro' + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash' + + expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-pro') + }) + + test('resolves sonnet model from shared family override', () => { + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash' + expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-flash') + }) + + test('resolves haiku model from shared family override', () => { + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'gemini-2.5-flash-lite' + expect(resolveGeminiModel('claude-haiku-4-5-20251001')).toBe( + 'gemini-2.5-flash-lite', + ) + }) + + test('resolves opus model from shared family override', () => { + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'gemini-2.5-pro' + expect(resolveGeminiModel('claude-opus-4-6')).toBe('gemini-2.5-pro') + }) + + test('uses shared family override', () => { + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'legacy-gemini-sonnet' + expect(resolveGeminiModel('claude-sonnet-4-6')).toBe( + 'legacy-gemini-sonnet', + ) + }) + + test('strips [1m] suffix before resolving', () => { + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash' + expect(resolveGeminiModel('claude-sonnet-4-6[1m]')).toBe( + 'gemini-2.5-flash', + ) + }) + + test('passes through explicit Gemini model names', () => { + expect(resolveGeminiModel('gemini-3.1-flash-lite-preview')).toBe( + 'gemini-3.1-flash-lite-preview', + ) + }) + + test('throws when family mapping is missing', () => { + expect(() => resolveGeminiModel('claude-sonnet-4-6')).toThrow( + 'Gemini provider requires GEMINI_MODEL or ANTHROPIC_DEFAULT_SONNET_MODEL to be configured.', + ) + }) +}) diff --git a/src/services/api/gemini/__tests__/streamAdapter.test.ts b/src/services/api/gemini/__tests__/streamAdapter.test.ts new file mode 100644 index 000000000..d7b42229f --- /dev/null +++ b/src/services/api/gemini/__tests__/streamAdapter.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from 'bun:test' +import { adaptGeminiStreamToAnthropic } from '../streamAdapter.js' +import type { GeminiStreamChunk } from '../types.js' + +function mockStream( + chunks: GeminiStreamChunk[], +): AsyncIterable { + return { + [Symbol.asyncIterator]() { + let index = 0 + return { + async next() { + if (index >= chunks.length) { + return { done: true, value: undefined } + } + return { done: false, value: chunks[index++] } + }, + } + }, + } +} + +async function collectEvents(chunks: GeminiStreamChunk[]) { + const events: any[] = [] + for await (const event of adaptGeminiStreamToAnthropic( + mockStream(chunks), + 'gemini-2.5-flash', + )) { + events.push(event) + } + return events +} + +describe('adaptGeminiStreamToAnthropic', () => { + test('converts text chunks', async () => { + const events = await collectEvents([ + { + candidates: [ + { + content: { + parts: [{ text: 'Hello' }], + }, + }, + ], + }, + { + candidates: [ + { + content: { + parts: [{ text: ' world' }], + }, + finishReason: 'STOP', + }, + ], + }, + ]) + + const textDeltas = events.filter( + event => + event.type === 'content_block_delta' && event.delta.type === 'text_delta', + ) + + expect(events[0].type).toBe('message_start') + expect(textDeltas).toHaveLength(2) + expect(textDeltas[0].delta.text).toBe('Hello') + expect(textDeltas[1].delta.text).toBe(' world') + + const messageDelta = events.find(event => event.type === 'message_delta') + expect(messageDelta.delta.stop_reason).toBe('end_turn') + }) + + test('converts thinking chunks and signatures', async () => { + const events = await collectEvents([ + { + candidates: [ + { + content: { + parts: [{ text: 'Think', thought: true }], + }, + }, + ], + }, + { + candidates: [ + { + content: { + parts: [{ thought: true, thoughtSignature: 'sig-123' }], + }, + finishReason: 'STOP', + }, + ], + }, + ]) + + const blockStart = events.find(event => event.type === 'content_block_start') + expect(blockStart.content_block.type).toBe('thinking') + + const signatureDelta = events.find( + event => + event.type === 'content_block_delta' && + event.delta.type === 'signature_delta', + ) + expect(signatureDelta.delta.signature).toBe('sig-123') + }) + + test('converts function calls to tool_use blocks', async () => { + const events = await collectEvents([ + { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: 'bash', + args: { command: 'ls' }, + }, + thoughtSignature: 'sig-tool', + }, + ], + }, + finishReason: 'STOP', + }, + ], + }, + ]) + + const blockStart = events.find(event => event.type === 'content_block_start') + expect(blockStart.content_block.type).toBe('tool_use') + expect(blockStart.content_block.name).toBe('bash') + + const signatureDelta = events.find( + event => + event.type === 'content_block_delta' && + event.delta.type === 'signature_delta', + ) + expect(signatureDelta.delta.signature).toBe('sig-tool') + + const inputDelta = events.find( + event => + event.type === 'content_block_delta' && + event.delta.type === 'input_json_delta', + ) + expect(inputDelta.delta.partial_json).toBe('{"command":"ls"}') + + const messageDelta = events.find(event => event.type === 'message_delta') + expect(messageDelta.delta.stop_reason).toBe('tool_use') + }) + + test('maps usage metadata into output tokens', async () => { + const events = await collectEvents([ + { + candidates: [ + { + content: { + parts: [{ text: 'Hello' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + thoughtsTokenCount: 2, + }, + }, + ]) + + const messageStart = events.find(event => event.type === 'message_start') + expect(messageStart.message.usage.input_tokens).toBe(10) + + const messageDelta = events.find(event => event.type === 'message_delta') + expect(messageDelta.usage.output_tokens).toBe(7) + }) +}) diff --git a/src/services/api/gemini/client.ts b/src/services/api/gemini/client.ts new file mode 100644 index 000000000..2c8b68f8e --- /dev/null +++ b/src/services/api/gemini/client.ts @@ -0,0 +1,97 @@ +import { parseSSEFrames } from 'src/cli/transports/SSETransport.js' +import { errorMessage } from 'src/utils/errors.js' +import { getProxyFetchOptions } from 'src/utils/proxy.js' +import type { + GeminiGenerateContentRequest, + GeminiStreamChunk, +} from './types.js' + +const DEFAULT_GEMINI_BASE_URL = + 'https://generativelanguage.googleapis.com/v1beta' + +const STREAM_DECODE_OPTS: TextDecodeOptions = { stream: true } + +function getGeminiBaseUrl(): string { + return (process.env.GEMINI_BASE_URL || DEFAULT_GEMINI_BASE_URL).replace( + /\/+$/, + '', + ) +} + +function getGeminiModelPath(model: string): string { + const normalized = model.replace(/^\/+/, '') + return normalized.startsWith('models/') ? normalized : `models/${normalized}` +} + +export async function* streamGeminiGenerateContent(params: { + model: string + body: GeminiGenerateContentRequest + signal: AbortSignal + fetchOverride?: typeof fetch +}): AsyncGenerator { + const fetchImpl = params.fetchOverride ?? fetch + const url = `${getGeminiBaseUrl()}/${getGeminiModelPath(params.model)}:streamGenerateContent?alt=sse` + + const response = await fetchImpl(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': process.env.GEMINI_API_KEY || '', + }, + body: JSON.stringify(params.body), + signal: params.signal, + ...getProxyFetchOptions({ forAnthropicAPI: false }), + }) + + if (!response.ok) { + const body = await response.text() + throw new Error( + `Gemini API request failed (${response.status} ${response.statusText}): ${body || 'empty response body'}`, + ) + } + + if (!response.body) { + throw new Error('Gemini API returned no response body') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, STREAM_DECODE_OPTS) + const { frames, remaining } = parseSSEFrames(buffer) + buffer = remaining + + for (const frame of frames) { + if (!frame.data || frame.data === '[DONE]') continue + try { + yield JSON.parse(frame.data) as GeminiStreamChunk + } catch (error) { + throw new Error( + `Failed to parse Gemini SSE payload: ${errorMessage(error)}`, + ) + } + } + } + + buffer += decoder.decode() + const { frames } = parseSSEFrames(buffer) + for (const frame of frames) { + if (!frame.data || frame.data === '[DONE]') continue + try { + yield JSON.parse(frame.data) as GeminiStreamChunk + } catch (error) { + throw new Error( + `Failed to parse trailing Gemini SSE payload: ${errorMessage(error)}`, + ) + } + } + } finally { + reader.releaseLock() + } +} diff --git a/src/services/api/gemini/convertMessages.ts b/src/services/api/gemini/convertMessages.ts new file mode 100644 index 000000000..4ac3a209d --- /dev/null +++ b/src/services/api/gemini/convertMessages.ts @@ -0,0 +1,278 @@ +import type { + BetaToolResultBlockParam, + BetaToolUseBlock, +} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import type { AssistantMessage, UserMessage } from '../../../types/message.js' +import { safeParseJSON } from '../../../utils/json.js' +import type { SystemPrompt } from '../../../utils/systemPromptType.js' +import { + GEMINI_THOUGHT_SIGNATURE_FIELD, + type GeminiContent, + type GeminiGenerateContentRequest, + type GeminiPart, +} from './types.js' + +export function anthropicMessagesToGemini( + messages: (UserMessage | AssistantMessage)[], + systemPrompt: SystemPrompt, +): Pick { + const contents: GeminiContent[] = [] + const toolNamesById = new Map() + + for (const msg of messages) { + if (msg.type === 'assistant') { + const content = convertInternalAssistantMessage(msg) + if (content.parts.length > 0) { + contents.push(content) + } + + const assistantContent = msg.message.content + if (Array.isArray(assistantContent)) { + for (const block of assistantContent) { + if (typeof block !== 'string' && block.type === 'tool_use') { + toolNamesById.set(block.id, block.name) + } + } + } + continue + } + + if (msg.type === 'user') { + const content = convertInternalUserMessage(msg, toolNamesById) + if (content.parts.length > 0) { + contents.push(content) + } + } + } + + const systemText = systemPromptToText(systemPrompt) + + return { + contents, + ...(systemText + ? { + systemInstruction: { + parts: [{ text: systemText }], + }, + } + : {}), + } +} + +function systemPromptToText(systemPrompt: SystemPrompt): string { + if (!systemPrompt || systemPrompt.length === 0) return '' + return systemPrompt.filter(Boolean).join('\n\n') +} + +function convertInternalUserMessage( + msg: UserMessage, + toolNamesById: ReadonlyMap, +): GeminiContent { + const content = msg.message.content + + if (typeof content === 'string') { + return { + role: 'user', + parts: createTextGeminiParts(content), + } + } + + if (!Array.isArray(content)) { + return { role: 'user', parts: [] } + } + + return { + role: 'user', + parts: content.flatMap(block => + convertUserContentBlockToGeminiParts(block, toolNamesById), + ), + } +} + +function convertUserContentBlockToGeminiParts( + block: string | Record, + toolNamesById: ReadonlyMap, +): GeminiPart[] { + if (typeof block === 'string') { + return createTextGeminiParts(block) + } + + if (block.type === 'text') { + return createTextGeminiParts(block.text) + } + + if (block.type === 'tool_result') { + const toolResult = block as unknown as BetaToolResultBlockParam + return [ + { + functionResponse: { + name: toolNamesById.get(toolResult.tool_use_id) ?? toolResult.tool_use_id, + response: toolResultToResponseObject(toolResult), + }, + }, + ] + } + + return [] +} + +function convertInternalAssistantMessage(msg: AssistantMessage): GeminiContent { + const content = msg.message.content + + if (typeof content === 'string') { + return { + role: 'model', + parts: createTextGeminiParts(content), + } + } + + if (!Array.isArray(content)) { + return { role: 'model', parts: [] } + } + + const parts: GeminiPart[] = [] + for (const block of content) { + if (typeof block === 'string') { + parts.push(...createTextGeminiParts(block)) + continue + } + + if (block.type === 'text') { + parts.push( + ...createTextGeminiParts( + block.text, + getGeminiThoughtSignature(block), + ), + ) + continue + } + + if (block.type === 'thinking') { + const thinkingPart = createThinkingGeminiPart( + block.thinking, + block.signature, + ) + if (thinkingPart) { + parts.push(thinkingPart) + } + continue + } + + if (block.type === 'tool_use') { + const toolUse = block as unknown as BetaToolUseBlock + parts.push({ + functionCall: { + name: toolUse.name, + args: normalizeToolUseInput(toolUse.input), + }, + ...(getGeminiThoughtSignature(block) && { + thoughtSignature: getGeminiThoughtSignature(block), + }), + }) + } + } + + return { role: 'model', parts } +} + +function createTextGeminiParts( + value: unknown, + thoughtSignature?: string, +): GeminiPart[] { + if (typeof value !== 'string' || value.length === 0) { + return [] + } + + return [ + { + text: value, + ...(thoughtSignature && { thoughtSignature }), + }, + ] +} + +function createThinkingGeminiPart( + value: unknown, + thoughtSignature?: string, +): GeminiPart | undefined { + if (typeof value !== 'string' || value.length === 0) { + return undefined + } + + return { + text: value, + thought: true, + ...(thoughtSignature && { thoughtSignature }), + } +} + +function normalizeToolUseInput(input: unknown): Record { + if (typeof input === 'string') { + const parsed = safeParseJSON(input) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + return parsed === null ? {} : { value: parsed } + } + + if (input && typeof input === 'object' && !Array.isArray(input)) { + return input as Record + } + + return input === undefined ? {} : { value: input } +} + +function toolResultToResponseObject( + block: BetaToolResultBlockParam, +): Record { + const result = normalizeToolResultContent(block.content) + if ( + result && + typeof result === 'object' && + !Array.isArray(result) + ) { + return block.is_error ? { ...result, is_error: true } : result + } + + return { + result, + ...(block.is_error ? { is_error: true } : {}), + } +} + +function normalizeToolResultContent(content: unknown): unknown { + if (typeof content === 'string') { + const parsed = safeParseJSON(content) + return parsed ?? content + } + + if (Array.isArray(content)) { + const text = content + .map(part => { + if (typeof part === 'string') return part + if ( + part && + typeof part === 'object' && + 'text' in part && + typeof part.text === 'string' + ) { + return part.text + } + return '' + }) + .filter(Boolean) + .join('\n') + + const parsed = safeParseJSON(text) + return parsed ?? text + } + + return content ?? '' +} + +function getGeminiThoughtSignature(block: Record): string | undefined { + const signature = block[GEMINI_THOUGHT_SIGNATURE_FIELD] + return typeof signature === 'string' && signature.length > 0 + ? signature + : undefined +} diff --git a/src/services/api/gemini/convertTools.ts b/src/services/api/gemini/convertTools.ts new file mode 100644 index 000000000..e287fa88d --- /dev/null +++ b/src/services/api/gemini/convertTools.ts @@ -0,0 +1,284 @@ +import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import type { + GeminiFunctionCallingConfig, + GeminiTool, +} from './types.js' + +const GEMINI_JSON_SCHEMA_TYPES = new Set([ + 'string', + 'number', + 'integer', + 'boolean', + 'object', + 'array', + 'null', +]) + +function normalizeGeminiJsonSchemaType( + value: unknown, +): string | string[] | undefined { + if (typeof value === 'string') { + return GEMINI_JSON_SCHEMA_TYPES.has(value) ? value : undefined + } + + if (Array.isArray(value)) { + const normalized = value.filter( + (item): item is string => + typeof item === 'string' && GEMINI_JSON_SCHEMA_TYPES.has(item), + ) + const unique = Array.from(new Set(normalized)) + if (unique.length === 0) return undefined + return unique.length === 1 ? unique[0] : unique + } + + return undefined +} + +function inferGeminiJsonSchemaTypeFromValue(value: unknown): string | undefined { + if (value === null) return 'null' + if (Array.isArray(value)) return 'array' + if (typeof value === 'string') return 'string' + if (typeof value === 'boolean') return 'boolean' + if (typeof value === 'number') { + return Number.isInteger(value) ? 'integer' : 'number' + } + if (typeof value === 'object') return 'object' + return undefined +} + +function inferGeminiJsonSchemaTypeFromEnum( + values: unknown[], +): string | string[] | undefined { + const inferred = values + .map(inferGeminiJsonSchemaTypeFromValue) + .filter((value): value is string => value !== undefined) + const unique = Array.from(new Set(inferred)) + if (unique.length === 0) return undefined + return unique.length === 1 ? unique[0] : unique +} + +function addNullToGeminiJsonSchemaType( + value: string | string[] | undefined, +): string | string[] | undefined { + if (value === undefined) return ['null'] + if (Array.isArray(value)) { + return value.includes('null') ? value : [...value, 'null'] + } + return value === 'null' ? value : [value, 'null'] +} + +function sanitizeGeminiJsonSchemaProperties( + value: unknown, +): Record> | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + + const sanitizedEntries = Object.entries(value as Record) + .map(([key, schema]) => [key, sanitizeGeminiJsonSchema(schema)] as const) + .filter(([, schema]) => Object.keys(schema).length > 0) + + if (sanitizedEntries.length === 0) { + return undefined + } + + return Object.fromEntries(sanitizedEntries) +} + +function sanitizeGeminiJsonSchemaArray( + value: unknown, +): Record[] | undefined { + if (!Array.isArray(value)) return undefined + + const sanitized = value + .map(item => sanitizeGeminiJsonSchema(item)) + .filter(item => Object.keys(item).length > 0) + + return sanitized.length > 0 ? sanitized : undefined +} + +function sanitizeGeminiJsonSchema( + schema: unknown, +): Record { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { + return {} + } + + const source = schema as Record + const result: Record = {} + + let type = normalizeGeminiJsonSchemaType(source.type) + + if (source.const !== undefined) { + result.enum = [source.const] + type = type ?? inferGeminiJsonSchemaTypeFromValue(source.const) + } else if (Array.isArray(source.enum) && source.enum.length > 0) { + result.enum = source.enum + type = type ?? inferGeminiJsonSchemaTypeFromEnum(source.enum) + } + + if (!type) { + if (source.properties && typeof source.properties === 'object') { + type = 'object' + } else if (source.items !== undefined || source.prefixItems !== undefined) { + type = 'array' + } + } + + if (source.nullable === true) { + type = addNullToGeminiJsonSchemaType(type) + } + + if (type) { + result.type = type + } + + if (typeof source.title === 'string') { + result.title = source.title + } + if (typeof source.description === 'string') { + result.description = source.description + } + if (typeof source.format === 'string') { + result.format = source.format + } + if (typeof source.pattern === 'string') { + result.pattern = source.pattern + } + if (typeof source.minimum === 'number') { + result.minimum = source.minimum + } else if (typeof source.exclusiveMinimum === 'number') { + result.minimum = source.exclusiveMinimum + } + if (typeof source.maximum === 'number') { + result.maximum = source.maximum + } else if (typeof source.exclusiveMaximum === 'number') { + result.maximum = source.exclusiveMaximum + } + if (typeof source.minItems === 'number') { + result.minItems = source.minItems + } + if (typeof source.maxItems === 'number') { + result.maxItems = source.maxItems + } + if (typeof source.minLength === 'number') { + result.minLength = source.minLength + } + if (typeof source.maxLength === 'number') { + result.maxLength = source.maxLength + } + if (typeof source.minProperties === 'number') { + result.minProperties = source.minProperties + } + if (typeof source.maxProperties === 'number') { + result.maxProperties = source.maxProperties + } + + const properties = sanitizeGeminiJsonSchemaProperties(source.properties) + if (properties) { + result.properties = properties + result.propertyOrdering = Object.keys(properties) + } + + if (Array.isArray(source.required)) { + const required = source.required.filter( + (item): item is string => typeof item === 'string', + ) + if (required.length > 0) { + result.required = required + } + } + + if (typeof source.additionalProperties === 'boolean') { + result.additionalProperties = source.additionalProperties + } else { + const additionalProperties = sanitizeGeminiJsonSchema( + source.additionalProperties, + ) + if (Object.keys(additionalProperties).length > 0) { + result.additionalProperties = additionalProperties + } + } + + const items = sanitizeGeminiJsonSchema(source.items) + if (Object.keys(items).length > 0) { + result.items = items + } + + const prefixItems = sanitizeGeminiJsonSchemaArray(source.prefixItems) + if (prefixItems) { + result.prefixItems = prefixItems + } + + const anyOf = sanitizeGeminiJsonSchemaArray(source.anyOf ?? source.oneOf) + if (anyOf) { + result.anyOf = anyOf + } + + return result +} + +function sanitizeGeminiFunctionParameters( + schema: unknown, +): Record { + const sanitized = sanitizeGeminiJsonSchema(schema) + if (Object.keys(sanitized).length > 0) { + return sanitized + } + + return { + type: 'object', + properties: {}, + } +} + +export function anthropicToolsToGemini(tools: BetaToolUnion[]): GeminiTool[] { + const functionDeclarations = tools + .filter(tool => { + return tool.type === 'custom' || !('type' in tool) || tool.type !== 'server' + }) + .map(tool => { + const anyTool = tool as Record + const name = (anyTool.name as string) || '' + const description = (anyTool.description as string) || '' + const inputSchema = + (anyTool.input_schema as Record | undefined) ?? { + type: 'object', + properties: {}, + } + + return { + name, + description, + parametersJsonSchema: sanitizeGeminiFunctionParameters(inputSchema), + } + }) + + return functionDeclarations.length > 0 + ? [{ functionDeclarations }] + : [] +} + +export function anthropicToolChoiceToGemini( + toolChoice: unknown, +): GeminiFunctionCallingConfig | undefined { + if (!toolChoice || typeof toolChoice !== 'object') return undefined + + const tc = toolChoice as Record + const type = tc.type as string + + switch (type) { + case 'auto': + return { mode: 'AUTO' } + case 'any': + return { mode: 'ANY' } + case 'tool': + return { + mode: 'ANY', + allowedFunctionNames: + typeof tc.name === 'string' ? [tc.name] : undefined, + } + default: + return undefined + } +} diff --git a/src/services/api/gemini/index.ts b/src/services/api/gemini/index.ts new file mode 100644 index 000000000..64dff7458 --- /dev/null +++ b/src/services/api/gemini/index.ts @@ -0,0 +1,192 @@ +import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import { randomUUID } from 'crypto' +import type { + AssistantMessage, + Message, + StreamEvent, + SystemAPIErrorMessage, +} from '../../../types/message.js' +import { getEmptyToolPermissionContext, type Tools } from '../../../Tool.js' +import { toolToAPISchema } from '../../../utils/api.js' +import { logForDebugging } from '../../../utils/debug.js' +import { + createAssistantAPIErrorMessage, + normalizeContentFromAPI, + normalizeMessagesForAPI, +} from '../../../utils/messages.js' +import type { SystemPrompt } from '../../../utils/systemPromptType.js' +import type { ThinkingConfig } from '../../../utils/thinking.js' +import type { Options } from '../claude.js' +import { streamGeminiGenerateContent } from './client.js' +import { anthropicMessagesToGemini } from './convertMessages.js' +import { + anthropicToolChoiceToGemini, + anthropicToolsToGemini, +} from './convertTools.js' +import { resolveGeminiModel } from './modelMapping.js' +import { adaptGeminiStreamToAnthropic } from './streamAdapter.js' +import { GEMINI_THOUGHT_SIGNATURE_FIELD } from './types.js' + +export async function* queryModelGemini( + messages: Message[], + systemPrompt: SystemPrompt, + tools: Tools, + signal: AbortSignal, + options: Options, + thinkingConfig: ThinkingConfig, +): AsyncGenerator< + StreamEvent | AssistantMessage | SystemAPIErrorMessage, + void +> { + try { + const geminiModel = resolveGeminiModel(options.model) + const messagesForAPI = normalizeMessagesForAPI(messages, tools) + + const toolSchemas = await Promise.all( + tools.map(tool => + toolToAPISchema(tool, { + getToolPermissionContext: options.getToolPermissionContext, + tools, + agents: options.agents, + allowedAgentTypes: options.allowedAgentTypes, + model: options.model, + }), + ), + ) + + const standardTools = toolSchemas.filter( + (t): t is BetaToolUnion & { type: string } => { + const anyTool = t as Record + return ( + anyTool.type !== 'advisor_20260301' && + anyTool.type !== 'computer_20250124' + ) + }, + ) + + const { contents, systemInstruction } = anthropicMessagesToGemini( + messagesForAPI, + systemPrompt, + ) + const geminiTools = anthropicToolsToGemini(standardTools) + const toolChoice = anthropicToolChoiceToGemini(options.toolChoice) + + const stream = streamGeminiGenerateContent({ + model: geminiModel, + signal, + fetchOverride: options.fetchOverride as typeof fetch | undefined, + body: { + contents, + ...(systemInstruction && { systemInstruction }), + ...(geminiTools.length > 0 && { tools: geminiTools }), + ...(toolChoice && { + toolConfig: { + functionCallingConfig: toolChoice, + }, + }), + generationConfig: { + ...(options.temperatureOverride !== undefined && { + temperature: options.temperatureOverride, + }), + ...(thinkingConfig.type !== 'disabled' && { + thinkingConfig: { + includeThoughts: true, + ...(thinkingConfig.type === 'enabled' && { + thinkingBudget: thinkingConfig.budgetTokens, + }), + }, + }), + }, + }, + }) + + logForDebugging( + `[Gemini] Calling model=${geminiModel}, messages=${contents.length}, tools=${geminiTools.length}`, + ) + + const adaptedStream = adaptGeminiStreamToAnthropic(stream, geminiModel) + const contentBlocks: Record = {} + let partialMessage: any = undefined + let ttftMs = 0 + const start = Date.now() + + for await (const event of adaptedStream) { + switch (event.type) { + case 'message_start': + partialMessage = (event as any).message + ttftMs = Date.now() - start + break + case 'content_block_start': { + const idx = (event as any).index + const cb = (event as any).content_block + if (cb.type === 'tool_use') { + contentBlocks[idx] = { ...cb, input: '' } + } else if (cb.type === 'text') { + contentBlocks[idx] = { ...cb, text: '' } + } else if (cb.type === 'thinking') { + contentBlocks[idx] = { ...cb, thinking: '', signature: '' } + } else { + contentBlocks[idx] = { ...cb } + } + break + } + case 'content_block_delta': { + const idx = (event as any).index + const delta = (event as any).delta + const block = contentBlocks[idx] + if (!block) break + + if (delta.type === 'text_delta') { + block.text = (block.text || '') + delta.text + } else if (delta.type === 'input_json_delta') { + block.input = (block.input || '') + delta.partial_json + } else if (delta.type === 'thinking_delta') { + block.thinking = (block.thinking || '') + delta.thinking + } else if (delta.type === 'signature_delta') { + if (block.type === 'thinking') { + block.signature = delta.signature + } else { + block[GEMINI_THOUGHT_SIGNATURE_FIELD] = delta.signature + } + } + break + } + case 'content_block_stop': { + const idx = (event as any).index + const block = contentBlocks[idx] + if (!block || !partialMessage) break + + const message: AssistantMessage = { + message: { + ...partialMessage, + content: normalizeContentFromAPI([block], tools, options.agentId), + }, + requestId: undefined, + type: 'assistant', + uuid: randomUUID(), + timestamp: new Date().toISOString(), + } + yield message + break + } + case 'message_delta': + case 'message_stop': + break + } + + yield { + type: 'stream_event', + event, + ...(event.type === 'message_start' ? { ttftMs } : undefined), + } as StreamEvent + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logForDebugging(`[Gemini] Error: ${errorMessage}`, { level: 'error' }) + yield createAssistantAPIErrorMessage({ + content: `API Error: ${errorMessage}`, + apiError: 'api_error', + error: error instanceof Error ? error : new Error(String(error)), + }) + } +} diff --git a/src/services/api/gemini/modelMapping.ts b/src/services/api/gemini/modelMapping.ts new file mode 100644 index 000000000..a18571174 --- /dev/null +++ b/src/services/api/gemini/modelMapping.ts @@ -0,0 +1,30 @@ +function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null { + if (/haiku/i.test(model)) return 'haiku' + if (/opus/i.test(model)) return 'opus' + if (/sonnet/i.test(model)) return 'sonnet' + return null +} + +export function resolveGeminiModel(anthropicModel: string): string { + if (process.env.GEMINI_MODEL) { + return process.env.GEMINI_MODEL + } + + const cleanModel = anthropicModel.replace(/\[1m\]$/i, '') + const family = getModelFamily(cleanModel) + + if (!family) { + return cleanModel + } + + const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL` + const resolvedModel = process.env[sharedEnvVar] + if (resolvedModel) { + return resolvedModel + } + + throw new Error( + `Gemini provider requires GEMINI_MODEL or ${sharedEnvVar} to be configured.`, + ) +} + diff --git a/src/services/api/gemini/streamAdapter.ts b/src/services/api/gemini/streamAdapter.ts new file mode 100644 index 000000000..56a30c3e7 --- /dev/null +++ b/src/services/api/gemini/streamAdapter.ts @@ -0,0 +1,244 @@ +import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import { randomUUID } from 'crypto' +import type { GeminiPart, GeminiStreamChunk } from './types.js' + +export async function* adaptGeminiStreamToAnthropic( + stream: AsyncIterable, + model: string, +): AsyncGenerator { + const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}` + let started = false + let stopped = false + let nextContentIndex = 0 + let openTextLikeBlock: + | { index: number; type: 'text' | 'thinking' } + | null = null + let sawToolUse = false + let finishReason: string | undefined + let inputTokens = 0 + let outputTokens = 0 + + for await (const chunk of stream) { + const usage = chunk.usageMetadata + if (usage) { + inputTokens = usage.promptTokenCount ?? inputTokens + outputTokens = + (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0) + } + + if (!started) { + started = true + yield { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + content: [], + model, + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: inputTokens, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } as BetaRawMessageStreamEvent + } + + const candidate = chunk.candidates?.[0] + const parts = candidate?.content?.parts ?? [] + + for (const part of parts) { + if (part.functionCall) { + if (openTextLikeBlock) { + yield { + type: 'content_block_stop', + index: openTextLikeBlock.index, + } as BetaRawMessageStreamEvent + openTextLikeBlock = null + } + + sawToolUse = true + const toolIndex = nextContentIndex++ + const toolId = `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}` + yield { + type: 'content_block_start', + index: toolIndex, + content_block: { + type: 'tool_use', + id: toolId, + name: part.functionCall.name || '', + input: {}, + }, + } as BetaRawMessageStreamEvent + + if (part.thoughtSignature) { + yield { + type: 'content_block_delta', + index: toolIndex, + delta: { + type: 'signature_delta', + signature: part.thoughtSignature, + }, + } as BetaRawMessageStreamEvent + } + + if (part.functionCall.args && Object.keys(part.functionCall.args).length > 0) { + yield { + type: 'content_block_delta', + index: toolIndex, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(part.functionCall.args), + }, + } as BetaRawMessageStreamEvent + } + + yield { + type: 'content_block_stop', + index: toolIndex, + } as BetaRawMessageStreamEvent + continue + } + + const textLikeType = getTextLikeBlockType(part) + if (textLikeType) { + if (!openTextLikeBlock || openTextLikeBlock.type !== textLikeType) { + if (openTextLikeBlock) { + yield { + type: 'content_block_stop', + index: openTextLikeBlock.index, + } as BetaRawMessageStreamEvent + } + + openTextLikeBlock = { + index: nextContentIndex++, + type: textLikeType, + } + + yield { + type: 'content_block_start', + index: openTextLikeBlock.index, + content_block: + textLikeType === 'thinking' + ? { + type: 'thinking', + thinking: '', + signature: '', + } + : { + type: 'text', + text: '', + }, + } as BetaRawMessageStreamEvent + } + + if (part.text) { + yield { + type: 'content_block_delta', + index: openTextLikeBlock.index, + delta: + textLikeType === 'thinking' + ? { + type: 'thinking_delta', + thinking: part.text, + } + : { + type: 'text_delta', + text: part.text, + }, + } as BetaRawMessageStreamEvent + } + + if (part.thoughtSignature) { + yield { + type: 'content_block_delta', + index: openTextLikeBlock.index, + delta: { + type: 'signature_delta', + signature: part.thoughtSignature, + }, + } as BetaRawMessageStreamEvent + } + + continue + } + + if (part.thoughtSignature && openTextLikeBlock) { + yield { + type: 'content_block_delta', + index: openTextLikeBlock.index, + delta: { + type: 'signature_delta', + signature: part.thoughtSignature, + }, + } as BetaRawMessageStreamEvent + } + } + + if (candidate?.finishReason) { + finishReason = candidate.finishReason + } + } + + if (!started) { + return + } + + if (openTextLikeBlock) { + yield { + type: 'content_block_stop', + index: openTextLikeBlock.index, + } as BetaRawMessageStreamEvent + } + + if (!stopped) { + yield { + type: 'message_delta', + delta: { + stop_reason: mapGeminiFinishReason(finishReason, sawToolUse), + stop_sequence: null, + }, + usage: { + output_tokens: outputTokens, + }, + } as BetaRawMessageStreamEvent + + yield { + type: 'message_stop', + } as BetaRawMessageStreamEvent + stopped = true + } +} + +function getTextLikeBlockType( + part: GeminiPart, +): 'text' | 'thinking' | null { + if (typeof part.text !== 'string') { + return null + } + return part.thought ? 'thinking' : 'text' +} + +function mapGeminiFinishReason( + reason: string | undefined, + sawToolUse: boolean, +): string { + switch (reason) { + case 'MAX_TOKENS': + return 'max_tokens' + case 'STOP': + case 'FINISH_REASON_UNSPECIFIED': + case 'SAFETY': + case 'RECITATION': + case 'BLOCKLIST': + case 'PROHIBITED_CONTENT': + case 'SPII': + case 'MALFORMED_FUNCTION_CALL': + default: + return sawToolUse ? 'tool_use' : 'end_turn' + } +} diff --git a/src/services/api/gemini/types.ts b/src/services/api/gemini/types.ts new file mode 100644 index 000000000..829a09f13 --- /dev/null +++ b/src/services/api/gemini/types.ts @@ -0,0 +1,80 @@ +export const GEMINI_THOUGHT_SIGNATURE_FIELD = '_geminiThoughtSignature' + +export type GeminiFunctionCall = { + name?: string + args?: Record +} + +export type GeminiFunctionResponse = { + name?: string + response?: Record +} + +export type GeminiPart = { + text?: string + thought?: boolean + thoughtSignature?: string + functionCall?: GeminiFunctionCall + functionResponse?: GeminiFunctionResponse +} + +export type GeminiContent = { + role: 'user' | 'model' + parts: GeminiPart[] +} + +export type GeminiFunctionDeclaration = { + name: string + description?: string + parameters?: Record + parametersJsonSchema?: Record +} + +export type GeminiTool = { + functionDeclarations: GeminiFunctionDeclaration[] +} + +export type GeminiFunctionCallingConfig = { + mode: 'AUTO' | 'ANY' | 'NONE' + allowedFunctionNames?: string[] +} + +export type GeminiGenerateContentRequest = { + contents: GeminiContent[] + systemInstruction?: { + parts: Array<{ text: string }> + } + tools?: GeminiTool[] + toolConfig?: { + functionCallingConfig: GeminiFunctionCallingConfig + } + generationConfig?: { + temperature?: number + thinkingConfig?: { + includeThoughts?: boolean + thinkingBudget?: number + } + } +} + +export type GeminiUsageMetadata = { + promptTokenCount?: number + candidatesTokenCount?: number + thoughtsTokenCount?: number + totalTokenCount?: number +} + +export type GeminiCandidate = { + content?: { + role?: string + parts?: GeminiPart[] + } + finishReason?: string + index?: number +} + +export type GeminiStreamChunk = { + candidates?: GeminiCandidate[] + usageMetadata?: GeminiUsageMetadata + modelVersion?: string +} diff --git a/src/services/tokenEstimation.ts b/src/services/tokenEstimation.ts index 07a5c9e0b..c59d53a3a 100644 --- a/src/services/tokenEstimation.ts +++ b/src/services/tokenEstimation.ts @@ -143,11 +143,16 @@ export async function countMessagesTokensWithAPI( ): Promise { return withTokenCountVCR(messages, tools, async () => { try { + const provider = getAPIProvider() + if (provider === 'gemini') { + return roughTokenCountEstimationForAPIRequest(messages, tools) + } + const model = getMainLoopModel() const betas = getModelBetas(model) const containsThinking = hasThinkingBlocks(messages) - if (getAPIProvider() === 'bedrock') { + if (provider === 'bedrock') { // @anthropic-sdk/bedrock-sdk doesn't support countTokens currently return countTokensWithBedrock({ model: normalizeModelStringForAPI(model), @@ -252,6 +257,11 @@ export async function countTokensViaHaikuFallback( messages: Anthropic.Beta.Messages.BetaMessageParam[], tools: Anthropic.Beta.Messages.BetaToolUnion[], ): Promise { + const provider = getAPIProvider() + if (provider === 'gemini') { + return roughTokenCountEstimationForAPIRequest(messages, tools) + } + // Check if messages contain thinking blocks const containsThinking = hasThinkingBlocks(messages) @@ -388,6 +398,29 @@ function roughTokenCountEstimationForContent( return totalTokens } +function roughTokenCountEstimationForAPIRequest( + messages: Anthropic.Beta.Messages.BetaMessageParam[], + tools: Anthropic.Beta.Messages.BetaToolUnion[], +): number { + let totalTokens = 0 + + for (const message of messages) { + totalTokens += roughTokenCountEstimationForContent( + message.content as + | string + | Array + | Array + | undefined, + ) + } + + if (tools.length > 0) { + totalTokens += roughTokenCountEstimation(jsonStringify(tools)) + } + + return totalTokens +} + function roughTokenCountEstimationForBlock( block: string | Anthropic.ContentBlock | Anthropic.ContentBlockParam, ): number { diff --git a/src/utils/__tests__/messages.test.ts b/src/utils/__tests__/messages.test.ts index 96d21934b..316b34250 100644 --- a/src/utils/__tests__/messages.test.ts +++ b/src/utils/__tests__/messages.test.ts @@ -20,6 +20,7 @@ import { isNotEmptyMessage, deriveUUID, normalizeMessages, + normalizeMessagesForAPI, isClassifierDenial, buildYoloRejectionMessage, buildClassifierUnavailableMessage, @@ -486,3 +487,23 @@ describe("normalizeMessages", () => { expect(normalized.length).toBe(1); }); }); + +describe("normalizeMessagesForAPI", () => { + test("preserves Gemini thought signature metadata on tool_use blocks", () => { + const assistant = makeAssistantMsg([ + { + type: "tool_use", + id: "tool-1", + name: "Bash", + input: { command: "pwd" }, + _geminiThoughtSignature: "sig-123", + }, + ]); + + const normalized = normalizeMessagesForAPI([assistant]); + const block = (normalized[0] as AssistantMessage).message.content[0] as any; + + expect(block.type).toBe("tool_use"); + expect(block._geminiThoughtSignature).toBe("sig-123"); + }); +}); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index d278c3a4a..aa722cf07 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -118,7 +118,9 @@ export function isAnthropicAuthEnabled(): boolean { isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || (settings as any).modelType === 'openai' || - !!process.env.OPENAI_BASE_URL + (settings as any).modelType === 'gemini' || + !!process.env.OPENAI_BASE_URL || + !!process.env.GEMINI_BASE_URL const apiKeyHelper = settings.apiKeyHelper const hasExternalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN || diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index 12c565658..5e5f57545 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -18,6 +18,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', // Endpoint config (base URLs, project/resource identifiers) 'ANTHROPIC_BASE_URL', 'ANTHROPIC_BEDROCK_BASE_URL', @@ -25,6 +26,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'ANTHROPIC_FOUNDRY_BASE_URL', 'ANTHROPIC_FOUNDRY_RESOURCE', 'ANTHROPIC_VERTEX_PROJECT_ID', + 'GEMINI_BASE_URL', // Region routing (per-model VERTEX_REGION_CLAUDE_* handled by prefix below) 'CLOUD_ML_REGION', // Auth @@ -36,6 +38,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CLAUDE_CODE_SKIP_BEDROCK_AUTH', 'CLAUDE_CODE_SKIP_VERTEX_AUTH', 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH', + 'GEMINI_API_KEY', // Model defaults — often set to provider-specific ID formats 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', @@ -53,6 +56,7 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'ANTHROPIC_SMALL_FAST_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION', 'CLAUDE_CODE_SUBAGENT_MODEL', + 'GEMINI_MODEL', ]) const PROVIDER_MANAGED_ENV_PREFIXES = [ @@ -147,7 +151,9 @@ export const SAFE_ENV_VARS = new Set([ 'CLAUDE_CODE_SUBAGENT_MODEL', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', 'CLAUDE_CODE_USE_VERTEX', + 'GEMINI_MODEL', 'DISABLE_AUTOUPDATER', 'DISABLE_BUG_COMMAND', 'DISABLE_COST_WARNINGS', diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 50fb61367..607d06d18 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -2249,10 +2249,13 @@ export function normalizeMessagesForAPI( } } - // When tool search is NOT enabled, explicitly construct tool_use - // block with only standard API fields to avoid sending fields like - // 'caller' that may be stored in sessions from tool search runs + // When tool search is NOT enabled, strip tool-search-only fields + // like 'caller', but preserve other provider metadata attached to + // the block (for example Gemini thought signatures on tool_use). + const { caller: _caller, ...toolUseRest } = block as ToolUseBlock & + Record & { caller?: unknown } return { + ...toolUseRest, type: 'tool_use' as const, id: toolUseBlk.id, name: canonicalName, diff --git a/src/utils/model/__tests__/providers.test.ts b/src/utils/model/__tests__/providers.test.ts index b028f1084..80c22faf8 100644 --- a/src/utils/model/__tests__/providers.test.ts +++ b/src/utils/model/__tests__/providers.test.ts @@ -1,8 +1,18 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from "../providers"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +let mockedModelType: "gemini" | undefined; + +mock.module("../../settings/settings.js", () => ({ + getInitialSettings: () => + mockedModelType ? { modelType: mockedModelType } : {}, +})); + +const { getAPIProvider, isFirstPartyAnthropicBaseUrl } = + await import("../providers"); describe("getAPIProvider", () => { const envKeys = [ + "CLAUDE_CODE_USE_GEMINI", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", @@ -10,10 +20,15 @@ describe("getAPIProvider", () => { const savedEnv: Record = {}; beforeEach(() => { - for (const key of envKeys) savedEnv[key] = process.env[key]; + mockedModelType = undefined; + for (const key of envKeys) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } }); afterEach(() => { + mockedModelType = undefined; for (const key of envKeys) { if (savedEnv[key] !== undefined) { process.env[key] = savedEnv[key]; @@ -24,12 +39,25 @@ describe("getAPIProvider", () => { }); test('returns "firstParty" by default', () => { - delete process.env.CLAUDE_CODE_USE_BEDROCK; - delete process.env.CLAUDE_CODE_USE_VERTEX; - delete process.env.CLAUDE_CODE_USE_FOUNDRY; expect(getAPIProvider()).toBe("firstParty"); }); + test('returns "gemini" when modelType is gemini', () => { + mockedModelType = "gemini"; + expect(getAPIProvider()).toBe("gemini"); + }); + + test("modelType takes precedence over environment variables", () => { + mockedModelType = "gemini"; + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + expect(getAPIProvider()).toBe("gemini"); + }); + + test('returns "gemini" when CLAUDE_CODE_USE_GEMINI is set', () => { + process.env.CLAUDE_CODE_USE_GEMINI = "1"; + expect(getAPIProvider()).toBe("gemini"); + }); + test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', () => { process.env.CLAUDE_CODE_USE_BEDROCK = "1"; expect(getAPIProvider()).toBe("bedrock"); @@ -45,6 +73,12 @@ describe("getAPIProvider", () => { expect(getAPIProvider()).toBe("foundry"); }); + test("bedrock takes precedence over gemini", () => { + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + process.env.CLAUDE_CODE_USE_GEMINI = "1"; + expect(getAPIProvider()).toBe("bedrock"); + }); + test("bedrock takes precedence over vertex", () => { process.env.CLAUDE_CODE_USE_BEDROCK = "1"; process.env.CLAUDE_CODE_USE_VERTEX = "1"; diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index d9bfae0f9..83f971385 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -12,6 +12,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = { vertex: 'claude-3-7-sonnet@20250219', foundry: 'claude-3-7-sonnet', openai: 'claude-3-7-sonnet-20250219', + gemini: 'claude-3-7-sonnet-20250219', } as const satisfies ModelConfig export const CLAUDE_3_5_V2_SONNET_CONFIG = { @@ -20,6 +21,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = { vertex: 'claude-3-5-sonnet-v2@20241022', foundry: 'claude-3-5-sonnet', openai: 'claude-3-5-sonnet-20241022', + gemini: 'claude-3-5-sonnet-20241022', } as const satisfies ModelConfig export const CLAUDE_3_5_HAIKU_CONFIG = { @@ -28,6 +30,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = { vertex: 'claude-3-5-haiku@20241022', foundry: 'claude-3-5-haiku', openai: 'claude-3-5-haiku-20241022', + gemini: 'claude-3-5-haiku-20241022', } as const satisfies ModelConfig export const CLAUDE_HAIKU_4_5_CONFIG = { @@ -36,6 +39,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = { vertex: 'claude-haiku-4-5@20251001', foundry: 'claude-haiku-4-5', openai: 'claude-haiku-4-5-20251001', + gemini: 'claude-haiku-4-5-20251001', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_CONFIG = { @@ -44,6 +48,7 @@ export const CLAUDE_SONNET_4_CONFIG = { vertex: 'claude-sonnet-4@20250514', foundry: 'claude-sonnet-4', openai: 'claude-sonnet-4-20250514', + gemini: 'claude-sonnet-4-20250514', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_5_CONFIG = { @@ -52,6 +57,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = { vertex: 'claude-sonnet-4-5@20250929', foundry: 'claude-sonnet-4-5', openai: 'claude-sonnet-4-5-20250929', + gemini: 'claude-sonnet-4-5-20250929', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_CONFIG = { @@ -60,6 +66,7 @@ export const CLAUDE_OPUS_4_CONFIG = { vertex: 'claude-opus-4@20250514', foundry: 'claude-opus-4', openai: 'claude-opus-4-20250514', + gemini: 'claude-opus-4-20250514', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_1_CONFIG = { @@ -68,6 +75,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = { vertex: 'claude-opus-4-1@20250805', foundry: 'claude-opus-4-1', openai: 'claude-opus-4-1-20250805', + gemini: 'claude-opus-4-1-20250805', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_5_CONFIG = { @@ -76,6 +84,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = { vertex: 'claude-opus-4-5@20251101', foundry: 'claude-opus-4-5', openai: 'claude-opus-4-5-20251101', + gemini: 'claude-opus-4-5-20251101', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_6_CONFIG = { @@ -84,6 +93,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = { vertex: 'claude-opus-4-6', foundry: 'claude-opus-4-6', openai: 'claude-opus-4-6', + gemini: 'claude-opus-4-6', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_6_CONFIG = { @@ -92,6 +102,7 @@ export const CLAUDE_SONNET_4_6_CONFIG = { vertex: 'claude-sonnet-4-6', foundry: 'claude-sonnet-4-6', openai: 'claude-sonnet-4-6', + gemini: 'claude-sonnet-4-6', } as const satisfies ModelConfig // @[MODEL LAUNCH]: Register the new config here. diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index a082cd298..a6f26cad9 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -2,23 +2,32 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from import { getInitialSettings } from '../settings/settings.js' import { isEnvTruthy } from '../envUtils.js' -export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' | 'openai' +export type APIProvider = + | 'firstParty' + | 'bedrock' + | 'vertex' + | 'foundry' + | 'openai' + | 'gemini' export function getAPIProvider(): APIProvider { // 1. Check settings.json modelType field (highest priority) const modelType = getInitialSettings().modelType if (modelType === 'openai') return 'openai' + if (modelType === 'gemini') return 'gemini' // 2. Check environment variables (backward compatibility) - return isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) - ? 'openai' - : isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) - ? 'bedrock' - : isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) - ? 'vertex' - : isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) - ? 'foundry' - : 'firstParty' + return isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) + ? 'bedrock' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) + ? 'vertex' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) + ? 'foundry' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) + ? 'openai' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) + ? 'gemini' + : 'firstParty' } export function getAPIProviderForStatsig(): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { diff --git a/src/utils/settings/__tests__/config.test.ts b/src/utils/settings/__tests__/config.test.ts index f8bf1b6ab..0b527ba32 100644 --- a/src/utils/settings/__tests__/config.test.ts +++ b/src/utils/settings/__tests__/config.test.ts @@ -474,3 +474,10 @@ describe("formatZodError", () => { } }); }); + +describe("gemini settings", () => { + test("accepts gemini modelType", () => { + const result = SettingsSchema().safeParse({ modelType: "gemini" }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 9c55a7f45..7e66623ca 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -373,11 +373,11 @@ export const SettingsSchema = lazySchema(() => .optional() .describe('Tool usage permissions configuration'), modelType: z - .enum(['anthropic', 'openai']) + .enum(['anthropic', 'openai', 'gemini']) .optional() .describe( - 'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API (/v1/chat/completions). ' + - 'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL in env.', + 'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API (/v1/chat/completions), and "gemini" uses the Gemini Generate Content API. ' + + 'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL in env. When set to "gemini", configure GEMINI_API_KEY, optional GEMINI_BASE_URL, and either GEMINI_MODEL or ANTHROPIC_DEFAULT_*_MODEL family env vars.', ), model: z .string() @@ -1153,3 +1153,4 @@ export type PluginConfig = { [serverName: string]: UserConfigValues } } + diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 39afd3ad6..021a66e1e 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -244,7 +244,8 @@ export function buildAPIProviderProperties(): Property[] { const providerLabel = { bedrock: 'AWS Bedrock', vertex: 'Google Vertex AI', - foundry: 'Microsoft Foundry' + foundry: 'Microsoft Foundry', + gemini: 'Gemini API' }[apiProvider]; properties.push({ label: 'API provider', @@ -320,6 +321,12 @@ export function buildAPIProviderProperties(): Property[] { value: 'Microsoft Foundry auth skipped' }); } + } else if (apiProvider === 'gemini') { + const geminiBaseUrl = process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta'; + properties.push({ + label: 'Gemini base URL', + value: geminiBaseUrl + }); } const proxyUrl = getProxyUrl(); if (proxyUrl) { From d703a7685d77df33733b82a983b86899fbe61bce Mon Sep 17 00:00:00 2001 From: SaltedFish555 <3262749586@qq.com> Date: Sun, 5 Apr 2026 12:55:41 +0800 Subject: [PATCH 2/2] Remove unrelated local files from Gemini PR --- .codex/environments/environment.toml | 11 -- AGENTS.md | 179 ------------------------- fixtures/token-count-7ef7321f9e68.json | 3 - fixtures/token-count-fafe44226655.json | 3 - 4 files changed, 196 deletions(-) delete mode 100644 .codex/environments/environment.toml delete mode 100644 AGENTS.md delete mode 100644 fixtures/token-count-7ef7321f9e68.json delete mode 100644 fixtures/token-count-fafe44226655.json diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml deleted file mode 100644 index 1b8f087ab..000000000 --- a/.codex/environments/environment.toml +++ /dev/null @@ -1,11 +0,0 @@ -# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY -version = 1 -name = "claude-code" - -[setup] -script = "" - -[[actions]] -name = "运行" -icon = "run" -command = "bun run dev" diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index c804ded04..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,179 +0,0 @@ -# AGENTS.md - -This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. - -## Project Overview - -This is a **reverse-engineered / decompiled** version of Anthropic's official Codex CLI tool. The goal is to restore core functionality while trimming secondary capabilities. Many modules are stubbed or feature-flagged off. The codebase has ~1341 tsc errors from decompilation (mostly `unknown`/`never`/`{}` types) — these do **not** block Bun runtime execution. - -## Commands - -```bash -# Install dependencies -bun install - -# Dev mode (runs cli.tsx with MACRO defines injected via -d flags) -bun run dev - -# Dev mode with debugger (set BUN_INSPECT=9229 to pick port) -bun run dev:inspect - -# Pipe mode -echo "say hello" | bun run src/entrypoints/cli.tsx -p - -# Build (code splitting, outputs dist/cli.js + ~450 chunk files) -bun run build - -# Test -bun test # run all tests -bun test src/utils/__tests__/hash.test.ts # run single file -bun test --coverage # with coverage report - -# Lint & Format (Biome) -bun run lint # check only -bun run lint:fix # auto-fix -bun run format # format all src/ - -# Health check -bun run health - -# Check unused exports -bun run check:unused - -# Docs dev server (Mintlify) -bun run docs:dev -``` - -详细的测试规范、覆盖状态和改进计划见 `docs/testing-spec.md`。 - -## Architecture - -### Runtime & Build - -- **Runtime**: Bun (not Node.js). All imports, builds, and execution use Bun APIs. -- **Build**: `build.ts` 执行 `Bun.build()` with `splitting: true`,入口 `src/entrypoints/cli.tsx`,输出 `dist/cli.js` + chunk files。默认启用 `AGENT_TRIGGERS_REMOTE` feature。构建后自动替换 `import.meta.require` 为 Node.js 兼容版本(产物 bun/node 都可运行)。 -- **Dev mode**: `scripts/dev.ts` 通过 Bun `-d` flag 注入 `MACRO.*` defines,运行 `src/entrypoints/cli.tsx`。默认启用 `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE` 四个 feature。 -- **Module system**: ESM (`"type": "module"`), TSX with `react-jsx` transform. -- **Monorepo**: Bun workspaces — internal packages live in `packages/` resolved via `workspace:*`. -- **Lint/Format**: Biome (`biome.json`)。`bun run lint` / `bun run lint:fix` / `bun run format`。 -- **Defines**: 集中管理在 `scripts/defines.ts`。当前版本 `2.1.888`。 - -### Entry & Bootstrap - -1. **`src/entrypoints/cli.tsx`** — True entrypoint。`main()` 函数按优先级处理多条快速路径: - - `--version` / `-v` — 零模块加载 - - `--dump-system-prompt` — feature-gated (DUMP_SYSTEM_PROMPT) - - `--Codex-in-chrome-mcp` / `--chrome-native-host` - - `--daemon-worker=` — feature-gated (DAEMON) - - `remote-control` / `rc` / `bridge` — feature-gated (BRIDGE_MODE) - - `daemon` — feature-gated (DAEMON) - - `ps` / `logs` / `attach` / `kill` / `--bg` — feature-gated (BG_SESSIONS) - - `--tmux` + `--worktree` 组合 - - 默认路径:加载 `main.tsx` 启动完整 CLI -2. **`src/main.tsx`** (~4680 行) — Commander.js CLI definition。注册大量 subcommands:`mcp` (serve/add/remove/list...)、`server`、`ssh`、`open`、`auth`、`plugin`、`agents`、`auto-mode`、`doctor`、`update` 等。主 `.action()` 处理器负责权限、MCP、会话恢复、REPL/Headless 模式分发。 -3. **`src/entrypoints/init.ts`** — One-time initialization (telemetry, config, trust dialog)。 - -### Core Loop - -- **`src/query.ts`** — The main API query function. Sends messages to Codex API, handles streaming responses, processes tool calls, and manages the conversation turn loop. -- **`src/QueryEngine.ts`** — Higher-level orchestrator wrapping `query()`. Manages conversation state, compaction, file history snapshots, attribution, and turn-level bookkeeping. Used by the REPL screen. -- **`src/screens/REPL.tsx`** — The interactive REPL screen (React/Ink component). Handles user input, message display, tool permission prompts, and keyboard shortcuts. - -### API Layer - -- **`src/services/api/Codex.ts`** — Core API client. Builds request params (system prompt, messages, tools, betas), calls the Anthropic SDK streaming endpoint, and processes `BetaRawMessageStreamEvent` events. -- Supports multiple providers: Anthropic direct, AWS Bedrock, Google Vertex, Azure. -- Provider selection in `src/utils/model/providers.ts`. - -### Tool System - -- **`src/Tool.ts`** — Tool interface definition (`Tool` type) and utilities (`findToolByName`, `toolMatchesName`). -- **`src/tools.ts`** — Tool registry. Assembles the tool list; some tools are conditionally loaded via `feature()` flags or `process.env.USER_TYPE`. -- **`src/tools//`** — 61 个 tool 目录(如 BashTool, FileEditTool, GrepTool, AgentTool, WebFetchTool, LSPTool, MCPTool 等)。每个 tool 包含 `name`、`description`、`inputSchema`、`call()` 及可选的 React 渲染组件。 -- **`src/tools/shared/`** — Tool 共享工具函数。 - -### UI Layer (Ink) - -- **`src/ink.ts`** — Ink render wrapper with ThemeProvider injection. -- **`src/ink/`** — Custom Ink framework (forked/internal): custom reconciler, hooks (`useInput`, `useTerminalSize`, `useSearchHighlight`), virtual list rendering. -- **`src/components/`** — 大量 React 组件(170+ 项),渲染于终端 Ink 环境中。关键组件: - - `App.tsx` — Root provider (AppState, Stats, FpsMetrics) - - `Messages.tsx` / `MessageRow.tsx` — Conversation message rendering - - `PromptInput/` — User input handling - - `permissions/` — Tool permission approval UI - - `design-system/` — 复用 UI 组件(Dialog, FuzzyPicker, ProgressBar, ThemeProvider 等) -- Components use React Compiler runtime (`react/compiler-runtime`) — decompiled output has `_c()` memoization calls throughout. - -### State Management - -- **`src/state/AppState.tsx`** — Central app state type and context provider. Contains messages, tools, permissions, MCP connections, etc. -- **`src/state/AppStateStore.ts`** — Default state and store factory. -- **`src/state/store.ts`** — Zustand-style store for AppState (`createStore`). -- **`src/state/selectors.ts`** — State selectors. -- **`src/bootstrap/state.ts`** — Module-level singletons for session-global state (session ID, CWD, project root, token counts, model overrides, client type, permission mode). - -### Bridge / Remote Control - -- **`src/bridge/`** (~35 files) — Remote Control / Bridge 模式。feature-gated by `BRIDGE_MODE`。包含 bridge API、会话管理、JWT 认证、消息传输、权限回调等。Entry: `bridgeMain.ts`。 -- CLI 快速路径: `Codex remote-control` / `Codex rc` / `Codex bridge`。 - -### Daemon Mode - -- **`src/daemon/`** — Daemon 模式(长驻 supervisor)。feature-gated by `DAEMON`。包含 `main.ts`(entry)和 `workerRegistry.ts`(worker 管理)。 - -### Context & System Prompt - -- **`src/context.ts`** — Builds system/user context for the API call (git status, date, AGENTS.md contents, memory files). -- **`src/utils/claudemd.ts`** — Discovers and loads AGENTS.md files from project hierarchy. - -### Feature Flag System - -Feature flags control which functionality is enabled at runtime: - -- **在代码中使用**: 统一通过 `import { feature } from 'bun:bundle'` 导入,调用 `feature('FLAG_NAME')` 返回 `boolean`。**不要**在 `cli.tsx` 或其他文件里自己定义 `feature` 函数或覆盖这个 import。 -- **启用方式**: 通过环境变量 `FEATURE_=1`。例如 `FEATURE_BUDDY=1 bun run dev` 启用 BUDDY 功能。 -- **Dev 默认 features**: `BUDDY`、`TRANSCRIPT_CLASSIFIER`、`BRIDGE_MODE`、`AGENT_TRIGGERS_REMOTE`(见 `scripts/dev.ts`)。 -- **Build 默认 features**: `AGENT_TRIGGERS_REMOTE`(见 `build.ts`)。 -- **常见 flag**: `BUDDY`, `DAEMON`, `BRIDGE_MODE`, `BG_SESSIONS`, `PROACTIVE`, `KAIROS`, `VOICE_MODE`, `FORK_SUBAGENT`, `SSH_REMOTE`, `DIRECT_CONNECT`, `TEMPLATES`, `CHICAGO_MCP`, `BYOC_ENVIRONMENT_RUNNER`, `SELF_HOSTED_RUNNER`, `COORDINATOR_MODE`, `UDS_INBOX`, `LODESTONE`, `ABLATION_BASELINE` 等。 -- **类型声明**: `src/types/internal-modules.d.ts` 中声明了 `bun:bundle` 模块的 `feature` 函数签名。 - -**新增功能的正确做法**: 保留 `import { feature } from 'bun:bundle'` + `feature('FLAG_NAME')` 的标准模式,在运行时通过环境变量或配置控制,不要绕过 feature flag 直接 import。 - -### Stubbed/Deleted Modules - -| Module | Status | -|--------|--------| -| Computer Use (`@ant/*`) | Stub packages in `packages/@ant/` | -| `*-napi` packages (audio, image, url, modifiers) | Stubs in `packages/` (except `color-diff-napi` which is fully implemented) | -| Analytics / GrowthBook / Sentry | Empty implementations | -| Magic Docs / Voice Mode / LSP Server | Removed | -| Plugins / Marketplace | Removed | -| MCP OAuth | Simplified | - -### Key Type Files - -- **`src/types/global.d.ts`** — Declares `MACRO`, `BUILD_TARGET`, `BUILD_ENV` and internal Anthropic-only identifiers. -- **`src/types/internal-modules.d.ts`** — Type declarations for `bun:bundle`, `bun:ffi`, `@anthropic-ai/mcpb`. -- **`src/types/message.ts`** — Message type hierarchy (UserMessage, AssistantMessage, SystemMessage, etc.). -- **`src/types/permissions.ts`** — Permission mode and result types. - -## Testing - -- **框架**: `bun:test`(内置断言 + mock) -- **单元测试**: 就近放置于 `src/**/__tests__/`,文件名 `.test.ts` -- **集成测试**: `tests/integration/` — 4 个文件(cli-arguments, context-build, message-pipeline, tool-chain) -- **共享 mock/fixture**: `tests/mocks/`(api-responses, file-system, fixtures/) -- **命名**: `describe("functionName")` + `test("behavior description")`,英文 -- **Mock 模式**: 对重依赖模块使用 `mock.module()` + `await import()` 解锁(必须内联在测试文件中,不能从共享 helper 导入) -- **当前状态**: ~1623 tests / 114 files (110 unit + 4 integration) / 0 fail(详见 `docs/testing-spec.md`) - -## Working with This Codebase - -- **Don't try to fix all tsc errors** — they're from decompilation and don't affect runtime. -- **Feature flags** — 默认全部关闭(`feature()` 返回 `false`)。Dev/build 各有自己的默认启用列表。不要在 `cli.tsx` 中重定义 `feature` 函数。 -- **React Compiler output** — Components have decompiled memoization boilerplate (`const $ = _c(N)`). This is normal. -- **`bun:bundle` import** — `import { feature } from 'bun:bundle'` 是 Bun 内置模块,由运行时/构建器解析。不要用自定义函数替代它。 -- **`src/` path alias** — tsconfig maps `src/*` to `./src/*`. Imports like `import { ... } from 'src/utils/...'` are valid. -- **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。 -- **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。 -- **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。 diff --git a/fixtures/token-count-7ef7321f9e68.json b/fixtures/token-count-7ef7321f9e68.json deleted file mode 100644 index 0b8d94697..000000000 --- a/fixtures/token-count-7ef7321f9e68.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "tokenCount": 3 -} \ No newline at end of file diff --git a/fixtures/token-count-fafe44226655.json b/fixtures/token-count-fafe44226655.json deleted file mode 100644 index 0b8d94697..000000000 --- a/fixtures/token-count-fafe44226655.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "tokenCount": 3 -} \ No newline at end of file