feat: rebrand to HeiCode and add dual-provider auth flow
Deploy VitePress Docs / build (push) Failing after 1m49s
Deploy VitePress Docs / deploy (push) Has been skipped

Rebrand cc-haha to HeiCode and implement TaijiAICloud/ClawdRouter login foundations across server and desktop, including provider presets, model discovery endpoints, and login UI/store scaffolding for OAuth and token paste flows.
This commit is contained in:
gongzhiyong
2026-04-29 19:08:06 +08:00
parent 777c596505
commit a09d909dcd
31 changed files with 1599 additions and 451 deletions
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
.git
.github
node_modules
desktop
docs
release-notes
fixtures/__pycache__
runtime/__pycache__
*.log
.tmp-*.log
.env
.env.*
!.env.example
.docker-assets/
!.docker-assets/bun-linux-aarch64-musl.zip
.DS_Store
build-artifacts
*.tsbuildinfo
+59
View File
@@ -0,0 +1,59 @@
# HeiCode Server — local API/WebSocket server in a container.
#
# NOTE:
# We intentionally do NOT use `FROM oven/bun:*` here because some environments
# have intermittent Docker Hub TLS proxy issues when pulling that image.
# Instead we use alpine (already commonly cached) and download Bun directly
# from GitHub release assets during build.
#
# Build: docker build -t heicode-server -f Dockerfile .
# Run: see docker-compose.yml at the repo root.
FROM alpine:latest
# Bun on Alpine still needs libstdc++/libgcc at runtime.
# Network to Alpine mirrors can be flaky in this environment, so retry.
RUN set -eux; \
for i in 1 2 3 4 5 6 7 8; do \
apk add --no-cache libstdc++ libgcc && break; \
echo "apk add failed (attempt ${i}), retrying..."; \
sleep 3; \
done
# Install Bun (musl build for Alpine on arm64) from a local build asset.
# This avoids flaky outbound TLS during `docker build` in restricted networks.
COPY .docker-assets/bun-linux-aarch64-musl.zip /tmp/bun.zip
RUN busybox unzip -q /tmp/bun.zip -d /tmp \
&& mv /tmp/bun-linux-aarch64-musl/bun /usr/local/bin/bun \
&& chmod +x /usr/local/bin/bun \
&& rm -rf /tmp/bun.zip /tmp/bun-linux-aarch64-musl
WORKDIR /app
# Install dependencies in a separate layer to maximise cache hits across
# code-only changes.
COPY package.json bun.lock bunfig.toml preload.ts ./
RUN bun install --frozen-lockfile
# Copy the rest of the source. We deliberately *don't* copy the desktop/
# Tauri tree because the server doesn't need it; it dramatically speeds up
# the build and keeps the image lean. The `desktop/` folder lives in the
# repo only as a sibling project.
COPY tsconfig.json ./
COPY src ./src
COPY adapters ./adapters
COPY runtime ./runtime
COPY stubs ./stubs
COPY fixtures ./fixtures
COPY bin ./bin
# Default port the server listens on.
ENV SERVER_PORT=3456
ENV SERVER_HOST=0.0.0.0
# Bind to /data so cc-haha's ~/.claude config persists across container restarts.
ENV CLAUDE_CONFIG_DIR=/data/.claude
RUN mkdir -p /data/.claude
EXPOSE 3456
CMD ["bun", "run", "src/server/index.ts"]
+98 -243
View File
@@ -1,302 +1,157 @@
# Claude Code Haha # HeiCode
<p align="center"> > 基于 [`cc-haha`](https://github.com/NanmiCoder/cc-haha) 二次开发的 Claude Code 客户端,登录后通过 **TaijiAICloud** 或 **ClawdRouter** 直接使用各家主流大模型,零配置开箱即用。
<img src="docs/images/app-icon.png" alt="Claude Code Haha" width="240">
</p>
<div align="center">
[![GitHub Stars](https://img.shields.io/github/stars/NanmiCoder/cc-haha?style=social)](https://github.com/NanmiCoder/cc-haha/stargazers)
[![GitHub Forks](https://img.shields.io/github/forks/NanmiCoder/cc-haha?style=social)](https://github.com/NanmiCoder/cc-haha/network/members)
[![GitHub Issues](https://img.shields.io/github/issues/NanmiCoder/cc-haha)](https://github.com/NanmiCoder/cc-haha/issues)
[![GitHub Pull Requests](https://img.shields.io/github/issues-pr/NanmiCoder/cc-haha)](https://github.com/NanmiCoder/cc-haha/pulls)
[![License](https://img.shields.io/github/license/NanmiCoder/cc-haha)](https://github.com/NanmiCoder/cc-haha/blob/main/LICENSE)
[![中文](https://img.shields.io/badge/🇨🇳_中文-当前-blue)](README.md)
[![English](https://img.shields.io/badge/🇺🇸_English-Available-green)](README.en.md)
[![Docs](https://img.shields.io/badge/📖_文档站点-Visit-D97757)](https://claudecode-haha.relakkesyang.org)
</div>
基于 Claude Code 泄露源码修复的**本地可运行版本**,支持接入任意 Anthropic 兼容 API(MiniMax、OpenRouter 等)。在完整 TUI 之外,还补全了 Computer Use(macOS / Windows)、打造了图形化**桌面端**,并支持通过 Telegram / 飞书**完整远程驱动**。
<p align="center">
<a href="#功能">功能</a> · <a href="#桌面端预览">桌面端</a> · <a href="#架构概览">架构概览</a> · <a href="#快速开始">快速开始</a> · <a href="docs/guide/env-vars.md">环境变量</a> · <a href="docs/guide/faq.md">FAQ</a> · <a href="docs/guide/global-usage.md">全局使用</a> · <a href="#更多文档">更多文档</a>
</p>
--- ---
## 功能 ## 当前形态
- 完整的 Ink TUI 交互界面(与官方 Claude Code 一致) - **登录方式**:用户启动 HeiCode 后只看到两个登录入口
- `--print` 无头模式(脚本/CI 场景) - **TaijiAICloud**(自建网关,基于 [`new-api`](https://github.com/Calcium-Ion/new-api))
- 支持 MCP 服务器、插件、Skills - **ClawdRouter**(聚合网关)
- 支持自定义 API 端点和模型([第三方模型使用指南](docs/guide/third-party-models.md)) - **支持模式**:
- **记忆系统**(跨会话持久化记忆)— [使用指南](docs/memory/01-usage-guide.md) - 浏览器跳转 OAuth(推荐 / 平台支持后启用)
- **多 Agent 系统**(多代理编排、并行任务、Teams 协作)— [使用指南](docs/agent/01-usage-guide.md) | [实现原理](docs/agent/02-implementation.md) - 复制 API Key 粘贴登录(兼容入口 / 立即可用)
- **Skills 系统**(可扩展能力插件、自定义工作流)— [使用指南](docs/skills/01-usage-guide.md) | [实现原理](docs/skills/02-implementation.md) - **协议**:两个平台均原生支持 Anthropic `/v1/messages`,HeiCode 直接调用,无需中台代理。
- **Channel 系统**(通过 Telegram/飞书/Discord 等 IM 远程控制 Agent)— [架构解析](docs/channel/01-channel-system.md) - **模型矩阵**:登录后自动从平台 `/v1/models` 拉取,覆盖 GPT / Claude / Gemini 全家桶。
- **Computer Use 桌面控制** — [功能指南](docs/features/computer-use.md) | [架构解析](docs/features/computer-use-architecture.md)
- **桌面端**(Tauri 2 + React 图形化客户端,多标签多会话)— [文档](docs/desktop/)
- 降级 Recovery CLI 模式(`CLAUDE_CODE_FORCE_RECOVERY_CLI=1 ./bin/claude-haha`)
--- ---
## 架构概览 ## 项目结构
<table> ```
<tr> heicode/
<td align="center" width="25%"><img src="docs/images/01-overall-architecture.png" alt="整体架构"><br><b>整体架构</b></td> ├── bin/ # CLI 入口(heicode / claude-haha 兼容别名)
<td align="center" width="25%"><img src="docs/images/02-request-lifecycle.png" alt="请求生命周期"><br><b>请求生命周期</b></td> ├── src/ # CLI + Server(Bun + TypeScript + Ink)
<td align="center" width="25%"><img src="docs/images/03-tool-system.png" alt="工具系统"><br><b>工具系统</b></td> │ ├── server/ # 桌面端用的本地 HTTP/WS Server
<td align="center" width="25%"><img src="docs/images/04-multi-agent.png" alt="多 Agent 架构"><br><b>多 Agent 架构</b></td> │ │ ├── config/providerPresets.json # ★ 仅保留 TaijiAICloud / ClawdRouter
</tr> │ │ ├── api/heicode-auth.ts # ★ 双 Provider 登录入口
<tr> │ │ └── api/providers.ts # ★ 加了 /v1/models 探活
<td align="center" width="25%"><img src="docs/images/05-terminal-ui.png" alt="终端 UI"><br><b>终端 UI</b></td> │ └── ...
<td align="center" width="25%"><img src="docs/images/06-permission-security.png" alt="权限与安全"><br><b>权限与安全</b></td> ├── desktop/ # Tauri 2 + React 桌面端
<td align="center" width="25%"><img src="docs/images/07-services-layer.png" alt="服务层"><br><b>服务层</b></td> └── docs/ # 文档(含 HEICODE-PLAN.md 路线图)
<td align="center" width="25%"><img src="docs/images/08-state-data-flow.png" alt="状态与数据流"><br><b>状态与数据流</b></td> ```
</tr>
</table>
---
## 桌面端预览
<p align="center">
<a href="https://github.com/NanmiCoder/cc-haha/releases"><img src="https://img.shields.io/badge/⬇_下载桌面端-macOS_%7C_Windows-D97757?style=for-the-badge" alt="下载桌面端"></a>
&nbsp;
<a href="docs/desktop/04-installation.md"><img src="https://img.shields.io/badge/📖_安装指南-Guide-gray?style=for-the-badge" alt="安装指南"></a>
</p>
<table>
<tr>
<td align="center" width="33%"><img src="docs/images/desktop_ui/01_full_ui.png" alt="主界面"><br><b>主界面</b></td>
<td align="center" width="33%"><img src="docs/images/desktop_ui/02_edit_code.png" alt="代码编辑"><br><b>代码编辑 & Diff 视图</b></td>
<td align="center" width="33%"><img src="docs/images/desktop_ui/03_ask_question_and_permission.png" alt="权限控制"><br><b>权限控制 & AI 提问</b></td>
</tr>
<tr>
<td align="center" width="33%"><img src="docs/images/desktop_ui/05_settings.png" alt="提供商设置"><br><b>多提供商管理</b></td>
<td align="center" width="33%"><img src="docs/images/desktop_ui/08_scheduled_task.png" alt="定时任务"><br><b>定时任务</b></td>
<td align="center" width="33%"><img src="docs/images/desktop_ui/07_im.png" alt="IM 适配器"><br><b>IM 适配器(Telegram / 飞书)</b></td>
</tr>
</table>
--- ---
## 快速开始 ## 快速开始
### 1. 安装 Bun ### 1. 准备依赖
```bash ```bash
# macOS / Linux # macOS / Linux
curl -fsSL https://bun.sh/install | bash curl -fsSL https://bun.sh/install | bash
# macOS (Homebrew)
brew install bun
# Windows (PowerShell) # Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex" powershell -c "irm bun.sh/install.ps1 | iex"
``` ```
> 精简版 Linux 如提示 `unzip is required`,先运行 `apt update && apt install -y unzip` ### 2. 安装项目
### 2. 安装依赖并配置
```bash ```bash
cd heicode
bun install bun install
cp .env.example .env
# 编辑 .env 填入你的 API Key,详见 docs/guide/env-vars.md
``` ```
### 3. 启动 ### 3. 启动 CLI(终端版)
#### macOS / Linux
```bash ```bash
./bin/claude-haha # 交互 TUI 模式 ./bin/heicode # 交互 TUI
./bin/claude-haha -p "your prompt here" # 无头模式 ./bin/heicode -p "your prompt" # 无头模式
./bin/claude-haha --help # 查看所有选项
``` ```
#### Windows ### 4. 启动桌面端
> **前置要求**:必须安装 [Git for Windows](https://git-scm.com/download/win)
```powershell
# PowerShell / cmd 直接调用 Bun
bun --env-file=.env ./src/entrypoints/cli.tsx
# 或在 Git Bash 中运行
./bin/claude-haha
```
### 4. 全局使用(可选)
将 `bin/` 加入 PATH 后可在任意目录启动,详见 [全局使用指南](docs/guide/global-usage.md):
```bash ```bash
export PATH="$HOME/path/to/claude-code-haha/bin:$PATH" # Terminal A — 本地 Server
```
### 5. 桌面端联调(Desktop)
如果你在开发或测试 `desktop/` 前端,需要同时启动 API 服务端和桌面前端。
#### 5.1 启动服务端
```bash
cd /Users/nanmi/workspace/myself_code/claude-code-haha
SERVER_PORT=3456 bun run src/server/index.ts SERVER_PORT=3456 bun run src/server/index.ts
# Terminal B — 桌面前端
cd desktop && bun run dev --host 127.0.0.1 --port 2024
``` ```
可选自检: 浏览器打开 `http://127.0.0.1:2024`。
---
## HeiCode Auth API
供桌面端 / CLI 调用的双 Provider 登录入口。
| Method · Path | 用途 |
|---|---|
| `GET /api/heicode-auth/providers` | 列出 2 个登录入口(TaijiAICloud / ClawdRouter)+ OAuth 是否就绪 |
| `POST /api/heicode-auth/login` | 粘贴 API Key 登录:校验 → 拉模型 → 保存 → 激活 |
| `POST /api/heicode-auth/oauth/start` | OAuth 启动(**当前为占位**,等平台支持) |
| `GET /api/heicode-auth/oauth/callback` | OAuth 回跳(**当前为占位**,等平台支持) |
| `GET /api/heicode-auth/status` | 当前登录状态 |
| `POST /api/heicode-auth/logout` | 登出 |
### 粘贴 API Key 登录示例
```bash ```bash
curl http://127.0.0.1:3456/health curl -X POST http://127.0.0.1:3456/api/heicode-auth/login \
-H 'Content-Type: application/json' \
-d '{"providerId":"taijiaicloud","apiKey":"sk-xxx"}'
``` ```
#### 5.2 启动桌面前端 返回:
```bash ```json
cd /Users/nanmi/workspace/myself_code/claude-code-haha/desktop {
bun run dev --host 127.0.0.1 --port 2024 "ok": true,
"provider": {
"id": "...",
"presetId": "taijiaicloud",
"name": "TaijiAICloud",
"baseUrl": "https://api.taijiaicloud.com",
"apiFormat": "anthropic",
"models": { "main": "claude-sonnet-4-6", "haiku": "...", "sonnet": "...", "opus": "..." }
},
"availableModels": [{ "id": "..." }, ...]
}
``` ```
然后在浏览器打开: ---
```text ## 给两个平台的对接清单
http://127.0.0.1:2024
```
#### 5.3 常见注意事项 详见 [`docs/HEICODE-PLAN.md`](./docs/HEICODE-PLAN.md)。简版:
- 如果 `3456` 端口已经被旧服务端占用,先执行 `lsof -nP -iTCP:3456 -sTCP:LISTEN` 找到 PID,再 `kill <PID>`。 ### TaijiAICloud(基于 new-api)
- 测试聊天时建议新建一个 session,并重新选择一个真实存在的工作目录。
- 如果某个旧 session 绑定的目录已被删除,服务端会返回 `Working directory does not exist`,这和服务端是否启动是两回事。 - 必备:`/v1/messages`(Anthropic 原生协议) · `/v1/models` · API Key 子分组管理
- 推荐:OAuth2 Authorization Code + PKCE 端点;Webhook(额度告警)
### ClawdRouter
- 必备:`/v1/messages` · `/v1/models`(已支持)
- 推荐:管理面 API(颁发短期 token);OAuth2 端点
--- ---
## 技术栈 ## 路线图
| 类别 | 技术 | - [x] **S0 品牌剥离**:包名 / Tauri / Cargo / 安装钩子改为 HeiCode
|------|------| - [x] **S0 双 Provider 预设**:providerPresets.json 仅保留 TaijiAICloud / ClawdRouter
| 运行时 | [Bun](https://bun.sh) | - [x] **S0 模型自动发现**:`/v1/models` 探活 + 默认模型自动选取
| 语言 | TypeScript | - [x] **S0 双 Provider 登录后端**:`/api/heicode-auth/*` 完整就绪(OAuth 占位)
| 终端 UI | React + [Ink](https://github.com/vadimdemedes/ink) | - [ ] **S1 桌面端登录页**:替换现有 Settings 的 Provider 列表为 2 卡片登录
| CLI 解析 | Commander.js | - [ ] **S1 CLI Onboarding**:原 `Onboarding.tsx` / `ConsoleOAuthFlow` 改为 HeiCode 双卡片
| API | Anthropic SDK | - [ ] **S1 OAuth 真实接入**:等 TaijiAICloud / ClawdRouter 提供 OAuth endpoints 后补全
| 协议 | MCP, LSP | - [ ] **S2 模型选择器 UX**:登录后 `/api/providers/:id/models` 渲染下拉选择默认模型
- [ ] **S2 配额状态栏**:拉平台 `/v1/usage` 实时展示余量
- [ ] **S3 Taiji Agent 工具融合**:MCP 自动挂载 / Skills 同步 / Agent 导入
--- ---
## 更多文档 ## License
| 文档 | 说明 | 继承上游 cc-haha 的 License。再往上游溯源是 Anthropic Claude Code 泄露源码,**仅供学习研究使用**。
|------|------|
| [环境变量](docs/guide/env-vars.md) | 完整环境变量参考和配置方式 |
| [第三方模型](docs/guide/third-party-models.md) | 接入 OpenAI / DeepSeek / Ollama 等非 Anthropic 模型 |
| [记忆系统](docs/memory/01-usage-guide.md) | 跨会话持久化记忆的使用与实现 |
| [多 Agent 系统](docs/agent/01-usage-guide.md) | 多代理编排、并行任务执行与 Teams 协作 |
| [Skills 系统](docs/skills/01-usage-guide.md) | 可扩展能力插件、自定义工作流与条件激活 |
| [Channel 系统](docs/channel/01-channel-system.md) | 通过 Telegram/飞书/Discord 等 IM 平台远程控制 Agent |
| [Computer Use](docs/features/computer-use.md) | 桌面控制功能(截屏、鼠标、键盘)— [架构解析](docs/features/computer-use-architecture.md) |
| [桌面端](docs/desktop/) | Tauri 2 + React 图形化客户端 — [快速上手](docs/desktop/01-quick-start.md) \| [架构设计](docs/desktop/02-architecture.md) \| [安装指南](docs/desktop/04-installation.md) |
| [全局使用](docs/guide/global-usage.md) | 在任意目录启动 claude-haha |
| [常见问题](docs/guide/faq.md) | 常见错误排查 |
| [源码修复记录](docs/reference/fixes.md) | 相对于原始泄露源码的修复内容 |
| [项目结构](docs/reference/project-structure.md) | 代码目录结构说明 |
--- ---
## 赞助与合作 ## 致谢
本项目由个人利用业余时间维护,欢迎企业或个人赞助支持持续开发,也可洽谈定制、集成或商务合作。 - 上游 [`cc-haha`](https://github.com/NanmiCoder/cc-haha) — 提供基础工程
- [`new-api`](https://github.com/Calcium-Ion/new-api) — 模型网关后端
<table>
<thead>
<tr>
<th width="220">赞助商</th>
<th align="left">介绍</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://jiekou.ai/referral?invited_code=OBNU3K">
<img src="docs/images/sponsors/jiekou-logo.svg" width="72" alt="接口AI"><br>
<strong>接口AI</strong>
</a>
</td>
<td valign="middle">
感谢 <a href="https://jiekou.ai/referral?invited_code=OBNU3K">接口AI</a> 赞助本项目!接口AI 提供官方资源直供与稳定高性能 API 体验,订阅包价格为官方 8 折;使用 <a href="https://jiekou.ai/referral?invited_code=OBNU3K">专属链接</a> 注册并绑定 GitHub,可领取 3 美元优惠券。
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">
<img src="docs/images/sponsors/shengsuanyun-logo.svg" width="180" alt="胜算云">
</a>
</td>
<td valign="middle">
感谢 <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">胜算云</a> 赞助本项目!胜算云是面向 AI Native Teams 的工业级 AI 任务并行执行平台,聚合 Claude、ChatGPT、Gemini 等海内外 LLM 及图片、视频多媒体模型算力;官方直连、非逆向,平台 SLA 可用性达 99.7%,可查看 <a href="https://watch.shengsuanyun.com/status/shengsuanyun">服务状态</a>。平台支持企业专属网关、成本与权限管控、智能路由、安全防护和 BYOK,按量与 tokens plan(即将上线)计费并可开票;使用 <a href="https://www.shengsuanyun.com/?from=CH_LEJ88KWR">专属链接</a> 注册可获 10 元模力及首充 10% 赠送。
</td>
</tr>
</tbody>
</table>
📧 **联系邮箱**:relakkes@gmail.com
---
## ☕ 请作者喝杯咖啡
如果这个项目对您有帮助,欢迎打赏支持,您的每一份支持都是我持续更新的动力 ❤️
<table>
<tr>
<td align="center" width="33%">
<img src="docs/images/donate/wechat_pay.jpeg" width="250" alt="微信赞赏"><br>
<b>微信赞赏</b>
</td>
<td align="center" width="33%">
<img src="docs/images/donate/zfb_pay.png" width="250" alt="支付宝"><br>
<b>支付宝</b>
</td>
<td align="center" width="33%">
<a href="https://buymeacoffee.com/relakkes" target="_blank">
<img src="docs/images/donate/bmc_button.png" width="250" alt="Buy Me a Coffee">
</a><br>
<b>Buy Me a Coffee</b>
</td>
</tr>
</table>
---
## 感谢
感谢以下开源项目和社区实践为本项目提供参考与启发:
- [React](https://github.com/facebook/react):前端工程与组件化 UI 生态。
- [Tauri](https://github.com/tauri-apps/tauri):跨端桌面应用能力与工程实践。
- [cc-switch](https://github.com/farion1231/cc-switch):模型供应商配置能力参考。
---
## ⭐ Star 趋势图
如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 Claude Code Haha!
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
</picture>
</a>
---
## Disclaimer
本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。
+3 -29
View File
@@ -1,30 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail # Backwards-compatible shim — forwards to the new HeiCode entrypoint.
# Will be removed in a future release.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" exec "$(dirname "${BASH_SOURCE[0]}")/heicode" "$@"
#Get your current working directory and export it as an environment variable.
export CALLER_DIR="${CALLER_DIR:-$(pwd -W 2>/dev/null || pwd)}"
cd "$ROOT_DIR"
# When spawned by the desktop/web server as a child CLI process,
# skip .env loading — the server has already set the correct env
# via cc-haha/settings.json. Loading .env would re-inject stale
# provider keys (e.g., a MiniMax key as ANTHROPIC_API_KEY) that
# override the active provider config.
if [[ "${CC_HAHA_SKIP_DOTENV:-0}" == "1" ]]; then
# Bun auto-loads .env by default; explicitly point to /dev/null to suppress.
ENV_FILE_FLAG="--env-file=/dev/null"
elif [[ -f .env ]]; then
ENV_FILE_FLAG="--env-file=.env"
else
ENV_FILE_FLAG=""
fi
# Force recovery CLI (simple readline REPL, no Ink TUI)
if [[ "${CLAUDE_CODE_FORCE_RECOVERY_CLI:-0}" == "1" ]]; then
exec bun $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@"
fi
# Default: full CLI with Ink TUI
exec bun $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@"
Executable
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Get your current working directory and export it as an environment variable.
export CALLER_DIR="${CALLER_DIR:-$(pwd -W 2>/dev/null || pwd)}"
cd "$ROOT_DIR"
# When spawned by the desktop/web server as a child CLI process,
# skip .env loading — the server has already set the correct env
# via heicode/settings.json. Loading .env would re-inject stale
# provider keys (e.g., a MiniMax key as ANTHROPIC_API_KEY) that
# override the active provider config.
if [[ "${HEICODE_SKIP_DOTENV:-${CC_HAHA_SKIP_DOTENV:-0}}" == "1" ]]; then
# Bun auto-loads .env by default; explicitly point to /dev/null to suppress.
ENV_FILE_FLAG="--env-file=/dev/null"
elif [[ -f .env ]]; then
ENV_FILE_FLAG="--env-file=.env"
else
ENV_FILE_FLAG=""
fi
# Force recovery CLI (simple readline REPL, no Ink TUI)
if [[ "${HEICODE_FORCE_RECOVERY_CLI:-${CLAUDE_CODE_FORCE_RECOVERY_CLI:-0}}" == "1" ]]; then
exec bun $ENV_FILE_FLAG ./src/localRecoveryCli.ts "$@"
fi
# Default: full CLI with Ink TUI
exec bun $ENV_FILE_FLAG ./src/entrypoints/cli.tsx "$@"
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "claude-code-desktop", "name": "heicode-desktop",
"private": true, "private": true,
"version": "0.1.8", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+16 -16
View File
@@ -327,22 +327,6 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "claude-code-desktop"
version = "0.1.8"
dependencies = [
"anyhow",
"portable-pty",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-updater",
]
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -1325,6 +1309,22 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "heicode-desktop"
version = "0.1.0"
dependencies = [
"anyhow",
"portable-pty",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-updater",
]
[[package]] [[package]]
name = "hex" name = "hex"
version = "0.4.3" version = "0.4.3"
+3 -3
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "claude-code-desktop" name = "heicode-desktop"
version = "0.1.8" version = "0.1.0"
edition = "2021" edition = "2021"
[lib] [lib]
name = "claude_code_desktop_lib" name = "heicode_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
+1 -1
View File
@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { fn main() {
claude_code_desktop_lib::run() heicode_desktop_lib::run()
} }
+5 -7
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json", "$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
"productName": "Claude Code Haha", "productName": "HeiCode",
"version": "0.1.8", "version": "0.1.0",
"identifier": "com.claude-code-haha.desktop", "identifier": "com.heicode.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
@@ -12,7 +12,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "Claude Code Haha", "title": "HeiCode",
"width": 1440, "width": 1440,
"height": 960, "height": 960,
"minWidth": 960, "minWidth": 960,
@@ -30,9 +30,7 @@
"plugins": { "plugins": {
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK",
"endpoints": [ "endpoints": [],
"https://github.com/NanmiCoder/cc-haha/releases/latest/download/latest.json"
],
"windows": { "windows": {
"installMode": "passive" "installMode": "passive"
} }
+1 -1
View File
@@ -2,7 +2,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "Claude Code Haha", "title": "HeiCode",
"width": 1440, "width": 1440,
"height": 960, "height": 960,
"minWidth": 960, "minWidth": 960,
+1 -1
View File
@@ -2,7 +2,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "Claude Code Haha", "title": "HeiCode",
"width": 1440, "width": 1440,
"height": 960, "height": 960,
"minWidth": 960, "minWidth": 960,
@@ -1,5 +1,5 @@
!macro NSIS_HOOK_PREINSTALL !macro NSIS_HOOK_PREINSTALL
DetailPrint "Stopping running Claude Code Haha sidecars..." DetailPrint "Stopping running HeiCode sidecars..."
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe' nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe'
Pop $0 Pop $0
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-aarch64-pc-windows-msvc.exe' nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-aarch64-pc-windows-msvc.exe'
@@ -10,7 +10,9 @@
!macroend !macroend
!macro NSIS_HOOK_PREUNINSTALL !macro NSIS_HOOK_PREUNINSTALL
DetailPrint "Stopping running Claude Code Haha processes..." DetailPrint "Stopping running HeiCode processes..."
nsExec::ExecToLog 'taskkill /F /T /IM heicode-desktop.exe'
Pop $0
nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe' nsExec::ExecToLog 'taskkill /F /T /IM claude-code-desktop.exe'
Pop $0 Pop $0
nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe' nsExec::ExecToLog 'taskkill /F /T /IM claude-sidecar-x86_64-pc-windows-msvc.exe'
+84
View File
@@ -0,0 +1,84 @@
// desktop/src/api/heicodeAuth.ts
//
// HeiCode 双 Provider 登录后端的客户端封装。
// 对应路由: src/server/api/heicode-auth.ts
import { api } from './client'
export type HeicodeProviderId = 'taijiaicloud' | 'clawdrouter'
export type HeicodeLoginProviderInfo = {
id: HeicodeProviderId
name: string
baseUrl: string
websiteUrl: string
apiKeyUrl?: string
promoText?: string
defaultModels: { main: string; haiku: string; sonnet: string; opus: string }
oauthEnabled: boolean
oauthStartUrl: string | null
}
export type HeicodeAuthStatus = {
loggedIn: boolean
source: 'cc-haha-provider' | 'original-settings' | 'env' | 'none'
activeProvider: {
id: string
presetId: string
name: string
baseUrl: string
models: { main: string; haiku: string; sonnet: string; opus: string }
} | null
}
export type HeicodeLoginInput = {
providerId: HeicodeProviderId
apiKey: string
displayName?: string
}
export type HeicodeLoginResult = {
ok: true
provider: {
id: string
presetId: string
name: string
baseUrl: string
apiFormat: 'anthropic' | 'openai_chat' | 'openai_responses'
models: { main: string; haiku: string; sonnet: string; opus: string }
}
availableModels: Array<{ id: string; owned_by?: string }>
}
export const heicodeAuthApi = {
listProviders() {
return api.get<{ providers: HeicodeLoginProviderInfo[] }>(
'/api/heicode-auth/providers',
)
},
status() {
return api.get<HeicodeAuthStatus>('/api/heicode-auth/status')
},
loginWithApiKey(input: HeicodeLoginInput) {
return api.post<HeicodeLoginResult>(
'/api/heicode-auth/login',
input,
// 登录会调上游 /v1/models 探活,给 60s 余量。
{ timeout: 60_000 },
)
},
// OAuth 当前是占位,但保留方法形态以便未来无痛切换。
startOAuth(providerId: HeicodeProviderId) {
return api.post<{ authorizeUrl: string; state: string }>(
'/api/heicode-auth/oauth/start',
{ providerId },
)
},
logout() {
return api.post<{ ok: true }>('/api/heicode-auth/logout')
},
}
+21 -1
View File
@@ -3,8 +3,10 @@ import { Sidebar } from './Sidebar'
import { ContentRouter } from './ContentRouter' import { ContentRouter } from './ContentRouter'
import { ToastContainer } from '../shared/Toast' import { ToastContainer } from '../shared/Toast'
import { UpdateChecker } from '../shared/UpdateChecker' import { UpdateChecker } from '../shared/UpdateChecker'
import { HeicodeLoginPage } from '../login/HeicodeLoginPage'
import { useSettingsStore } from '../../stores/settingsStore' import { useSettingsStore } from '../../stores/settingsStore'
import { useUIStore, type SettingsTab } from '../../stores/uiStore' import { useUIStore, type SettingsTab } from '../../stores/uiStore'
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts' import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
import { initializeDesktopServerUrl } from '../../lib/desktopRuntime' import { initializeDesktopServerUrl } from '../../lib/desktopRuntime'
import { TabBar } from './TabBar' import { TabBar } from './TabBar'
@@ -16,6 +18,9 @@ import { useTranslation } from '../../i18n'
export function AppShell() { export function AppShell() {
const fetchSettings = useSettingsStore((s) => s.fetchAll) const fetchSettings = useSettingsStore((s) => s.fetchAll)
const sidebarOpen = useUIStore((s) => s.sidebarOpen) const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const fetchAuth = useHeicodeAuthStore((s) => s.fetch)
const authStatus = useHeicodeAuthStore((s) => s.status)
const authHasFetched = useHeicodeAuthStore((s) => s.hasFetched)
const [ready, setReady] = useState(false) const [ready, setReady] = useState(false)
const [startupError, setStartupError] = useState<string | null>(null) const [startupError, setStartupError] = useState<string | null>(null)
const t = useTranslation() const t = useTranslation()
@@ -27,6 +32,9 @@ export function AppShell() {
try { try {
await initializeDesktopServerUrl() await initializeDesktopServerUrl()
await fetchSettings() await fetchSettings()
// Pull HeiCode login state up-front so the shell can decide whether
// to render the login page or the workspace.
await fetchAuth()
// Restore tabs from localStorage // Restore tabs from localStorage
await useTabStore.getState().restoreTabs() await useTabStore.getState().restoreTabs()
@@ -51,7 +59,7 @@ export function AppShell() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [fetchSettings]) }, [fetchSettings, fetchAuth])
// Listen for macOS native menu navigation events (About / Settings) // Listen for macOS native menu navigation events (About / Settings)
useEffect(() => { useEffect(() => {
@@ -85,6 +93,18 @@ export function AppShell() {
) )
} }
// Gate the workspace behind a successful HeiCode login. We treat any of the
// existing auth sources (heicode-auth provider, original ~/.claude/settings.json,
// or process env) as logged-in to avoid forcing existing users to re-login.
if (authHasFetched && !authStatus?.loggedIn) {
return (
<>
<HeicodeLoginPage />
<ToastContainer />
</>
)
}
return ( return (
<div className="h-screen flex overflow-hidden bg-[var(--color-surface)]"> <div className="h-screen flex overflow-hidden bg-[var(--color-surface)]">
<div <div
@@ -0,0 +1,84 @@
// desktop/src/components/login/HeicodeLoginPage.tsx
//
// 全屏登录页 — 当 HeiCode 检测到尚未登录时由 AppShell 渲染。
// 仅 2 个登录入口:TaijiAICloud / ClawdRouter,没有第三选项。
import { useEffect } from 'react'
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
import { ProviderLoginCard } from './ProviderLoginCard'
import { useTranslation } from '../../i18n'
type Props = {
onLoggedIn?: () => void
}
export function HeicodeLoginPage({ onLoggedIn }: Props) {
const t = useTranslation()
const {
providers,
hasFetched,
isLoading,
error,
fetch: fetchAuth,
stopOAuthPolling,
} = useHeicodeAuthStore()
useEffect(() => {
if (!hasFetched) {
void fetchAuth()
}
return () => {
stopOAuthPolling()
}
}, [hasFetched, fetchAuth, stopOAuthPolling])
return (
<div
className="flex h-screen w-screen flex-col items-center justify-center overflow-auto bg-[var(--color-surface)] px-6 py-10"
data-testid="heicode-login-page"
>
<div className="mb-8 text-center">
<h1 className="text-3xl font-semibold text-[var(--color-text-primary)]">
HeiCode
</h1>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
{t('login.subtitle')}
</p>
</div>
{!hasFetched && isLoading ? (
<div className="text-sm text-[var(--color-text-tertiary)]">
{t('common.loading')}
</div>
) : null}
{hasFetched && providers.length === 0 ? (
<div className="rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface-elevated,#fff)] p-4 text-sm text-[var(--color-error)]">
{t('login.errors.noProviders')}
</div>
) : null}
{hasFetched && providers.length > 0 ? (
<div className="grid w-full max-w-4xl grid-cols-1 gap-6 md:grid-cols-2">
{providers.map((provider) => (
<ProviderLoginCard
key={provider.id}
provider={provider}
onLoggedIn={onLoggedIn}
/>
))}
</div>
) : null}
{error ? (
<div className="mt-6 max-w-xl rounded-md bg-[var(--color-error-bg,rgba(220,38,38,.06))] px-4 py-3 text-sm text-[var(--color-error)]">
{error}
</div>
) : null}
<p className="mt-10 max-w-xl text-center text-xs text-[var(--color-text-tertiary)] leading-relaxed">
{t('login.footnote')}
</p>
</div>
)
}
@@ -0,0 +1,236 @@
// desktop/src/components/login/ProviderLoginCard.tsx
//
// 单个登录入口卡片。两种登录方式同时呈现:
// 1. 浏览器跳转 OAuth (推荐) —— 平台支持时启用
// 2. 复制粘贴 API Key —— 兼容入口
import { useState } from 'react'
import { open as shellOpen } from '@tauri-apps/plugin-shell'
import type { HeicodeLoginProviderInfo } from '../../api/heicodeAuth'
import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore'
import { useTranslation } from '../../i18n'
type Props = {
provider: HeicodeLoginProviderInfo
onLoggedIn?: () => void
}
/**
* Treat any of the following as a "local override":
* - http:// (not https://) — typical for in-cluster gateways
* - localhost / 127.0.0.1
* - 10/172.16-31/192.168 RFC1918 ranges
* - any hostname without a dot (e.g. `new-api`, `heicode-server`) — Docker DNS aliases
*/
function isLocalBaseUrl(rawUrl: string): boolean {
if (!rawUrl) return false
let host: string
try {
const url = new URL(rawUrl)
if (url.protocol === 'http:') return true
host = url.hostname
} catch {
return false
}
if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') return true
if (!host.includes('.')) return true
if (host.startsWith('10.')) return true
if (host.startsWith('192.168.')) return true
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true
return false
}
export function ProviderLoginCard({ provider, onLoggedIn }: Props) {
const t = useTranslation()
const { loginWithApiKey, startOAuth, startOAuthPolling, isLoggingIn } =
useHeicodeAuthStore()
const [apiKey, setApiKey] = useState('')
const [showKey, setShowKey] = useState(false)
const [localError, setLocalError] = useState<string | null>(null)
const [busy, setBusy] = useState<'oauth' | 'paste' | null>(null)
const handleOAuth = async () => {
if (!provider.oauthEnabled) return
setLocalError(null)
setBusy('oauth')
try {
const { authorizeUrl } = await startOAuth(provider.id)
try {
await shellOpen(authorizeUrl)
} catch {
setLocalError(t('login.errors.openBrowser'))
}
startOAuthPolling()
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(null)
}
}
const handlePasteLogin = async () => {
if (!apiKey.trim()) {
setLocalError(t('login.errors.emptyKey'))
return
}
setLocalError(null)
setBusy('paste')
try {
await loginWithApiKey({ providerId: provider.id, apiKey: apiKey.trim() })
setApiKey('')
onLoggedIn?.()
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(null)
}
}
const handleOpenKeyPage = async () => {
if (!provider.apiKeyUrl) return
try {
await shellOpen(provider.apiKeyUrl)
} catch {
// ignore — desktop tauri only
}
}
const local = isLocalBaseUrl(provider.baseUrl)
return (
<div className="flex flex-col gap-4 rounded-[var(--radius-lg)] border border-[var(--color-border-separator)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-card)]">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
{provider.name}
</h3>
{provider.oauthEnabled ? (
<span className="rounded-full bg-[var(--color-success-bg,rgba(22,163,74,.1))] px-2 py-0.5 text-xs text-[var(--color-success)]">
{t('login.tags.recommended')}
</span>
) : (
<span className="rounded-full bg-[var(--color-surface-muted,#f0f0f0)] px-2 py-0.5 text-xs text-[var(--color-text-tertiary)]">
{t('login.tags.comingSoon')}
</span>
)}
</div>
{/* Always-visible baseUrl indicator. Helps the user immediately tell
whether this card is pointing at the public TaijiAICloud endpoint
or has been overridden to a local new-api dev gateway. */}
<div
className="flex flex-wrap items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]"
data-testid={`heicode-login-card-baseurl-${provider.id}`}
>
<span>{t('login.baseUrl.label')}</span>
<code className="rounded bg-[var(--color-surface-muted,#f0f0f0)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text-secondary)] break-all">
{provider.baseUrl}
</code>
{local ? (
<span
className="rounded-full bg-[var(--color-warning-bg,rgba(217,119,6,.1))] px-2 py-0.5 text-[10px] uppercase tracking-wide text-[var(--color-warning)]"
title={t('login.baseUrl.localHint')}
data-testid={`heicode-login-card-local-tag-${provider.id}`}
>
{t('login.baseUrl.localTag')}
</span>
) : null}
</div>
{provider.promoText ? (
<p className="text-sm text-[var(--color-text-secondary)] leading-relaxed">
{provider.promoText}
</p>
) : null}
{local ? (
<p className="rounded-md bg-[var(--color-warning-bg,rgba(217,119,6,.06))] px-3 py-2 text-xs text-[var(--color-warning)] leading-relaxed">
{t('login.baseUrl.localBanner', { id: provider.id.toUpperCase() })}
</p>
) : null}
<div className="flex flex-col gap-2">
<button
type="button"
onClick={handleOAuth}
disabled={!provider.oauthEnabled || isLoggingIn || busy !== null}
className="rounded-md bg-[image:var(--gradient-btn-primary)] px-4 py-2.5 text-sm text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-opacity hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-50"
>
{busy === 'oauth'
? t('login.oauth.opening')
: provider.oauthEnabled
? t('login.oauth.button')
: t('login.oauth.disabled')}
</button>
{!provider.oauthEnabled ? (
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('login.oauth.disabledHint')}
</p>
) : null}
</div>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span className="h-px flex-1 bg-[var(--color-border-separator)]" />
{t('login.divider.or')}
<span className="h-px flex-1 bg-[var(--color-border-separator)]" />
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-[var(--color-text-secondary)]">
{t('login.paste.label')}
</label>
<div className="flex items-stretch gap-1 rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface-elevated,#fff)] focus-within:border-[var(--color-text-accent)]">
<input
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void handlePasteLogin()
}
}}
placeholder={t('login.paste.placeholder')}
className="flex-1 bg-transparent px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none"
autoComplete="off"
spellCheck={false}
data-testid={`heicode-login-key-input-${provider.id}`}
/>
<button
type="button"
onClick={() => setShowKey((v) => !v)}
className="px-3 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
>
{showKey ? t('login.paste.hide') : t('login.paste.show')}
</button>
</div>
<button
type="button"
onClick={handlePasteLogin}
disabled={isLoggingIn || busy !== null || apiKey.trim().length === 0}
className="rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] px-4 py-2 text-sm text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-50"
data-testid={`heicode-login-key-submit-${provider.id}`}
>
{busy === 'paste'
? t('login.paste.submitting')
: t('login.paste.submit')}
</button>
</div>
{provider.apiKeyUrl ? (
<button
type="button"
onClick={handleOpenKeyPage}
className="self-start text-xs text-[var(--color-text-accent)] hover:underline"
>
{t('login.paste.getKey')} →
</button>
) : null}
{localError ? (
<div className="rounded-md bg-[var(--color-error-bg,rgba(220,38,38,.06))] px-3 py-2 text-xs text-[var(--color-error)]">
{localError}
</div>
) : null}
</div>
)
}
+25
View File
@@ -952,6 +952,31 @@ export const en = {
'serverVerb.Task started': 'Task started', 'serverVerb.Task started': 'Task started',
'serverVerb.Task in progress': 'Task in progress', 'serverVerb.Task in progress': 'Task in progress',
// ─── HeiCode Login ──────────────────────────────────────
'login.subtitle': 'Choose how to sign in to start using HeiCode',
'login.footnote': 'HeiCode talks directly to TaijiAICloud or ClawdRouter; your API key never leaves this machine.',
'login.tags.recommended': 'Recommended',
'login.tags.comingSoon': 'Coming soon',
'login.oauth.button': 'Sign in with browser',
'login.oauth.opening': 'Opening browser…',
'login.oauth.disabled': 'Browser sign-in (coming soon)',
'login.oauth.disabledHint': 'OAuth will become available once the platform exposes its authorize endpoint.',
'login.divider.or': 'or',
'login.paste.label': 'Paste API Key',
'login.paste.placeholder': 'sk-…',
'login.paste.show': 'Show',
'login.paste.hide': 'Hide',
'login.paste.submit': 'Sign in',
'login.paste.submitting': 'Verifying…',
'login.paste.getKey': 'Get API Key',
'login.errors.openBrowser': 'Failed to open browser. Please copy the URL manually.',
'login.errors.emptyKey': 'Please paste an API key first.',
'login.errors.noProviders': 'No login providers configured. Check your HeiCode server.',
'login.baseUrl.label': 'Endpoint',
'login.baseUrl.localTag': 'Local',
'login.baseUrl.localHint': 'Pointing at a private/local address — paste a token issued by THIS endpoint.',
'login.baseUrl.localBanner': 'This {id} entry has been redirected to a local gateway via env override. Paste an API token from the LOCAL endpoint above (not the public platform).',
// ─── Tabs ────────────────────────────────────── // ─── Tabs ──────────────────────────────────────
'tabs.close': 'Close', 'tabs.close': 'Close',
'tabs.closeOthers': 'Close Others', 'tabs.closeOthers': 'Close Others',
+25
View File
@@ -954,6 +954,31 @@ export const zh: Record<TranslationKey, string> = {
'serverVerb.Task started': '任务已启动', 'serverVerb.Task started': '任务已启动',
'serverVerb.Task in progress': '任务进行中', 'serverVerb.Task in progress': '任务进行中',
// ─── HeiCode 登录 ──────────────────────────────────────
'login.subtitle': '选择登录方式以开始使用 HeiCode',
'login.footnote': 'HeiCode 直接连 TaijiAICloud 或 ClawdRouter,API Key 仅保存在你这台机器上。',
'login.tags.recommended': '推荐',
'login.tags.comingSoon': '即将开放',
'login.oauth.button': '浏览器登录',
'login.oauth.opening': '正在打开浏览器…',
'login.oauth.disabled': '浏览器登录(即将开放)',
'login.oauth.disabledHint': '等平台开放 OAuth 授权端点后,浏览器登录会立即可用。',
'login.divider.or': '或',
'login.paste.label': '粘贴 API Key',
'login.paste.placeholder': 'sk-…',
'login.paste.show': '显示',
'login.paste.hide': '隐藏',
'login.paste.submit': '登录',
'login.paste.submitting': '校验中…',
'login.paste.getKey': '获取 API Key',
'login.errors.openBrowser': '无法打开浏览器,请手动复制链接。',
'login.errors.emptyKey': '请先粘贴 API Key。',
'login.errors.noProviders': '未配置登录入口,请检查 HeiCode 服务端。',
'login.baseUrl.label': '接口地址',
'login.baseUrl.localTag': '本地',
'login.baseUrl.localHint': '当前指向私网/本地地址 — 请粘贴该地址签发的 Token,而不是公网平台的 Token。',
'login.baseUrl.localBanner': '当前 {id} 入口已被环境变量重定向到本地网关。请粘贴这个本地地址签发的 API Token(不是公网平台的)。',
// ─── Tabs ────────────────────────────────────── // ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭', 'tabs.close': '关闭',
'tabs.closeOthers': '关闭其他', 'tabs.closeOthers': '关闭其他',
+144
View File
@@ -0,0 +1,144 @@
// desktop/src/stores/heicodeAuthStore.ts
//
// HeiCode 全局登录态。AppShell 在启动时调 fetch();登录页里调 loginWithApiKey() / startOAuth()。
import { create } from 'zustand'
import {
heicodeAuthApi,
type HeicodeAuthStatus,
type HeicodeLoginInput,
type HeicodeLoginProviderInfo,
type HeicodeLoginResult,
} from '../api/heicodeAuth'
const OAUTH_POLL_INTERVAL_MS = 2_000
const OAUTH_POLL_TIMEOUT_MS = 5 * 60_000
type HeicodeAuthState = {
status: HeicodeAuthStatus | null
providers: HeicodeLoginProviderInfo[]
hasFetched: boolean
isLoading: boolean
isLoggingIn: boolean
error: string | null
fetch: () => Promise<void>
refreshStatus: () => Promise<void>
loginWithApiKey: (input: HeicodeLoginInput) => Promise<HeicodeLoginResult>
startOAuth: (providerId: HeicodeLoginProviderInfo['id']) => Promise<{ authorizeUrl: string }>
startOAuthPolling: () => void
stopOAuthPolling: () => void
logout: () => Promise<void>
clearError: () => void
}
export const useHeicodeAuthStore = create<HeicodeAuthState>((set, get) => {
let pollTimer: ReturnType<typeof setTimeout> | null = null
let pollDeadline = 0
const stopPolling = () => {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
}
return {
status: null,
providers: [],
hasFetched: false,
isLoading: false,
isLoggingIn: false,
error: null,
fetch: async () => {
set({ isLoading: true, error: null })
try {
const [{ providers }, status] = await Promise.all([
heicodeAuthApi.listProviders(),
heicodeAuthApi.status(),
])
set({ providers, status, hasFetched: true, isLoading: false })
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : String(err),
})
}
},
refreshStatus: async () => {
try {
const status = await heicodeAuthApi.status()
set({ status })
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) })
}
},
loginWithApiKey: async (input) => {
set({ isLoggingIn: true, error: null })
try {
const result = await heicodeAuthApi.loginWithApiKey(input)
// 登录成功后立即刷新状态。
const status = await heicodeAuthApi.status()
set({ isLoggingIn: false, status })
return result
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ isLoggingIn: false, error: message })
throw err
}
},
startOAuth: async (providerId) => {
set({ isLoggingIn: true, error: null })
try {
const res = await heicodeAuthApi.startOAuth(providerId)
set({ isLoggingIn: false })
return { authorizeUrl: res.authorizeUrl }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ isLoggingIn: false, error: message })
throw err
}
},
startOAuthPolling: () => {
stopPolling()
pollDeadline = Date.now() + OAUTH_POLL_TIMEOUT_MS
const tick = async () => {
if (Date.now() > pollDeadline) {
stopPolling()
return
}
await get().refreshStatus()
if (get().status?.loggedIn) {
stopPolling()
return
}
pollTimer = setTimeout(tick, OAUTH_POLL_INTERVAL_MS)
}
pollTimer = setTimeout(tick, OAUTH_POLL_INTERVAL_MS)
},
stopOAuthPolling: () => stopPolling(),
logout: async () => {
set({ isLoading: true, error: null })
try {
await heicodeAuthApi.logout()
const status = await heicodeAuthApi.status()
set({ isLoading: false, status })
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : String(err),
})
throw err
}
},
clearError: () => set({ error: null }),
}
})
+55
View File
@@ -0,0 +1,55 @@
# HeiCode docker stack
#
# Usage:
# docker compose up -d --build
# curl http://127.0.0.1:3456/health
# curl http://127.0.0.1:3456/api/heicode-auth/providers
#
# This compose joins the existing `new-api_new-api-network` so HeiCode can
# reach the local new-api gateway directly via http://new-api:3000
# (no need to expose new-api on the host LAN).
services:
heicode-server:
build:
context: .
dockerfile: Dockerfile
image: heicode-server:latest
container_name: heicode-server
restart: unless-stopped
ports:
- "3456:3456"
environment:
SERVER_PORT: "3456"
SERVER_HOST: "0.0.0.0"
# Auth on the *server* itself is off in dev so the desktop UI can talk
# to it without a bearer. Set to "1" in any non-trivial deployment.
SERVER_AUTH_REQUIRED: "0"
CLAUDE_CONFIG_DIR: "/data/.claude"
# When you want HeiCode to default to the local new-api gateway
# instead of TaijiAICloud / ClawdRouter, point ANTHROPIC_BASE_URL at
# the in-network address. The auth flow can override this at runtime.
# ANTHROPIC_BASE_URL: "http://new-api:3000"
# ANTHROPIC_AUTH_TOKEN: ""
volumes:
- heicode-data:/data
networks:
- default
- new-api-network
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:3456/health"]
interval: 15s
timeout: 5s
retries: 6
start_period: 20s
volumes:
heicode-data:
networks:
# `default` is automatic; we declare it only so we can list `new-api-network`
# alongside it without accidentally dropping the default.
default:
new-api-network:
external: true
name: new-api_new-api-network
+191
View File
@@ -0,0 +1,191 @@
# HeiCode 产品化沉淀文档
> 这份文档把"为什么这么改、改了什么、还差什么"沉淀下来,**作为后续开发与平台对接的唯一权威**。
---
## 一、产品定位
**HeiCode = 开箱即用的 Claude Code**,用户登录平台账号后直接用,不接触 API Key。
| 维度 | 设计 |
|---|---|
| 用户 | 个人开发者 |
| 形态 | Claude Code 桌面客户端 + CLI |
| 模型来源 | TaijiAICloud(自建网关,基于 new-api) + ClawdRouter(聚合网关) |
| 计费 | 完全在平台侧(不在 HeiCode 内做任何计费) |
| 中台 | **不需要**。客户端 → 平台直连。|
| 私有化 | **不做**。HeiCode 只对外销售客户端,平台是你公司运营的 SaaS。|
---
## 二、用户登录流程(最终形态)
```
启动 HeiCode
↓
看到登录页(仅 2 个入口,无第三选项):
┌───────────────────────┐ ┌──────────────────────┐
│ TaijiAICloud │ │ ClawdRouter │
│ • 浏览器跳转 OAuth │ │ • 浏览器跳转 OAuth │
│ • 粘贴 API Key │ │ • 粘贴 API Key │
└───────────────────────┘ └──────────────────────┘
↓
登录成功
↓
HeiCode 自动 /v1/models 拉模型列表 → 注入 ANTHROPIC_DEFAULT_*_MODEL
↓
正常使用 Claude Code TUI / 桌面端
```
---
## 三、已完成(S0 阶段)
### 1. 品牌剥离
- [x] `package.json` `name` → `heicode` (`bin: heicode + claude-haha 兼容)
- [x] `bin/heicode` 新可执行入口;`bin/claude-haha` 改为兼容 shim
- [x] `desktop/package.json` `name` → `heicode-desktop`
- [x] `desktop/src-tauri/Cargo.toml` `name` → `heicode-desktop`,`lib.name` → `heicode_desktop_lib`
- [x] `desktop/src-tauri/src/main.rs` 调用更新
- [x] `desktop/src-tauri/tauri.conf.json` `productName: HeiCode`,`identifier: com.heicode.desktop`,updater endpoint 清空(避免误连上游 release)
- [x] `desktop/src-tauri/tauri.macos.conf.json` / `tauri.windows.conf.json` 窗口标题改为 HeiCode
- [x] `desktop/src-tauri/windows-installer-hooks.nsh` 卸载钩子兼容 `heicode-desktop.exe`
### 2. Provider 预设重构
- [x] `src/server/config/providerPresets.json` **仅保留**:
- `official`(保留为内部占位,使原 `activateOfficial()` 调用不破)
- `taijiaicloud`(featured)
- `clawdrouter`(featured)
- 第三方厂商预设(DeepSeek / Kimi / MiniMax 等)全部移除。
### 3. 模型自动发现
- [x] `ProviderService.fetchProviderModels()`:
- 优先 `GET <baseUrl>/v1/models`
- 兼容 `GET <baseUrl>/models`
- 同时带 `Authorization: Bearer` 和 `x-api-key`,兼容两种平台
- 智能解析 `data` / `models` / 数组三种返回结构
- [x] `GET /api/providers/:id/models` — 已保存的 Provider 拉模型
- [x] `POST /api/providers/models` — 临时 baseUrl + apiKey 拉模型(登录前用)
### 4. 双 Provider 登录后端
- [x] 新建 `src/server/api/heicode-auth.ts`,路由挂在 `/api/heicode-auth/*`
- [x] `GET /api/heicode-auth/providers` — 列 2 个登录入口(含 OAuth 启用状态)
- [x] `POST /api/heicode-auth/login` — 粘贴 API Key 登录:校验 → 拉模型 → 保存 → 激活
- [x] `GET /api/heicode-auth/status` — 当前登录态
- [x] `POST /api/heicode-auth/logout` — 登出
- [x] `POST /api/heicode-auth/oauth/start` — OAuth 启动(**占位**)
- [x] `GET /api/heicode-auth/oauth/callback` — OAuth 回跳(**占位**)
### 5. 文档
- [x] 根 `README.md` 重写为 HeiCode 中文版
- [x] 本文件(`docs/HEICODE-PLAN.md`)
---
## 四、待完成
### S1 阶段(前端 UI 层)
#### 桌面端
- [ ] 替换现有 `desktop/src/pages/Settings.tsx` 的 Provider 区域为「双卡片登录」UI
- [ ] 卡片内:① 浏览器登录按钮(disabled 直到 OAuth 就绪) ② 粘贴 API Key 表单(直接调 `/api/heicode-auth/login`)
- [ ] 登录成功后:自动跳到模型选择页 → 调 `GET /api/providers/:id/models` 渲染下拉
- [ ] 顶部状态栏显示当前 Provider + 模型 + (未来)配额
#### CLI(Ink TUI)
- [ ] 替换 `src/components/Onboarding.tsx` 中的 OAuth 步骤为 HeiCode 登录
- [ ] 把 `ConsoleOAuthFlow` 替换为 `HeicodeProviderPicker` 组件(双卡片)
- [ ] `src/commands/login/login.tsx` 同步替换
> ⚠️ CLI 部分的限制:当前 `src/main.tsx` 是上游 805KB 预构建包,这块 UI 改动需要从源头改并重新走 build pipeline。**第一阶段建议把 CLI 的 onboarding 标记成"先用 desktop 完成登录,CLI 自动读取已登录态"**,等真正打 release 时再把 CLI UI 重 build。
### S2 阶段(OAuth 真实接入)
等 TaijiAICloud / ClawdRouter 平台开放 OAuth 端点后,把 `handleOAuthStart` / `handleOAuthCallback` 占位实装:
需要平台提供(OAuth2 Authorization Code + PKCE):
1. **Authorize endpoint**:`GET <platform>/oauth/authorize?response_type=code&client_id=heicode-desktop&redirect_uri=...&code_challenge=...&state=...&scope=models:read,messages:write`
2. **Token endpoint**:`POST <platform>/oauth/token` 接受 `grant_type=authorization_code&code=...&code_verifier=...&client_id=...`
3. **(可选) Refresh endpoint**:`grant_type=refresh_token`
4. **Redirect URI 白名单**:允许 `http://127.0.0.1:<port>/api/heicode-auth/oauth/callback?providerId=<id>` 中的 127.0.0.1 任意端口(参考 Claude Code / GitHub CLI 做法)
实装位置:`src/server/api/heicode-auth.ts` 中的 `handleOAuthStart` / `handleOAuthCallback`,可以参考已存在的 `src/server/services/hahaOAuthService.ts`(Claude.ai OAuth 实现)抽取通用逻辑。
### S3 阶段(差异化)
- [ ] **配额状态栏**:拉平台 `/v1/usage` 或 new-api `/api/quota/`
- [ ] **多模型快捷切换**:顶部下拉选模型(已具备 `/api/providers/:id/models`)
- [ ] **Taiji Agent 工具融合**:通过 MCP 把 Taiji Agent 的工具挂载进 HeiCode
---
## 五、给 TaijiAICloud / ClawdRouter 的对接 SOW
### TaijiAICloud(基于 new-api)
| # | 资产 / 接口 | 状态 | 说明 |
|---|---|---|---|
| 1 | `/v1/messages` Anthropic 原生协议 | **必须确认** | new-api 配置项: 启用 Claude Messages 转发 |
| 2 | `/v1/models` | 应已就绪 | new-api 默认提供 |
| 3 | OAuth2 Authorization Code + PKCE | **待开发** | 见 S2 阶段 |
| 4 | 子 Token 管理(限定 model 白名单 / 每日额度 / 过期) | new-api 自带 | 平台直接复用 |
| 5 | 调用日志 / 审计(≥180天) | new-api 自带 | `/api/log/` |
| 6 | (可选) Webhook 额度告警 | 待开发 | new-api 原生没有 |
### ClawdRouter
| # | 资产 / 接口 | 状态 | 说明 |
|---|---|---|---|
| 1 | `/v1/messages` Anthropic 原生协议 | ✅ | [文档](https://www.clawdrouter.com/docs/) |
| 2 | `/v1/chat/completions` OpenAI 协议 | ✅ | 同上 |
| 3 | `/v1/models` | **待确认** | 文档未明示,需要平台确认或补 |
| 4 | OAuth2 端点 | **待开发** | 见 S2 阶段 |
| 5 | 管理面 API(创建/吊销子 Key、用量查询) | 待开发 | 用于 HeiCode 自动颁发 token |
| 6 | `Request-Id` 自定义头 | ✅ | HeiCode 调用时带 `Request-Id: <user>:<session>:<uuid>` 做归属审计 |
---
## 六、目录与命令速查
```
# 启动桌面端联调
cd cc-haha
bun install
SERVER_PORT=3456 bun run src/server/index.ts &
cd desktop && bun run dev --host 127.0.0.1 --port 2024
# 测登录后端 (粘贴 token 模式)
curl -X POST http://127.0.0.1:3456/api/heicode-auth/login \
-H 'Content-Type: application/json' \
-d '{"providerId":"taijiaicloud","apiKey":"sk-xxx"}'
curl http://127.0.0.1:3456/api/heicode-auth/status
```
---
## 七、变更清单(git 友好)
```
新增:
src/server/api/heicode-auth.ts ← 双 Provider 登录后端
bin/heicode ← 新 CLI 入口
README.md ← 完全重写
docs/HEICODE-PLAN.md ← 本文件
修改:
package.json ← name: heicode
bin/claude-haha ← 改为兼容 shim
src/server/api/providers.ts ← 加 /models 端点
src/server/services/providerService.ts ← 加 fetchProviderModels
src/server/router.ts ← 注册 heicode-auth 路由
src/server/config/providerPresets.json ← 重写为 2 个 preset
desktop/package.json ← name: heicode-desktop
desktop/src-tauri/Cargo.toml ← name + lib.name
desktop/src-tauri/src/main.rs ← lib 名同步
desktop/src-tauri/tauri.conf.json ← productName + identifier + updater 清空
desktop/src-tauri/tauri.macos.conf.json ← 窗口标题
desktop/src-tauri/tauri.windows.conf.json ← 窗口标题
desktop/src-tauri/windows-installer-hooks.nsh ← 进程名兼容
```
+7 -5
View File
@@ -1,14 +1,16 @@
{ {
"name": "claude-code-local", "name": "heicode",
"version": "999.0.0-local", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"bin": { "bin": {
"claude-haha": "./bin/claude-haha" "heicode": "./bin/heicode",
"claude-haha": "./bin/heicode"
}, },
"scripts": { "scripts": {
"claude-haha": "bun run ./bin/claude-haha", "heicode": "bun run ./bin/heicode",
"start": "bun run ./bin/claude-haha", "claude-haha": "bun run ./bin/heicode",
"start": "bun run ./bin/heicode",
"docs:dev": "vitepress dev docs", "docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs" "docs:preview": "vitepress preview docs"
+290
View File
@@ -0,0 +1,290 @@
/**
* HeiCode Auth API — TaijiAICloud / ClawdRouter 双 Provider 登录入口。
*
* 设计目标:
* - 用户启动 HeiCode 后看到 2 个登录卡片 (TaijiAICloud / ClawdRouter)。
* - 每张卡片提供两种登录方式:
* A. 浏览器跳转 OAuth (推荐) —— 平台支持时启用,本端零改动。
* B. 粘贴 API Key (兼容入口) —— 立即可用,不依赖平台改造。
* - 登录成功后, HeiCode 自动调用 /v1/models 拉模型列表, 让用户挑默认模型。
*
* 路由:
* GET /api/heicode-auth/providers
* — 返回 2 个登录入口的元数据 + 当前 OAuth 是否就绪。
* POST /api/heicode-auth/login
* — { providerId, apiKey, displayName? } → 校验 → 拉模型 → 保存为 SavedProvider → 激活。
* POST /api/heicode-auth/oauth/start
* — { providerId } → 返回 authorize URL (调起本端 callback listener)。 [STUB]
* GET /api/heicode-auth/oauth/callback
* — 平台回跳: code + state → 换 token → 保存为 SavedProvider → 激活。 [STUB]
* GET /api/heicode-auth/status
* — 当前是否登录 / 哪个 Provider / 模型预览。
* POST /api/heicode-auth/logout
* — 清掉当前 active provider (不删除 saved 列表里的记录)。
*
* NOTE: OAuth 部分目前是占位实现,等 TaijiAICloud / ClawdRouter 平台开放
* OAuth2 Authorization Code + PKCE 端点之后再补。
* 平台需要提供:
* 1. authorize endpoint (浏览器登录页面)
* 2. token endpoint (code → access_token)
* 3. (可选) refresh endpoint
* 约定 redirect_uri = http://127.0.0.1:<heicode-server-port>/api/heicode-auth/oauth/callback?providerId=<id>
*/
import { z } from 'zod'
import { ProviderService } from '../services/providerService.js'
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
const providerService = new ProviderService()
const SUPPORTED_LOGIN_PROVIDER_IDS = ['taijiaicloud', 'clawdrouter'] as const
type SupportedLoginProviderId = (typeof SUPPORTED_LOGIN_PROVIDER_IDS)[number]
function isSupportedLoginProvider(id: string): id is SupportedLoginProviderId {
return (SUPPORTED_LOGIN_PROVIDER_IDS as readonly string[]).includes(id)
}
const LoginRequestSchema = z.object({
providerId: z.enum(['taijiaicloud', 'clawdrouter']),
apiKey: z.string().min(8, 'API Key 长度过短'),
displayName: z.string().min(1).optional(),
})
const OAuthStartSchema = z.object({
providerId: z.enum(['taijiaicloud', 'clawdrouter']),
})
export async function handleHeicodeAuthApi(
req: Request,
url: URL,
segments: string[],
): Promise<Response> {
try {
const action = segments[2]
const subAction = segments[3]
// GET /api/heicode-auth/providers
if (action === 'providers' && req.method === 'GET') {
return Response.json({ providers: listLoginProviders() })
}
// POST /api/heicode-auth/login
if (action === 'login' && req.method === 'POST') {
return await handleLoginWithApiKey(req)
}
// GET /api/heicode-auth/status
if (action === 'status' && req.method === 'GET') {
const status = await providerService.checkAuthStatus()
const { providers, activeId } = await providerService.listProviders()
const active = activeId ? providers.find(p => p.id === activeId) : null
return Response.json({
loggedIn: status.hasAuth,
source: status.source,
activeProvider: active
? {
id: active.id,
presetId: active.presetId,
name: active.name,
baseUrl: active.baseUrl,
models: active.models,
}
: null,
})
}
// POST /api/heicode-auth/logout
if (action === 'logout' && req.method === 'POST') {
await providerService.activateOfficial() // clears active provider
return Response.json({ ok: true })
}
// /api/heicode-auth/oauth/*
if (action === 'oauth') {
if (subAction === 'start' && req.method === 'POST') {
return await handleOAuthStart(req)
}
if (subAction === 'callback' && req.method === 'GET') {
return await handleOAuthCallback(url)
}
throw notFound()
}
throw notFound()
} catch (err) {
return errorResponse(err)
}
}
// ─── Login helpers ─────────────────────────────────────────────
type LoginProviderInfo = {
id: SupportedLoginProviderId
name: string
baseUrl: string
websiteUrl: string
apiKeyUrl?: string
promoText?: string
defaultModels: { main: string; haiku: string; sonnet: string; opus: string }
/** 是否已配置 OAuth(占位,等平台支持后改为 true) */
oauthEnabled: boolean
/** OAuth 启动入口(前端按它跳就行);oauthEnabled=false 时为 null */
oauthStartUrl: string | null
}
function listLoginProviders(): LoginProviderInfo[] {
return SUPPORTED_LOGIN_PROVIDER_IDS.map(id => {
const preset = PROVIDER_PRESETS.find(p => p.id === id)
if (!preset) {
throw ApiError.internal(`Missing preset for login provider: ${id}`)
}
return {
id,
name: preset.name,
baseUrl: preset.baseUrl,
websiteUrl: preset.websiteUrl,
apiKeyUrl: preset.apiKeyUrl,
promoText: preset.promoText,
defaultModels: preset.defaultModels,
// TODO: 等平台支持 OAuth 后改为 true 并提供 oauthStartUrl
oauthEnabled: false,
oauthStartUrl: null,
}
})
}
async function handleLoginWithApiKey(req: Request): Promise<Response> {
const body = await parseJsonBody(req)
const parsed = LoginRequestSchema.safeParse(body)
if (!parsed.success) {
throw ApiError.badRequest(parsed.error.issues.map(i => i.message).join('; '))
}
const { providerId, apiKey, displayName } = parsed.data
if (!isSupportedLoginProvider(providerId)) {
throw ApiError.badRequest(`Unsupported provider: ${providerId}`)
}
const preset = PROVIDER_PRESETS.find(p => p.id === providerId)
if (!preset) {
throw ApiError.internal(`Missing preset for provider: ${providerId}`)
}
// 1. 用 /v1/models 探活 + 拉模型列表
const probe = await providerService.fetchProviderModels({
baseUrl: preset.baseUrl,
apiKey,
apiFormat: preset.apiFormat,
})
if (probe.models.length === 0) {
throw ApiError.badRequest(
`校验失败: ${probe.error ?? '该 API Key 在 ' + preset.name + ' 上无可用模型'}`,
)
}
// 2. 把当前用户已经存过的同 preset provider 找出来,有则更新,没有则创建
const existing = await providerService.listProviders()
const sameProvider = existing.providers.find(p => p.presetId === preset.id)
// 选择默认模型: 优先用 preset 里指定的; 如果不在返回列表里,退回到第一个返回的模型
const availableIds = new Set(probe.models.map(m => m.id))
const pick = (preferred: string): string => {
if (preferred && availableIds.has(preferred)) return preferred
return probe.models[0]?.id ?? preferred
}
const models = {
main: pick(preset.defaultModels.main),
haiku: pick(preset.defaultModels.haiku),
sonnet: pick(preset.defaultModels.sonnet),
opus: pick(preset.defaultModels.opus),
}
let saved
if (sameProvider) {
saved = await providerService.updateProvider(sameProvider.id, {
name: displayName ?? sameProvider.name,
apiKey,
baseUrl: preset.baseUrl,
apiFormat: preset.apiFormat,
models,
})
} else {
saved = await providerService.addProvider({
presetId: preset.id,
name: displayName ?? preset.name,
apiKey,
baseUrl: preset.baseUrl,
apiFormat: preset.apiFormat,
models,
})
}
// 3. 激活成 active provider
await providerService.activateProvider(saved.id)
return Response.json({
ok: true,
provider: {
id: saved.id,
presetId: saved.presetId,
name: saved.name,
baseUrl: saved.baseUrl,
apiFormat: saved.apiFormat,
models: saved.models,
},
availableModels: probe.models,
})
}
// ─── OAuth scaffold (TODO: 平台支持后实装) ──────────────────────
async function handleOAuthStart(req: Request): Promise<Response> {
const body = await parseJsonBody(req)
const parsed = OAuthStartSchema.safeParse(body)
if (!parsed.success) {
throw ApiError.badRequest(parsed.error.issues.map(i => i.message).join('; '))
}
// 占位 —— 等 TaijiAICloud / ClawdRouter 提供 OAuth2 Authorization endpoint 后实装。
// 实装思路:
// 1. 生成 PKCE codeVerifier + state, 缓存到 in-memory session map
// 2. 拼出 authorizeUrl = `${platform_authorize_endpoint}?response_type=code&client_id=...
// &redirect_uri=http://127.0.0.1:<port>/api/heicode-auth/oauth/callback?providerId=${providerId}
// &code_challenge=...&code_challenge_method=S256&state=...`
// 3. 返回 { authorizeUrl, state }
// 4. 前端用 shell::open 打开 authorizeUrl
throw ApiError.badRequest(
`${parsed.data.providerId} 暂未启用 OAuth,请先用「粘贴 API Key」登录。OAuth 功能将在平台支持后开放。`,
)
}
async function handleOAuthCallback(_url: URL): Promise<Response> {
// 占位 —— 配合 handleOAuthStart 使用。流程:
// 1. 校验 state → 取出 codeVerifier
// 2. 调平台 token endpoint: code + codeVerifier → access_token + refresh_token
// 3. 用拿到的 access_token 调 /v1/models 验证 + 保存为 SavedProvider + 激活
// 4. 返回成功页面给浏览器, 关闭 tab
return new Response(
`<!doctype html><html><body style="font-family:system-ui;padding:32px;text-align:center">
<h2>OAuth 回跳通道未启用</h2>
<p>请先在 HeiCode 中使用「粘贴 API Key」方式登录。</p>
<p>OAuth 功能将在 TaijiAICloud / ClawdRouter 平台支持后开放。</p>
</body></html>`,
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
)
}
// ─── Helpers ───────────────────────────────────────────────────
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
try {
return (await req.json()) as Record<string, unknown>
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
}
function notFound(): ApiError {
return new ApiError(404, 'Not Found', 'NOT_FOUND')
}
+37
View File
@@ -13,6 +13,8 @@
* POST /api/providers/official — activate official (clear env) * POST /api/providers/official — activate official (clear env)
* POST /api/providers/:id/test — test a saved provider * POST /api/providers/:id/test — test a saved provider
* POST /api/providers/test — test unsaved config * POST /api/providers/test — test unsaved config
* GET /api/providers/:id/models — fetch /v1/models for a saved provider
* POST /api/providers/models — fetch /v1/models for ad-hoc baseUrl + apiKey (used during login flow)
*/ */
import { z } from 'zod' import { z } from 'zod'
@@ -41,6 +43,11 @@ export async function handleProvidersApi(
return await handleTestUnsaved(req) return await handleTestUnsaved(req)
} }
// POST /api/providers/models — fetch models given baseUrl + apiKey (used by login flow)
if (id === 'models' && req.method === 'POST') {
return await handleFetchModelsUnsaved(req)
}
// GET /api/providers/presets // GET /api/providers/presets
if (id === 'presets' && req.method === 'GET') { if (id === 'presets' && req.method === 'GET') {
return Response.json({ presets: PROVIDER_PRESETS }) return Response.json({ presets: PROVIDER_PRESETS })
@@ -102,6 +109,18 @@ export async function handleProvidersApi(
return Response.json({ result }) return Response.json({ result })
} }
// GET /api/providers/:id/models — fetch /v1/models using saved credentials
if (action === 'models') {
if (req.method !== 'GET') throw methodNotAllowed(req.method)
const provider = await providerService.getProvider(id)
const result = await providerService.fetchProviderModels({
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
apiFormat: provider.apiFormat,
})
return Response.json(result)
}
// /api/providers/:id // /api/providers/:id
if (req.method === 'GET') { if (req.method === 'GET') {
const provider = await providerService.getProvider(id) const provider = await providerService.getProvider(id)
@@ -157,6 +176,24 @@ async function handleTestUnsaved(req: Request): Promise<Response> {
} }
} }
const FetchModelsSchema = z.object({
baseUrl: z.string().url(),
apiKey: z.string().min(1),
apiFormat: z.enum(['anthropic', 'openai_chat', 'openai_responses']).optional(),
})
async function handleFetchModelsUnsaved(req: Request): Promise<Response> {
const body = await parseJsonBody(req)
try {
const input = FetchModelsSchema.parse(body)
const result = await providerService.fetchProviderModels(input)
return Response.json(result)
} catch (err) {
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
throw err
}
}
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> { async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
try { try {
return (await req.json()) as Record<string, unknown> return (await req.json()) as Record<string, unknown>
+23 -134
View File
@@ -14,156 +14,45 @@
"websiteUrl": "https://www.anthropic.com/claude-code" "websiteUrl": "https://www.anthropic.com/claude-code"
}, },
{ {
"id": "deepseek", "id": "taijiaicloud",
"name": "DeepSeek", "name": "TaijiAICloud",
"baseUrl": "https://api.deepseek.com/anthropic", "baseUrl": "https://api.taijiaicloud.com",
"apiFormat": "anthropic",
"defaultModels": {
"main": "deepseek-v4-pro",
"haiku": "deepseek-v4-flash",
"sonnet": "deepseek-v4-pro",
"opus": "deepseek-v4-pro"
},
"needsApiKey": true,
"websiteUrl": "https://platform.deepseek.com",
"apiKeyUrl": "https://platform.deepseek.com/api_keys"
},
{
"id": "zhipuglm",
"name": "Zhipu GLM",
"baseUrl": "https://open.bigmodel.cn/api/anthropic",
"apiFormat": "anthropic",
"defaultModels": {
"main": "glm-5.1",
"haiku": "glm-4.5-air",
"sonnet": "glm-5-turbo",
"opus": "glm-5.1"
},
"needsApiKey": true,
"websiteUrl": "https://open.bigmodel.cn",
"apiKeyUrl": "https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D",
"promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。"
},
{
"id": "kimi",
"name": "Kimi",
"baseUrl": "https://api.kimi.com/coding",
"apiFormat": "anthropic",
"defaultModels": {
"main": "kimi-k2.6",
"haiku": "kimi-k2.6",
"sonnet": "kimi-k2.6",
"opus": "kimi-k2.6"
},
"needsApiKey": true,
"websiteUrl": "https://platform.moonshot.cn",
"apiKeyUrl": "https://platform.kimi.com/console/api-keys"
},
{
"id": "minimax",
"name": "MiniMax",
"baseUrl": "https://api.minimaxi.com/anthropic",
"apiFormat": "anthropic",
"defaultModels": {
"main": "MiniMax-M2.7",
"haiku": "MiniMax-M2.7",
"sonnet": "MiniMax-M2.7",
"opus": "MiniMax-M2.7"
},
"needsApiKey": true,
"websiteUrl": "https://platform.minimaxi.com",
"apiKeyUrl": "https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link"
},
{
"id": "jiekouai",
"name": "接口AI",
"baseUrl": "https://api.jiekou.ai/anthropic",
"apiFormat": "anthropic", "apiFormat": "anthropic",
"defaultModels": { "defaultModels": {
"main": "claude-sonnet-4-6", "main": "claude-sonnet-4-6",
"haiku": "claude-haiku-4-5-20251001", "haiku": "claude-haiku-4-5-20251001",
"sonnet": "claude-sonnet-4-6", "sonnet": "claude-sonnet-4-6",
"opus": "claude-opus-4-7" "opus": "claude-opus-4-6"
}, },
"needsApiKey": true, "needsApiKey": true,
"websiteUrl": "https://jiekou.ai", "websiteUrl": "https://api.taijiaicloud.com",
"apiKeyUrl": "https://jiekou.ai/referral?invited_code=OBNU3K", "apiKeyUrl": "https://api.taijiaicloud.com/dashboard/keys",
"promoText": "接口AI为 cc-haha 的用户提供官方资源与稳定高性能体验,订阅包价格为官方 8 折;绑定 GitHub 后还可领取 3 美元优惠券。", "promoText": "TaijiAICloud 是 HeiCode 推荐的国内 LLM 网关,原生支持 Anthropic /v1/messages 协议,覆盖 GPT / Claude / Gemini 等主流模型。",
"featured": true,
"defaultEnv": {
"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none"
}
},
{
"id": "shengsuanyun",
"name": "胜算云",
"baseUrl": "https://router.shengsuanyun.com/api",
"apiFormat": "anthropic",
"defaultModels": {
"main": "anthropic/claude-sonnet-4.6",
"haiku": "anthropic/claude-haiku-4.5:thinking",
"sonnet": "anthropic/claude-sonnet-4.6",
"opus": "anthropic/claude-opus-4.7"
},
"needsApiKey": true,
"websiteUrl": "https://www.shengsuanyun.com",
"apiKeyUrl": "https://www.shengsuanyun.com/?from=CH_LEJ88KWR",
"promoText": "胜算云为 cc-haha 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!",
"featured": true, "featured": true,
"defaultEnv": { "defaultEnv": {
"API_TIMEOUT_MS": "3000000", "API_TIMEOUT_MS": "3000000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
"ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none"
} }
}, },
{ {
"id": "lmstudio", "id": "clawdrouter",
"name": "LM Studio", "name": "ClawdRouter",
"baseUrl": "http://localhost:1234", "baseUrl": "https://api.clawdrouter.com",
"apiFormat": "anthropic", "apiFormat": "anthropic",
"defaultModels": { "defaultModels": {
"main": "qwen/qwen3.6-27b", "main": "claude-sonnet-4-6",
"haiku": "qwen/qwen3.6-27b", "haiku": "claude-haiku-4-5-20251001",
"sonnet": "qwen/qwen3.6-27b", "sonnet": "claude-sonnet-4-6",
"opus": "qwen/qwen3.6-27b" "opus": "claude-opus-4-6"
},
"needsApiKey": false,
"websiteUrl": "https://lmstudio.ai/docs/integrations/claude-code",
"promoText": "LM Studio 使用 Anthropic 兼容协议,Base URL 填 http://localhost:1234,不要追加 /v1。Claude Code 的提示词、工具和 Skill 会占用较多上下文,请在本地模型设置里把 Context Window 调大,建议至少 200K。",
"defaultEnv": {
"ANTHROPIC_AUTH_TOKEN": "lmstudio"
}
},
{
"id": "ollama",
"name": "Ollama",
"baseUrl": "http://localhost:11434",
"apiFormat": "anthropic",
"defaultModels": {
"main": "qwen3.6:27b",
"haiku": "qwen3.6:27b",
"sonnet": "qwen3.6:27b",
"opus": "qwen3.6:27b"
},
"needsApiKey": false,
"websiteUrl": "https://docs.ollama.com/integrations/claude-code",
"promoText": "Ollama 使用 Anthropic 兼容协议,Base URL 填 http://localhost:11434,不要追加 /v1。Claude Code 的提示词、工具和 Skill 会占用较多上下文,请在本地模型设置里把 Context Window 调大,建议至少 200K。",
"defaultEnv": {
"ANTHROPIC_AUTH_TOKEN": "ollama"
}
},
{
"id": "custom",
"name": "Custom",
"baseUrl": "",
"apiFormat": "anthropic",
"defaultModels": {
"main": "",
"haiku": "",
"sonnet": "",
"opus": ""
}, },
"needsApiKey": true, "needsApiKey": true,
"websiteUrl": "" "websiteUrl": "https://www.clawdrouter.com",
"apiKeyUrl": "https://www.clawdrouter.com/dashboard/keys",
"promoText": "ClawdRouter 双协议聚合网关,同时支持 Anthropic /v1/messages 与 OpenAI /v1/chat/completions,覆盖 OpenAI / Anthropic / Google 全家桶。",
"featured": true,
"defaultEnv": {
"API_TIMEOUT_MS": "3000000",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
}
} }
] ]
+26 -1
View File
@@ -32,4 +32,29 @@ const ProviderPresetsSchema = z.array(ProviderPresetSchema)
export type ModelMapping = z.infer<typeof ModelMappingSchema> export type ModelMapping = z.infer<typeof ModelMappingSchema>
export type ProviderPreset = z.infer<typeof ProviderPresetSchema> export type ProviderPreset = z.infer<typeof ProviderPresetSchema>
export const PROVIDER_PRESETS = ProviderPresetsSchema.parse(providerPresetsJson) /**
* Per-provider base URL overrides via env. This lets a developer point
* HeiCode at a *local* gateway (e.g. a self-hosted new-api on Docker)
* without editing the preset JSON file.
*
* Examples:
* HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000
* HEICODE_CLAWDROUTER_BASE_URL=http://localhost:4000
*
* The override is applied at module load time. Restart the server after
* changing these.
*/
const PROVIDER_BASE_URL_ENV_MAP: Record<string, string> = {
taijiaicloud: 'HEICODE_TAIJIAICLOUD_BASE_URL',
clawdrouter: 'HEICODE_CLAWDROUTER_BASE_URL',
}
const parsedPresets = ProviderPresetsSchema.parse(providerPresetsJson)
export const PROVIDER_PRESETS = parsedPresets.map((preset) => {
const envKey = PROVIDER_BASE_URL_ENV_MAP[preset.id]
if (!envKey) return preset
const override = process.env[envKey]?.trim()
if (!override) return preset
return { ...preset, baseUrl: override.replace(/\/+$/, '') }
})
+10 -5
View File
@@ -56,12 +56,17 @@ export function startServer(port = PORT, host = HOST) {
* Auth is required when explicitly opted in or when bound to a non-localhost address. * Auth is required when explicitly opted in or when bound to a non-localhost address.
* - Default localhost dev: no auth needed (tests pass as-is). * - Default localhost dev: no auth needed (tests pass as-is).
* - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically. * - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically.
* - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost. * - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
* - Explicit opt-out: SERVER_AUTH_REQUIRED=0 disables auth even on 0.0.0.0
* (use only in trusted environments — e.g. a Docker container whose port
* is published to host loopback only). Takes precedence over the auto rule.
*/ */
const authRequired = const authForcedOff = process.env.SERVER_AUTH_REQUIRED === '0'
SERVER_OPTIONS.authRequired || const authRequired = authForcedOff
process.env.SERVER_AUTH_REQUIRED === '1' || ? false
host !== '127.0.0.1' : SERVER_OPTIONS.authRequired ||
process.env.SERVER_AUTH_REQUIRED === '1' ||
host !== '127.0.0.1'
const server = Bun.serve<WebSocketData>({ const server = Bun.serve<WebSocketData>({
port, port,
+4
View File
@@ -18,6 +18,7 @@ import { handlePluginsApi } from './api/plugins.js'
import { handleSkillsApi } from './api/skills.js' import { handleSkillsApi } from './api/skills.js'
import { handleComputerUseApi } from './api/computer-use.js' import { handleComputerUseApi } from './api/computer-use.js'
import { handleHahaOAuthApi } from './api/haha-oauth.js' import { handleHahaOAuthApi } from './api/haha-oauth.js'
import { handleHeicodeAuthApi } from './api/heicode-auth.js'
import { handleMcpApi } from './api/mcp.js' import { handleMcpApi } from './api/mcp.js'
export async function handleApiRequest(req: Request, url: URL): Promise<Response> { export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
@@ -72,6 +73,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'haha-oauth': case 'haha-oauth':
return handleHahaOAuthApi(req, url, segments) return handleHahaOAuthApi(req, url, segments)
case 'heicode-auth':
return handleHeicodeAuthApi(req, url, segments)
case 'adapters': case 'adapters':
return handleAdaptersApi(req, url, segments) return handleAdaptersApi(req, url, segments)
+96
View File
@@ -403,6 +403,69 @@ export class ProviderService {
}) })
} }
// --- Models discovery ---
/**
* Fetch the list of models exposed by a provider.
*
* Strategy:
* 1. Try `GET <baseUrl>/v1/models` (OpenAI-compatible — what TaijiAICloud (new-api) and ClawdRouter both expose).
* 2. Fall back to `GET <baseUrl>/models` (some Anthropic-flavoured proxies put it there).
* 3. If neither works, return an empty list — the UI should let the user enter a model id manually.
*
* Auth header is chosen based on `apiFormat`:
* - 'anthropic' → both `Authorization: Bearer` AND `x-api-key` (covers TaijiAICloud + ClawdRouter, harmless extras).
* - other → `Authorization: Bearer`.
*/
async fetchProviderModels(input: {
baseUrl: string
apiKey: string
apiFormat?: ApiFormat
}): Promise<{ models: Array<{ id: string; owned_by?: string }>; source: string; error?: string }> {
const base = input.baseUrl.replace(/\/+$/, '')
if (!base) {
return { models: [], source: '', error: 'Missing baseUrl' }
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (input.apiKey) {
headers['Authorization'] = `Bearer ${input.apiKey}`
headers['x-api-key'] = input.apiKey
headers['anthropic-version'] = '2023-06-01'
}
const candidates = [`${base}/v1/models`, `${base}/models`]
let lastError: string | undefined
for (const url of candidates) {
try {
const response = await fetch(url, {
method: 'GET',
headers,
signal: AbortSignal.timeout(15000),
})
if (!response.ok) {
lastError = `HTTP ${response.status} from ${url}`
continue
}
const body = (await response.json().catch(() => null)) as Record<string, unknown> | null
const list = extractModelList(body)
if (list.length > 0) {
return { models: list, source: url }
}
lastError = `Empty/unrecognised response from ${url}`
} catch (err) {
if (err instanceof DOMException && err.name === 'TimeoutError') {
lastError = `Timeout calling ${url}`
} else {
lastError = err instanceof Error ? err.message : String(err)
}
}
}
return { models: [], source: '', error: lastError ?? 'Unable to fetch models' }
}
async testProviderConfig(input: TestProviderInput): Promise<ProviderTestResult> { async testProviderConfig(input: TestProviderInput): Promise<ProviderTestResult> {
const format: ApiFormat = input.apiFormat ?? 'anthropic' const format: ApiFormat = input.apiFormat ?? 'anthropic'
const base = input.baseUrl.replace(/\/+$/, '') const base = input.baseUrl.replace(/\/+$/, '')
@@ -571,6 +634,39 @@ function buildDirectTestRequest(
} }
} }
/**
* Normalise responses from /v1/models (OpenAI-style { data: [{ id }] })
* and other shapes into a flat list of { id, owned_by? }.
*/
function extractModelList(body: unknown): Array<{ id: string; owned_by?: string }> {
if (!body || typeof body !== 'object') return []
const entries: unknown[] = Array.isArray((body as { data?: unknown }).data)
? ((body as { data: unknown[] }).data)
: Array.isArray((body as { models?: unknown }).models)
? ((body as { models: unknown[] }).models)
: Array.isArray(body)
? (body as unknown[])
: []
const result: Array<{ id: string; owned_by?: string }> = []
for (const entry of entries) {
if (typeof entry === 'string') {
result.push({ id: entry })
continue
}
if (entry && typeof entry === 'object') {
const rec = entry as Record<string, unknown>
const id = (rec.id ?? rec.model ?? rec.name) as string | undefined
if (typeof id === 'string' && id.length > 0) {
const owned = rec.owned_by as string | undefined
result.push(owned ? { id, owned_by: owned } : { id })
}
}
}
return result
}
function validateResponseBody( function validateResponseBody(
body: Record<string, unknown> | null, body: Record<string, unknown> | null,
format: ApiFormat, format: ApiFormat,