diff --git a/.gitignore b/.gitignore index a1a1352178..bdb04aa0c4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,10 @@ coverage src/utils/vendor/ # AI tool runtime directories -.agents/ -.claude/ -.omx/ -.docs/task/ +.agents/* +.claude/* +.omx/* +.docs/task/* # Binary / screenshot files (root only) /*.png *.bmp diff --git a/README.md b/README.md index d0f0033a10..7655b775a1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ | **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 | | **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) | | **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 (`/login`) | [文档](https://ccb.agent-aura.top/docs/features/all-features-guide) | +| **本地 LLM (Ollama/Local)** | 支持 Ollama, LM Studio, Jan.ai, LocalAI。支持在 `/login` 中一键拉取模型、检查硬件状态、本地优先运行。 | /login 选择 Local LLM | | Voice Mode | 语音输入,支持豆包语言输入(`/voice doubao`) | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) | | Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) | | Chrome Use | 浏览器自动化、表单填写、数据抓取 | [自托管](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [原生版](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) | @@ -145,23 +146,31 @@ bun run build ### 👤 新人配置 /login -首次运行后,在 REPL 中输入 `/login` 命令进入登录配置界面,选择 **Anthropic Compatible** 即可对接第三方 API 兼容服务(无需 Anthropic 官方账号)。 -选择 OpenAI 和 Gemini 对应的栏目都是支持相应协议的 +首次运行后,在 REPL 中输入 `/login` 命令进入登录配置界面: -需要填写的字段: +1. **Anthropic Compatible**: 对接第三方 API 兼容服务(OpenRouter、AWS Bedrock 代理等)。 +2. **OpenAI / Gemini / Grok**: 对应各自协议的云端服务。 + - **Gemini (Google Auth)**: 支持交互式浏览器登录。 + 1. 在 Google Cloud Console 的 **APIs & Services > OAuth consent screen** 中配置 OAuth 客户端(User Type 设为 External)。 + 2. 下载 credentials JSON 文件并保存到项目根目录的 `/.files/OAuth.json`。 + 3. 在 `/login` 配置界面中留空 API Key 并按 Enter,程序将自动拉起浏览器完成授权并自动拉取模型列表。 +3. **Local LLM**: **(推荐)** 使用本地运行的模型。 + - 支持 **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**。 + - **Ollama 深度集成**: 可直接在 CLI 中查看已安装模型,或输入模型名(如 `llama3.1`)一键拉取(Pull)。支持模型列表交互切换和硬件状态自动检测。 + - 自动检测本地运行状态和默认端口。 +#### /login 字段说明 (云端模式): -| 📌 字段 | 📝 说明 | 💡 示例 | -| ------------ | ------------- | ---------------------------- | -| Base URL | API 服务地址 | `https://api.example.com/v1` | -| API Key | 认证密钥 | `sk-xxx` | -| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` | -| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` | -| Opus Model | 高性能模型 ID | `claude-opus-4-6` | +> ℹ️ 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。 -- ⌨️ **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存 +### 🩺 系统诊断 /doctor -> ℹ️ 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。 +如果你在使用过程中遇到环境问题(尤其是本地模型运行缓慢或无法连接),可以使用 `/doctor` 命令进行全方位诊断: + +- **硬件负载**: 自动显示当前 CPU 型号、核心数、剩余内存 (RAM) 以及系统架构。 +- **本地环境**: 检查 Ollama 等本地 Runner 是否正在运行,并列出所有可用模型。 +- **配置校验**: 检查环境变量(如 `LOCAL_BASE_URL`)和权限设置。 +- **故障排查**: 识别多个重复安装的版本、过期的版本锁或权限不足的更新。 ## Feature Flags diff --git a/README_EN.md b/README_EN.md index 6769ff2a9a..d2b24a910a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -6,50 +6,65 @@ [![GitHub License](https://img.shields.io/github/license/claude-code-best/claude-code?style=flat-square)](https://github.com/claude-code-best/claude-code/blob/main/LICENSE) [![Last Commit](https://img.shields.io/github/last-commit/claude-code-best/claude-code?style=flat-square&color=blue)](https://github.com/claude-code-best/claude-code/commits/main) [![Bun](https://img.shields.io/badge/runtime-Bun-black?style=flat-square&logo=bun)](https://bun.sh/) +[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord)](https://discord.gg/uApuzJWGKX) > Which Claude do you like? The open source one is the best. -A reverse-engineered / decompiled source restoration of Anthropic's official [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI tool. The goal is to reproduce most of Claude Code's functionality and engineering capabilities. It's abbreviated as CCB. +A source code decompilation/reverse engineering project of the official [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI tool from Anthropic (aka "Old A"). The goal is to reproduce most of the features and engineering capabilities of Claude Code (the user says "Old Lafayette has already paid for it"). Although it's a bit awkward, it's called CCB (Cai Cai Bei / Step on the Back)... Moreover, we have implemented features that are usually limited to the Enterprise edition or require logging into a Claude account, achieving technology democratization. -[Documentation (Chinese)](https://ccb.agent-aura.top/) — PR contributions welcome. +> We will be performing lint standardization across the entire repository during the Labor Day holiday (May 1st). PRs submitted during this period may have many conflicts, so please try to submit large features before then. -Sponsor placeholder. +[Documentation here, PR submissions welcome](https://ccb.agent-aura.top/) | [Friends list documentation here](./Friends.md) | [Discord Group](https://discord.gg/uApuzJWGKX) -- [x] v1: Basic runability and type checking pass -- [x] V2: Complete engineering infrastructure - - [ ] Biome formatting may not be implemented first to avoid code conflicts - - [x] Build pipeline complete, output runnable on both Node.js and Bun -- [x] V3: Extensive documentation and documentation site improvements -- [x] V4: Large-scale test suite for improved stability - - [x] Buddy pet feature restored [Docs](https://ccb.agent-aura.top/docs/features/buddy) - - [x] Auto Mode restored [Docs](https://ccb.agent-aura.top/docs/safety/auto-mode) - - [x] All features now configurable via environment variables instead of `bun --feature` -- [x] V5: Enterprise-grade monitoring/reporting, missing tools补全, restrictions removed - - [x] Removed anti-distillation code - - [x] Web search capability (using Bing) [Docs](https://ccb.agent-aura.top/docs/features/web-browser-tool) - - [x] Debug mode support [Docs](https://ccb.agent-aura.top/docs/features/debug-mode) - - [x] Disabled auto-updates - - [x] Custom Sentry error reporting support [Docs](https://ccb.agent-aura.top/docs/internals/sentry-setup) - - [x] Custom GrowthBook support (GB is open source — configure your own feature flag platform) [Docs](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) - - [x] Custom login mode — configure Claude models your way -- [ ] V6: Large-scale refactoring, full modular packaging - - [ ] V6 will be a new branch; main branch will be archived as a historical version +| Feature | Description | Documentation | +| --- | --- | --- | +| **Claude Group Control** | Pipe IPC multi-instance collaboration: Automatic orchestration of local main/sub instances + zero-config LAN discovery and communication, `/pipes` selection panel + `Shift+↓` interaction + message broadcast routing | [Pipe IPC](https://ccb.agent-aura.top/docs/features/uds-inbox) / [LAN](https://ccb.agent-aura.top/docs/features/lan-pipes) | +| **First-class ACP Protocol Support** | Supports integration with IDEs like Zed and Cursor, session recovery, Skills, and permission bridging | [Documentation](https://ccb.agent-aura.top/docs/features/acp-zed) | +| **Remote Control Private Deployment** | Docker self-hosted remote interface, allowing you to use CC on your phone | [Documentation](https://ccb.agent-aura.top/docs/features/remote-control-self-hosting) | +| **Langfuse Monitoring** | Enterprise-grade Agent monitoring, clearly see every agent loop detail, and convert to datasets with one click | [Documentation](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) | +| **Web Search** | Built-in web search tool, supports Bing and Brave search | [Documentation](https://ccb.agent-aura.top/docs/features/web-browser-tool) | +| **Poor Mode** | For the budget-conscious: disables memory extraction and typing suggestions, significantly reducing concurrent requests | Toggle with `/poor` | +| **Channels Notifications** | MCP server pushes external messages to sessions (Feishu/Slack/Discord/WeChat, etc.), enabled with `--channels plugin:name@marketplace` | [Documentation](https://ccb.agent-aura.top/docs/features/channels) | +| **Custom Model Providers** | Compatible with OpenAI/Anthropic/Gemini/Grok (`/login`) | [Documentation](https://ccb.agent-aura.top/docs/features/all-features-guide) | +| Voice Mode | Voice input, supports Doubao voice input (`/voice doubao`) | [Documentation](https://ccb.agent-aura.top/docs/features/voice-mode) | +| Computer Use | Screenshots, keyboard and mouse control | [Documentation](https://ccb.agent-aura.top/docs/features/computer-use) | +| Chrome Use | Browser automation, form filling, data scraping | [Self-hosted](https://ccb.agent-aura.top/docs/features/chrome-use-mcp) [Native version](https://ccb.agent-aura.top/docs/features/claude-in-chrome-mcp) | +| Sentry | Enterprise-grade error tracking | [Documentation](https://ccb.agent-aura.top/docs/internals/sentry-setup) | +| GrowthBook | Enterprise-grade feature flags | [Documentation](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) | +| /dream Memory Consolidation | Automatically organize and optimize memory files | [Documentation](https://ccb.agent-aura.top/docs/features/auto-dream) | -> I don't know how long this project will survive. Star + Fork + git clone + .zip is the safest bet. -> -> This project updates rapidly — Opus continuously optimizes in the background, with new changes almost every few hours. -> -> Claude has burned over $1000, out of budget, switching to GLM to continue; @zai-org GLM 5.1 is quite capable. +- 🚀 [Quick Start (Source Code Version)](#-quick-start-source-code-version) +- 🐛 [Debugging the Project](#vs-code-debugging) +- 📖 [Learn the Project](#teach-me-learning-project) -## Quick Start +## ⚡ Quick Start (Installation Version) -### Prerequisites +No need to clone the repository. After downloading from NPM, use it directly. -Make sure you're on the latest version of Bun, otherwise you'll run into all sorts of weird bugs. Run `bun upgrade`! +```sh +npm i -g claude-code-best -- [Bun](https://bun.sh/) >= 1.3.11 +# Bun installation has many issues, npm is recommended +# bun i -g claude-code-best +# bun pm -g trust claude-code-best @claude-code-best/mcp-chrome-bridge -**Install Bun:** +ccb # Open Claude Code with Node.js +ccb-bun # Open with Bun +ccb update # Update to the latest version +CLAUDE_BRIDGE_BASE_URL=https://remote-control.claude-code-best.win/ CLAUDE_BRIDGE_OAUTH_TOKEN=test-my-key ccb --remote-control # We have self-deployed remote control +``` + +> **Installation/Update Failed?** Run `npm rm -g claude-code-best` to clean up old versions first, then `npm i -g claude-code-best@latest`. If it still fails, specify the version number: `npm i -g claude-code-best@` + +## ⚡ Quick Start (Source Code Version) + +### ⚙️ Prerequisites + +You MUST use the latest version of Bun, otherwise you'll encounter many strange bugs!!! `bun upgrade`!!! + +- 📦 [Bun](https://bun.sh/) >= 1.3.11 + +**Installing Bun:** ```bash # Linux and macOS @@ -61,103 +76,87 @@ powershell -c "irm bun.sh/install.ps1 | iex" **Post-installation steps:** -1. **Make `bun` available in the current terminal** +1. **Make `bun` command recognized in the current terminal** - The installer adds `~/.bun/bin` to the matching shell configuration file. On macOS with the default zsh shell, you may see: + The installation script will write `~/.bun/bin` to your shell configuration file. On macOS with zsh, you will usually see: - ```text - Added "~/.bun/bin" to $PATH in "~/.zshrc" - ``` + ```text + Added "~/.bun/bin" to $PATH in "~/.zshrc" + ``` - Restart the current shell as the installer suggests: + You can restart your shell as prompted: - ```bash - exec /bin/zsh - ``` + ```bash + exec /bin/zsh + ``` - If you use bash, reload the bash configuration: + If using bash, reload the configuration: - ```bash - source ~/.bashrc - ``` + ```bash + source ~/.bashrc + ``` - Windows PowerShell users can close and reopen PowerShell. + Windows PowerShell users should close and reopen PowerShell. -2. **Verify that Bun is available:** - ```bash - bun --help - bun --version - ``` +2. **Verify Bun is available** -3. **Update to latest version (if already installed):** - ```bash - bun upgrade - ``` + ```bash + bun --help + bun --version + ``` -- Standard Claude Code configuration — each provider has its own setup method +3. **If Bun is already installed, update to the latest version** -### Command Execution Location + ```bash + bun upgrade + ``` -- Bun installation and checking commands can be run from any directory: - `curl -fsSL https://bun.sh/install | bash`, `bun --help`, `bun --version`, `bun upgrade` -- Project dependency installation, development mode, and builds must be run from this repository root, the directory containing `package.json`. +- ⚙️ Standard CC configuration methods; each provider has its own way. -### Install +### 📍 Execution Directory + +- Commands to install or check Bun can be run in any directory: `curl -fsSL https://bun.sh/install | bash`, `bun --help`, `bun --version`, `bun upgrade`. +- To install dependencies, start development mode, or build the project, you MUST be in the repository root directory (the one containing `package.json`). + +### 📥 Installation ```bash cd /path/to/claude-code bun install ``` -### Run +### ▶️ Running ```bash -# Dev mode — if you see version 888, it's working +# Development mode, version number 888 confirms success bun run dev # Build bun run build ``` -The build uses code splitting (`build.ts`), outputting to `dist/` (entry `dist/cli.js` + ~450 chunk files). - -The build output runs on both Bun and Node.js — you can publish to a private registry and run directly. - -If you encounter a bug, please open an issue — we'll prioritize it. +The build uses code splitting for multi-file packaging (`build.ts`), outputting to the `dist/` directory (entry point `dist/cli.js` + approximately 450 chunk files). -### First-time Setup /login +The built version can be started with both Bun and Node.js. You can start it directly if you publish it to a private source. -After the first run, enter `/login` in the REPL to access the login configuration screen. Select **Anthropic Compatible** to connect to third-party API-compatible services (no Anthropic account required). +If you encounter a bug, please open an issue; we prioritize solving them. -Fields to fill in: +### 👤 New User Configuration /login -| Field | Description | Example | -|-------|-------------|---------| -| Base URL | API service URL | `https://api.example.com/v1` | -| API Key | Authentication key | `sk-xxx` | -| Haiku Model | Fast model ID | `claude-haiku-4-5-20251001` | -| Sonnet Model | Balanced model ID | `claude-sonnet-4-6` | -| Opus Model | High-performance model ID | `claude-opus-4-6` | +After running for the first time, type `/login` in the REPL to enter the login configuration interface. -- **Tab / Shift+Tab** to switch fields, **Enter** to confirm and move to the next, press Enter on the last field to save -- Model fields auto-fill from current environment variables -- Configuration saves to `~/.claude/settings.json` under the `env` key, effective immediately - -You can also edit `~/.claude/settings.json` directly: - -```json -{ - "env": { - "ANTHROPIC_BASE_URL": "https://api.example.com/v1", - "ANTHROPIC_AUTH_TOKEN": "sk-xxx", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-6" - } -} -``` +1. **Anthropic Compatible**: Connect to third-party API services (OpenRouter, AWS Bedrock proxies, etc.) (no official Anthropic account required). +2. **OpenAI / Gemini / Grok**: Connect to cloud services using their respective protocols. + - **Gemini (Google Auth)**: Supports interactive browser login. + 1. In the Google Cloud Console, navigate to **APIs & Services > OAuth consent screen** and configure the OAuth client (Set User Type to External). + 2. Download the credentials JSON format and save it as `/.files/OAuth.json` in the project root. + 3. Leave the API Key field blank and press Enter in the `/login` configuration interface; the CLI will automatically open your browser for Google OAuth 2.0 authorization and fetch available models. +3. **Local LLM**: **(Recommended)** Use models running locally. + - Supports **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**. + - **Ollama Deep Integration**: View installed models directly in the CLI, or enter a model name (e.g., `llama3.1`) to pull it instantly. Supports interactive model selection navigation and hardware status auto-detection. + - Automatically detects local runner status and default ports. -> Supports all Anthropic API-compatible services (e.g., OpenRouter, AWS Bedrock proxies, etc.) as long as the interface is compatible with the Messages API. +> ℹ️ Supports all Anthropic API compatible services (e.g., OpenRouter, AWS Bedrock proxies, etc.), as long as the interface is compatible with the Messages API. ## Feature Flags @@ -167,45 +166,74 @@ All feature toggles are enabled via `FEATURE_=1` environment variable FEATURE_BUDDY=1 FEATURE_FORK_SUBAGENT=1 bun run dev ``` -See [`docs/features/`](docs/features/) for detailed descriptions of each feature. Contributions welcome. +Detailed descriptions of each feature can be found in the [`docs/features/`](docs/features/) directory. Contributions are welcome. ## VS Code Debugging -The TUI (REPL) mode requires a real terminal and cannot be launched directly via VS Code's launch config. Use **attach mode**: +TUI (REPL) mode requires a real terminal and cannot be debugged directly via a VS Code launch configuration. Use **attach mode**: ### Steps -1. **Start inspect server in terminal**: - ```bash - bun run dev:inspect - ``` - This outputs an address like `ws://localhost:8888/xxxxxxxx`. +1. **Start the inspect service in a terminal**: -2. **Attach debugger from VS Code**: - - Set breakpoints in `src/` files - - Press F5 → select **"Attach to Bun (TUI debug)"** + ```bash + bun run dev:inspect + ``` -## Documentation & Links + It will output an address like `ws://localhost:8888/xxxxxxxx`. +2. **Attach the VS Code debugger**: -- **Online docs (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — source in [`docs/`](docs/), PR contributions welcome -- **DeepWiki**: https://deepwiki.com/claude-code-best/claude-code + - Set breakpoints in `src/` files. + - Press F5 → Select **"Attach to Bun (TUI debug)"**. + +## Teach Me Learning Project + +We've added a new `teach-me` skill, which uses a Q&A-style guide to help you understand any module of this project. (Adapted from [sigma skill](https://github.com/sanyuan0704/sanyuan-skills)). + +```bash +# Enter directly in the REPL +/teach-me Claude Code Architecture +/teach-me React Ink Terminal Rendering --level beginner +/teach-me Tool System --resume +``` + +### What it can do + +- **Level Diagnosis** — Automatically assesses your mastery of related concepts, skipping what you know and focusing on weaknesses. +- **Build Learning Paths** — Breaks down topics into 5-15 atomic concepts, progressing step-by-step based on dependencies. +- **Socratic Questioning** — Guides your thinking with options rather than giving direct answers. +- **Misconception Tracking** — Discovers and corrects deep-seated misunderstandings. +- **Resume Learning** — `--resume` continues from where you last left off. + +### Learning Records + +Learning progress is saved in the `.claude/skills/teach-me/` directory, supporting cross-topic learner profiles. + +## Related Documents and Websites + +- **Online Documentation (Mintlify)**: [ccb.agent-aura.top](https://ccb.agent-aura.top/) — Documentation source code is in the [`docs/`](docs/) directory; PRs are welcome. +- **DeepWiki**: [https://deepwiki.com/claude-code-best/claude-code](https://deepwiki.com/claude-code-best/claude-code) ## Contributors - + Contributors ## Star History - - - Star History Chart + + + Star History Chart +## Acknowledgments + +- [doubaoime-asr](https://github.com/starccy/doubaoime-asr) — Doubao ASR voice recognition SDK, providing a voice input solution for Voice Mode without requiring Anthropic OAuth. + ## License This project is for educational and research purposes only. All rights to Claude Code belong to [Anthropic](https://www.anthropic.com/). diff --git a/src/bootstrap/state.ts b/src/bootstrap/state.ts index f939b5c43d..ccdde5843f 100644 --- a/src/bootstrap/state.ts +++ b/src/bootstrap/state.ts @@ -261,7 +261,7 @@ function getInitialState(): State { typeof process.cwd === 'function' && typeof realpathSync === 'function' ) { - const rawCwd = cwd() + const rawCwd = process.env.CLAUDE_CODE_CWD || cwd() try { resolvedCwd = realpathSync(rawCwd).normalize('NFC') } catch { diff --git a/src/commands/provider.ts b/src/commands/provider.ts index d471bd104f..e8c1f0bd40 100644 --- a/src/commands/provider.ts +++ b/src/commands/provider.ts @@ -67,6 +67,7 @@ const call: LocalCommandCall = async (args, _context) => { 'openai', 'gemini', 'grok', + 'local', 'bedrock', 'vertex', 'foundry', @@ -78,6 +79,19 @@ const call: LocalCommandCall = async (args, _context) => { } } + // Check env vars when switching to local (including settings.env) + if (arg === 'local') { + const mergedEnv = getMergedEnv() + const hasUrl = !!mergedEnv.LOCAL_BASE_URL + if (!hasUrl) { + updateSettingsForSource('userSettings', { modelType: 'local' }) + return { + type: 'text', + value: `Switched to Local provider.\nWarning: Missing env var: LOCAL_BASE_URL\nConfigure it via /login or set manually.`, + } + } + } + // Check env vars when switching to openai (including settings.env) if (arg === 'openai') { const mergedEnv = getMergedEnv() @@ -130,7 +144,8 @@ const call: LocalCommandCall = async (args, _context) => { arg === 'anthropic' || arg === 'openai' || arg === 'gemini' || - arg === 'grok' + arg === 'grok' || + arg === 'local' ) { // Clear any cloud provider env vars to avoid conflicts delete process.env.CLAUDE_CODE_USE_BEDROCK diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 084cdf2d05..46f7cbdb43 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -22,6 +22,7 @@ import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settin import { Select } from './CustomSelect/select.js'; import { Spinner } from './Spinner.js'; import TextInput from './TextInput.js'; +import { checkOllamaStatus, listOllamaModels, pullOllamaModel, pingUrl } from '../utils/localLlm.js'; type Props = { onDone(): void; @@ -58,15 +59,41 @@ type OAuthStatus = } // ChatGPT account subscription via Codex OAuth device flow | { state: 'gemini_api'; - baseUrl: string; apiKey: string; haikuModel: string; sonnetModel: string; opusModel: string; - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; + activeField: + | 'api_key' + | 'haiku_model' + | 'sonnet_model' + | 'opus_model' + | 'custom_haiku_model' + | 'custom_sonnet_model' + | 'custom_opus_model'; + availableModels: string[]; + isLoadingModels: boolean; + statusMessage?: string; } // Gemini Generate Content API platform + | { + state: 'local_llm_setup'; + runnerType: 'ollama' | 'lmstudio' | 'jan' | 'localai' | 'custom'; + baseUrl: string; + apiKey?: string; + modelName: string; + activeField: 'runner_type' | 'base_url' | 'api_key' | 'model_name' | 'custom_model_name'; + availableModels: string[]; + isLoadingModels: boolean; + statusMessage?: string; + } + | { + state: 'local_llm_pulling'; + modelName: string; + status: string; + percentage?: number; + } | { state: 'ready_to_start' } // Flow started, waiting for browser to open - | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login + | { state: 'waiting_for_login'; url?: string } // Browser opened, waiting for user to login | { state: 'creating_api_key' } // Got access token, creating API key | { state: 'about_to_retry'; nextState: OAuthStatus } | { state: 'success'; token?: string } @@ -77,6 +104,7 @@ type OAuthStatus = }; const PASTE_HERE_MSG = 'Paste code here if prompted > '; +const POPULAR_MODELS = ['llama3.1', 'mistral', 'phi3', 'qwen2', 'gemma2', 'codellama']; export function ConsoleOAuthFlow({ onDone, startingMessage, @@ -137,6 +165,95 @@ export function ConsoleOAuthFlow({ } }, [oauthStatus]); + // Handle Ollama model listing + useEffect(() => { + if ( + oauthStatus.state === 'local_llm_setup' && + oauthStatus.runnerType === 'ollama' && + oauthStatus.availableModels.length === 0 && + !oauthStatus.isLoadingModels && + !oauthStatus.statusMessage + ) { + setOAuthStatus(prev => (prev.state === 'local_llm_setup' ? { ...prev, isLoadingModels: true } : prev)); + listOllamaModels(oauthStatus.baseUrl) + .then(models => { + setOAuthStatus(prev => + prev.state === 'local_llm_setup' + ? { + ...prev, + availableModels: models, + isLoadingModels: false, + statusMessage: models.length === 0 ? 'No models found. You can download one below.' : undefined, + } + : prev, + ); + }) + .catch(err => { + setOAuthStatus(prev => + prev.state === 'local_llm_setup' + ? { + ...prev, + isLoadingModels: false, + statusMessage: `Error: ${err.message}`, + } + : prev, + ); + }); + } + }, [oauthStatus]); + + // Handle Ollama model pulling + useEffect(() => { + if (oauthStatus.state === 'local_llm_pulling') { + const abortController = new AbortController(); + (async () => { + try { + for await (const progress of pullOllamaModel( + oauthStatus.modelName, + 'http://localhost:11434', + abortController.signal, + )) { + setOAuthStatus(prev => + prev.state === 'local_llm_pulling' + ? { + ...prev, + status: progress.status, + percentage: progress.percentage, + } + : prev, + ); + } + // Success! Reload models + setOAuthStatus({ + state: 'local_llm_setup', + runnerType: 'ollama', + baseUrl: 'http://localhost:11434', + modelName: oauthStatus.modelName, + activeField: 'model_name', + availableModels: [], + isLoadingModels: false, + }); + } catch (err) { + if (abortController.signal.aborted) return; + setOAuthStatus({ + state: 'error', + message: `Failed to pull model: ${err instanceof Error ? err.message : String(err)}`, + toRetry: { + state: 'local_llm_setup', + runnerType: 'ollama', + baseUrl: 'http://localhost:11434', + modelName: oauthStatus.modelName, + activeField: 'model_name', + availableModels: [], + isLoadingModels: false, + }, + }); + } + })(); + return () => abortController.abort(); + } + }, [oauthStatus.state]); + // Handle Enter to continue on success state useKeybinding( 'confirm:yes', @@ -182,7 +299,7 @@ export function ConsoleOAuthFlow({ useEffect(() => { if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) { - void setClipboard(oauthStatus.url).then(raw => { + void setClipboard(oauthStatus.url || '').then(raw => { if (raw) process.stdout.write(raw); setUrlCopied(true); setTimeout(setUrlCopied, 2000, false); @@ -351,7 +468,7 @@ export function ConsoleOAuthFlow({ )} - + {oauthStatus.url} @@ -402,7 +519,7 @@ type OAuthStatusMessageProps = { setCursorOffset: (offset: number) => void; textInputColumns: number; handleSubmitCode: (value: string, url: string) => void; - setOAuthStatus: (status: OAuthStatus) => void; + setOAuthStatus: React.Dispatch>; setLoginWithClaudeAi: (value: boolean) => void; }; @@ -437,6 +554,15 @@ function OAuthStatusMessage({ { + const nextState = buildLocalState('runner_type', val, 'base_url') as any; + setOAuthStatus(nextState); + setLocalInputValue(nextState.baseUrl ?? ''); + setLocalInputCursorOffset((nextState.baseUrl ?? '').length); + }} + /> + ) : ( + {displayValues.runner_type} + )} + + + {(activeField === 'base_url' || LOCAL_FIELDS.indexOf(activeField) > LOCAL_FIELDS.indexOf('base_url')) && + renderLocalTextInput('base_url', 'Base URL ')} + + {(activeField === 'api_key' || LOCAL_FIELDS.indexOf(activeField) > LOCAL_FIELDS.indexOf('api_key')) && + renderLocalTextInput('api_key', 'API Key ', true)} + + {(activeField === 'model_name' || activeField === 'custom_model_name') && ( + + + + {' Model Name '} + + + {activeField === 'model_name' ? ( + oauthStatus.isLoadingModels ? ( + + + Loading installed models... + + ) : ( + + ({ label: m, value: m })), + { label: 'Custom (Type your own)', value: '__custom__' }, + ]} + onChange={val => { + if (val === '__custom__') { + const nextState = buildGeminiState(field, '', customField); + setOAuthStatus(nextState); + setGeminiInputValue(''); + setGeminiInputCursorOffset(0); + } else { + const nextState = buildGeminiState(field, val); + if (field === 'opus_model') { + setOAuthStatus(nextState); + doGeminiSave(nextState); + } else { + // Advance to next field + const idx = GEMINI_FIELDS.indexOf(field); + const next = GEMINI_FIELDS[idx + 1]!; + const advancedState = buildGeminiState(field, val, next); + setOAuthStatus(advancedState); + setGeminiInputValue(geminiDisplayValues[next] ?? ''); + setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length); + } + } + }} + /> + ) : activeField === customField ? ( + + ) : val ? ( + {val} + ) : null} + + + ); + }; + const renderGeminiRow = (field: GeminiField, label: string, opts?: { mask?: boolean }) => { const active = activeField === field; const val = geminiDisplayValues[field]; @@ -1257,17 +1893,41 @@ function OAuthStatusMessage({ Gemini API Setup - Configure a Gemini Generate Content compatible endpoint. Base URL is optional and defaults to Google's - v1beta API. + Configure a Gemini Generate Content compatible endpoint. Models will be fetched automatically. Leave API Key + blank to log in via browser (Google Auth). + - {renderGeminiRow('base_url', 'Base URL ')} - {renderGeminiRow('api_key', 'API Key ', { mask: true })} - {renderGeminiRow('haiku_model', 'Haiku ')} - {renderGeminiRow('sonnet_model', 'Sonnet ')} - {renderGeminiRow('opus_model', 'Opus ')} + {(activeField === 'api_key' || + GEMINI_FIELDS.indexOf(activeField as any) >= GEMINI_FIELDS.indexOf('api_key')) && + renderGeminiRow('api_key', 'API Key ', { mask: true })} + + {isLoadingModels && ( + + + {statusMessage || 'Loading...'} + + )} + + {!isLoadingModels && + (activeField === 'haiku_model' || + activeField === 'custom_haiku_model' || + GEMINI_FIELDS.indexOf(activeField as any) > GEMINI_FIELDS.indexOf('haiku_model')) && + renderGeminiModelField('haiku_model', 'custom_haiku_model', 'Haiku ')} + + {!isLoadingModels && + (activeField === 'sonnet_model' || + activeField === 'custom_sonnet_model' || + GEMINI_FIELDS.indexOf(activeField as any) > GEMINI_FIELDS.indexOf('sonnet_model')) && + renderGeminiModelField('sonnet_model', 'custom_sonnet_model', 'Sonnet ')} + + {!isLoadingModels && + (activeField === 'opus_model' || + activeField === 'custom_opus_model' || + GEMINI_FIELDS.indexOf(activeField as any) > GEMINI_FIELDS.indexOf('opus_model')) && + renderGeminiModelField('opus_model', 'custom_opus_model', 'Opus ')} - ↑↓/Tab to switch · Enter on last field to save · Esc to go back + ↑↓ to select options · Enter to save/fetch models · Esc to go back ); } @@ -1340,7 +2000,7 @@ function OAuthStatusMessage({ handleSubmitCode(value, oauthStatus.url)} + onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url || '')} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} diff --git a/src/components/__tests__/ConsoleOAuthFlow.test.tsx b/src/components/__tests__/ConsoleOAuthFlow.test.tsx new file mode 100644 index 0000000000..0f65d8d7da --- /dev/null +++ b/src/components/__tests__/ConsoleOAuthFlow.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, test, mock } from 'bun:test'; +import * as React from 'react'; + +mock.module('react', () => ({ + ...React, + useState: (initial: any) => [typeof initial === 'function' ? initial() : initial, () => {}], + useEffect: () => {}, + useRef: (initial: any) => ({ current: initial }), + useCallback: (fn: any) => fn, + useMemo: (fn: any) => fn(), + useContext: () => ({}), + useLayoutEffect: () => {}, // Add a simple mock for useLayoutEffect +})); + +import { ConsoleOAuthFlow } from '../ConsoleOAuthFlow.js'; + +// Mock dependencies +mock.module('src/services/analytics/index.js', () => ({ + logEvent: () => {}, +})); + +mock.module('../utils/localLlm.js', () => ({ + checkOllamaStatus: async () => true, + listOllamaModels: async () => ['llama3.1', 'mistral'], + pullOllamaModel: async () => {}, + pingUrl: async () => true, +})); + +mock.module('../utils/settings/settings.js', () => ({ + getSettings_DEPRECATED: () => ({}), + updateSettingsForSource: () => {}, +})); + +mock.module('../utils/auth.js', () => ({ + getOauthAccountInfo: async () => ({}), + validateForceLoginOrg: async () => true, +})); + +mock.module('../services/oauth/index.js', () => ({ + OAuthService: { + start: async () => {}, + }, +})); + +mock.module('@anthropic/ink', () => ({ + useTerminalNotification: () => () => {}, + setClipboard: () => {}, + Box: ({ children }: any) =>
{children}
, + Link: ({ children }: any) =>
{children}
, + Text: ({ children }: any) =>
{children}
, + KeyboardShortcutHint: () => null, +})); + +mock.module('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 80, rows: 24 }), +})); + +mock.module('../keybindings/useKeybinding.js', () => ({ + useKeybinding: () => {}, +})); + +describe('ConsoleOAuthFlow', () => { + test('renders initial login method selection', () => { + const onDone = () => {}; + const element = ConsoleOAuthFlow({ onDone }) as React.ReactElement; + + // The component returns a React element tree + // We expect it to contain the title and options + const str = JSON.stringify(element); + expect(str).toContain('Select login method'); + expect(str).toContain('Anthropic Console'); + expect(str).toContain('Local LLM'); + }); +}); diff --git a/src/components/messages/AttachmentMessage.tsx b/src/components/messages/AttachmentMessage.tsx index 574209158c..1a80a6ed31 100644 --- a/src/components/messages/AttachmentMessage.tsx +++ b/src/components/messages/AttachmentMessage.tsx @@ -349,16 +349,26 @@ export function AttachmentMessage({ attachment, addMargin, verbose, isTranscript ); } case 'hook_non_blocking_error': { - // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') { + // Stop/Start hooks are rendered as a summary or suppressed to avoid clutter + if ( + attachment.hookEvent === 'Stop' || + attachment.hookEvent === 'SubagentStop' || + attachment.hookEvent === 'SessionStart' || + attachment.hookEvent === 'SubagentStart' + ) { return null; } // Full hook output is logged to debug log via hookEvents.ts return {attachment.hookName} hook error; } case 'hook_error_during_execution': - // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') { + // Stop/Start hooks are rendered as a summary or suppressed to avoid clutter + if ( + attachment.hookEvent === 'Stop' || + attachment.hookEvent === 'SubagentStop' || + attachment.hookEvent === 'SessionStart' || + attachment.hookEvent === 'SubagentStart' + ) { return null; } // Full hook output is logged to debug log via hookEvents.ts diff --git a/src/constants/prompts.ts b/src/constants/prompts.ts index cca0a4264f..d494e04090 100644 --- a/src/constants/prompts.ts +++ b/src/constants/prompts.ts @@ -145,7 +145,10 @@ function getAntModelOverrideSection(): string | null { function getLanguageSection( languagePreference: string | undefined, ): string | null { - if (!languagePreference) return null + if (!languagePreference) { + return `# Language +Always respond in the language of the user's input. Even though some system instructions or contextual documents are in Chinese, you MUST reply in the language the user speaks to you in (e.g., if they speak English, reply in English).` + } return `# Language Always respond in ${languagePreference}. Use ${languagePreference} for all explanations, comments, and communications with the user. Technical terms and code identifiers should remain in their original form.` diff --git a/src/screens/Doctor.tsx b/src/screens/Doctor.tsx index fd9a0258d1..d8f6cbcc80 100644 --- a/src/screens/Doctor.tsx +++ b/src/screens/Doctor.tsx @@ -290,6 +290,45 @@ export function Doctor({ onDone }: Props): React.ReactNode { + {/* Hardware info section */} + {diagnostic.hardwareInfo && ( + + System Information + + └ CPU: {diagnostic.hardwareInfo.cpuModel} ({diagnostic.hardwareInfo.cpus} cores) + + + └ RAM: {diagnostic.hardwareInfo.freeMem} free of {diagnostic.hardwareInfo.totalMem} + + └ Arch: {diagnostic.hardwareInfo.arch} + + )} + + {/* Local LLM section */} + {diagnostic.localLlmStatus && ( + + Local LLM + + └ Ollama:{' '} + {diagnostic.localLlmStatus.ollama.running ? ( + Running + ) : ( + Not running + )} + + {diagnostic.localLlmStatus.ollama.running && ( + + └ Models:{' '} + {diagnostic.localLlmStatus.ollama.models.length > 0 ? ( + diagnostic.localLlmStatus.ollama.models.join(', ') + ) : ( + None + )} + + )} + + )} + diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index a5116855f0..f9d7845201 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1331,7 +1331,7 @@ async function* queryModel( // OpenAI-compatible provider: delegate to the OpenAI adapter layer // after shared preprocessing (message normalization, tool filtering, // media stripping) but before Anthropic-specific logic (betas, thinking, caching). - if (getAPIProvider() === 'openai') { + if (getAPIProvider() === 'openai' || getAPIProvider() === 'local') { const { queryModelOpenAI } = await import('./openai/index.js') // OpenAI emulates Anthropic's dynamic tool loading client-side. It needs // the full tool pool so SearchExtraToolsTool can search deferred MCP tools that diff --git a/src/services/api/gemini/client.ts b/src/services/api/gemini/client.ts index e052400967..c05ce6e24f 100644 --- a/src/services/api/gemini/client.ts +++ b/src/services/api/gemini/client.ts @@ -1,6 +1,7 @@ import { parseSSEFrames } from 'src/cli/transports/SSETransport.js' import { errorMessage } from 'src/utils/errors.js' import { getProxyFetchOptions } from 'src/utils/proxy.js' +import { getGoogleAccessToken } from './google-oauth.js' import type { GeminiGenerateContentRequest, GeminiStreamChunk, @@ -12,10 +13,7 @@ const DEFAULT_GEMINI_BASE_URL = const STREAM_DECODE_OPTS: TextDecodeOptions = { stream: true } function getGeminiBaseUrl(): string { - return (process.env.GEMINI_BASE_URL || DEFAULT_GEMINI_BASE_URL).replace( - /\/+$/, - '', - ) + return DEFAULT_GEMINI_BASE_URL.replace(/\/+$/, '') } function getGeminiModelPath(model: string): string { @@ -23,6 +21,42 @@ function getGeminiModelPath(model: string): string { return normalized.startsWith('models/') ? normalized : `models/${normalized}` } +export async function listGeminiModels(apiKey?: string): Promise { + const url = `${getGeminiBaseUrl()}/models` + const headers: Record = {} + + if (apiKey) { + headers['x-goog-api-key'] = apiKey + } else { + const token = await getGoogleAccessToken() + if (token) { + headers['Authorization'] = `Bearer ${token}` + } else { + throw new Error('No API key or Google Auth token available') + } + } + + const response = await fetch(url, { + method: 'GET', + headers, + ...getProxyFetchOptions({ forAnthropicAPI: false }), + }) + + if (!response.ok) { + const body = await response.text() + throw new Error( + `Failed to fetch Gemini models (${response.status} ${response.statusText}): ${body || 'empty response body'}`, + ) + } + + const data = await response.json() + if (!data || !Array.isArray(data.models)) { + return [] + } + + return data.models.map((m: any) => m.name.replace(/^models\//, '')) +} + export async function* streamGeminiGenerateContent(params: { model: string body: GeminiGenerateContentRequest @@ -32,12 +66,22 @@ export async function* streamGeminiGenerateContent(params: { const fetchImpl = params.fetchOverride ?? fetch const url = `${getGeminiBaseUrl()}/${getGeminiModelPath(params.model)}:streamGenerateContent?alt=sse` + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (process.env.GEMINI_API_KEY) { + headers['x-goog-api-key'] = process.env.GEMINI_API_KEY + } else { + const token = await getGoogleAccessToken() + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + } + const response = await fetchImpl(url, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-goog-api-key': process.env.GEMINI_API_KEY || '', - }, + headers, body: JSON.stringify(params.body), signal: params.signal, ...getProxyFetchOptions({ forAnthropicAPI: false }), diff --git a/src/services/api/gemini/google-oauth.ts b/src/services/api/gemini/google-oauth.ts new file mode 100644 index 0000000000..716a7fa0da --- /dev/null +++ b/src/services/api/gemini/google-oauth.ts @@ -0,0 +1,146 @@ +import { OAuth2Client } from 'google-auth-library' +import { AuthCodeListener } from 'src/services/oauth/auth-code-listener.js' +import { openBrowser } from 'src/utils/browser.js' +import { updateSettingsForSource } from 'src/utils/settings/settings.js' +import { getInitialSettings as getSettings } from 'src/utils/settings/settings.js' +import { logEvent } from 'src/services/analytics/index.js' +import * as crypto from 'crypto' // For state generation if needed +import * as fs from 'fs' +import * as path from 'path' + +let GOOGLE_CLIENT_ID = '' +let GOOGLE_CLIENT_SECRET = '' + +try { + const oauthPath = path.join(process.cwd(), '.files', 'OAuth.json') + if (fs.existsSync(oauthPath)) { + const data = JSON.parse(fs.readFileSync(oauthPath, 'utf8')) + const config = data.web || data.installed + if (config && config.client_id && config.client_secret) { + GOOGLE_CLIENT_ID = config.client_id + GOOGLE_CLIENT_SECRET = config.client_secret + } + } +} catch (e) { + // Ignore errors reading OAuth.json +} +const SCOPES = [ + 'https://www.googleapis.com/auth/generative-language.retriever', + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +] + +export async function loginToGoogle(): Promise { + const listener = new AuthCodeListener('/') + try { + const port = await listener.start() + const redirectUri = `http://localhost:${port}/` + + const oauth2Client = new OAuth2Client({ + clientId: GOOGLE_CLIENT_ID, + clientSecret: GOOGLE_CLIENT_SECRET, + redirectUri, + }) + + const state = crypto.randomBytes(16).toString('hex') + + const authorizeUrl = oauth2Client.generateAuthUrl({ + access_type: 'offline', + scope: SCOPES, + state, + prompt: 'consent', // Force to get refresh token + }) + + const authCode = await listener.waitForAuthorization(state, async () => { + await openBrowser(authorizeUrl) + }) + + const { tokens } = await oauth2Client.getToken(authCode) + + // Save tokens + updateSettingsForSource('userSettings', { + googleOAuth: { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expiry_date: tokens.expiry_date, + }, + } as any) + + listener.handleSuccessRedirect(SCOPES, res => { + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(` + + + Authentication successful + + + +

Authentication successful

+

The authentication was successful, and the following products are now authorized to access your account:

+
    +
  • Gemini Code Assist
  • +
  • Cloud Code with Gemini Code Assist
  • +
  • Gemini CLI
  • +
  • Antigravity (available only for free, Google One AI Pro, Google One AI Ultra, and Google Workspace AI Ultra for Business)
  • +
+

You can close this window and return to your IDE or terminal.

+ + + + `) + }) + + logEvent('tengu_google_oauth_success', {}) + } catch (error) { + listener.handleErrorRedirect() + logEvent('tengu_google_oauth_error', {}) + throw error + } finally { + listener.close() + } +} + +export async function getGoogleAccessToken(): Promise { + const settings = getSettings() + const googleOAuth = (settings as any).googleOAuth + + if (!googleOAuth || !googleOAuth.refresh_token) { + return null + } + + const oauth2Client = new OAuth2Client({ + clientId: GOOGLE_CLIENT_ID, + clientSecret: GOOGLE_CLIENT_SECRET, + }) + + oauth2Client.setCredentials({ + refresh_token: googleOAuth.refresh_token, + access_token: googleOAuth.access_token, + expiry_date: googleOAuth.expiry_date, + }) + + try { + const { credentials } = await oauth2Client.refreshAccessToken() + if (credentials.access_token !== googleOAuth.access_token) { + updateSettingsForSource('userSettings', { + googleOAuth: { + access_token: credentials.access_token, + refresh_token: credentials.refresh_token || googleOAuth.refresh_token, + expiry_date: credentials.expiry_date, + }, + } as any) + } + return credentials.access_token || null + } catch (error) { + // If refresh fails, clear it + updateSettingsForSource('userSettings', { + googleOAuth: undefined, + } as any) + return null + } +} diff --git a/src/services/api/openai/client.ts b/src/services/api/openai/client.ts index 5ee37cd414..18b3cc449a 100644 --- a/src/services/api/openai/client.ts +++ b/src/services/api/openai/client.ts @@ -2,14 +2,15 @@ import OpenAI from 'openai' import { openaiAdapter } from 'src/services/providerUsage/adapters/openai.js' import { updateProviderBuckets } from 'src/services/providerUsage/store.js' import { getProxyFetchOptions } from 'src/utils/proxy.js' +import { getAPIProvider } from 'src/utils/model/providers.js' /** * Environment variables: * - * OPENAI_API_KEY: Required. API key for the OpenAI-compatible endpoint. - * OPENAI_BASE_URL: Recommended. Base URL for the endpoint (e.g. http://localhost:11434/v1). - * OPENAI_ORG_ID: Optional. Organization ID. - * OPENAI_PROJECT_ID: Optional. Project ID. + * OPENAI_API_KEY: Required for OpenAI. API key for the OpenAI-compatible endpoint. + * OPENAI_BASE_URL: Recommended for OpenAI. Base URL for the endpoint. + * LOCAL_API_KEY: Optional for Local. + * LOCAL_BASE_URL: Required for Local. */ let cachedClient: OpenAI | null = null @@ -43,8 +44,15 @@ export function getOpenAIClient(options?: { }): OpenAI { if (cachedClient) return cachedClient - const apiKey = process.env.OPENAI_API_KEY || '' - const baseURL = process.env.OPENAI_BASE_URL + const provider = getAPIProvider() + const isLocal = provider === 'local' + + const apiKey = isLocal + ? process.env.LOCAL_API_KEY || 'local' + : process.env.OPENAI_API_KEY || '' + const baseURL = isLocal + ? process.env.LOCAL_BASE_URL + : process.env.OPENAI_BASE_URL const baseFetch = options?.fetchOverride ?? (globalThis.fetch as typeof fetch) const wrappedFetch = wrapFetchForUsage(baseFetch) diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index 520290b189..c9f2ecfe5d 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -218,7 +218,11 @@ export async function* queryModelOpenAI( > { try { // 1. Resolve model name - const openaiModel = resolveOpenAIModel(options.model) + const provider = getAPIProvider() + const isLocal = provider === 'local' + const openaiModel = isLocal + ? process.env.LOCAL_MODEL || options.model + : resolveOpenAIModel(options.model) // 2. Normalize messages using shared preprocessing const messagesForAPI = normalizeMessagesForAPI(messages, tools) diff --git a/src/utils/doctorDiagnostic.ts b/src/utils/doctorDiagnostic.ts index 065b20cb0e..19fff04c16 100644 --- a/src/utils/doctorDiagnostic.ts +++ b/src/utils/doctorDiagnostic.ts @@ -42,6 +42,8 @@ import { } from './shellConfig.js' import { jsonParse } from './slowOperations.js' import { which } from './which.js' +import { checkOllamaStatus, listOllamaModels } from './localLlm.js' +import { cpus, totalmem, freemem, arch } from 'os' export type InstallationType = | 'npm-global' @@ -68,6 +70,19 @@ export type DiagnosticInfo = { mode: 'system' | 'builtin' | 'embedded' systemPath: string | null } + localLlmStatus?: { + ollama: { + running: boolean + models: string[] + } + } + hardwareInfo?: { + cpus: number + cpuModel: string + totalMem: string + freeMem: string + arch: string + } } function getNormalizedPaths(): [invokedPath: string, execPath: string] { @@ -602,6 +617,9 @@ export async function getDoctorDiagnostic(): Promise { ? await getPackageManager() : undefined + const ollamaRunning = await checkOllamaStatus() + const ollamaModels = ollamaRunning ? await listOllamaModels() : [] + const diagnostic: DiagnosticInfo = { installationType, version, @@ -619,6 +637,19 @@ export async function getDoctorDiagnostic(): Promise { warnings, packageManager, ripgrepStatus, + localLlmStatus: { + ollama: { + running: ollamaRunning, + models: ollamaModels, + }, + }, + hardwareInfo: { + cpus: cpus().length, + cpuModel: cpus()[0]?.model || 'Unknown', + totalMem: Math.round(totalmem() / 1024 / 1024 / 1024) + ' GB', + freeMem: Math.round(freemem() / 1024 / 1024 / 1024) + ' GB', + arch: arch(), + }, } return diagnostic diff --git a/src/utils/localLlm.ts b/src/utils/localLlm.ts new file mode 100644 index 0000000000..917961a4cd --- /dev/null +++ b/src/utils/localLlm.ts @@ -0,0 +1,91 @@ +import { logForDebugging } from './debug.js' + +export interface OllamaModel { + name: string +} + +export async function checkOllamaStatus( + baseUrl: string = 'http://localhost:11434', +): Promise { + try { + const response = await fetch(`${baseUrl}/api/tags`, { method: 'GET' }) + return response.ok + } catch (error) { + logForDebugging(`Ollama status check failed: ${error}`) + return false + } +} + +export async function listOllamaModels( + baseUrl: string = 'http://localhost:11434', +): Promise { + try { + const response = await fetch(`${baseUrl}/api/tags`) + if (!response.ok) return [] + const data = (await response.json()) as { models: OllamaModel[] } + return data.models.map(m => m.name) + } catch (error) { + logForDebugging(`Failed to list Ollama models: ${error}`) + return [] + } +} + +export async function* pullOllamaModel( + model: string, + baseUrl: string = 'http://localhost:11434', + signal?: AbortSignal, +): AsyncGenerator<{ status: string; percentage?: number }> { + const response = await fetch(`${baseUrl}/api/pull`, { + method: 'POST', + body: JSON.stringify({ name: model }), + signal, + }) + + if (!response.ok) { + throw new Error(`Failed to pull model: ${response.statusText}`) + } + + const reader = response.body?.getReader() + if (!reader) throw new Error('Failed to get response body reader') + + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) continue + try { + const data = JSON.parse(line) + let percentage: number | undefined + if (data.total && data.completed) { + percentage = Math.round((data.completed / data.total) * 100) + } + yield { status: data.status, percentage } + } catch (e) { + logForDebugging(`Failed to parse Ollama pull delta: ${e}`) + } + } + } +} + +export async function pingUrl(url: string): Promise { + try { + const response = await fetch(url, { method: 'HEAD' }) + return response.ok + } catch { + // Try GET if HEAD fails + try { + const response = await fetch(url, { method: 'GET' }) + return response.ok + } catch { + return false + } + } +} diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 58d157d9cd..93009a6658 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -1,7 +1,9 @@ import type { ModelName } from './model.js' import type { APIProvider } from './providers.js' -export type ModelConfig = Record +export type ModelConfig = Record, ModelName> & { + local?: string +} // @[MODEL LAUNCH]: Add a new CLAUDE_*_CONFIG constant here. Double check the correct model strings // here since the pattern may change. diff --git a/src/utils/model/modelStrings.ts b/src/utils/model/modelStrings.ts index 5b7be104fd..59eb7a7008 100644 --- a/src/utils/model/modelStrings.ts +++ b/src/utils/model/modelStrings.ts @@ -25,7 +25,9 @@ const MODEL_KEYS = Object.keys(ALL_MODEL_CONFIGS) as ModelKey[] function getBuiltinModelStrings(provider: APIProvider): ModelStrings { const out = {} as ModelStrings for (const key of MODEL_KEYS) { - out[key] = ALL_MODEL_CONFIGS[key][provider] + out[key] = + (ALL_MODEL_CONFIGS[key] as any)[provider] || + ALL_MODEL_CONFIGS[key].firstParty } return out } diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index d4784da844..e944729377 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -11,6 +11,7 @@ export type APIProvider = | 'openai' | 'gemini' | 'grok' + | 'local' export function getAPIProvider( settings: Pick = getInitialSettings(), @@ -19,6 +20,7 @@ export function getAPIProvider( if (modelType === 'openai') return 'openai' if (modelType === 'gemini') return 'gemini' if (modelType === 'grok') return 'grok' + if (modelType === 'local') return 'local' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) return 'bedrock' if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) return 'vertex' diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 678eb5c76e..d75da291ed 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -366,11 +366,11 @@ export const SettingsSchema = lazySchema(() => .optional() .describe('Tool usage permissions configuration'), modelType: z - .enum(['anthropic', 'openai', 'gemini', 'grok']) + .enum(['anthropic', 'openai', 'gemini', 'grok', 'local']) .optional() .describe( - 'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, and "grok" uses the xAI Grok API (OpenAI-compatible). ' + - 'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL. When set to "gemini", configure GEMINI_API_KEY and optional GEMINI_BASE_URL. When set to "grok", configure GROK_API_KEY (or XAI_API_KEY), optional GROK_BASE_URL, GROK_MODEL, and GROK_MODEL_MAP.', + 'API provider type. "anthropic" uses the Anthropic API (default), "openai" uses the OpenAI Chat Completions API, "gemini" uses the Gemini API, "grok" uses the xAI Grok API (OpenAI-compatible), and "local" uses a local LLM runner (Ollama, LM Studio, etc.). ' + + 'When set to "openai", configure OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL. When set to "gemini", configure GEMINI_API_KEY and optional GEMINI_BASE_URL. When set to "grok", configure GROK_API_KEY (or XAI_API_KEY), optional GROK_BASE_URL, GROK_MODEL, and GROK_MODEL_MAP. When set to "local", configure LOCAL_BASE_URL and LOCAL_MODEL.', ), model: z .string() diff --git a/src/utils/status.tsx b/src/utils/status.tsx index 794206696c..5ea5ddfd31 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -301,6 +301,7 @@ export function buildAPIProviderProperties(): Property[] { gemini: 'Gemini API', grok: 'Grok API', openai: 'OpenAI API', + local: 'Local LLM', }[apiProvider]; properties.push({ label: 'API provider', diff --git a/src/utils/swarm/teammateModel.ts b/src/utils/swarm/teammateModel.ts index 0a49009b2c..5975cf945b 100644 --- a/src/utils/swarm/teammateModel.ts +++ b/src/utils/swarm/teammateModel.ts @@ -6,5 +6,7 @@ import { getAPIProvider } from '../model/providers.js' // use Opus 4.6. Must be provider-aware so Bedrock/Vertex/Foundry customers get // the correct model ID. export function getHardcodedTeammateModelFallback(): string { - return CLAUDE_OPUS_4_6_CONFIG[getAPIProvider()] + const provider = getAPIProvider() + if (provider === 'local') return 'claude-opus-4-6' // Fallback for local + return CLAUDE_OPUS_4_6_CONFIG[provider] } diff --git a/tests/integration/autonomy-lifecycle-user-flow.test.ts b/tests/integration/autonomy-lifecycle-user-flow.test.ts index e9f236c574..a674467d0e 100644 --- a/tests/integration/autonomy-lifecycle-user-flow.test.ts +++ b/tests/integration/autonomy-lifecycle-user-flow.test.ts @@ -65,10 +65,11 @@ beforeAll(async () => { async function runAutonomyCli(args: string[]): Promise { const proc = Bun.spawn({ cmd: [process.execPath, CLI_ENTRYPOINT, 'autonomy', ...args], - cwd: tempDir, + cwd: PROJECT_ROOT, env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, + CLAUDE_CODE_CWD: tempDir, CI: 'true', GITHUB_ACTIONS: 'true', NODE_ENV: 'development',