diff --git a/AGENTS.md b/AGENTS.md index ea5e7756..a11253fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Keep writing-code docs in these files only: | `docs/PLAN.md` | Current milestone checklist. | | `docs/PROVIDERS.md` | Future account-provider design. Not a current milestone. | -Do not add new `TODO.md`, `NOTES.md`, or extra plan files. The only extra design doc is `docs/PROVIDERS.md` (WorkBuddy / Qoder CN / future account types). User-facing install stays in `README.md` / `README_ZH.md`. Local protocol facts stay in ignored `docs/capture-notes.md`. Host ops stay gitignored in `docs/PRIVATE_DEPLOYMENT.md`. +Do not add new `TODO.md`, `NOTES.md`, or extra plan files. The only extra design doc is `docs/PROVIDERS.md` (WorkBuddy / Qoder CN / future account types). User-facing install stays in `README.md` (Chinese) / `README_EN.md` (English). Local protocol facts stay in ignored `docs/capture-notes.md`. Host ops stay gitignored in `docs/PRIVATE_DEPLOYMENT.md`. ## Do diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d88cf0..40529805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,24 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. ### English - Add Qoder CN accounts as `provider=qoder` + `region=cn`, using pinned `@qodercn-ai/qoderclicn@1.1.27` and `.qoder-cn` +- Wait for the Qoder worker AuthManager before browser or PAT login, so the first click does not fail while WASM is still starting +- Stop locally rejecting oversized Qoder prompts; let the upstream quota or context limit decide - Keep Qoder failover inside the same region so Global 429s do not land on CN - Send CodeBuddy CLI 2.139.0 channel headers on WorkBuddy chat, with CN and Global Origin/host kept separate +- Swap the repository front page to the Chinese README; the English README now lives at `README_EN.md` +- Replace the social card and console screenshot with a single overview card (`docs/assets/overview-card.png`) in both READMEs +- Redesign both READMEs around the console design language: add project-native SVG hero, console window, and architecture visuals, and reorder sections so quick start and client setup lead ### 中文 - 支持添加 Qoder 国内版账号(`provider=qoder`,`region=cn`),使用 pinned `@qodercn-ai/qoderclicn@1.1.27` 和 `.qoder-cn` +- Qoder 浏览器 / PAT 登录会先等 worker AuthManager 就绪,避免第一次点击时 WASM 还在启动就报错 +- 取消 Qoder 本地超大 prompt 预检,过大请求改由上游额度 / 上下文限制处理 - Qoder 故障切换限制在同一 region,国际版 429 不会打到国内版 - WorkBuddy 聊天补齐 CodeBuddy CLI 2.139.0 通道头,国内版 / 国际版 Origin 与 host 仍分开 +- 仓库首页改为中文 README,英文 README 移至 `README_EN.md` +- 两个 README 顶部的社交卡和控制台截图替换为一张概览卡(`docs/assets/overview-card.png`) +- 按控制台设计语言重做两个 README:新增项目原生 SVG hero、控制台窗口与架构图,并重排章节,让快速开始与接入说明前置 ## 0.2.10 - 2026-08-26 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06af71c7..8dbc10a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,8 +33,9 @@ cd frontend && npm run sync The favicon set and OG social card live in `frontend/public/`. After editing them: 1. `cd frontend && npm run sync` — copies the assets into `internal/webui/static/` (embedded into the Go binary by `//go:embed`). -2. Manually copy `frontend/public/og-card.svg` to `docs/assets/og-card.svg` so the `README.md` relative link still resolves. -3. Add a matching entry under `## Unreleased` in `CHANGELOG.md` in both `### English` and `### 中文`. +2. Add a matching entry under `## Unreleased` in `CHANGELOG.md` in both `### English` and `### 中文`. + +The READMEs instead show `docs/assets/overview-card.png`, which is hand-maintained; regenerate it when login methods, account types, endpoints, or deployment targets change. Adding a new file to the favicon suite? Update three places: diff --git a/Makefile b/Makefile index bfcedd1d..47f4cfc2 100644 --- a/Makefile +++ b/Makefile @@ -26,9 +26,8 @@ frontend-build: ## Build frontend sync: frontend-build ## Build frontend and sync embedded assets into Go cd frontend && npm run sync -favicon-sync: sync ## Sync favicon suite and OG card to docs/assets/ for README relative links - cp frontend/public/og-card.svg docs/assets/og-card.svg - @echo "synced: frontend/public/og-card.svg -> docs/assets/og-card.svg" +favicon-sync: sync ## Sync favicon suite into the embedded webui static assets + @echo "synced: frontend/public -> internal/webui/static" @ls -1 frontend/public/favicon*.svg frontend/public/apple-touch-icon.svg frontend/public/og-card.svg frontend/public/site.webmanifest 2>/dev/null # ── Docker ────────────────────────────────────────────────────────── diff --git a/PROVIDERS_TASKS.md b/PROVIDERS_TASKS.md deleted file mode 100644 index 2c4e755a..00000000 --- a/PROVIDERS_TASKS.md +++ /dev/null @@ -1,193 +0,0 @@ -# Provider 多上游任务跟踪 - -来源设计:`docs/PROVIDERS.md`(多上游账号类型:WorkBuddy 对照与扩展计划) -创建日期:2026-08-25 -总体状态:**J0-J4 代码完成;真实账号验收项待用户执行**。实现由用户于 2026-08-25 明确授权开工;P.1 的三项真实登录验收仍需人工完成。 - -## 状态约定 - -- `- [ ]` 未开始 -- `- [x]` 已完成 -- 进行中的任务在行尾标注「进行中」 -- `docs/PROVIDERS.md:行号` 指向设计细节;行号基于 2026-08-25 版本,文档更新后按章节名重新定位 - -## 进度总览 - -| 阶段 | 目标 | 状态 | 前置 | 设计细节 | -|------|------|------|------|----------| -| 前置门槛 | Qoder H7 收口 + Phase I 边界 | 代码就绪;真实登录验收待用户 | - | `docs/PROVIDERS.md:517` | -| J0 | 控制面认识 provider,Qoder 行为零变化 | 完成 | H7 验收 | `docs/PROVIDERS.md:519` | -| J1 | Runtime / Executor 接口,调度 route-aware | 完成 | J0 | `docs/PROVIDERS.md:531` | -| J2 | WorkBuddy 可用最小闭环 | 代码+契约测试完成;真实账号聊天待用户 | J1 + 真实账号 | `docs/PROVIDERS.md:551` | -| J3 | 池质量:quota / failover / 健康 | 完成 | J2 | `docs/PROVIDERS.md:572` | -| J4 | 同名模型跨 provider Route Pool | 完成 | J3 + Phase I | `docs/PROVIDERS.md:582` | -| J5 | 后续上游(Cursor 等) | 排期外 | J0/J1 稳定 | `docs/PROVIDERS.md:603` | - -## 前置门槛 - -- [ ] P.1 Qoder H7 验收:空安装 -> 登录 -> 聊天全链路通过(`docs/PROVIDERS.md:14`、`docs/PROVIDERS.md:517`)。**需用户人工执行**:空安装浏览器登录、`qoder-native-v1` 导入变 hot、双账号 A 限流 B 接管三项都无法在本环境代验(需要真实 Qoder 账号浏览器授权)。 -- [ ] P.2 Phase I 协议边界:canonical conversation 设计定稿(可与 J0/J1 并行;J4 的 `/v1/messages` 与工具 ID 映射依赖它,`docs/PROVIDERS.md:592-593`)。**明确移出本文件范围**:归 `docs/PLAN.md` Phase I(I1-I4)独立里程碑;本文件的 J4.8 已记录 OpenAI 短接落地、canonical 工具 ID 映射待 Phase I。此项不阻塞 J0-J4 验收。 - -关注点:H7 和 Phase I 未收口前不实现 WorkBuddy 流量,否则会冻住 Qoder 控制面(`docs/PROVIDERS.md:675`)。本次开工依据为用户 2026-08-25 指令,P.1 真实账号验收仍单独跟踪。 - -## J0 - 控制面先认识 provider - -目标:Qoder 行为零变化;数据库和 API 能记下账号类型。细节:`docs/PROVIDERS.md:519-530` - -- [x] J0.1 新增不可变 migration `003_account_providers.sql`:`accounts` 加 `provider` / `provider_region`(历史空值按 `qoder/global` 读),新增 `account_credential_payloads` 表;不改 `001` / `002` checksum(`docs/PROVIDERS.md:303-317`)。证据:`internal/accounts/migrations.go` -- [x] J0.2 `Store.Create` / `handleAccounts` POST / GET 列表读写 `provider` 与 `region`,不再丢字段;`/api/accounts` 永不返回 raw token(`docs/PROVIDERS.md:147-153`、`docs/PROVIDERS.md:325`)。证据:`internal/accounts/store.go`、`internal/api/accounts.go`、`internal/api/providers_test.go` -- [x] J0.3 静态 `ProviderRegistry` + `ProviderDescriptor`;非法 provider / region / credential format 直接 400(`docs/PROVIDERS.md:214-246`、`docs/PROVIDERS.md:525`)。证据:`internal/providers/registry.go`、`TestAccountsRejectUnknownProvider` -- [x] J0.4 前端 AccountType 下拉由 descriptor 生成,提交保留 `provider` + `region` 两个字段,与后端枚举对齐(`docs/PROVIDERS.md:141-142`、`docs/PROVIDERS.md:299`)。证据:`frontend/src/components/AddAccountModal.tsx`、`/api/providers` -- [x] J0.5 Manager 通过 descriptor 判断 runtime;只有 `qoder + child_process` spawn daemon(`docs/PROVIDERS.md:152-153`、`docs/PROVIDERS.md:282-288`)。证据:`internal/accounts/manager.go`、`TestManagerDoesNotSpawnDaemonForInProcessProvider` -- [x] J0.6 回归:现有账号重启后仍是 Qoder,worker 数不变(`docs/PROVIDERS.md:528`)。证据:`go test ./...` 全绿;migration 003 对历史行默认 `qoder/global`,`TestStorePersistsProviderAndRegion` 覆盖 legacy 默认值 - -完成标准:控制台创建 Qoder 账号与现在完全一致;API 写入未知 provider 被拒绝(`docs/PROVIDERS.md:530`)。两项均有测试覆盖。 - -关注点: - -- Qoder 凭证继续走 `account_credentials.user_blob/machine_id`,不强制迁移(`docs/PROVIDERS.md:319-323`)。已保持 -- 禁止把 WorkBuddy token 写进 `.qoder/.auth/user`、用 `auths/*.json` 目录替代 SQLite、用 `state.json` 做第二套冷却存储(`docs/PROVIDERS.md:346-350`)。WorkBuddy 凭证只进 `account_credential_payloads` - -## J1 - Runtime / Executor 接口 - -目标:调度器看到的是账号,执行器按 runtime kind 分发。细节:`docs/PROVIDERS.md:531-549` - -- [x] J1.1 落地小接口:`CredentialCodec`、`LoginSessionProvider`、`ChatExecutor`、`ModelCatalogProvider`、`ErrorClassifier`(`docs/PROVIDERS.md:248-259`)。证据:`internal/providers/interfaces.go` -- [x] J1.2 `Pool.Item` 增加 provider family / region / runtime kind / route capabilities;`Pool.Pick` 支持 route-aware 查询:public model、provider family、账号 pin、excluded、capability(`docs/PROVIDERS.md:395-400`、`docs/PROVIDERS.md:423-437`)。证据:`internal/accounts/pool.go`(`PickRoute`/`LenRoute`)。capability filter 由 provider 前缀 + 模型目录精确匹配承担 -- [x] J1.3 `ChatExecutor` 按 item 分支;Qoder 分支保持现测例,payload 形状一行不改(`docs/PROVIDERS.md:377-393`)。证据:`internal/executor/chat.go`;`buildWorkerPayload` 未改动,原 executor 测试全部通过 -- [x] J1.4 Models 拉取按账号分发,API 层合并为目录并集,每条带 `owned_by` / `provider`(`docs/PROVIDERS.md:404-410`)。证据:`internal/api/workerproxy.go` `fetchProviderModels` -- [x] J1.5 内存 `ModelRouteRegistry`:从各账号目录构建 `public model -> provider -> native model -> account`,不新增 SQLite 路由表(`docs/PROVIDERS.md:449-459`)。证据:`internal/providers/routes.go` -- [x] J1.6 登录 / 导入 / export 由 provider capability 接口路由,不再一律 worker proxy(`docs/PROVIDERS.md:154-158`、`docs/PROVIDERS.md:356-374`)。证据:`internal/api/accounts.go` provider-native action 分发 -- [x] J1.7 单测:假 in-process adapter 能被 pick 到,且不启动 Node(`docs/PROVIDERS.md:543`)。证据:`internal/executor/providers_test.go` - -完成标准(`docs/PROVIDERS.md:545-549`): - -1. [x] 不接真实 WorkBuddy 也能跑通「非 Qoder 账号不拉起 daemon」(`TestManagerDoesNotSpawnDaemonForInProcessProvider`) -2. [x] 请求不支持的模型时不会 pick 到不相关账号(`TestInProcessProviderUnsupportedModelDoesNotFailoverToQoder`) -3. [x] 尝试次数按当前 route pool 候选数计算,而不是整个 `Pool.Len()`(`LenRoute`,executor attempts 已接入 provider 过滤语义) - -关注点: - -- 不在 `manager.go` / `chat.go` 写 provider 特例;控制面只依赖 descriptor 和小接口(`docs/PROVIDERS.md:216-218`)。`chat.go` 仅通过 `providers.Registry` 分发,无 WorkBuddy 协议常量 -- `ProviderCapabilities` 是产品级能力,`RouteTarget.Capabilities` 是单模型实际能力,两者不能混(`docs/PROVIDERS.md:268`)。已分离 -- 未实现的能力接口返回显式 `unsupported`,不让调用方猜 nil(`docs/PROVIDERS.md:267`)。`Adapter.Supports` + nil 接口检查 - -## J2 - WorkBuddy adapter 最小闭环 - -目标:一个 CN 或 global 账号能从控制台登录并聊天。细节:`docs/PROVIDERS.md:551-570` - -- [x] J2.1 建 Go 包 `internal/providers/workbuddy`(auth、headers、payload、sse、client)(`docs/PROVIDERS.md:555`、`docs/PROVIDERS.md:636-642`) -- [x] J2.2 浏览器登录:`POST /v2/plugin/auth/state` -> 打开 `authUrl` -> poll token -> `GET /v2/plugin/login/account` 拿 uid / enterpriseId / nickname -> 写 SQLite(`docs/PROVIDERS.md:63-71`、`docs/PROVIDERS.md:363-371`)。证据:`client.go StartLogin/PollLogin` + `TestLoginStatePollAndStore` -- [x] J2.3 请求前 token refresh(带 `X-Refresh-Token`);`401 + 12153` session dead 时禁用账号并要求重登;chat 请求禁止带 refresh 头(`docs/PROVIDERS.md:85-87`、`docs/PROVIDERS.md:119-123`)。证据:`Refresh`/`credential`/`SetChatHeaders`;测试断言 chat 无 refresh 头 -- [x] J2.4 chat 执行:强制 `stream=true`、`tool_choice` 字符串化、关键头(`Authorization`、`X-User-Id`、`X-Enterprise-Id`、`X-Product: SaaS`、`X-Domain`、UA `CLI/2.63.2 CodeBuddy/2.63.2`)、SSE 透传 / 非流式聚合、tool_calls 按 index 合并、保留 `reasoning_content`(`docs/PROVIDERS.md:89-95`、`docs/PROVIDERS.md:638-640`)。证据:`payload.go`、`headers.go`、`sse.go`、契约测试 -- [x] J2.5 动态模型目录:`GET /console/enterprises/personal/models`,只暴露 `agents[].name == "cli"` 且未 disabled 的模型;失败明确报错,不内置静态白名单(`docs/PROVIDERS.md:97-103`)。证据:`Models` + `TestModelsFiltersCliAgentAndDisabled`;无 fallback 表 -- [x] J2.6 错误映射进现有 taxonomy:`hard_credit`/402 -> `quota`,`soft_rate`/429 -> `rate_limit`,`session_dead` -> `auth`,404/5xx -> `unavailable`;在 adapter 里映射,不把 WorkBuddy `ErrKind` 塞进 SQLite(`docs/PROVIDERS.md:115-126`)。证据:`Classify` + `TestErrorMapping`;SQLite 仍只存 `last_error_kind` -- [x] J2.7 控制台表单和登录提示由 WorkBuddy descriptor 生成;无 PAT tab,不写死 provider 分支(`docs/PROVIDERS.md:361-374`)。证据:`AddAccountModal` 按 `capabilities.pat_login` 隐藏 PAT -- [x] J2.8 导入 / 导出 `workbuddy-oauth-v1` JSON;导入校验必须有 `accessToken` 与 `uid`,缺 uid 不能当 ready(`docs/PROVIDERS.md:327-339`、`docs/PROVIDERS.md:371-373`)。证据:`ValidateCredential`/`DecodeCredential`/import handler;缺 uid 账号保持 disabled -- [x] J2.9 单测用 httptest 固定 state / token / chat / models,不打真上游(`docs/PROVIDERS.md:563`)。证据:`internal/providers/workbuddy/client_test.go` - -完成标准(`docs/PROVIDERS.md:565-570`): - -1. [ ] 空账号 -> 选 WorkBuddy -> 浏览器登录 -> 「只回复OK」成功。**需用户人工执行**(真实 WorkBuddy 账号浏览器授权) -2. [ ] 导入 JSON -> 无需浏览器即可 chat。**需用户人工执行**(需要真实 accessToken JSON) -3. [x] 同池一个 Qoder + 一个 WorkBuddy:pin Qoder 仍走 daemon,pin WorkBuddy 不碰 Node。单测覆盖(`TestInProcessProviderPinnedChatDoesNotTouchWorkers`) -4. [x] Qoder 原有 stream / tools / reasoning / usage 测试全绿(`go test ./...`) - -关注点: - -- WorkBuddy 不启动 Node,不重启容器;ready = token 有效且最近 refresh/probe 成功,没有 WASM `hot`(`docs/PROVIDERS.md:369-375`)。已按此实现 -- CN billing host 是 `www.codebuddy.cn`,与 chat host 不同(`docs/PROVIDERS.md:109`)。已按 `BillingBaseCN` 常量实现 -- 参考仓库无 LICENSE 文件:只参考协议行为,代码按本仓库结构重写(`docs/PROVIDERS.md:627-630`)。全部代码为独立实现 - -## J3 - 池质量 - -目标:WorkBuddy 账号进入现有调度质量体系。细节:`docs/PROVIDERS.md:572-580` - -- [x] J3.1 余额探测写入账号视图,只展示,不改变默认 round-robin(`docs/PROVIDERS.md:105-113`、`docs/PROVIDERS.md:574`)。billing endpoint 常量与 headers 已实现;余额展示面板按 R.6 第一期范围裁剪,不改变调度 -- [x] J3.2 `402` / 积分不足 -> `quota` 冷却(默认长冷却)(`docs/PROVIDERS.md:495-503`)。证据:`markInProcessError`(quota -> 1h 冷却、不轮转) -- [x] J3.3 failover 基线只限同 provider family 内(`docs/PROVIDERS.md:119-123`、`docs/PROVIDERS.md:576`)。证据:`PickRoute(ProviderFilter)` + `TestPickRouteRespectsProviderFamilyAndCooldown` -- [x] J3.4 健康:token 过期、refresh 失败、upstream 5xx 分类处理(`docs/PROVIDERS.md:577`)。请求前 refresh;session dead -> auth + `login_required`;5xx -> unavailable -- [x] J3.5 确认不默认自动签到;签到若做必须是账号级 opt-in,默认关,失败不阻塞 chat(`docs/PROVIDERS.md:505-513`)。未实现签到路径,默认即关闭 - -完成标准:WorkBuddy A 429 时请求落到 WorkBuddy B;`cross_provider_model_pool=false` 时不会误打到 Qoder(`docs/PROVIDERS.md:580`)。单测覆盖同 family failover;真实双 WorkBuddy 账号场景需用户验证。 - -关注点:积分只是可选 provider 信号,不能替换总调度器,不做「积分最高者优先」总策略(`docs/PROVIDERS.md:113`、`docs/PROVIDERS.md:401`)。默认调度保持 round-robin + pin。 - -## J4 - 同名模型跨 Provider Route Pool 与协议 - -目标:显式开启后,`glm-5.2` 这类 bare ID 可在同一 public model ID 内跨 provider failover。细节:`docs/PROVIDERS.md:582-601` - -- [x] J4.1 增加 `cross_provider_model_pool` 开关,默认关闭;关闭时 bare ID 留给 Qoder,WorkBuddy 用 `workbuddy/glm-5.2` 前缀或账号 pin(`docs/PROVIDERS.md:432-441`、`docs/PROVIDERS.md:584-585`)。证据:`CROSS_PROVIDER_MODEL_POOL` 环境开关,默认 false -- [x] J4.2 开启后 bare ID 进 route pool;provider 前缀 ID 强制单一 provider,`qoder/glm-5.2` 永不打到 WorkBuddy(`docs/PROVIDERS.md:443-445`)。证据:`resolveProviderFilter` + `TestResolveProviderFilterPinsFamilyAndKeepsBareID` -- [x] J4.3 调度:provider family 先 round-robin,family 内账号再 round-robin;游标按 `public model -> provider family` 与 `public model + provider family -> account` 两个维度维护(`docs/PROVIDERS.md:445-470`)。实现为共享 `Pool.next` + `ProviderFilter` 的单游标轮转;语义等价于 family 内 RR,双维度独立游标留待真实双 provider 负载下细化 -- [x] J4.4 capability filter 覆盖 context、tools、images、reasoning;参数错误、模型不存在、能力不足不 failover(`docs/PROVIDERS.md:469-472`)。模型不存在/能力不支持直接报错不换号(`TestInProcessProviderUnsupportedModelDoesNotFailoverToQoder`);逐能力参数过滤随真实目录数据启用 -- [x] J4.5 `model_settings` 演进为 provider-scoped,同名模型不共享冲突 context 值(`docs/PROVIDERS.md:475`)。设置 key 使用完整请求 ID(含前缀),`glm-5.2` 与 `qoder/glm-5.2` 独立 -- [x] J4.6 响应 `model` 重写回客户端请求的 public model ID;可带 `X-CLI2API-Provider` 观测头(`docs/PROVIDERS.md:477-479`)。证据:`handleChatCompletions` -- [x] J4.7 Access 页测试聊天能选 WorkBuddy 账号(`docs/PROVIDERS.md:591`)。Access 页沿用账号 pin header,机制可用;真实 WorkBuddy 账号下的人工确认待用户 -- [x] J4.8 Phase I canonical conversation 落地后,WorkBuddy adapter 消费 canonical;OpenAI 路径可短接,但工具 ID 映射必须走同一套 canonical 规则(`docs/PROVIDERS.md:592-593`)。OpenAI 短接已实现;canonical 工具 ID 映射绑定 Phase I(P.2),未定稿前不重复实现 -- [x] J4.9 流式 failover 边界:只有第一个输出字节前允许重试,已开始输出不能重放(`docs/PROVIDERS.md:471`、`docs/PROVIDERS.md:600`)。executor 只在拿到 2xx 响应后才向客户端写首字节;一旦 relay 开始不再重试 - -完成标准(`docs/PROVIDERS.md:596-601`): - -1. [ ] Qoder + WorkBuddy 都提供 `glm-5.2` 时,开启开关后两次请求分别落到两个 provider family。**需用户人工执行**(需两边真实账号) -2. [ ] Qoder A 429 可切 Qoder B;Qoder 全不可用可切同 route pool 的 WorkBuddy。同 family failover 有单测;跨 provider 场景需真实账号 -3. [x] `qoder/glm-5.2` 永远不会打到 WorkBuddy;账号 pin 不支持时直接报错,不静默换号(`resolveProviderFilter` + pinned in-process 测试) -4. [x] 流式首字节前可 failover,首字节后不重放(executor 语义) -5. [x] Qoder 原有模型列表、模型映射、stream / tools / reasoning / usage 测试全绿(`go test ./...`) - -关注点: - -- 匹配必须精确,不做模糊别名、字符串相似或手写 alias 表(`docs/PROVIDERS.md:445`)。模型 ID 只做 trim/大小写规范化,无 alias 表 -- 不默认把 provider 数量、剩余积分、账号数量当 provider 权重(`docs/PROVIDERS.md:486-489`)。未实现任何 provider 权重 -- usage / credits 只记录实际 provider,Qoder credits 与 WorkBuddy 积分不相加、不比较(`docs/PROVIDERS.md:480`)。usage 按 item/provider 独立返回 - -## J5 - 后续上游(排期外) - -新上游 = 新 provider descriptor + capability implementations + credential format,不改 SQLite 主表(`docs/PROVIDERS.md:603-614`)。**本节整体排期外**:不阻塞 J0-J4 验收,亦无需用户在本次验收中执行。 - -- [ ] J5.1 Anthropic `/v1/messages` 入口(协议入口,不是账号类型;按 Phase I 独立里程碑)(`docs/PROVIDERS.md:610`) -- [ ] J5.2 Cursor(等 Qoder + 协议边界更稳;只复用 J0/J1 注册表和 executor 接口,不复用 WorkBuddy client)(`docs/PROVIDERS.md:611-614`) -- [ ] J5.3 其他 CLI 登录态网关:先问 runtime kind,再问要不要 child process(`docs/PROVIDERS.md:612`) - -## 横切风险(全程盯住) - -来源:`docs/PROVIDERS.md:665-675` - -- [x] R.1 服务条款:只允许用户自己有权使用的账号;控制台文案克制,不承诺「无限额度」 -- [x] R.2 协议漂移:WorkBuddy host / path / header 全部做成 adapter 常量并配契约测试;不把 CLI UA、CodeBuddy header、协议常量放进 Qoder adapter。证据:常量全部在 `internal/providers/workbuddy`;Qoder 路径零协议改动 -- [x] R.3 模型撞名:默认模式 bare ID 留给 Qoder;Route Pool 开启后必须先过 capability filter -- [x] R.4 跨 provider failover:Qoder quota 不等于 WorkBuddy 积分;基线只同 provider family failover -- [x] R.5 token 安全:token 只进 SQLite payload(权限 0600);`/api/accounts` 不回凭据;export 需 API key。证据:SQLite 文件 0600(`OpenStore`);列表/详情响应无 token 字段;`/api/*` 全部挂 `withAPIKey` -- [x] R.6 范围膨胀:签到、积分看板、keepalive 第一期不做;第一期只做登录和聊天。签到/看板/keepalive 未实现 -- [x] R.7 参考仓库边界:workbuddy2api 只作协议事实来源,架构不参考;文件账号池、积分选号、独立 server、脚本登录、静态模型表不搬(`docs/PROVIDERS.md:646-657`)。存储/调度/服务形态全部沿用本仓库 - -## 实现前锁定的产品决策 - -来源:`docs/PROVIDERS.md:691-701` - -- [x] D.1 WorkBuddy 是账号类型,不是新监听端口或新容器 -- [x] D.2 默认调度仍是 round-robin + pin;积分只展示、只作 quota 信号 -- [x] D.3 自动签到默认关闭 -- [x] D.4 动态模型失败不回退静态表 -- [x] D.5 同名模型不静默跨上游;跨 provider Route Pool 必须显式开启,且只限同一个 public model ID -- [x] D.6 WorkBuddy 不启动 Node -- [x] D.7 当前里程碑仍是 Qoder;本文件不是开工许可。本次实现依据用户 2026-08-25 明确授权,此决策文档保持原样 - -## 待用户人工验证清单 - -以下项目需要真实账号/浏览器授权,本环境无法代验。完成后请自行勾选对应条目: - -1. P.1 / J2 完成标准 1:空安装 -> 创建 Qoder 账号 -> 浏览器登录 -> 发送「只回复OK」 -2. P.1:导入 `qoder-native-v1` -> daemon hot,无浏览器登录 -3. P.1:两个 Qoder 账号,A 触发限流后请求经 B 成功 -4. J2 完成标准 1:控制台选 WorkBuddy CN/Global -> 浏览器登录 -> 发送「只回复OK」 -5. J2 完成标准 2:导入 `workbuddy-oauth-v1` JSON(含 accessToken + uid)-> 直接 chat -6. J4 完成标准 1/2:Qoder + WorkBuddy 同名 `glm-5.2`,开启 `CROSS_PROVIDER_MODEL_POOL=1` 后验证双 family 轮转与跨 family failover - -## 验证记录(2026-08-25) - -- `go build ./...` 通过 -- `go test ./...` 全部通过(accounts / api / executor / providers.workbuddy / auth / config / update / updater) -- `cd frontend && pnpm build` 通过(tsc 无错误,产物正常生成) -- WorkBuddy 协议契约测试基于 httptest,未访问真实上游 - diff --git a/README.md b/README.md index 5c5a4aca..5a89135f 100644 --- a/README.md +++ b/README.md @@ -1,224 +1,231 @@ # CLI2API -[中文](README_ZH.md) · [Issues](https://github.com/caigee-cmd/cli2api/issues) · [Contributing](CONTRIBUTING.md) +[English](README_EN.md) · [问题反馈](https://github.com/caigee-cmd/cli2api/issues) · [贡献指南](CONTRIBUTING.md) [![CI](https://github.com/caigee-cmd/cli2api/actions/workflows/ci.yml/badge.svg)](https://github.com/caigee-cmd/cli2api/actions/workflows/ci.yml) [![Docker Image](https://ghcr-badge.egpl.dev/caigee-cmd/cli2api/latest_tag?label=docker&color=blue)](https://github.com/caigee-cmd/cli2api/pkgs/container/cli2api) [![License](https://img.shields.io/github/license/caigee-cmd/cli2api)](LICENSE) -Turn **your own Qoder CLI login** into a local OpenAI-compatible API. +

+ CLI2API — 把你的 Qoder CLI 登录态,变成一个本机运行的 OpenAI 兼容 API +

-CLI2API is an unofficial, self-hosted gateway for Qoder. Keep using OpenAI SDKs, Codex, CherryStudio, and other compatible clients—just point their Base URL at this local service. +把你自己的 **Qoder CLI 登录态**,变成一个本机运行的 OpenAI 兼容 API。 -![CLI2API social card](docs/assets/og-card.svg) - -![CLI2API console](docs/assets/console.png) +CLI2API 是一个非官方、自托管的 Qoder API 网关。启动以后,你可以继续使用熟悉的 OpenAI SDK、Codex、CherryStudio 等客户端,只需要把 Base URL 指向本机服务。 > [!IMPORTANT] -> CLI2API is not affiliated with or endorsed by Qoder. Use only accounts you are authorized to use, and follow the terms of Qoder and related services. - -## What you get - -- A local OpenAI-compatible `POST /v1/chat/completions` endpoint -- Streaming and non-streaming responses, tool calls, and `reasoning_content` -- A web console for Qoder accounts, models, and API testing -- Multi-account routing, account pinning, concurrency limits, cooldowns, and failover -- A Docker Compose deployment for personal development, homelabs, and private servers - -CLI2API does not spawn a full Qoder CLI Agent for every request. Authentication, WASM encoding, and the Qoder cloud HTTP/SSE connection stay warm in long-lived workers. - -## Quick start - -Requirements: Docker Desktop on macOS/Windows, or Docker Engine with Compose on Linux, plus a Qoder account you control. On Windows, Docker Desktop must be using Linux containers. - -macOS / Linux: - -```bash -git clone https://github.com/caigee-cmd/cli2api.git -cd cli2api -./scripts/start.sh -``` - -Windows PowerShell: - -```powershell -git clone https://github.com/caigee-cmd/cli2api.git -Set-Location cli2api -powershell -ExecutionPolicy Bypass -File .\scripts\start.ps1 -``` +> CLI2API 与 Qoder 官方无关联,也未获得官方背书。请只使用你有权使用的账号,并遵守 Qoder 及相关服务条款。 -The launcher creates `deploy/.env` when needed, starts the published image, and builds locally if the image is unavailable. +## 你会得到什么 -On first startup, the service generates a random API key, stores it in SQLite, and prints it once in the logs. Save it first: +- 一个兼容 OpenAI Chat Completions 的本地接口:`/v1/chat/completions` +- 支持流式输出、非流式输出、工具调用和 `reasoning_content` +- 一个可以管理 Qoder 账号、模型和接口测试的 Web 控制台 +- 多账号路由、账号固定、并发限制、冷却和故障切换 +- 一个适合个人开发、家庭实验室和私有部署的 Docker Compose 服务 -```bash -docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs qoder-api-proxy -``` +它不会为每个请求启动完整的 Qoder CLI Agent,而是让鉴权、WASM 编码和 Qoder 云端 HTTP/SSE 连接保持在常驻 worker 中。 -Open `http://127.0.0.1:3010`, sign in with that key, and add Qoder accounts from **Accounts**. +![CLI2API 支持的登录方式、账号类型、端点和部署形态](docs/assets/overview-card.png) -The default deployment publishes only `127.0.0.1:3010`; it does not expose the service publicly. +## 控制台 -## Managed updates +

+ CLI2API 控制台 Accounts 页:每个账号显示登录方式、就绪状态与额度,右侧 Access 面板提供 Base URL 与快速验证 +

-Start CLI2API once, then install the optional host updater. +账号、模型、接入和日志都在同一个 Web 控制台里管理:每个账号独立登录(浏览器 OAuth、PAT 或凭证导入),就绪状态和额度一目了然,Access 页可以直接复制 Base URL 并做一次快速验证。 -| Host | Application runtime | Host updater | -|------|---------------------|--------------| -| Linux `amd64` / `arm64` | matching Linux Docker image | systemd + Unix Socket | -| macOS Intel / Apple Silicon | Docker Desktop Linux container | per-user LaunchAgent | -| Windows `amd64` / `arm64` | Docker Desktop Linux container | current-user Scheduled Task | +## 工作方式 -The main API service remains a Linux container; native macOS and Windows application binaries are not published. Release images support `linux/amd64` and `linux/arm64`. Each release also includes six native updater assets and `cli2api-updater_checksums.txt`. +

+ CLI2API 架构:OpenAI 客户端经 Go 控制面路由到每账号独立 Node worker,再连接 Qoder 云端 +

-Installers prefer a checksum-verified prebuilt updater. Linux first reuses the architecture-matched updater inside the running container. If an older release has no updater asset, the installers try the latest protocol-compatible asset and finally fall back to a local Go `1.25.6+` build. +每个启用账号拥有独立的 Node 进程和运行目录,避免共享 Qoder WASM 上下文。Go 负责账号持久化、调度、并发限制、冷却、失败切换和子进程生命周期。 -macOS + Docker Desktop: +## 先跑起来 -```bash -./deploy/install-updater.sh -docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --force-recreate qoder-api-proxy -``` +依赖:macOS / Windows 使用 Docker Desktop,Linux 使用 Docker Engine + Compose;另外需要一个你自己控制的 Qoder 账号。Windows 的 Docker Desktop 必须切换到 Linux containers。 -Linux + systemd: +macOS / Linux: ```bash -sudo ./deploy/install-updater.sh -docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --force-recreate qoder-api-proxy +git clone https://github.com/caigee-cmd/cli2api.git +cd cli2api +./scripts/start.sh ``` -Windows + Docker Desktop, from PowerShell as the logged-in Docker user: +Windows PowerShell: ```powershell -powershell -ExecutionPolicy Bypass -File .\deploy\install-updater.ps1 -docker compose --env-file deploy\.env -f deploy\docker-compose.yml up -d --force-recreate qoder-api-proxy +git clone https://github.com/caigee-cmd/cli2api.git +Set-Location cli2api +powershell -ExecutionPolicy Bypass -File .\scripts\start.ps1 ``` -Linux uses a private Unix Socket. macOS uses a per-user LaunchAgent, and Windows uses a current-user Scheduled Task; both Docker Desktop platforms connect through a token-protected updater bound to `127.0.0.1`. The Windows installer also verifies that a container can reach the updater through `host.docker.internal` when `qoder-api-proxy` already exists. - -The **System** page permits only the immediate next stable release. It does not accept custom targets, skip releases, or install prereleases. Before replacement, the service pauses new requests, drains active requests, and creates an integrity-checked SQLite snapshot under `/data/backups`. +启动脚本会自动创建 `deploy/.env`,优先启动已发布镜像;镜像不可用时会自动从源码构建。 -Only `qoder-api-proxy` is recreated; the `qoder-data` volume is never deleted. A failed versioned health check restores both the previous image and the pre-update SQLite snapshot, and pins `CLI2API_IMAGE` back to the previous version for future restarts. The five most recent snapshots are retained. - -## Maintainer release - -After `main` passes CI, publish the next patch release with one command: +首次启动时,服务会生成一个随机 API Key,保存到 SQLite,并在日志中打印一次。请先保存它: ```bash -gh workflow run release.yml --ref main +docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs qoder-api-proxy ``` -Write bilingual user-facing notes in `CHANGELOG.md` under `## Unreleased` before publishing. Each change needs a matching bullet in `### English` and `### 中文`; the workflow copies those notes into the GitHub Release and the console update page. - -The workflow waits for the exact `main` commit to pass CI, calculates the next patch from the latest published stable release, creates an invisible draft release, builds six checksum-verified updater binaries, verifies the `linux/amd64` and `linux/arm64` image manifest, and only then publishes the GitHub Release and moves the stable image aliases. After publication it freezes the Unreleased notes under the new version heading. Do not create or push the version tag manually. +然后打开 `http://127.0.0.1:3010`,使用这个 Key 登录控制台,在 **Accounts** 页面添加 Qoder 账号。 -You can also use **Actions → Release → Run workflow**. If a pre-publication job fails, use **Re-run failed jobs** on the same run; the draft release remains invisible to application update checks. +默认只监听本机地址 `127.0.0.1:3010`,不会直接暴露到公网。 -## Connect an OpenAI client +## 接入 OpenAI 客户端 -Configure your client with: +客户端配置如下: ```text Base URL: http://127.0.0.1:3010/v1 -API Key: +API Key: <首次启动时生成的 Key> ``` -Or make a direct request: +也可以直接发送一个请求: ```bash -export CLI2API_API_KEY='paste-the-key-printed-on-first-start' +export CLI2API_API_KEY='粘贴首次启动时输出的密钥' curl http://127.0.0.1:3010/v1/chat/completions \ -H "Authorization: Bearer $CLI2API_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "qwen3.7-plus", - "messages": [{"role": "user", "content": "Reply with OK only"}], + "messages": [{"role": "user", "content": "只回复 OK"}], "stream": false }' ``` -PowerShell equivalent: +PowerShell 等价写法: ```powershell -$env:CLI2API_API_KEY = "paste-the-key-printed-on-first-start" +$env:CLI2API_API_KEY = "粘贴首次启动时输出的密钥" $Headers = @{ Authorization = "Bearer $env:CLI2API_API_KEY" } $Body = @{ model = "qwen3.7-plus" - messages = @(@{ role = "user"; content = "Reply with OK only" }) + messages = @(@{ role = "user"; content = "只回复 OK" }) stream = $false } | ConvertTo-Json -Depth 4 Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:3010/v1/chat/completions" -Headers $Headers -ContentType "application/json" -Body $Body ``` -Without an account header, the scheduler selects a ready account. Pin a request to a specific account with: +不指定账号时,调度器会从可用账号中选择一个。需要固定账号时,添加请求头: ```text X-Qoder-Account: acc_... ``` -## Use cases +## 适合什么场景 -- Connect Qoder to local or private-server tooling -- Reuse OpenAI-compatible clients and scripts -- Route requests across multiple Qoder accounts with failover -- Keep Qoder login state available without starting a full CLI Agent per request +- 想在本机或私有服务器上统一接入 Qoder +- 已经在使用 OpenAI API 格式的客户端或脚本 +- 需要在多个 Qoder 账号之间自动路由和故障切换 +- 想保留 Qoder 登录能力,同时避免每个请求启动完整 CLI Agent -Supported account types are Qoder Global, Qoder CN, and WorkBuddy. CLI2API is a local gateway; it does not provide accounts, quotas, or an official API service. +目前支持的账号类型是 Qoder 国际版、Qoder 国内版和 WorkBuddy。CLI2API 是本地代理,不提供账号、额度或官方 API 服务。 -## How it works +## 支持的功能 -```text -OpenAI client - -> Go API + SQLite account control plane - -> one isolated Node worker per Qoder account - -> Qoder cloud HTTP/SSE API +- Qoder 国际版 / 国内版的浏览器 Device Flow OAuth、PAT、`qoder-native-v1` 凭证导入/导出 +- OpenAI 兼容的 `GET /v1/models` +- 流式和非流式响应 +- Tool calls 与 `reasoning_content` +- 多 Qoder 账号、账号固定、调度、冷却和故障切换 +- React + Tailwind + HeroUI 控制台,支持明暗主题 +- SQLite 持久化账号凭证,账号运行目录临时化 +- GitHub Actions 自动测试、构建容器和发布 GHCR 镜像 + +## 配置 + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `QODER_DATA_DIR` | `/data` | SQLite 数据库和持久化账号凭证 | +| `QODER_RUNTIME_DIR` | `/run/cli2api` | 临时的账号 Qoder 运行目录 | +| `QODER_MAX_INFLIGHT` | `4` | 单账号最大并发请求数 | +| `QODER_WORKER_BASE_PORT` | `32100` | 内部 worker 端口起点 | +| `QODERCLI_JS` | 镜像默认值 | 固定的 Qoder 国际版 CLI bundle | +| `QODERCNCLI_JS` | 镜像默认值 | 固定的 Qoder 国内版 CLI bundle | +| `UPDATE_GITHUB_TOKEN` | 空 | 可选的 GitHub Release 查询 Token | +| `UPDATE_AGENT_URL` | 空 | Docker Desktop 本机 updater 地址,由安装器写入 | +| `UPDATE_AGENT_TOKEN` | 空 | Docker Desktop updater 令牌,由安装器写入 | +| `CLI2API_UPDATER_SOCKET_DIR` | 按平台设置 | 只读挂载 Linux updater Socket 的宿主机目录 | + +API Key 首次生成后只保存在 SQLite 中。服务不再支持通过环境变量设置或轮换 API Key。 + +## 接口 + +| 方法 | 路径 | 说明 | +|------|--------|------| +| `GET` | `/health` | 健康检查,不需要 API Key | +| `GET` | `/v1/models` | 模型列表 | +| `POST` | `/v1/chat/completions` | OpenAI 兼容对话接口 | +| `GET/POST` | `/api/*` | 控制台管理接口 | + +除 `/health` 外,控制台和 API 都需要 SQLite 中保存的 API Key。 + +## 页面安全更新 + +先启动一次 CLI2API,再安装可选的本机更新器。 + +| 宿主机 | 主程序运行方式 | 本机更新器 | +|--------|----------------|------------| +| Linux `amd64` / `arm64` | 对应架构的 Linux Docker 镜像 | systemd + Unix Socket | +| macOS Intel / Apple Silicon | Docker Desktop Linux 容器 | 当前用户 LaunchAgent | +| Windows `amd64` / `arm64` | Docker Desktop Linux 容器 | 当前用户计划任务 | + +主 API 服务始终运行在 Linux 容器中,不发布 macOS / Windows 原生主程序。发布镜像同时支持 `linux/amd64` 和 `linux/arm64`;每个 Release 还会附带 6 个平台/架构 updater 与 `cli2api-updater_checksums.txt`。 + +安装器优先下载并校验预编译 updater。Linux 会先复用当前容器中架构匹配的 updater;如果旧版本没有发布资产,则尝试最新且协议兼容的资产,最后才回退到本机 Go `1.25.6+` 编译。 + +macOS + Docker Desktop: + +```bash +./deploy/install-updater.sh +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --force-recreate qoder-api-proxy +``` + +Linux + systemd: + +```bash +sudo ./deploy/install-updater.sh +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --force-recreate qoder-api-proxy ``` -Each enabled account gets its own Node process and runtime directory so Qoder WASM state is not shared across accounts. Go owns persistence, scheduling, concurrency limits, cooldowns, failover, and child-process lifecycle. +Windows + Docker Desktop,请使用当前运行 Docker Desktop 的登录用户打开 PowerShell: -## Features +```powershell +powershell -ExecutionPolicy Bypass -File .\deploy\install-updater.ps1 +docker compose --env-file deploy\.env -f deploy\docker-compose.yml up -d --force-recreate qoder-api-proxy +``` + +Linux 使用私有 Unix Socket。macOS 使用当前用户的 LaunchAgent,Windows 使用当前用户的计划任务;两个 Docker Desktop 平台都通过强令牌访问仅监听 `127.0.0.1` 的 updater。若 `qoder-api-proxy` 已存在,Windows 安装器还会验证容器能否通过 `host.docker.internal` 访问 updater。 -- Browser Device Flow OAuth, PAT, and `qoder-native-v1` credential import/export for Qoder Global and Qoder CN -- OpenAI-compatible `GET /v1/models` -- Streaming and non-streaming responses -- Tool calls and `reasoning_content` -- Multiple Qoder accounts with pinning, routing, cooldown, and failover -- React + Tailwind + HeroUI console with light and dark themes -- Persistent SQLite credentials with ephemeral per-account runtime homes -- GitHub Actions for tests, container builds, and GHCR releases +安装后,控制台 **系统更新** 页面只允许更新到紧邻的下一个稳定版本,不接受自定义版本、跳版本或预发布版本。更新前会暂停新请求、等待在途请求结束,并在 `/data/backups` 创建经过完整性校验的 SQLite 快照。 -## Configuration +更新过程只重建 `qoder-api-proxy`,不会执行 `docker compose down -v`,也不会删除 `qoder-data`。如果新版健康检查失败,会同时恢复旧镜像和更新前的 SQLite 快照,并把 `CLI2API_IMAGE` 固定回旧版本,避免以后重启时再次进入失败版本。默认保留最近 5 份快照。 -| Variable | Default | Purpose | -|----------|---------|---------| -| `QODER_DATA_DIR` | `/data` | SQLite database and durable account credentials | -| `QODER_RUNTIME_DIR` | `/run/cli2api` | Ephemeral per-account Qoder runtime homes | -| `QODER_MAX_INFLIGHT` | `4` | Maximum concurrent requests per account | -| `QODER_WORKER_BASE_PORT` | `32100` | Internal worker port range | -| `QODERCLI_JS` | image default | Pinned Qoder Global CLI bundle | -| `QODERCNCLI_JS` | image default | Pinned Qoder CN CLI bundle | -| `UPDATE_GITHUB_TOKEN` | empty | Optional GitHub token for release checks | -| `UPDATE_AGENT_URL` | empty | Docker Desktop host updater URL, written by the installer | -| `UPDATE_AGENT_TOKEN` | empty | Docker Desktop updater token, written by the installer | -| `CLI2API_UPDATER_SOCKET_DIR` | platform-specific | Host directory mounted read-only for the Linux updater socket | +## 维护者一键发布 -The API key is generated once and stored in SQLite. There is no environment-variable bootstrap path; changing container environment variables does not replace the stored key. +确认 `main` 的 CI 已通过后,只需要执行: + +```bash +gh workflow run release.yml --ref main +``` -## Endpoints +发布前先在 `CHANGELOG.md` 的 `## Unreleased` 下写中英双语用户说明。每条改动都要在 `### English` 和 `### 中文` 里各有一条对应条目;工作流会把这些说明写入 GitHub Release,控制台更新页也会展示它们。 -| Method | Path | Purpose | -|--------|------|---------| -| `GET` | `/health` | Health probe; no API key required | -| `GET` | `/v1/models` | Model catalog | -| `POST` | `/v1/chat/completions` | OpenAI-compatible chat | -| `GET/POST` | `/api/*` | Console management API | +工作流会先等待当前 `main` 提交的 CI 通过,再根据最新已发布稳定版本自动增加 patch 版本;它会创建不可见的 Draft Release,构建并校验 6 个 updater,验证 `linux/amd64` 与 `linux/arm64` 镜像,全部完成后才正式发布 GitHub Release 并移动稳定镜像别名。发布成功后再把 Unreleased 冻结到对应版本标题下。不要再手动创建或推送版本 Tag。 -All console and API routes except `/health` require the API key stored in SQLite. +也可以进入 **Actions → Release → Run workflow** 点击发布。如果正式发布前失败,直接在同一次运行中选择 **Re-run failed jobs**;Draft Release 不会被应用的更新检查发现。 -## Development +## 从源码开发 -Requirements: Go `1.25.6+`, Node.js `20+`, npm, and Docker for container development. +环境要求:Go `1.25.6+`、Node.js `20+`、npm,以及容器开发所需的 Docker。 ```bash # Go API @@ -229,55 +236,55 @@ go vet ./... cd worker npm test -# Console +# 控制台 cd ../frontend npm ci npm run build npm run lint ``` -After frontend changes, run `npm run sync` to update the static assets embedded by Go: +修改前端后运行 `npm run sync`,将构建结果同步到 Go 的嵌入静态资源中: ```bash cd frontend npm run sync ``` -Build and start the container from source: +从源码构建并启动容器: ```bash cd deploy docker compose up -d --build ``` -See [CONTRIBUTING.md](CONTRIBUTING.md) for repository rules and validation details. +更多仓库规则和验证命令见 [CONTRIBUTING.md](CONTRIBUTING.md)。 -## Documentation +## 文档 -- [Architecture, login, routing, and console design](docs/DESIGN.md) -- [Current milestone and development plan](docs/PLAN.md) -- [Docker Compose deployment](deploy/README.md) -- [Changelog](CHANGELOG.md) -- [Security policy](SECURITY.md) +- [架构、登录、路由和控制台设计](docs/DESIGN.md) +- [当前里程碑与开发计划](docs/PLAN.md) +- [Docker Compose 部署说明](deploy/README.md) +- [变更记录](CHANGELOG.md) +- [安全问题报告](SECURITY.md) -## Security and privacy +## 安全与隐私 -- Never expose the service without the generated API key. -- Never commit `.qoder`, tokens, cookies, auth blobs, raw captures, or host details. -- Credential export is an explicit sensitive operation; protect exported files. -- Upstream API or Qoder CLI changes may break compatibility; qodercli is pinned and checked. -- Please report security issues privately according to [SECURITY.md](SECURITY.md), not in a public issue. +- 不要在没有 API Key 保护的情况下暴露服务。 +- 不要提交 `.qoder`、Token、Cookie、登录 Blob、原始抓包或主机信息。 +- 账号凭证导出是显式敏感操作,请妥善保管导出文件。 +- 上游 API 或 Qoder CLI 更新可能导致兼容性变化;项目会固定并检查 qodercli 版本。 +- 发现安全问题请不要直接公开 Issue,按 [SECURITY.md](SECURITY.md) 的方式私下报告。 -## Contributing +## 贡献 -Issues, documentation improvements, and pull requests are welcome. Before submitting a change: +欢迎提交 Issue、改进文档和 Pull Request。提交前请确认: -- Keep the scope clear and avoid new component libraries or unnecessary service dependencies. -- Run tests and build commands relevant to your changes. -- Remove tokens, login state, raw protocol captures, and real deployment details from the diff. +- 变更范围清晰,未引入新的组件库或不必要的服务依赖。 +- 已运行与改动相关的测试和构建命令。 +- Diff 中不包含 Token、登录态、原始协议抓包或真实部署信息。 -See [CONTRIBUTING.md](CONTRIBUTING.md). +详见 [CONTRIBUTING.md](CONTRIBUTING.md)。 -## License +## 许可证 [MIT](LICENSE) diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 00000000..f4db996d --- /dev/null +++ b/README_EN.md @@ -0,0 +1,290 @@ +# CLI2API + +[中文](README.md) · [Issues](https://github.com/caigee-cmd/cli2api/issues) · [Contributing](CONTRIBUTING.md) + +[![CI](https://github.com/caigee-cmd/cli2api/actions/workflows/ci.yml/badge.svg)](https://github.com/caigee-cmd/cli2api/actions/workflows/ci.yml) +[![Docker Image](https://ghcr-badge.egpl.dev/caigee-cmd/cli2api/latest_tag?label=docker&color=blue)](https://github.com/caigee-cmd/cli2api/pkgs/container/cli2api) +[![License](https://img.shields.io/github/license/caigee-cmd/cli2api)](LICENSE) + +

+ CLI2API — your Qoder CLI login, served as a local OpenAI-compatible API +

+ +Turn **your own Qoder CLI login** into a local OpenAI-compatible API. + +CLI2API is an unofficial, self-hosted gateway for Qoder. Keep using OpenAI SDKs, Codex, CherryStudio, and other compatible clients—just point their Base URL at this local service. + +> [!IMPORTANT] +> CLI2API is not affiliated with or endorsed by Qoder. Use only accounts you are authorized to use, and follow the terms of Qoder and related services. + +## What you get + +- A local OpenAI-compatible `POST /v1/chat/completions` endpoint +- Streaming and non-streaming responses, tool calls, and `reasoning_content` +- A web console for Qoder accounts, models, and API testing +- Multi-account routing, account pinning, concurrency limits, cooldowns, and failover +- A Docker Compose deployment for personal development, homelabs, and private servers + +CLI2API does not spawn a full Qoder CLI Agent for every request. Authentication, WASM encoding, and the Qoder cloud HTTP/SSE connection stay warm in long-lived workers. + +![Supported login methods, account types, endpoints, and deployment targets](docs/assets/overview-card.png) + +## Console + +

+ CLI2API console Accounts page: each account shows its login method, ready state, and quota, with an Access panel offering the Base URL and a quick check +

+ +Accounts, models, access, and logs all live in one web console. Each account signs in on its own (browser OAuth, PAT, or credential import), readiness and quota are visible at a glance, and the Access page lets you copy the Base URL and run a quick check. + +## How it works + +

+ CLI2API architecture: OpenAI clients are routed by the Go control plane to one isolated Node worker per account, then to the Qoder cloud +

+ +Each enabled account gets its own Node process and runtime directory so Qoder WASM state is not shared across accounts. Go owns persistence, scheduling, concurrency limits, cooldowns, failover, and child-process lifecycle. + +## Quick start + +Requirements: Docker Desktop on macOS/Windows, or Docker Engine with Compose on Linux, plus a Qoder account you control. On Windows, Docker Desktop must be using Linux containers. + +macOS / Linux: + +```bash +git clone https://github.com/caigee-cmd/cli2api.git +cd cli2api +./scripts/start.sh +``` + +Windows PowerShell: + +```powershell +git clone https://github.com/caigee-cmd/cli2api.git +Set-Location cli2api +powershell -ExecutionPolicy Bypass -File .\scripts\start.ps1 +``` + +The launcher creates `deploy/.env` when needed, starts the published image, and builds locally if the image is unavailable. + +On first startup, the service generates a random API key, stores it in SQLite, and prints it once in the logs. Save it first: + +```bash +docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs qoder-api-proxy +``` + +Open `http://127.0.0.1:3010`, sign in with that key, and add Qoder accounts from **Accounts**. + +The default deployment publishes only `127.0.0.1:3010`; it does not expose the service publicly. + +## Connect an OpenAI client + +Configure your client with: + +```text +Base URL: http://127.0.0.1:3010/v1 +API Key: +``` + +Or make a direct request: + +```bash +export CLI2API_API_KEY='paste-the-key-printed-on-first-start' + +curl http://127.0.0.1:3010/v1/chat/completions \ + -H "Authorization: Bearer $CLI2API_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "qwen3.7-plus", + "messages": [{"role": "user", "content": "Reply with OK only"}], + "stream": false + }' +``` + +PowerShell equivalent: + +```powershell +$env:CLI2API_API_KEY = "paste-the-key-printed-on-first-start" +$Headers = @{ Authorization = "Bearer $env:CLI2API_API_KEY" } +$Body = @{ + model = "qwen3.7-plus" + messages = @(@{ role = "user"; content = "Reply with OK only" }) + stream = $false +} | ConvertTo-Json -Depth 4 +Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:3010/v1/chat/completions" -Headers $Headers -ContentType "application/json" -Body $Body +``` + +Without an account header, the scheduler selects a ready account. Pin a request to a specific account with: + +```text +X-Qoder-Account: acc_... +``` + +## Use cases + +- Connect Qoder to local or private-server tooling +- Reuse OpenAI-compatible clients and scripts +- Route requests across multiple Qoder accounts with failover +- Keep Qoder login state available without starting a full CLI Agent per request + +Supported account types are Qoder Global, Qoder CN, and WorkBuddy. CLI2API is a local gateway; it does not provide accounts, quotas, or an official API service. + +## Features + +- Browser Device Flow OAuth, PAT, and `qoder-native-v1` credential import/export for Qoder Global and Qoder CN +- OpenAI-compatible `GET /v1/models` +- Streaming and non-streaming responses +- Tool calls and `reasoning_content` +- Multiple Qoder accounts with pinning, routing, cooldown, and failover +- React + Tailwind + HeroUI console with light and dark themes +- Persistent SQLite credentials with ephemeral per-account runtime homes +- GitHub Actions for tests, container builds, and GHCR releases + +## Configuration + +| Variable | Default | Purpose | +|----------|---------|---------| +| `QODER_DATA_DIR` | `/data` | SQLite database and durable account credentials | +| `QODER_RUNTIME_DIR` | `/run/cli2api` | Ephemeral per-account Qoder runtime homes | +| `QODER_MAX_INFLIGHT` | `4` | Maximum concurrent requests per account | +| `QODER_WORKER_BASE_PORT` | `32100` | Internal worker port range | +| `QODERCLI_JS` | image default | Pinned Qoder Global CLI bundle | +| `QODERCNCLI_JS` | image default | Pinned Qoder CN CLI bundle | +| `UPDATE_GITHUB_TOKEN` | empty | Optional GitHub token for release checks | +| `UPDATE_AGENT_URL` | empty | Docker Desktop host updater URL, written by the installer | +| `UPDATE_AGENT_TOKEN` | empty | Docker Desktop updater token, written by the installer | +| `CLI2API_UPDATER_SOCKET_DIR` | platform-specific | Host directory mounted read-only for the Linux updater socket | + +The API key is generated once and stored in SQLite. There is no environment-variable bootstrap path; changing container environment variables does not replace the stored key. + +## Endpoints + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/health` | Health probe; no API key required | +| `GET` | `/v1/models` | Model catalog | +| `POST` | `/v1/chat/completions` | OpenAI-compatible chat | +| `GET/POST` | `/api/*` | Console management API | + +All console and API routes except `/health` require the API key stored in SQLite. + +## Managed updates + +Start CLI2API once, then install the optional host updater. + +| Host | Application runtime | Host updater | +|------|---------------------|--------------| +| Linux `amd64` / `arm64` | matching Linux Docker image | systemd + Unix Socket | +| macOS Intel / Apple Silicon | Docker Desktop Linux container | per-user LaunchAgent | +| Windows `amd64` / `arm64` | Docker Desktop Linux container | current-user Scheduled Task | + +The main API service remains a Linux container; native macOS and Windows application binaries are not published. Release images support `linux/amd64` and `linux/arm64`. Each release also includes six native updater assets and `cli2api-updater_checksums.txt`. + +Installers prefer a checksum-verified prebuilt updater. Linux first reuses the architecture-matched updater inside the running container. If an older release has no updater asset, the installers try the latest protocol-compatible asset and finally fall back to a local Go `1.25.6+` build. + +macOS + Docker Desktop: + +```bash +./deploy/install-updater.sh +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --force-recreate qoder-api-proxy +``` + +Linux + systemd: + +```bash +sudo ./deploy/install-updater.sh +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --force-recreate qoder-api-proxy +``` + +Windows + Docker Desktop, from PowerShell as the logged-in Docker user: + +```powershell +powershell -ExecutionPolicy Bypass -File .\deploy\install-updater.ps1 +docker compose --env-file deploy\.env -f deploy\docker-compose.yml up -d --force-recreate qoder-api-proxy +``` + +Linux uses a private Unix Socket. macOS uses a per-user LaunchAgent, and Windows uses a current-user Scheduled Task; both Docker Desktop platforms connect through a token-protected updater bound to `127.0.0.1`. The Windows installer also verifies that a container can reach the updater through `host.docker.internal` when `qoder-api-proxy` already exists. + +The **System** page permits only the immediate next stable release. It does not accept custom targets, skip releases, or install prereleases. Before replacement, the service pauses new requests, drains active requests, and creates an integrity-checked SQLite snapshot under `/data/backups`. + +Only `qoder-api-proxy` is recreated; the `qoder-data` volume is never deleted. A failed versioned health check restores both the previous image and the pre-update SQLite snapshot, and pins `CLI2API_IMAGE` back to the previous version for future restarts. The five most recent snapshots are retained. + +## Maintainer release + +After `main` passes CI, publish the next patch release with one command: + +```bash +gh workflow run release.yml --ref main +``` + +Write bilingual user-facing notes in `CHANGELOG.md` under `## Unreleased` before publishing. Each change needs a matching bullet in `### English` and `### 中文`; the workflow copies those notes into the GitHub Release and the console update page. + +The workflow waits for the exact `main` commit to pass CI, calculates the next patch from the latest published stable release, creates an invisible draft release, builds six checksum-verified updater binaries, verifies the `linux/amd64` and `linux/arm64` image manifest, and only then publishes the GitHub Release and moves the stable image aliases. After publication it freezes the Unreleased notes under the new version heading. Do not create or push the version tag manually. + +You can also use **Actions → Release → Run workflow**. If a pre-publication job fails, use **Re-run failed jobs** on the same run; the draft release remains invisible to application update checks. + +## Development + +Requirements: Go `1.25.6+`, Node.js `20+`, npm, and Docker for container development. + +```bash +# Go API +go test ./... +go vet ./... + +# Qoder worker +cd worker +npm test + +# Console +cd ../frontend +npm ci +npm run build +npm run lint +``` + +After frontend changes, run `npm run sync` to update the static assets embedded by Go: + +```bash +cd frontend +npm run sync +``` + +Build and start the container from source: + +```bash +cd deploy +docker compose up -d --build +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for repository rules and validation details. + +## Documentation + +- [Architecture, login, routing, and console design](docs/DESIGN.md) +- [Current milestone and development plan](docs/PLAN.md) +- [Docker Compose deployment](deploy/README.md) +- [Changelog](CHANGELOG.md) +- [Security policy](SECURITY.md) + +## Security and privacy + +- Never expose the service without the generated API key. +- Never commit `.qoder`, tokens, cookies, auth blobs, raw captures, or host details. +- Credential export is an explicit sensitive operation; protect exported files. +- Upstream API or Qoder CLI changes may break compatibility; qodercli is pinned and checked. +- Please report security issues privately according to [SECURITY.md](SECURITY.md), not in a public issue. + +## Contributing + +Issues, documentation improvements, and pull requests are welcome. Before submitting a change: + +- Keep the scope clear and avoid new component libraries or unnecessary service dependencies. +- Run tests and build commands relevant to your changes. +- Remove tokens, login state, raw protocol captures, and real deployment details from the diff. + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +[MIT](LICENSE) diff --git a/README_ZH.md b/README_ZH.md deleted file mode 100644 index 09b4f883..00000000 --- a/README_ZH.md +++ /dev/null @@ -1,283 +0,0 @@ -# CLI2API - -[English](README.md) · [问题反馈](https://github.com/caigee-cmd/cli2api/issues) · [贡献指南](CONTRIBUTING.md) - -[![CI](https://github.com/caigee-cmd/cli2api/actions/workflows/ci.yml/badge.svg)](https://github.com/caigee-cmd/cli2api/actions/workflows/ci.yml) -[![Docker Image](https://ghcr-badge.egpl.dev/caigee-cmd/cli2api/latest_tag?label=docker&color=blue)](https://github.com/caigee-cmd/cli2api/pkgs/container/cli2api) -[![License](https://img.shields.io/github/license/caigee-cmd/cli2api)](LICENSE) - -把你自己的 **Qoder CLI 登录态**,变成一个本机运行的 OpenAI 兼容 API。 - -CLI2API 是一个非官方、自托管的 Qoder API 网关。启动以后,你可以继续使用熟悉的 OpenAI SDK、Codex、CherryStudio 等客户端,只需要把 Base URL 指向本机服务。 - -![CLI2API 社交卡](docs/assets/og-card.svg) - -![CLI2API 控制台](docs/assets/console.png) - -> [!IMPORTANT] -> CLI2API 与 Qoder 官方无关联,也未获得官方背书。请只使用你有权使用的账号,并遵守 Qoder 及相关服务条款。 - -## 你会得到什么 - -- 一个兼容 OpenAI Chat Completions 的本地接口:`/v1/chat/completions` -- 支持流式输出、非流式输出、工具调用和 `reasoning_content` -- 一个可以管理 Qoder 账号、模型和接口测试的 Web 控制台 -- 多账号路由、账号固定、并发限制、冷却和故障切换 -- 一个适合个人开发、家庭实验室和私有部署的 Docker Compose 服务 - -它不会为每个请求启动完整的 Qoder CLI Agent,而是让鉴权、WASM 编码和 Qoder 云端 HTTP/SSE 连接保持在常驻 worker 中。 - -## 先跑起来 - -依赖:macOS / Windows 使用 Docker Desktop,Linux 使用 Docker Engine + Compose;另外需要一个你自己控制的 Qoder 账号。Windows 的 Docker Desktop 必须切换到 Linux containers。 - -macOS / Linux: - -```bash -git clone https://github.com/caigee-cmd/cli2api.git -cd cli2api -./scripts/start.sh -``` - -Windows PowerShell: - -```powershell -git clone https://github.com/caigee-cmd/cli2api.git -Set-Location cli2api -powershell -ExecutionPolicy Bypass -File .\scripts\start.ps1 -``` - -启动脚本会自动创建 `deploy/.env`,优先启动已发布镜像;镜像不可用时会自动从源码构建。 - -首次启动时,服务会生成一个随机 API Key,保存到 SQLite,并在日志中打印一次。请先保存它: - -```bash -docker compose --env-file deploy/.env -f deploy/docker-compose.yml logs qoder-api-proxy -``` - -然后打开 `http://127.0.0.1:3010`,使用这个 Key 登录控制台,在 **Accounts** 页面添加 Qoder 账号。 - -默认只监听本机地址 `127.0.0.1:3010`,不会直接暴露到公网。 - -## 页面安全更新 - -先启动一次 CLI2API,再安装可选的本机更新器。 - -| 宿主机 | 主程序运行方式 | 本机更新器 | -|--------|----------------|------------| -| Linux `amd64` / `arm64` | 对应架构的 Linux Docker 镜像 | systemd + Unix Socket | -| macOS Intel / Apple Silicon | Docker Desktop Linux 容器 | 当前用户 LaunchAgent | -| Windows `amd64` / `arm64` | Docker Desktop Linux 容器 | 当前用户计划任务 | - -主 API 服务始终运行在 Linux 容器中,不发布 macOS / Windows 原生主程序。发布镜像同时支持 `linux/amd64` 和 `linux/arm64`;每个 Release 还会附带 6 个平台/架构 updater 与 `cli2api-updater_checksums.txt`。 - -安装器优先下载并校验预编译 updater。Linux 会先复用当前容器中架构匹配的 updater;如果旧版本没有发布资产,则尝试最新且协议兼容的资产,最后才回退到本机 Go `1.25.6+` 编译。 - -macOS + Docker Desktop: - -```bash -./deploy/install-updater.sh -docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --force-recreate qoder-api-proxy -``` - -Linux + systemd: - -```bash -sudo ./deploy/install-updater.sh -docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --force-recreate qoder-api-proxy -``` - -Windows + Docker Desktop,请使用当前运行 Docker Desktop 的登录用户打开 PowerShell: - -```powershell -powershell -ExecutionPolicy Bypass -File .\deploy\install-updater.ps1 -docker compose --env-file deploy\.env -f deploy\docker-compose.yml up -d --force-recreate qoder-api-proxy -``` - -Linux 使用私有 Unix Socket。macOS 使用当前用户的 LaunchAgent,Windows 使用当前用户的计划任务;两个 Docker Desktop 平台都通过强令牌访问仅监听 `127.0.0.1` 的 updater。若 `qoder-api-proxy` 已存在,Windows 安装器还会验证容器能否通过 `host.docker.internal` 访问 updater。 - -安装后,控制台 **系统更新** 页面只允许更新到紧邻的下一个稳定版本,不接受自定义版本、跳版本或预发布版本。更新前会暂停新请求、等待在途请求结束,并在 `/data/backups` 创建经过完整性校验的 SQLite 快照。 - -更新过程只重建 `qoder-api-proxy`,不会执行 `docker compose down -v`,也不会删除 `qoder-data`。如果新版健康检查失败,会同时恢复旧镜像和更新前的 SQLite 快照,并把 `CLI2API_IMAGE` 固定回旧版本,避免以后重启时再次进入失败版本。默认保留最近 5 份快照。 - -## 维护者一键发布 - -确认 `main` 的 CI 已通过后,只需要执行: - -```bash -gh workflow run release.yml --ref main -``` - -发布前先在 `CHANGELOG.md` 的 `## Unreleased` 下写中英双语用户说明。每条改动都要在 `### English` 和 `### 中文` 里各有一条对应条目;工作流会把这些说明写入 GitHub Release,控制台更新页也会展示它们。 - -工作流会先等待当前 `main` 提交的 CI 通过,再根据最新已发布稳定版本自动增加 patch 版本;它会创建不可见的 Draft Release,构建并校验 6 个 updater,验证 `linux/amd64` 与 `linux/arm64` 镜像,全部完成后才正式发布 GitHub Release 并移动稳定镜像别名。发布成功后再把 Unreleased 冻结到对应版本标题下。不要再手动创建或推送版本 Tag。 - -也可以进入 **Actions → Release → Run workflow** 点击发布。如果正式发布前失败,直接在同一次运行中选择 **Re-run failed jobs**;Draft Release 不会被应用的更新检查发现。 - -## 接入 OpenAI 客户端 - -客户端配置如下: - -```text -Base URL: http://127.0.0.1:3010/v1 -API Key: <首次启动时生成的 Key> -``` - -也可以直接发送一个请求: - -```bash -export CLI2API_API_KEY='粘贴首次启动时输出的密钥' - -curl http://127.0.0.1:3010/v1/chat/completions \ - -H "Authorization: Bearer $CLI2API_API_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "model": "qwen3.7-plus", - "messages": [{"role": "user", "content": "只回复 OK"}], - "stream": false - }' -``` - -PowerShell 等价写法: - -```powershell -$env:CLI2API_API_KEY = "粘贴首次启动时输出的密钥" -$Headers = @{ Authorization = "Bearer $env:CLI2API_API_KEY" } -$Body = @{ - model = "qwen3.7-plus" - messages = @(@{ role = "user"; content = "只回复 OK" }) - stream = $false -} | ConvertTo-Json -Depth 4 -Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:3010/v1/chat/completions" -Headers $Headers -ContentType "application/json" -Body $Body -``` - -不指定账号时,调度器会从可用账号中选择一个。需要固定账号时,添加请求头: - -```text -X-Qoder-Account: acc_... -``` - -## 适合什么场景 - -- 想在本机或私有服务器上统一接入 Qoder -- 已经在使用 OpenAI API 格式的客户端或脚本 -- 需要在多个 Qoder 账号之间自动路由和故障切换 -- 想保留 Qoder 登录能力,同时避免每个请求启动完整 CLI Agent - -目前支持的账号类型是 Qoder 国际版、Qoder 国内版和 WorkBuddy。CLI2API 是本地代理,不提供账号、额度或官方 API 服务。 - -## 工作方式 - -```text -OpenAI 客户端 - -> Go API + SQLite 账号控制面 - -> 每个 Qoder 账号一个隔离 Node worker - -> Qoder 云端 HTTP/SSE API -``` - -每个启用账号拥有独立的 Node 进程和运行目录,避免共享 Qoder WASM 上下文。Go 负责账号持久化、调度、并发限制、冷却、失败切换和子进程生命周期。 - -## 支持的功能 - -- Qoder 国际版 / 国内版的浏览器 Device Flow OAuth、PAT、`qoder-native-v1` 凭证导入/导出 -- OpenAI 兼容的 `GET /v1/models` -- 流式和非流式响应 -- Tool calls 与 `reasoning_content` -- 多 Qoder 账号、账号固定、调度、冷却和故障切换 -- React + Tailwind + HeroUI 控制台,支持明暗主题 -- SQLite 持久化账号凭证,账号运行目录临时化 -- GitHub Actions 自动测试、构建容器和发布 GHCR 镜像 - -## 配置 - -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `QODER_DATA_DIR` | `/data` | SQLite 数据库和持久化账号凭证 | -| `QODER_RUNTIME_DIR` | `/run/cli2api` | 临时的账号 Qoder 运行目录 | -| `QODER_MAX_INFLIGHT` | `4` | 单账号最大并发请求数 | -| `QODER_WORKER_BASE_PORT` | `32100` | 内部 worker 端口起点 | -| `QODERCLI_JS` | 镜像默认值 | 固定的 Qoder 国际版 CLI bundle | -| `QODERCNCLI_JS` | 镜像默认值 | 固定的 Qoder 国内版 CLI bundle | -| `UPDATE_GITHUB_TOKEN` | 空 | 可选的 GitHub Release 查询 Token | -| `UPDATE_AGENT_URL` | 空 | Docker Desktop 本机 updater 地址,由安装器写入 | -| `UPDATE_AGENT_TOKEN` | 空 | Docker Desktop updater 令牌,由安装器写入 | -| `CLI2API_UPDATER_SOCKET_DIR` | 按平台设置 | 只读挂载 Linux updater Socket 的宿主机目录 | - -API Key 首次生成后只保存在 SQLite 中。服务不再支持通过环境变量设置或轮换 API Key。 - -## 接口 - -| 方法 | 路径 | 说明 | -|------|--------|------| -| `GET` | `/health` | 健康检查,不需要 API Key | -| `GET` | `/v1/models` | 模型列表 | -| `POST` | `/v1/chat/completions` | OpenAI 兼容对话接口 | -| `GET/POST` | `/api/*` | 控制台管理接口 | - -除 `/health` 外,控制台和 API 都需要 SQLite 中保存的 API Key。 - -## 从源码开发 - -环境要求:Go `1.25.6+`、Node.js `20+`、npm,以及容器开发所需的 Docker。 - -```bash -# Go API -go test ./... -go vet ./... - -# Qoder worker -cd worker -npm test - -# 控制台 -cd ../frontend -npm ci -npm run build -npm run lint -``` - -修改前端后运行 `npm run sync`,将构建结果同步到 Go 的嵌入静态资源中: - -```bash -cd frontend -npm run sync -``` - -从源码构建并启动容器: - -```bash -cd deploy -docker compose up -d --build -``` - -更多仓库规则和验证命令见 [CONTRIBUTING.md](CONTRIBUTING.md)。 - -## 文档 - -- [架构、登录、路由和控制台设计](docs/DESIGN.md) -- [当前里程碑与开发计划](docs/PLAN.md) -- [Docker Compose 部署说明](deploy/README.md) -- [变更记录](CHANGELOG.md) -- [安全问题报告](SECURITY.md) - -## 安全与隐私 - -- 不要在没有 API Key 保护的情况下暴露服务。 -- 不要提交 `.qoder`、Token、Cookie、登录 Blob、原始抓包或主机信息。 -- 账号凭证导出是显式敏感操作,请妥善保管导出文件。 -- 上游 API 或 Qoder CLI 更新可能导致兼容性变化;项目会固定并检查 qodercli 版本。 -- 发现安全问题请不要直接公开 Issue,按 [SECURITY.md](SECURITY.md) 的方式私下报告。 - -## 贡献 - -欢迎提交 Issue、改进文档和 Pull Request。提交前请确认: - -- 变更范围清晰,未引入新的组件库或不必要的服务依赖。 -- 已运行与改动相关的测试和构建命令。 -- Diff 中不包含 Token、登录态、原始协议抓包或真实部署信息。 - -详见 [CONTRIBUTING.md](CONTRIBUTING.md)。 - -## 许可证 - -[MIT](LICENSE) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 64a32671..a91600da 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ -# CaiAI 前端设计规范 +# CLI2API 前端设计规范 -本项目是「公开营销页 + 已登录 SaaS 控制台」的双表面应用,基于 React、Vite、HeroUI v3、Tailwind v4 和 Phosphor 图标。前端改动应保持现有安静、功能导向、暖白象牙色的界面风格。控制台侧重克制与密度,营销页允许更大的排版与动效自由(见下文「营销表面」)。不要为单个功能引入新的视觉语言。 +本项目是纯已登录控制台应用(登录页 + 内部页面),基于 React、Vite、HeroUI v3、Tailwind v4 和 Phosphor 图标。前端改动应保持现有安静、功能导向、暖白象牙色的界面风格。不要为单个功能引入新的视觉语言。 ## 唯一真相来源 @@ -31,25 +31,6 @@ - 毛玻璃、光斑/球体、装饰性色块 - 为每个功能都加一个新强调色 -## 营销表面(公开页) - -上面「视觉方向」里的密度要求与「避免营销式布局」条款**仅适用于已登录控制台**(当前项目为 `/`、`/accounts`、`/providers`、`/access`)。公开营销页(`/`、`/pricing`、`/docs`、`/about`、条款页)是另一张脸,允许更大的表达自由度,但底色、token、克制感不变。 - -允许: - -- 大号 display 排版:`font-display` + `text-4xl → text-6xl`,仅用于营销页的 hero / 区块标题。 -- 单一、低透明度、同色系的柔光 hero glow(如 `bg-foreground/[0.04] blur-3xl`),一个视口一个,不叠第二个。 -- 一个交互式焦点物件(如首页的 `ApiKeyCard`),作为页面的触觉中心;必须遵守 `prefersReducedMotion()`,reduced-motion 下静止。 -- `bg-gradient-notice` 营销条,用于公告 / 促销 / 充值提示这类「需要跳一下」的条带。 -- GSAP 营销动效(进场、滚动揭示),快速且克制,遵守现有缓动 token。 - -仍然禁止: - -- 彩色堆叠面板、套娃卡片 -- 多个发光光斑、彩色光球、毛玻璃 -- 无意义的大面积渐变背景 -- 营销页里出现第二种强调色 - ## 颜色 Token 用语义 token,不要硬编码一次性颜色。 @@ -230,10 +211,10 @@ CLI2API 的浏览器图标、PWA 清单和社交卡走极简 line-icon 路线, | `frontend/public/favicon.svg` | source | 主图标,`stroke="currentColor"` 主题自适应 | | `frontend/public/favicon-dark.svg` | source | 显式 `#A78BFA` stroke,深色模式回退 | | `frontend/public/apple-touch-icon.svg` | source | iOS 启动图标,180×180 暖白底圆角 | -| `frontend/public/og-card.svg` | source | 1280×640 社交卡(README 分享预览) | +| `frontend/public/og-card.svg` | source | 1280×640 社交卡(运行时 og:image) | | `frontend/public/site.webmanifest` | source | PWA 清单 | | `internal/webui/static/*` | runtime | 同上副本,被 Go `//go:embed` 打包进二进制 | -| `docs/assets/og-card.svg` | docs | README 相对链接副本 | +| `docs/assets/overview-card.png` | docs | README 顶部概览卡(手工维护) | ### 设计约束 @@ -247,7 +228,7 @@ CLI2API 的浏览器图标、PWA 清单和社交卡走极简 line-icon 路线, 1. 改 `frontend/public/*` 源文件 2. `make sync` — 复制到 `internal/webui/static/` 并嵌入 Go 二进制 -3. `make favicon-sync` — 额外把 `og-card.svg` 同步到 `docs/assets/` +3. `make favicon-sync` — 等价于 `make sync`,只同步 favicon 套件到嵌入静态资源 4. `CHANGELOG.md` `## Unreleased` 加双语条目 5. 如果新增了静态资源文件,三处都要改: - `frontend/scripts/sync-static.mjs` 的 `for (const name of [...])` 白名单 @@ -258,7 +239,7 @@ CLI2API 的浏览器图标、PWA 清单和社交卡走极简 line-icon 路线, # Qoder API Proxy 产品与运行时约束 -以下内容保留当前项目的后端架构、协议边界、账号路由和部署约束;前端视觉与交互以本文前半部分的 CaiAI 设计规范为准。 +以下内容保留当前项目的后端架构、协议边界、账号路由和部署约束;前端视觉与交互以本文前半部分的设计规范为准。 Canonical design and product notes for agents. Plans live in `docs/PLAN.md`. Hard rules live in `AGENTS.md`. @@ -369,6 +350,12 @@ Supported account onboarding: - `qoder-native-v1` JSON import containing the encrypted `.auth/user` blob and its matching `machine_id` (Global lives under `.qoder`, CN under `.qoder-cn`) +Qoder login waits for the per-account worker to report `hasAuthManager` on `/health` +before proxying `/admin/login/device` or `/admin/login/pat`. `cmd.Start()` still +returns immediately after spawn; WASM init can take several seconds on a cold CN +CLI, so the first click must not race `ECONNREFUSED` or an uncaptured AuthManager. +A missing AuthManager during login is `not_ready` (503), not `auth`. + Arbitrary `access_token` / `refresh_token` JSON is not supported. Qoder credentials also depend on private user material, organization data, encryption keys, and device identity. The API never returns raw credentials except through the explicit export @@ -380,10 +367,10 @@ Error taxonomy (do not treat every 429 as empty balance): | Kind | Signal | HTTP | Fail over | Cooldown | |------|--------|------|-----------|----------| -| quota | `insufficient_quota`, `#token-limit`, oversized prompt | 429 | no | no | +| quota | `insufficient_quota`, `#token-limit` | 429 | no | no | | rate_limit | generic 429 / too many requests | 429 | yes | ~60s, honor Retry-After, cap 10m | | auth | 401/403, FORBIDDEN | 401/403 | yes | ~30s + rewarm | -| not_ready | hot context missing | 503 | yes | ~10s | +| not_ready | hot context missing, AuthManager not captured, worker not warm | 503 | yes | ~10s | | unavailable | transport / 5xx | 502/503 | yes | ~15s | `QODER_MAX_INFLIGHT` default 4. WASM encode + rewarm share one lock; do not hold it across upstream fetch. @@ -400,10 +387,11 @@ not add its own cache. Go `refreshOne` fetches quota after health for hot/ready accounts and stores a `QuotaSnapshot` on the pool item. Quota is display-only: fetch failures are swallowed and -never flip account readiness, cooldown, or scheduling. WorkBuddy and other in-process -providers report no quota; the card simply omits the block. The account card renders one -progress bar with `remaining/total `, `--danger` at 100% / exceeded, `--warning` at -≥80%, plus an optional add-on line. +never flip account readiness, cooldown, or scheduling. Qoder reads quota from the daemon; +WorkBuddy reads remaining credits from its billing meter API (`unit: credits`). An +in-process provider without a quota surface simply omits the block. The account card +renders one progress bar with `remaining/total `, `--danger` at 100% / exceeded, +`--warning` at ≥80%, plus an optional add-on line. Distinguish this account-level quota from the request-level `insufficient_quota` error kind: that error means a per-request token/model limit, not a zero account balance. @@ -477,7 +465,7 @@ Ideas intentionally not copied: ## Design system -The console follows the CaiAI frontend design baseline, adapted to this Vite app. +The console follows the frontend design baseline in the first half of this doc, adapted to this Vite app. ### Surface and tone @@ -542,6 +530,7 @@ After UI edits: | `frontend/src/components/layout/` | Shell / menu | | `internal/accounts/` | SQLite account repository, migrations, snapshots, scheduler, child lifecycle, request logs | | `internal/logs/` | Runtime ring buffer and async request recorder | +| `internal/providers/` | Provider registry, route pools, in-process adapters (`workbuddy/`) | | `internal/update/` | Release selection and updater client | | `internal/updater/` | Host-side Docker replacement and rollback | | `worker/src/daemon.mjs` | One-account Qoder runtime only | diff --git a/docs/PLAN.md b/docs/PLAN.md index 8f3ad9fd..41ec6ac2 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,6 +1,6 @@ # CLI2API Plan -last-updated: 2026-08-26 +last-updated: 2026-08-27 Qoder-first OpenAI-compatible proxy. Cursor and other CLIs wait until the current Qoder milestone is done. @@ -41,7 +41,7 @@ Do not: | F | Error taxonomy, account failover, console sign-in, Accounts login | | G | Replaced by Phase H SQLite account control plane | | H–K | SQLite control plane, protocol notes, logs, quota display | -| L | Qoder CN (`qoder` + `region=cn`) — planned, see below | +| L | Qoder CN (`qoder` + `region=cn`) — code complete, L6 acceptance pending | Typical small-chat latency is ~1-2s after warmup, versus ~10s+ for spawn-CLI wrappers. @@ -226,12 +226,25 @@ Do not invent a `qodercn` provider family. Do not upgrade the 1.1.27 pin. ### L6 acceptance +- [x] Browser/PAT login waits for worker `hasAuthManager` before proxying device-flow (first-click CN race) - [ ] Empty CN account → browser or PAT login → chat `只回复OK` - [ ] Import CN `qoder-native-v1` → daemon becomes hot - [ ] Mixed pool: pin stays on the matching CLI; Global 429 does not hit CN - [ ] Existing Qoder stream / tools / reasoning / usage / quota tests stay green - [ ] WorkBuddy path unchanged +## WorkBuddy acceptance (J0–J4 implemented) + +Code and httptest contract tests are done; design lives in `docs/PROVIDERS.md`. +Remaining work is real-account acceptance, which cannot be done without live +WorkBuddy logins: + +- [ ] Empty account → select WorkBuddy CN/Global → browser login → chat `只回复OK` +- [ ] Import `workbuddy-oauth-v1` JSON (accessToken + uid) → chat without browser +- [ ] Two WorkBuddy accounts: A rate-limited → request succeeds through B +- [ ] With `CROSS_PROVIDER_MODEL_POOL=1`, bare `glm-5.2` lands on both Qoder and + WorkBuddy families; Qoder fully down → failover to WorkBuddy in the same route pool + ## Later - Cursor provider diff --git a/docs/assets/README.md b/docs/assets/README.md index b377a94c..b9394fde 100644 --- a/docs/assets/README.md +++ b/docs/assets/README.md @@ -1,16 +1,18 @@ # docs/assets/ -Static assets embedded by `README.md` and `README_ZH.md` via relative links. +Static assets embedded by `README.md` and `README_EN.md` via relative links. ## Files | File | Source | Update rule | |------|--------|-------------| -| `console.png` | Manual screenshot capture of the web console | Re-capture on every UI redesign | -| `og-card.svg` | Hand-synced from `frontend/public/og-card.svg` | Re-copy when the favicon suite changes | +| `overview-card.png` | Hand-maintained overview card in both READMEs | Regenerate when supported login methods, account types, endpoints, or deployment targets change | +| `readme/hero-{zh,en}.svg` | Hand-maintained SVG heroes (console dark-theme palette, favicon bolt motif) | Re-sync copy with the tagline and flow text in the matching README | +| `readme/console-window-{zh,en}.svg` | Hand-maintained console mockup (Accounts + Access) | Regenerate when console pages, account card fields, or nav items change | +| `readme/architecture-{zh,en}.svg` | Hand-maintained architecture diagram | Regenerate when the request path or worker model changes | ## Why this folder is hand-maintained -- `README.md` lives at the repo root and uses **relative** paths so it renders correctly on github.com, npmjs.com, and `go install` package pages. -- `frontend/public/og-card.svg` is the **runtime** asset served by the Go binary; the file in this folder is the **documentation** copy. -- Keep them byte-identical. The `favicon-suite` task in this repo's hand-off log includes copying `frontend/public/og-card.svg` here. +- Both READMEs live at the repo root and use **relative** paths so they render correctly on github.com, npmjs.com, and `go install` package pages. +- `frontend/public/og-card.svg` is the **runtime** social-preview asset served by the Go binary; it is not linked from the READMEs. +- SVG assets stay GitHub-safe: no scripts, remote fonts, or filters; each carries `` and `<desc>` for accessibility. diff --git a/docs/assets/console.png b/docs/assets/console.png deleted file mode 100644 index 6848d037..00000000 Binary files a/docs/assets/console.png and /dev/null differ diff --git a/docs/assets/og-card.svg b/docs/assets/og-card.svg deleted file mode 100644 index 076421c2..00000000 --- a/docs/assets/og-card.svg +++ /dev/null @@ -1,52 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640" width="1280" height="640" font-family="-apple-system, 'Inter', 'Outfit', system-ui, sans-serif"> - <defs> - <style> - .bolt { stroke: #7C3AED; stroke-width: 14; stroke-linejoin: round; stroke-linecap: round; fill: none; } - .word { font-weight: 700; letter-spacing: -0.02em; fill: #1F2937; } - .sub { font-weight: 500; fill: #6B7280; } - .mono { font-family: 'IBM Plex Mono', 'JetBrains Mono', ui-monospace, monospace; font-weight: 500; fill: #1F2937; } - .chip { fill: #EDE9FE; } - .chip-text { fill: #7C3AED; font-weight: 500; } - .frame { fill: none; stroke: #E5E0D5; stroke-width: 1; } - .terminal-bg { fill: #FFFFFF; stroke: #E5E0D5; stroke-width: 1; } - .prompt { fill: #7C3AED; } - </style> - </defs> - - <!-- Background --> - <rect width="1280" height="640" fill="#FAF7F2"/> - - <!-- Outer hairline frame, 32px inset --> - <rect x="32" y="32" width="1216" height="576" rx="16" class="frame"/> - - <!-- Service-running dot, top-right --> - <circle cx="1208" cy="64" r="4" fill="#22D3EE"/> - - <!-- Left: bolt mark, vertically centered, ~280px tall --> - <g transform="translate(140, 180)"> - <path class="bolt" d="M195 0 L60 175 L165 175 L135 140 L202.5 140 L165 280 L300 105 L195 105 L225 140 L157.5 140 Z"/> - <circle cx="135" cy="230" r="14" fill="#22D3EE"/> - </g> - - <!-- Center: wordmark + subtitle --> - <text x="500" y="298" class="word" font-size="112">CLI2API</text> - <text x="500" y="356" class="sub" font-size="28">Qoder CLI → OpenAI-compatible API</text> - - <!-- Right: minimal terminal mockup, vertically centered --> - <g transform="translate(820, 230)"> - <rect width="380" height="180" rx="12" class="terminal-bg"/> - <text x="24" y="56" class="mono" font-size="20"> - <tspan class="prompt">$ </tspan> - <tspan>./cli2api serve</tspan> - </text> - <text x="24" y="96" class="mono" font-size="18" fill="#6B7280">▸ account: acc_8f2k (ready)</text> - <text x="24" y="124" class="mono" font-size="18" fill="#6B7280">▸ endpoint: :3010/v1</text> - <text x="24" y="156" class="mono" font-size="18" fill="#6B7280">▸ mode: streaming · cooldown off</text> - </g> - - <!-- Bottom-left: monospaced prompt chip --> - <g transform="translate(64, 552)"> - <rect width="232" height="36" rx="8" class="chip"/> - <text x="16" y="24" font-family="IBM Plex Mono, JetBrains Mono, ui-monospace, monospace" font-size="16" class="chip-text">❯ ./cli2api serve</text> - </g> -</svg> diff --git a/docs/assets/overview-card.png b/docs/assets/overview-card.png new file mode 100644 index 00000000..bfee9a6c Binary files /dev/null and b/docs/assets/overview-card.png differ diff --git a/docs/assets/readme/architecture-en.svg b/docs/assets/readme/architecture-en.svg new file mode 100644 index 00000000..0d0538e9 --- /dev/null +++ b/docs/assets/readme/architecture-en.svg @@ -0,0 +1,69 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500" role="img" aria-labelledby="arch-title-en arch-desc-en" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', system-ui, sans-serif"> + <title id="arch-title-en">CLI2API architecture — OpenAI clients are routed by a Go control plane to one Node worker per account, then to the Qoder cloud + Dark architecture diagram: from top to bottom, OpenAI clients, the CLI2API Go API with SQLite control plane, one isolated Node worker per account, and the Qoder cloud; annotations note warm login sessions, isolated HOMEs, and region-scoped failover. + + + + HOW IT WORKS + request path + + + + OpenAI client + SDK · Codex · CherryStudio + + + + + + + + + CLI2API · Go API + SQLite + scheduling · concurrency · cooldown · failover + + + + + + + + + + + + + + Node worker · acct A + own HOME · WASM + + + Node worker · acct B + own HOME · WASM + + + + + + + + + + + + Qoder cloud + HTTP / SSE · Global and CN routed separately + + + + Warm login sessions + no full CLI spawn per request + + + One process and HOME per account + WASM contexts never shared + + + Region-scoped failover + Global 429s never land on CN + diff --git a/docs/assets/readme/architecture-zh.svg b/docs/assets/readme/architecture-zh.svg new file mode 100644 index 00000000..ec9bcc7e --- /dev/null +++ b/docs/assets/readme/architecture-zh.svg @@ -0,0 +1,69 @@ + + CLI2API 架构 — OpenAI 客户端经 Go 控制面路由到每账号独立 Node worker,再连接 Qoder 云端 + 深色架构图:自上而下为 OpenAI 客户端、CLI2API 的 Go API 与 SQLite 控制面、每账号一个独立 Node worker,最后连接 Qoder 云端;右侧标注常驻登录态、独立 HOME 与同区域故障切换。 + + + + HOW IT WORKS + 请求路径 + + + + OpenAI 客户端 + SDK · Codex · CherryStudio + + + + + + + + + CLI2API · Go API + SQLite + 调度 · 并发限制 · 冷却 · 故障切换 + + + + + + + + + + + + + + Node worker · 账号 A + 独立 HOME · WASM + + + Node worker · 账号 B + 独立 HOME · WASM + + + + + + + + + + + + Qoder 云端 + HTTP / SSE · 国际版与国内版独立路由 + + + + 登录态常驻 + 不为每个请求启动完整 CLI + + + 每账号一个进程与 HOME + WASM 上下文互不共享 + + + 同区域故障切换 + 国际版 429 不会打到国内版 + diff --git a/docs/assets/readme/console-window-en.svg b/docs/assets/readme/console-window-en.svg new file mode 100644 index 00000000..3d46acef --- /dev/null +++ b/docs/assets/readme/console-window-en.svg @@ -0,0 +1,120 @@ + + CLI2API console — Accounts page + Warm ivory console: sidebar with Overview, Models, Access, Accounts, Logs, and System; the Accounts page shows three ready accounts with quota bars, and the Access panel shows Base URL and API key. + + + + + + + + + 127.0.0.1:3010 + + + + + + + + + + CLI2API + + Overview + Models + Access + + Accounts + Logs + System + + + Accounts + One login per account · browser sign-in first, PAT only when needed + + + + + 3 / 3 ready + + + Add account + + + + + + + + Qoder Global + alice@dev · browser login + + + Ready + + + Quota 80% + 400 / 500 remaining + + + + + + + + Qoder CN + bob@dev · PAT + + + Ready + + + Quota 66% + 330 / 500 remaining + + + + + W + WorkBuddy + team@work · browser login + + + Ready + Remaining credits + 1,200 credits + + + + + Qoder Global + carol@dev · credential import + + + Ready + + + + + Connect an OpenAI client + + Base URL + + http://127.0.0.1:3010/v1 + + API Key + + sk-c2a-9f3e········ + + Quick check + + $ curl /v1/chat/completions + -H "Authorization: Bearer" + ✓ 200 · stream ok + + + Copy and test + + + All routes except /health need the key + diff --git a/docs/assets/readme/console-window-zh.svg b/docs/assets/readme/console-window-zh.svg new file mode 100644 index 00000000..9728ebd0 --- /dev/null +++ b/docs/assets/readme/console-window-zh.svg @@ -0,0 +1,120 @@ + + CLI2API 控制台 — Accounts 页面 + 暖白象牙色控制台:左侧导航为 Overview、Models、Access、Accounts、Logs、System;Accounts 页显示 3 个就绪账号与额度条,右侧 Access 面板展示 Base URL 与 API Key。 + + + + + + + + + 127.0.0.1:3010 + + + + + + + + + + CLI2API + + Overview + Models + Access + + Accounts + Logs + System + + + Accounts + 每个账号一个登录态 · 浏览器登录优先,仅在必要时使用 PAT + + + + + 3 / 3 就绪 + + + 添加账号 + + + + + + + + Qoder Global + alice@dev · 浏览器登录 + + + 就绪 + + + 额度 80% + 剩余 400 / 500 + + + + + + + + Qoder CN + bob@dev · PAT + + + 就绪 + + + 额度 66% + 剩余 330 / 500 + + + + + W + WorkBuddy + team@work · 浏览器登录 + + + 就绪 + 剩余积分 1,200 + 1,200 credits + + + + + Qoder Global + carol@dev · 凭证导入 + + + 就绪 + + + + + 接入 OpenAI 客户端 + + Base URL + + http://127.0.0.1:3010/v1 + + API Key + + sk-c2a-9f3e········ + + 快速验证 + + $ curl /v1/chat/completions + -H "Authorization: Bearer" + ✓ 200 · stream ok + + + 复制并测试 + + + 除 /health 外均需 API Key + diff --git a/docs/assets/readme/hero-en.svg b/docs/assets/readme/hero-en.svg new file mode 100644 index 00000000..905903d2 --- /dev/null +++ b/docs/assets/readme/hero-en.svg @@ -0,0 +1,72 @@ + + CLI2API — your Qoder CLI login, served as a local OpenAI-compatible API + Dark card: project name and one-line value on the left, and a terminal panel on the right showing an OpenAI client connecting through the CLI2API gateway to the Qoder cloud. + + + + + + QODER CLI → OPENAI API + + + + + + CLI2API + + Your own Qoder CLI login, served + as a local OpenAI-compatible API + + + + Docker Compose + + OpenAI-compatible + + MIT License + + + + + + + + + + + localhost:3010 + + + + + OpenAI + client + + + CLI2API + local gateway + + + Qoder + cloud + + + + + + + + + + + + any client + Go + Node + HTTP / SSE + + + + POST /v1/chat/completions + stream · tool_calls · reasoning_content + + diff --git a/docs/assets/readme/hero-zh.svg b/docs/assets/readme/hero-zh.svg new file mode 100644 index 00000000..de7eb9b3 --- /dev/null +++ b/docs/assets/readme/hero-zh.svg @@ -0,0 +1,72 @@ + + CLI2API — 把你的 Qoder CLI 登录态,变成一个本机运行的 OpenAI 兼容 API + 深色卡片:左侧是项目名称与一句话价值,右侧终端面板展示 OpenAI 客户端经 CLI2API 网关连接到 Qoder 云端的请求流。 + + + + + + QODER CLI → OPENAI API + + + + + + CLI2API + + 把你自己的 Qoder CLI 登录态, + 变成一个本机运行的 OpenAI 兼容 API + + + + Docker Compose + + OpenAI 兼容 + + MIT License + + + + + + + + + + + localhost:3010 + + + + + OpenAI + 客户端 + + + CLI2API + 本机网关 + + + Qoder + 云端 + + + + + + + + + + + + 任意客户端 + Go + Node + HTTP / SSE + + + + POST /v1/chat/completions + stream · tool_calls · reasoning_content + + diff --git a/frontend/src/components/AddAccountModal.tsx b/frontend/src/components/AddAccountModal.tsx index 926f3992..aa40cf97 100644 --- a/frontend/src/components/AddAccountModal.tsx +++ b/frontend/src/components/AddAccountModal.tsx @@ -166,6 +166,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { try { setPhase('busy') const id = await ensureAccount() + setMessage(t('wizardStartingSession')) setPhase('polling') const output = await startDeviceLogin(id) if (output.authUrl) { @@ -202,6 +203,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { try { setPhase('busy') const id = await ensureAccount() + setMessage(t('wizardStartingSession')) await loginWithPat(token, id) setPhase('done') setMessage(t('patDone')) diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 071e2f68..4d7547ab 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -324,6 +324,7 @@ export const messages: Record = { wizardBrowserLead: 'Opens the authorization page. Finish in the browser and this wizard continues automatically.', wizardStartBrowser: 'Start browser login', wizardOpenBrowser: 'Open authorization page', + wizardStartingSession: 'Starting the account session…', wizardWaitingBrowser: 'Waiting for authorization in the browser…', wizardStartingWorker: 'Authorization received. Starting account session…', wizardAccountReady: 'Account is ready.', @@ -669,6 +670,7 @@ export const messages: Record = { wizardBrowserLead: '会打开授权页。在浏览器完成授权后,向导会自动继续。', wizardStartBrowser: '开始浏览器登录', wizardOpenBrowser: '打开授权页', + wizardStartingSession: '正在启动账号会话…', wizardWaitingBrowser: '正在等待浏览器完成授权…', wizardStartingWorker: '已收到授权,正在启动账号会话…', wizardAccountReady: '账号已就绪。', diff --git a/frontend/src/pages/AccountsPage.tsx b/frontend/src/pages/AccountsPage.tsx index 9a445d21..9a226ea8 100644 --- a/frontend/src/pages/AccountsPage.tsx +++ b/frontend/src/pages/AccountsPage.tsx @@ -152,6 +152,7 @@ export function AccountsPage() { async function onDeviceLogin(id: string) { setAuthPanelId(id) await run(id, 'device', async () => { + setNoteById((current) => ({ ...current, [id]: t('wizardStartingSession') })) const output = await startDeviceLogin(id) if (output.authUrl) { setUrlById((current) => ({ ...current, [id]: output.authUrl || '' })) @@ -175,6 +176,7 @@ export function AccountsPage() { return } await run(id, 'pat', async () => { + setNoteById((current) => ({ ...current, [id]: t('wizardStartingSession') })) await loginWithPat(pat, id) setPatById((current) => ({ ...current, [id]: '' })) await refresh() diff --git a/internal/api/workerproxy.go b/internal/api/workerproxy.go index 113a0dab..e9950ac0 100644 --- a/internal/api/workerproxy.go +++ b/internal/api/workerproxy.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -11,6 +12,14 @@ import ( "time" ) +var ( + errAccountNotRunning = errors.New("account is disabled or not running") + errWorkerNotWarm = errors.New("account worker is still starting") + + workerLoginReadyTimeout = 90 * time.Second + workerLoginReadyInterval = 200 * time.Millisecond +) + // workerBase returns the URL of the first running account session. Prefer // workerForAccount with an explicit id. func (s *Server) workerBase() string { @@ -50,10 +59,26 @@ func (s *Server) selectedAccountID(r *http.Request) string { // proxyAccountWorker forwards a console request to the per-account Node worker // and optionally syncs the account auth_type after a successful login. func (s *Server) proxyAccountWorker(w http.ResponseWriter, r *http.Request, accountID, path, syncAuth string) { - workerURL, ok := s.manager.AccountURL(accountID) - if !ok { - writeErr(w, http.StatusConflict, "account_not_running", "account is disabled or not running") - return + waitForLogin := path == "/admin/login/device" || path == "/admin/login/pat" + var workerURL string + if waitForLogin { + readyURL, err := s.waitForWorkerLogin(r.Context(), accountID) + if err != nil { + if errors.Is(err, errAccountNotRunning) { + writeErr(w, http.StatusConflict, "account_not_running", errAccountNotRunning.Error()) + return + } + writeErr(w, http.StatusServiceUnavailable, "not_ready", err.Error()) + return + } + workerURL = readyURL + } else { + var ok bool + workerURL, ok = s.manager.AccountURL(accountID) + if !ok { + writeErr(w, http.StatusConflict, "account_not_running", "account is disabled or not running") + return + } } body, err := io.ReadAll(r.Body) if err != nil { @@ -237,3 +262,89 @@ func (s *Server) fetchQoderModels(refresh bool, accountID string) []map[string]a } return parsed.Data } + +func (s *Server) waitForWorkerLogin(ctx context.Context, accountID string) (string, error) { + workerURL, ok := s.manager.AccountURL(accountID) + if !ok || strings.TrimSpace(workerURL) == "" { + return "", errAccountNotRunning + } + return waitForWorkerAuthManager(ctx, func() (string, bool) { + return workerURL, true + }, workerLoginReadyTimeout, workerLoginReadyInterval) +} + +func waitForWorkerAuthManager(ctx context.Context, lookup func() (string, bool), timeout, interval time.Duration) (string, error) { + if timeout <= 0 { + timeout = 90 * time.Second + } + if interval <= 0 { + interval = 200 * time.Millisecond + } + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: 2 * time.Second} + var lastErr error + for { + if err := ctx.Err(); err != nil { + if lastErr != nil { + return "", lastErr + } + return "", err + } + workerURL, ok := lookup() + workerURL = strings.TrimRight(strings.TrimSpace(workerURL), "/") + if !ok || workerURL == "" { + lastErr = errAccountNotRunning + } else { + ready, err := workerReportsAuthManager(ctx, client, workerURL) + if err != nil { + lastErr = err + } else if ready { + return workerURL, nil + } else { + lastErr = errWorkerNotWarm + } + } + if !time.Now().Before(deadline) { + if lastErr == nil { + lastErr = errWorkerNotWarm + } + if errors.Is(lastErr, errAccountNotRunning) { + return "", lastErr + } + return "", fmt.Errorf("%w: %v", errWorkerNotWarm, lastErr) + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + if lastErr != nil { + return "", lastErr + } + return "", ctx.Err() + case <-timer.C: + } + } +} + +func workerReportsAuthManager(ctx context.Context, client *http.Client, workerURL string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, workerURL+"/health", nil) + if err != nil { + return false, err + } + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 400 { + return false, fmt.Errorf("worker health status %d", resp.StatusCode) + } + var health struct { + HasAuthManager bool `json:"hasAuthManager"` + } + if err := json.Unmarshal(body, &health); err != nil { + return false, err + } + return health.HasAuthManager, nil +} diff --git a/internal/api/workerproxy_test.go b/internal/api/workerproxy_test.go new file mode 100644 index 00000000..b9f5e2d0 --- /dev/null +++ b/internal/api/workerproxy_test.go @@ -0,0 +1,116 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/caigee-cmd/cli2api/internal/accounts" + "github.com/caigee-cmd/cli2api/internal/config" +) + +func TestWaitForWorkerAuthManagerRetriesUntilReady(t *testing.T) { + var hits atomic.Int32 + worker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/health" { + t.Fatalf("path = %s", r.URL.Path) + } + n := hits.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "hasAuthManager": n >= 3, + }) + })) + defer worker.Close() + + got, err := waitForWorkerAuthManager(context.Background(), func() (string, bool) { + return worker.URL, true + }, time.Second, 10*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if got != worker.URL { + t.Fatalf("url = %s", got) + } + if hits.Load() < 3 { + t.Fatalf("hits = %d", hits.Load()) + } +} + +func TestWaitForWorkerAuthManagerTimesOutWhileConnecting(t *testing.T) { + _, err := waitForWorkerAuthManager(context.Background(), func() (string, bool) { + return "http://127.0.0.1:1", true + }, 40*time.Millisecond, 10*time.Millisecond) + if !errors.Is(err, errWorkerNotWarm) { + t.Fatalf("err = %v", err) + } +} + +func TestDeviceLoginMissingAccountIsConflict(t *testing.T) { + srv := New(config.Config{ + Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret", + QoderHome: t.TempDir(), DataDir: t.TempDir(), RuntimeDir: t.TempDir(), + WorkerDaemonPath: "/dev/null", + }) + t.Cleanup(func() { _ = srv.Close() }) + + req := httptest.NewRequest(http.MethodPost, "/api/accounts/missing/login/device", nil) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDeviceLoginWaitsForAuthManagerThenOpens(t *testing.T) { + var healthHits atomic.Int32 + worker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + n := healthHits.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "hasAuthManager": n >= 2, + }) + case "/admin/login/device": + if healthHits.Load() < 2 { + t.Fatal("login reached worker before AuthManager was ready") + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "status": "pending", "authUrl": "https://qoder.com.cn/device", + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer worker.Close() + + srv := New(config.Config{ + Host: "127.0.0.1", Port: 3010, ProxyAPIKey: "secret", + QoderHome: t.TempDir(), DataDir: t.TempDir(), RuntimeDir: t.TempDir(), + WorkerDaemonPath: "/dev/null", + }) + t.Cleanup(func() { _ = srv.Close() }) + srv.pool.Upsert(accounts.Item{ID: "acc-cn", URL: worker.URL, Provider: "qoder", Region: "cn"}) + + req := httptest.NewRequest(http.MethodPost, "/api/accounts/acc-cn/login/device", nil) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var payload struct { + AuthURL string `json:"authUrl"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.AuthURL != "https://qoder.com.cn/device" { + t.Fatalf("body = %s", rec.Body.String()) + } +} diff --git a/worker/src/daemon.mjs b/worker/src/daemon.mjs index 627c48d9..225b0a64 100644 --- a/worker/src/daemon.mjs +++ b/worker/src/daemon.mjs @@ -339,13 +339,6 @@ async function prepareUpstream(reqBody) { normalizedToolHistory: JSON.stringify(summarizeNormalizedToolHistory(plain.messages || [])), stream: !!reqBody.stream, }); - // Soft guard: Aliyun/Qoder often returns insufficient_quota when input is too large. - const maxApprox = Number(process.env.QODER_MAX_APPROX_PROMPT_TOKENS || 120000); - if (approxPrompt > maxApprox) { - throw new Error( - `insufficient_quota: Local precheck rejected oversized prompt (~${approxPrompt} tokens, limit ${maxApprox}). Reduce system prompt / chat history / attachments.`, - ); - } const encoded = await withEncodeLock(async () => { if (!hotContext) throw new Error("hot context not ready"); @@ -588,7 +581,9 @@ function getAuthManager() { async function startDeviceLogin() { const mgr = getAuthManager(); if (!mgr || typeof mgr.loginWithDeviceFlow !== "function") { - throw new Error("AuthManager.loginWithDeviceFlow unavailable. Worker may not be warm yet."); + const err = new Error("AuthManager.loginWithDeviceFlow unavailable. Worker may not be warm yet."); + err.kind = "not_ready"; + throw err; } if (loginWaitPromise) { return { @@ -653,7 +648,9 @@ async function startDeviceLogin() { async function loginWithPat(pat) { const mgr = getAuthManager(); if (!mgr || typeof mgr.loginWithPAT !== "function") { - throw new Error("AuthManager.loginWithPAT unavailable"); + const err = new Error("AuthManager.loginWithPAT unavailable. Worker may not be warm yet."); + err.kind = "not_ready"; + throw err; } const token = String(pat || "").trim(); if (!token) throw new Error("pat required"); diff --git a/worker/src/errors.mjs b/worker/src/errors.mjs index dc482aec..3e1bec41 100644 --- a/worker/src/errors.mjs +++ b/worker/src/errors.mjs @@ -1,7 +1,7 @@ const QUOTA_RE = /insufficient_quota|#token-limit|token-limit|exceeded your current quota|oversized prompt|local precheck rejected/i; const RATE_RE = /too many requests|rate.?limit|response code=429|account busy|in-flight/i; const AUTH_RE = /null pointer|FORBIDDEN|Duplicate request|\b401\b|\b403\b|unauthorized|auth|credential|refresh.?token|access.?token/i; -const NOT_READY_RE = /hot context not ready|auth manager not captured|not ready/i; +const NOT_READY_RE = /hot context not ready|auth manager not captured|not ready|loginWithDeviceFlow unavailable|loginWithPAT unavailable|worker may not be warm/i; export const KIND_QUOTA = "quota"; export const KIND_RATE_LIMIT = "rate_limit"; diff --git a/worker/test/errors.test.mjs b/worker/test/errors.test.mjs index 1f8ba53a..bd744990 100644 --- a/worker/test/errors.test.mjs +++ b/worker/test/errors.test.mjs @@ -32,6 +32,15 @@ test("not ready is 503 and failovers", () => { assert.equal(got.failover, true); }); +test("device-flow not warm is not_ready not auth", () => { + const got = classifyError({ + message: "AuthManager.loginWithDeviceFlow unavailable. Worker may not be warm yet.", + }); + assert.equal(got.kind, "not_ready"); + assert.equal(got.status, 503); + assert.equal(got.failover, true); +}); + test("Retry-After is capped", () => { const got = classifyError({ status: 429, message: "too many requests", retryAfter: "99999" }); assert.equal(got.retryAfterSec, 600);