Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/pin-mismatch.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: qodercli pin mismatch
description: Worker failed because CLI needles no longer match 1.1.27
description: Worker failed because CLI needles no longer match 1.1.32
labels: [qodercli]
body:
- type: textarea
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Upgrade the pinned Qoder CLIs from 1.1.27 to 1.1.32 (`@qoder-ai/qodercli` and `@qodercn-ai/qoderclicn`), with all worker compat needles re-verified against both new bundles
- Route chat by each account's live model catalog so a request like `hy3` only hits accounts that actually serve it; unknown models return `model_not_available` without cooling healthy accounts
- Render System page release notes as markdown for the current console language, in a box that grows with the text and scrolls when it overflows
- Paginate request history on Logs and filter by time range, account, model, stream mode, and error kind; runtime logs can also filter by account

### 中文

- 钉的 Qoder CLI 从 1.1.27 升级到 1.1.32(`@qoder-ai/qodercli` 与 `@qodercn-ai/qoderclicn`),worker 全部兼容 needles 已在两个新版 bundle 上重新验证
- 聊天按每个账号的动态模型目录选号,`hy3` 这类请求只会打到真正有该模型的账号;全池都没有时返回 `model_not_available`,不再给健康账号打冷却
- 控制台 System 页版本说明按当前语言渲染 markdown,说明框随文本增高,超出后可向下滚动
- 日志页请求历史支持分页,以及时间范围、账号、模型、流式模式、错误类型筛选;运行日志也可按账号筛选

## 0.2.12 - 2026-08-27

### English
Expand Down
2 changes: 1 addition & 1 deletion deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ FROM node:20-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl tini \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g @qoder-ai/qodercli@1.1.27 @qodercn-ai/qoderclicn@1.1.27
&& npm install -g @qoder-ai/qodercli@1.1.32 @qodercn-ai/qoderclicn@1.1.32
WORKDIR /app
COPY --from=build /out/qoder-api-proxy /app/qoder-api-proxy
COPY --from=build /out/cli2api-updater /app/cli2api-updater
Expand Down
19 changes: 16 additions & 3 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,9 @@ Client (OpenAI SDK / Codex / CherryStudio)
-> region=cn: https://gateway.qoder.com.cn/... (WASM-encoded URL)
```

Worker pins `@qoder-ai/qodercli@1.1.27` for `qoder/global`. Qoder CN
Worker pins `@qoder-ai/qodercli@1.1.32` for `qoder/global`. Qoder CN
(`provider=qoder`, `region=cn`) is the same daemon with a second pin,
`@qodercn-ai/qoderclicn@1.1.27`, and HOME `.qoder-cn`. Needle mismatch
`@qodercn-ai/qoderclicn@1.1.32`, and HOME `.qoder-cn`. Needle mismatch
exits loudly. See `docs/PROVIDERS.md` 「Qoder CN」; do not spawn a full
CLI per request and do not invent a `qodercn` family.

Expand All @@ -287,6 +287,16 @@ There are no hand-written model aliases, display-name maps, or product overrides
the dynamic catalog is unavailable, the worker returns an explicit catalog error rather
than guessing an upstream key or allowing Qoder to silently choose a fallback model.

Go keeps a per-account catalog snapshot on the pool item and uses it as the chat
route pool. `POST /v1/chat/completions` refreshes stale snapshots, then
`PickRoute` only selects accounts whose catalog contains the requested public
ID or native key. Accounts with an unknown catalog (nil snapshot) stay eligible
so a cold worker is not skipped. If no candidate serves the model, the request
returns `model_not_available` without calling a worker or cooling an account.
A worker-level miss still failovers to another eligible account, but it does
not put the healthy account into cooldown; the stale ID is dropped from that
account's snapshot so the next pick cannot repeat it.

## Protocol adapters

Public protocols and the Qoder upstream format are separate contracts. Do not let
Expand Down Expand Up @@ -371,6 +381,7 @@ Error taxonomy (do not treat every 429 as empty balance):
| 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, AuthManager not captured, worker not warm | 503 | yes | ~10s |
| model_not_available | catalog miss for the requested public ID | 400 | yes, only other accounts that serve the model | no |
| 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.
Expand Down Expand Up @@ -432,6 +443,8 @@ Rules:
- Capture daemon output through `ManagerConfig.MaxLogWriters`; prefix lines with `[account={id}]`.
- Runtime ring redacts obvious secrets and is lost on restart. Docker compose logs remain the durable operator stream for first-boot API key recovery.
- `/api/logs/*` requires the SQLite API key.
- Request history list accepts `account`, `status`, `stream`, `error_kind`, `model`, `q`, `from`, `to`, `limit`, and `offset`. The console `/logs` page paginates this list and exposes those filters.
- Runtime snapshot accepts `account` in addition to `level` and `q`.

## Managed update

Expand All @@ -443,7 +456,7 @@ Release packaging keeps the application as a Linux container and publishes a `li

Maintainers do not create version tags manually. A serialized `workflow_dispatch` release waits for CI on the exact `main` commit, calculates the next patch after the latest published stable release, creates or resumes an invisible draft release, uploads all updater assets, builds a candidate multi-architecture image, promotes and verifies immutable version tags, and finally publishes the release. Mutable `latest` and release-series aliases move only after publication. Failed pre-publication runs leave a resumable draft rather than exposing an update to the console.

Release notes come from `CHANGELOG.md`, not generated commit lists. Maintainers write matching `### English` and `### 中文` bullets under `## Unreleased`; the workflow copies that bilingual body onto the GitHub Release, which the console update page shows as-is. After the release is public, a follow-up commit freezes those notes under the new version heading so the next patch starts from an empty Unreleased section.
Release notes come from `CHANGELOG.md`, not generated commit lists. Maintainers write matching `### English` and `### 中文` bullets under `## Unreleased`; the workflow copies that bilingual body onto the GitHub Release. The console System page extracts the current UI language, renders the markdown (lists, inline code, links), and shows it in a box that grows with the notes then scrolls. After the release is public, a follow-up commit freezes those notes under the new version heading so the next patch starts from an empty Unreleased section.

The host boundary is explicitly versioned through `protocol_version`. Version `1` is current; version `0` is temporarily accepted for an older updater that omitted the field. Any other version is rejected before an update request is submitted. New updater releases must remain backward-compatible with the immediately previous application release so the latest-asset bootstrap path stays safe.

Expand Down
16 changes: 10 additions & 6 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CLI2API Plan

last-updated: 2026-08-27
last-updated: 2026-08-28

Qoder-first OpenAI-compatible proxy. Cursor and other CLIs wait until the current Qoder milestone is done.

Expand Down Expand Up @@ -158,6 +158,7 @@ output without copying sub2api billing, audit-body storage, or ops preaggregatio
- [x] Expose authenticated `/api/logs/requests` and `/api/logs/runtime`
- [x] Add console `/logs` with Request history and Runtime tabs
- [x] Purge request history at 7 days or 20_000 rows
- [x] Paginate request history and filter by time, account, model, stream, and error kind

## Phase K — account quota display

Expand All @@ -175,11 +176,14 @@ no scheduling, billing, or persistence changes.

Goal: add Qoder China as `provider=qoder` + `region=cn`. Reuse the existing
child-process worker and `qoder-native-v1` credentials. Pin a second CLI,
`@qodercn-ai/qoderclicn@1.1.27`, instead of a new protocol adapter.
`@qodercn-ai/qoderclicn@1.1.32` (both CLIs move in lockstep — see
`docs/PROVIDERS.md`), instead of a new protocol adapter.

Design facts and locked product decisions: `docs/PROVIDERS.md` section
「Qoder CN(中国大陆版)」. Do not spawn a full `qoderclicn` per request.
Do not invent a `qodercn` provider family. Do not upgrade the 1.1.27 pin.
Do not invent a `qodercn` provider family. (2026-08-28: both pins upgraded
together to 1.1.32 after re-verifying all needles; future upgrades must move
both CLIs in lockstep.)

### L0 descriptor

Expand All @@ -191,15 +195,15 @@ Do not invent a `qodercn` provider family. Do not upgrade the 1.1.27 pin.

### L1 worker CLI filename

- [x] Hook `qoderclicn.js` in `rewrite-loader.mjs` (same needles as 1.1.27 global; `includes("qodercli.js")` does **not** match `qoderclicn.js`)
- [x] Hook `qoderclicn.js` in `rewrite-loader.mjs` (same needles as the global CLI at the time — pinned 1.1.27 then, now 1.1.32 in lockstep; `includes("qodercli.js")` does **not** match `qoderclicn.js`)
- [x] Resolve default bundle paths for both packages in `daemon.mjs`
- [x] Choose `hotEndpoint` fallback from worker-only `QODER_SITE` or the loaded filename (`api1.qoder.sh` vs `gateway.qoder.com.cn`)
- [x] Do not use `QODERCLI_SITE` to flip CN/Global; that env does not change compile-time `Xi`

### L2 manager spawn

- [x] Add `QODERCNCLI_JS` / `QoderCNCLIPath`; missing CN path fails spawn, never falls back to global CLI
- [x] Keep `HOME={runtime}`. Pass CLI-readable dirs: `QODER_CONFIG_DIR={home}/.qoder` or `QODERCN_CONFIG_DIR={home}/.qoder-cn`. Do **not** rely on `QODER_HOME` — 1.1.27 CLI ignores it
- [x] Keep `HOME={runtime}`. Pass CLI-readable dirs: `QODER_CONFIG_DIR={home}/.qoder` or `QODERCN_CONFIG_DIR={home}/.qoder-cn`. Do **not** rely on `QODER_HOME` — the pinned CLI ignores it (1.1.27 at the time of this note; both CLIs now pinned to 1.1.32)
- [x] `materializeHome` / `SyncCredential` take region and read/write `.qoder` or `.qoder-cn`
- [x] Export `qoder-native-v1` includes `provider` + `region`; import without region stays global for old bundles
- [x] Unit test: CN account env points at `qoderclicn.js` + `.qoder-cn`; global still uses `qodercli.js` + `.qoder`
Expand All @@ -221,7 +225,7 @@ Do not invent a `qodercn` provider family. Do not upgrade the 1.1.27 pin.

### L5 image and config

- [x] Install both `@qoder-ai/qodercli@1.1.27` and `@qodercn-ai/qoderclicn@1.1.27` in the image
- [x] Install both `@qoder-ai/qodercli@1.1.32` and `@qodercn-ai/qoderclicn@1.1.32` in the image
- [x] Export `QODERCLI_JS` and `QODERCNCLI_JS`
- [x] README / CHANGELOG bilingual notes

Expand Down
30 changes: 15 additions & 15 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 多上游账号类型:WorkBuddy 对照与扩展计划

last-updated: 2026-08-26
status: WorkBuddy J0–J4 已落地;Qoder CN 为下一个 Qoder-family 扩展,尚未开工
last-updated: 2026-08-28
status: WorkBuddy J0–J4 已落地;Qoder CN 代码完成,L6 真实账号验收未完成
routing-reference: [router-for-me/CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) `main` @ `dc3c3b1`
workbuddy-reference: [Sliverkiss/workbuddy2api](https://github.com/Sliverkiss/workbuddy2api) `master` @ `92514d8ba06413c3b620e96da3ebc38e6c7beda0`

Expand Down Expand Up @@ -708,9 +708,9 @@ WorkBuddy / CodeBuddy 协议实现参考:

# Qoder CN(中国大陆版)

last-updated: 2026-08-26
status: 调研完成,尚未开工
cli-pin: `@qoder-ai/qodercli@1.1.27` 与 `@qodercn-ai/qoderclicn@1.1.27`
last-updated: 2026-08-28
status: 代码完成(pin 1.1.32);L6 真实账号验收未完成
cli-pin: `@qoder-ai/qodercli@1.1.32` 与 `@qodercn-ai/qoderclicn@1.1.32`
reference-proxy: [lininn/qorder-proxy](https://github.com/lininn/qorder-proxy) `main` @ 2026-08-26(只借包名 / 登录入口,不借进程模型)

## 结论
Expand All @@ -727,11 +727,11 @@ reference-proxy: [lininn/qorder-proxy](https://github.com/lininn/qorder-proxy) `
| 要不要新 family `qodercn`? | 不要。`provider=qoder`,`region=cn`。控制台显示「Qoder 国内版」。 |
| 能不能抄 qorder-proxy? | 不能抄架构。它每请求 spawn 一次 CLI,违反本仓库硬规则。包名、PAT 入口、双后端对照可以参考。 |

一句话:Qoder CN 是 **同一条 Qoder 执行路径上的第二套 pinned CLI**,扩展点已经存在,但今天整条 spawn 链把 region 丢掉了。
一句话:Qoder CN 是 **同一条 Qoder 执行路径上的第二套 pinned CLI**。spawn 链已按 region 分支;剩下的是 L6 真实账号验收。

## 调研事实(1.1.27)

npm 上的中国版包名是 `@qodercn-ai/qoderclicn`(bin `qoderclicn`,bundle `bundle/qoderclicn.js`),不是 `qodercncli`。国际版是 `@qoder-ai/qodercli`(bin `qodercli`,bundle `bundle/qodercli.js`)。两边 latest 都到了 1.1.31;本仓库继续钉 **1.1.27**,本里程碑不升级 CLI。
npm 上的中国版包名是 `@qodercn-ai/qoderclicn`(bin `qoderclicn`,bundle `bundle/qoderclicn.js`),不是 `qodercncli`。国际版是 `@qoder-ai/qodercli`(bin `qodercli`,bundle `bundle/qodercli.js`)。两边按同一版本号同构发布;2026-08-28 起本仓库钉 **1.1.32**(此前为 1.1.27,升级时重新验证了全部 5 条 needles)。

同一份源码,编译期写入站点:

Expand Down Expand Up @@ -811,17 +811,17 @@ WorkBuddy 的 region 只换 Go 里的 host 常量。Qoder CN 必须换 **CLI 二
Client
-> Go :3010
-> accounts.Pool(provider=qoder 时默认同 region failover)
-> region=global: Node daemon + @qoder-ai/qodercli@1.1.27
-> region=global: Node daemon + @qoder-ai/qodercli@1.1.32
HOME/.qoder/.auth -> api*.qoder.sh
-> region=cn: Node daemon + @qodercn-ai/qoderclicn@1.1.27
-> region=cn: Node daemon + @qodercn-ai/qoderclicn@1.1.32
HOME/.qoder-cn/.auth -> gateway.qoder.com.cn
```

规则:

- 不新增 provider family,不新增 SQLite 列,不新增 worker 入口文件
- 不把 CN 做成 in-process HTTP
- 不升级 pinned CLI 版本
- 升级 pinned CLI 版本时两边一起升,且先在两边的新 bundle 上重新验证全部 5 条 needles(2026-08-28 已从 1.1.27 一起升到 1.1.32,`worker/src/compat.mjs`)
- 登录仍走现有 worker `/admin/login/{device,status,pat}`;`account.Provider == "qoder"` 的分支保持
- 导入导出仍是 `qoder-native-v1`;创建 / import 必须带对的 `region`。export JSON 要带 `provider` 与 `region`,否则 CN blob 会被默认成 Global
- 公共模型前缀仍是 `qoder/`。不要引入 `qodercn/`,否则会把 region 做成第二套 family
Expand All @@ -834,7 +834,7 @@ Client
2. CN 支持浏览器 device-flow、PAT、native import;CLI 源码里两种登录都在。若真实 CN device-flow 不可用,再降级为 PAT + import,不要一开始就砍掉浏览器。
3. CN PAT 向导文案指向 `https://qoder.com.cn/account/integrations`;不要把国际版 PAT 填进 CN 账号。
4. 调度按 **provider + region** 隔离 failover。不要按积分、站点亲和或模型名猜。
5. 镜像同时安装两套 1.1.27 CLI。体积换正确性。
5. 镜像同时安装两套 1.1.32 CLI。体积换正确性。
6. 不把 `QODERCLI_SITE=cn` 当作「一个二进制两种站点」的方案。

## 实施顺序
Expand All @@ -854,9 +854,9 @@ Client
### L1 — worker 按 CLI 文件名工作,而不是写死国际版

- `rewrite-loader.mjs` hook `qodercli.js` **或** `qoderclicn.js`
- `compat.mjs` 继续钉 1.1.27;错误信息同时提两个包名
- `compat.mjs` 钉 1.1.32;错误信息同时提两个包名
- `daemon.mjs` 默认路径同时找两个 bundle;fallback host 按实际加载的文件名或 worker-only `QODER_SITE=cn|global`(不要用 `QODERCLI_SITE`,它翻不了编译期 `Xi`)
- 配置目录由 Go 显式传入 CLI 能读的变量:Global `QODER_CONFIG_DIR={home}/.qoder`,CN `QODERCN_CONFIG_DIR={home}/.qoder-cn`。现有 `QODER_HOME` 是本仓库自己的标签,**1.1.27 CLI 不读它**;Global 今天能工作是因为 `HOME={runtime}` + 默认 `~/.qoder`
- 配置目录由 Go 显式传入 CLI 能读的变量:Global `QODER_CONFIG_DIR={home}/.qoder`,CN `QODERCN_CONFIG_DIR={home}/.qoder-cn`。现有 `QODER_HOME` 是本仓库自己的标签,**1.1.32 CLI 不读它**;Global 今天能工作是因为 `HOME={runtime}` + 默认 `~/.qoder`

完成标准:用 `QODERCLI_JS=.../qoderclicn.js` 启动 daemon 时 needles 通过,不再因为文件名不是 `qodercli.js` 而跳过 patch。

Expand Down Expand Up @@ -895,7 +895,7 @@ Client

### L5 — 镜像与配置

- `deploy/Dockerfile` 同时 `npm i -g @qoder-ai/qodercli@1.1.27 @qodercn-ai/qoderclicn@1.1.27`
- `deploy/Dockerfile` 同时 `npm i -g @qoder-ai/qodercli@1.1.32 @qodercn-ai/qoderclicn@1.1.32`
- 默认 env:`QODERCLI_JS=.../qodercli.js`,`QODERCNCLI_JS=.../qoderclicn.js`
- `internal/config` 读取第二条路径
- 文档:README 账号类型多一行国内版;CHANGELOG Unreleased 双语
Expand All @@ -914,7 +914,7 @@ Client

- **站点开关是编译期的**:装错包或只改 env,账号会打到错误云。启动时在 daemon 日志里打出 `site=cn|global` 和 bundle 路径。
- **HOME 目录名**:写到 `.qoder` 的 CN 凭证会被国际版 CLI 当成自己的,表现为怪认证错误。materialize / sync 必须按 region 分支,并覆盖回归测试。
- **needles 漂移**:现在 1.1.27 同构;以后升 CN 而不升 Global(或反过来)会让一边 hooks 失效。两边继续锁同一版本。
- **needles 漂移**:两边同构发布(1.1.32 起继续如此);以后升 CN 而不升 Global(或反过来)会让一边 hooks 失效。两边继续锁同一版本,升级时一起升并重新验证 `compat.mjs` 的 5 条 needles。
- **镜像体积**:两份 ~28MB CLI。可接受。不要试图用一份 JS 加 env 伪造站点。
- **device-flow 是否真能在 CN 用**:源码有函数不等于生产授权页可用。L6 用真实号验证;失败则控制台把 CN 的浏览器 tab 降为次要,PAT 为主。
- **服务条款**:只允许用户自己的国内版账号。文案不承诺「国内无限额度」或跨站共用。
9 changes: 8 additions & 1 deletion frontend/src/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ export type RequestLogQuery = {
status?: string
stream?: boolean
error_kind?: string
model?: string
q?: string
from?: string
to?: string
limit?: number
offset?: number
}
Expand All @@ -78,7 +81,10 @@ export function fetchRequestLogs(query: RequestLogQuery = {}) {
if (query.status) params.set('status', query.status)
if (query.stream != null) params.set('stream', query.stream ? '1' : '0')
if (query.error_kind) params.set('error_kind', query.error_kind)
if (query.model) params.set('model', query.model)
if (query.q) params.set('q', query.q)
if (query.from) params.set('from', query.from)
if (query.to) params.set('to', query.to)
if (query.limit != null) params.set('limit', String(query.limit))
if (query.offset != null) params.set('offset', String(query.offset))
const suffix = params.toString() ? `?${params}` : ''
Expand All @@ -93,12 +99,13 @@ export function clearRequestLogs() {
return api<{ ok: boolean; deleted: number }>('/api/logs/requests', { method: 'DELETE' })
}

export function fetchRuntimeLogs(query: { after?: number; limit?: number; level?: string; q?: string } = {}) {
export function fetchRuntimeLogs(query: { after?: number; limit?: number; level?: string; q?: string; account?: string } = {}) {
const params = new URLSearchParams()
if (query.after != null) params.set('after', String(query.after))
if (query.limit != null) params.set('limit', String(query.limit))
if (query.level) params.set('level', query.level)
if (query.q) params.set('q', query.q)
if (query.account) params.set('account', query.account)
const suffix = params.toString() ? `?${params}` : ''
return api<RuntimeLogList>(`/api/logs/runtime${suffix}`)
}
Loading
Loading