feat: add support for local LLM provider and integrate Google OAuth flow

This commit is contained in:
reikernodd 2026-05-08 22:31:11 +01:00
parent 68c7ebb242
commit ddc1071810
20 changed files with 982 additions and 140 deletions

View File

@ -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,10 +146,16 @@ 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**: 对应各自协议的云端服务。
3. **Local LLM**: **(推荐)** 使用本地运行的模型。
- 支持 **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**
- **Ollama 深度集成**: 可直接在 CLI 中查看已安装模型,或输入模型名(如 `llama3.1`一键拉取Pull
- 自动检测本地运行状态和默认端口。
#### /login 字段说明 (云端模式)
| 📌 字段 | 📝 说明 | 💡 示例 |
@ -163,6 +170,15 @@ bun run build
> 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
### 🩺 系统诊断 /doctor
如果你在使用过程中遇到环境问题(尤其是本地模型运行缓慢或无法连接),可以使用 `/doctor` 命令进行全方位诊断:
- **硬件负载**: 自动显示当前 CPU 型号、核心数、剩余内存 (RAM) 以及系统架构。
- **本地环境**: 检查 Ollama 等本地 Runner 是否正在运行,并列出所有可用模型。
- **配置校验**: 检查环境变量(如 `LOCAL_BASE_URL`)和权限设置。
- **故障排查**: 识别多个重复安装的版本、过期的版本锁或权限不足的更新。
## Feature Flags
所有功能开关通过 `FEATURE_<FLAG_NAME>=1` 环境变量启用,例如:

View File

@ -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@<version_number>`
## ⚡ 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,89 @@ 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 uses code splitting for multi-file packaging (`build.ts`), outputting to the `dist/` directory (entry point `dist/cli.js` + approximately 450 chunk files).
The build output runs on both Bun and Node.js — you can publish to a private registry and run directly.
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.
If you encounter a bug, please open an issue — we'll prioritize it.
If you encounter a bug, please open an issue; we prioritize solving them.
### First-time Setup /login
### 👤 New User Configuration /login
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).
After running for the first time, type `/login` in the REPL to enter the login configuration interface. Select **Anthropic Compatible** to connect to third-party API services (no official Anthropic account required).
Options for OpenAI and Gemini are also available for their respective protocols.
Fields to fill in:
| 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` |
| 📌 Field | 📝 Description | 💡 Example |
| --- | --- | --- |
| Base URL | API Service Address | `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` |
- **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
- ⌨️ **Tab / Shift+Tab** to switch fields, **Enter** to confirm and jump to the next, press Enter on the last field to save.
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"
}
}
```
> 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 +168,74 @@ All feature toggles are enabled via `FEATURE_<FLAG_NAME>=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
<a href="https://github.com/claude-code-best/claude-code/graphs/contributors">
<img src="https://contrib.rocks/image?repo=claude-code-best/claude-code" />
<img src="contributors.svg" alt="Contributors" />
</a>
## Star History
<a href="https://www.star-history.com/?repos=claude-code-best%2Fclaude-code&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=claude-code-best%2Fclaude-code&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=claude-code-best%2Fclaude-code&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=claude-code-best%2Fclaude-code&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=claude-code-best/claude-code&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=claude-code-best/claude-code&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=claude-code-best/claude-code&type=date&legend=top-left" />
</picture>
</a>
## 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/).

View File

@ -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 {

View File

@ -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()
@ -129,7 +143,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

View File

@ -17,6 +17,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;
@ -55,8 +56,25 @@ type OAuthStatus =
opusModel: string;
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
} // 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 }
@ -67,6 +85,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,
@ -127,6 +146,94 @@ export function ConsoleOAuthFlow({
}
}, [oauthStatus]);
// Handle Ollama model listing
useEffect(() => {
if (
oauthStatus.state === 'local_llm_setup' &&
oauthStatus.runnerType === 'ollama' &&
oauthStatus.availableModels.length === 0 &&
!oauthStatus.isLoadingModels
) {
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',
@ -172,7 +279,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);
@ -341,7 +448,7 @@ export function ConsoleOAuthFlow({
</Text>
)}
</Box>
<Link url={oauthStatus.url}>
<Link url={oauthStatus.url || ''}>
<Text dimColor>{oauthStatus.url}</Text>
</Link>
</Box>
@ -427,6 +534,15 @@ function OAuthStatusMessage({
<Box>
<Select
options={[
{
label: (
<Text>
Local LLM · <Text dimColor>Ollama, LM Studio, Jan.ai, LocalAI</Text>
{'\n'}
</Text>
),
value: 'local_llm',
},
{
label: (
<Text>
@ -493,6 +609,19 @@ function OAuthStatusMessage({
},
]}
onChange={value => {
if (value === 'local_llm') {
logEvent('tengu_local_llm_selected', {});
setOAuthStatus({
state: 'local_llm_setup',
runnerType: 'ollama',
baseUrl: 'http://localhost:11434',
modelName: '',
activeField: 'runner_type',
availableModels: [],
isLoadingModels: false,
});
return;
}
if (value === 'custom_platform') {
logEvent('tengu_custom_platform_selected', {});
setOAuthStatus({
@ -545,6 +674,329 @@ function OAuthStatusMessage({
</Box>
);
case 'local_llm_setup': {
type LocalField = 'runner_type' | 'base_url' | 'api_key' | 'model_name' | 'custom_model_name';
const LOCAL_FIELDS: LocalField[] = ['runner_type', 'base_url', 'api_key', 'model_name', 'custom_model_name'];
const activeField = oauthStatus.activeField;
const displayValues: Record<LocalField, string> = {
runner_type: oauthStatus.runnerType,
base_url: oauthStatus.baseUrl,
api_key: (oauthStatus as any).apiKey ?? '',
model_name: oauthStatus.modelName,
custom_model_name: oauthStatus.modelName,
};
const [localInputValue, setLocalInputValue] = useState(displayValues[activeField] ?? '');
const [localInputCursorOffset, setLocalInputCursorOffset] = useState((displayValues[activeField] ?? '').length);
const buildLocalState = useCallback(
(field: LocalField, val: string, nextField?: LocalField): OAuthStatus => {
const newState = { ...oauthStatus } as any;
if (field === 'runner_type') {
newState.runnerType = val;
if (val === 'ollama') newState.baseUrl = 'http://localhost:11434';
else if (val === 'lmstudio') newState.baseUrl = 'http://localhost:1234/v1';
else if (val === 'jan') newState.baseUrl = 'http://localhost:1337/v1';
else if (val === 'localai') newState.baseUrl = 'http://localhost:8080/v1';
} else if (field === 'base_url') {
newState.baseUrl = val;
} else if (field === 'api_key') {
newState.apiKey = val;
} else if (field === 'model_name' || field === 'custom_model_name') {
newState.modelName = val;
}
if (nextField) newState.activeField = nextField;
return newState;
},
[oauthStatus],
);
const doLocalSave = useCallback(
async (stateToSave: any) => {
const { runnerType, baseUrl, modelName, apiKey } = stateToSave;
const env: Record<string, string> = {
LOCAL_BASE_URL: baseUrl,
LOCAL_MODEL: modelName || 'llama3.1',
LOCAL_RUNNER_TYPE: runnerType,
};
if (apiKey) env.LOCAL_API_KEY = apiKey;
updateSettingsForSource('userSettings', {
modelType: 'local',
env,
} as any);
updateSettingsForSource('userSettings', {
model: modelName || 'llama3.1',
} as any);
setOAuthStatus({ state: 'success' });
void onDone();
},
[onDone, setOAuthStatus],
);
const handleLocalEnter = useCallback(() => {
if (activeField === 'custom_model_name' && localInputValue) {
if (oauthStatus.runnerType === 'ollama' && !oauthStatus.availableModels.includes(localInputValue)) {
setOAuthStatus({
state: 'local_llm_pulling',
modelName: localInputValue,
status: 'Starting download...',
});
return;
}
const nextState = buildLocalState(activeField, localInputValue);
setOAuthStatus(nextState);
doLocalSave(nextState);
return;
}
const idx = LOCAL_FIELDS.indexOf(activeField);
if (idx === LOCAL_FIELDS.length - 1 || activeField === 'model_name') {
const nextState = buildLocalState(activeField, localInputValue);
setOAuthStatus(nextState);
doLocalSave(nextState);
} else {
// find next interactive field (skip model_name if custom_model_name is next, but that's handled by onChange)
const next = LOCAL_FIELDS[idx + 1]!;
const nextState = buildLocalState(activeField, localInputValue, next) as any;
setOAuthStatus(nextState);
const nextVal =
nextState[
next === 'runner_type'
? 'runnerType'
: next === 'base_url'
? 'baseUrl'
: next === 'api_key'
? 'apiKey'
: 'modelName'
];
setLocalInputValue(nextVal ?? '');
setLocalInputCursorOffset((nextVal ?? '').length);
}
}, [activeField, localInputValue, oauthStatus, buildLocalState, doLocalSave, setOAuthStatus]);
useKeybinding(
'tabs:next',
() => {
if (activeField === 'runner_type' || activeField === 'model_name') return; // Handled by Select component natively
const idx = LOCAL_FIELDS.indexOf(activeField);
if (idx < LOCAL_FIELDS.length - 1) {
const next = LOCAL_FIELDS[idx + 1]!;
const nextState = buildLocalState(activeField, localInputValue, next) as any;
setOAuthStatus(nextState);
const nextVal =
nextState[
next === 'runner_type'
? 'runnerType'
: next === 'base_url'
? 'baseUrl'
: next === 'api_key'
? 'apiKey'
: 'modelName'
];
setLocalInputValue(nextVal ?? '');
setLocalInputCursorOffset((nextVal ?? '').length);
}
},
{ context: 'FormField' },
);
useKeybinding(
'tabs:previous',
() => {
if (activeField === 'runner_type' || activeField === 'model_name') return; // Select components trap up/down
const idx = LOCAL_FIELDS.indexOf(activeField);
if (idx > 0) {
const next = LOCAL_FIELDS[idx - 1]!;
const nextState = buildLocalState(activeField, localInputValue, next) as any;
setOAuthStatus(nextState);
const nextVal =
nextState[
next === 'runner_type'
? 'runnerType'
: next === 'base_url'
? 'baseUrl'
: next === 'api_key'
? 'apiKey'
: 'modelName'
];
setLocalInputValue(nextVal ?? '');
setLocalInputCursorOffset((nextVal ?? '').length);
}
},
{ context: 'FormField' },
);
useKeybinding(
'confirm:no',
() => {
setOAuthStatus({ state: 'idle' });
},
{ context: 'Confirmation' },
);
const localColumns = useTerminalSize().columns - 20;
const renderLocalTextInput = (field: LocalField, label: string, mask?: boolean) => {
const active = activeField === field;
const val = displayValues[field];
return (
<Box>
<Text backgroundColor={active ? 'suggestion' : undefined} color={active ? 'inverseText' : undefined}>
{` ${label} `}
</Text>
<Text> </Text>
{active ? (
<TextInput
value={localInputValue}
onChange={setLocalInputValue}
onSubmit={handleLocalEnter}
cursorOffset={localInputCursorOffset}
onChangeCursorOffset={setLocalInputCursorOffset}
columns={localColumns}
mask={mask ? '*' : undefined}
focus={true}
/>
) : val ? (
<Text color="success">{mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val}</Text>
) : null}
</Box>
);
};
const runnerTypeOptions = [
{ label: 'Ollama', value: 'ollama' },
{ label: 'LM Studio', value: 'lmstudio' },
{ label: 'Jan.ai', value: 'jan' },
{ label: 'LocalAI', value: 'localai' },
{ label: 'Custom', value: 'custom' },
];
return (
<Box flexDirection="column" gap={1}>
<Text bold>Local LLM Setup</Text>
<Text dimColor>Configure a local LLM runner. Ollama is recommended.</Text>
<Box flexDirection="column" gap={1}>
<Box>
<Text
backgroundColor={activeField === 'runner_type' ? 'suggestion' : undefined}
color={activeField === 'runner_type' ? 'inverseText' : undefined}
>
{' Runner Type '}
</Text>
<Text> </Text>
{activeField === 'runner_type' ? (
<Select
options={runnerTypeOptions}
onChange={val => {
const nextState = buildLocalState('runner_type', val, 'base_url') as any;
setOAuthStatus(nextState);
setLocalInputValue(nextState.baseUrl ?? '');
setLocalInputCursorOffset((nextState.baseUrl ?? '').length);
}}
/>
) : (
<Text color="success">{displayValues.runner_type}</Text>
)}
</Box>
{(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') && (
<Box flexDirection="column">
<Box>
<Text
backgroundColor={activeField === 'model_name' ? 'suggestion' : undefined}
color={activeField === 'model_name' ? 'inverseText' : undefined}
>
{' Model Name '}
</Text>
<Text> </Text>
{activeField === 'model_name' ? (
oauthStatus.isLoadingModels ? (
<Box gap={1}>
<Spinner />
<Text>Loading installed models...</Text>
</Box>
) : (
<Box flexDirection="column">
<Select
options={[
...oauthStatus.availableModels.map(m => ({ label: m, value: m })),
...POPULAR_MODELS.filter(m => !oauthStatus.availableModels.includes(m)).map(m => ({
label: `${m} (Download)`,
value: m,
})),
{ label: 'Custom (Type your own)', value: '__custom__' },
]}
onChange={val => {
if (val === '__custom__') {
const nextState = buildLocalState('model_name', '', 'custom_model_name') as any;
setOAuthStatus(nextState);
setLocalInputValue('');
setLocalInputCursorOffset(0);
} else {
const nextState = buildLocalState('model_name', val);
if (oauthStatus.runnerType === 'ollama' && !oauthStatus.availableModels.includes(val)) {
setOAuthStatus({
state: 'local_llm_pulling',
modelName: val,
status: 'Starting download...',
});
} else {
setOAuthStatus(nextState);
doLocalSave(nextState);
}
}
}}
/>
</Box>
)
) : activeField === 'custom_model_name' ? (
<TextInput
value={localInputValue}
onChange={setLocalInputValue}
onSubmit={handleLocalEnter}
cursorOffset={localInputCursorOffset}
onChangeCursorOffset={setLocalInputCursorOffset}
columns={localColumns}
focus={true}
/>
) : (
<Text color="success">{displayValues.model_name}</Text>
)}
</Box>
</Box>
)}
</Box>
<Text dimColor> to select options · Enter to save · Esc to go back</Text>
</Box>
);
}
case 'local_llm_pulling': {
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>Downloading {oauthStatus.modelName}...</Text>
<Box gap={1}>
<Spinner />
<Text>{oauthStatus.status}</Text>
{oauthStatus.percentage !== undefined && <Text color="success">{oauthStatus.percentage}%</Text>}
</Box>
<Text dimColor>Please wait, this may take a few minutes depending on your internet speed.</Text>
</Box>
);
}
case 'custom_platform': {
type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model'];
@ -1006,7 +1458,7 @@ function OAuthStatusMessage({
[activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel],
);
const doGeminiSave = useCallback(() => {
const doGeminiSave = useCallback(async () => {
const finalVals = { ...geminiDisplayValues, [activeField]: geminiInputValue };
if (!finalVals.haiku_model || !finalVals.sonnet_model || !finalVals.opus_model) {
setOAuthStatus({
@ -1025,6 +1477,29 @@ function OAuthStatusMessage({
return;
}
if (!finalVals.api_key) {
setOAuthStatus({ state: 'waiting_for_login' });
try {
const { loginToGoogle } = await import('src/services/api/gemini/google-oauth.js');
await loginToGoogle();
} catch (e) {
setOAuthStatus({
state: 'error',
message: `Google Login failed: ${e instanceof Error ? e.message : e}`,
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: 'api_key',
},
});
return;
}
}
const env: Record<string, string> = {};
if (finalVals.base_url) env.GEMINI_BASE_URL = finalVals.base_url;
if (finalVals.api_key) env.GEMINI_API_KEY = finalVals.api_key;
@ -1137,7 +1612,7 @@ function OAuthStatusMessage({
<Text bold>Gemini API Setup</Text>
<Text dimColor>
Configure a Gemini Generate Content compatible endpoint. Base URL is optional and defaults to Google&apos;s
v1beta API.
v1beta API. Leave API Key blank to log in via browser (Google Auth).
</Text>
<Box flexDirection="column" gap={1}>
{renderGeminiRow('base_url', 'Base URL ')}
@ -1219,7 +1694,7 @@ function OAuthStatusMessage({
<TextInput
value={pastedCode}
onChange={setPastedCode}
onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url)}
onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url || '')}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
columns={textInputColumns}

View File

@ -290,6 +290,45 @@ export function Doctor({ onDone }: Props): React.ReactNode {
<SandboxDoctorSection />
{/* Hardware info section */}
{diagnostic.hardwareInfo && (
<Box flexDirection="column">
<Text bold>System Information</Text>
<Text>
CPU: {diagnostic.hardwareInfo.cpuModel} ({diagnostic.hardwareInfo.cpus} cores)
</Text>
<Text>
RAM: {diagnostic.hardwareInfo.freeMem} free of {diagnostic.hardwareInfo.totalMem}
</Text>
<Text> Arch: {diagnostic.hardwareInfo.arch}</Text>
</Box>
)}
{/* Local LLM section */}
{diagnostic.localLlmStatus && (
<Box flexDirection="column">
<Text bold>Local LLM</Text>
<Text>
Ollama:{' '}
{diagnostic.localLlmStatus.ollama.running ? (
<Text color="success">Running</Text>
) : (
<Text color="error">Not running</Text>
)}
</Text>
{diagnostic.localLlmStatus.ollama.running && (
<Text>
Models:{' '}
{diagnostic.localLlmStatus.ollama.models.length > 0 ? (
diagnostic.localLlmStatus.ollama.models.join(', ')
) : (
<Text dimColor>None</Text>
)}
</Text>
)}
</Box>
)}
<McpParsingWarnings />
<KeybindingWarnings />

View File

@ -1362,7 +1362,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 ToolSearchTool can search deferred MCP tools that

View File

@ -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,
@ -32,12 +33,22 @@ export async function* streamGeminiGenerateContent(params: {
const fetchImpl = params.fetchOverride ?? fetch
const url = `${getGeminiBaseUrl()}/${getGeminiModelPath(params.model)}:streamGenerateContent?alt=sse`
const headers: Record<string, string> = {
'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 }),

View File

@ -0,0 +1,110 @@
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
const GOOGLE_CLIENT_ID = '32555940559.apps.googleusercontent.com'
const GOOGLE_CLIENT_SECRET = 'ZmssLNjJy2998hD4CTg2ejr2'
const SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
export async function loginToGoogle(): Promise<void> {
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(`
<html>
<body>
<h1>Successfully logged in to Google!</h1>
<p>You can close this tab and return to Claude Code.</p>
<script>window.close();</script>
</body>
</html>
`)
})
logEvent('tengu_google_oauth_success', {})
} catch (error) {
listener.handleErrorRedirect()
logEvent('tengu_google_oauth_error', {})
throw error
} finally {
listener.close()
}
}
export async function getGoogleAccessToken(): Promise<string | null> {
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
}
}

View File

@ -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)

View File

@ -17,6 +17,7 @@ import {
anthropicToolsToOpenAI,
anthropicToolChoiceToOpenAI,
} from '@ant/model-provider'
import { getAPIProvider } from '../../../utils/model/providers.js'
import { normalizeMessagesForAPI } from '../../../utils/messages.js'
import { toolToAPISchema } from '../../../utils/api.js'
import {
@ -189,7 +190,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)

View File

@ -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<DiagnosticInfo> {
? 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<DiagnosticInfo> {
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

91
src/utils/localLlm.ts Normal file
View File

@ -0,0 +1,91 @@
import { logForDebugging } from './debug.js'
export interface OllamaModel {
name: string
}
export async function checkOllamaStatus(
baseUrl: string = 'http://localhost:11434',
): Promise<boolean> {
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<string[]> {
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<boolean> {
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
}
}
}

View File

@ -1,7 +1,9 @@
import type { ModelName } from './model.js'
import type { APIProvider } from './providers.js'
export type ModelConfig = Record<APIProvider, ModelName>
export type ModelConfig = Record<Exclude<APIProvider, 'local'>, ModelName> & {
local?: string
}
// @[MODEL LAUNCH]: Add a new CLAUDE_*_CONFIG constant here. Double check the correct model strings
// here since the pattern may change.

View File

@ -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
}

View File

@ -11,6 +11,7 @@ export type APIProvider =
| 'openai'
| 'gemini'
| 'grok'
| 'local'
export function getAPIProvider(
settings: Pick<SettingsJson, 'modelType'> = 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'

View File

@ -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()

View File

@ -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',

View File

@ -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]
}

View File

@ -14,6 +14,7 @@ import {
import { listAutonomyFlows } from '../../src/utils/autonomyFlows'
const CLI_ENTRYPOINT = resolve(import.meta.dir, '../../src/entrypoints/cli.tsx')
const PROJECT_ROOT = resolve(import.meta.dir, '../../')
let tempDir = ''
let configDir = ''
@ -22,10 +23,11 @@ let previousConfigDir: string | undefined
async function runAutonomyCli(args: string[]): Promise<string> {
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',