Skip to content
Merged
17 changes: 17 additions & 0 deletions changelog/unreleased/qoder-in-process-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### English

- Qoder accounts can now run fully in-process from a PAT, with no per-account Node+WASM worker. A new `qoder-pat-v1` credential format stores a Personal Access Token; the new in-process `Bridge` (`internal/providers/qoder/bridge.go`) performs the PAT→jobToken exchange, proactive and forced token refresh, `/user/status` + OpenAPI `/api/v2/quota/usage` quota resolution, and `/model/list` catalog fetch — all via the `qauth` package ported from EchoPing07/Qoder-2API-Go (MIT, see `internal/providers/qoder/qauth/NOTICE`). Chat signing continues to use the existing in-process COSY client. The runtime no longer spawns a Node worker for PAT-backed accounts, eliminating the per-account WASM process, the liveness watchdog, and the restart ladder for them.
- Check-in (签到) now runs entirely in Go. The OpenAPI `/sash/api/v1/me/campaigns` list + `/claim` calls are made directly with the bridge's `securityOauthToken` and a machine identity derived deterministically from the PAT (no `runtime-info` child process). Token refresh before the call and force-refresh on a 401 are handled by the bridge. The worker `/admin/checkin` endpoint is now a legacy fallback for accounts still on the `qoder-native-v1` blob.
- Existing Qoder accounts that still carry the legacy `qoder-native-v1` CLI blob (no PAT) keep working via the worker path until the operator re-logs them with a PAT. The console's PAT login path (`LoginPAT`) now accepts a Qoder PAT and stores it in the `qoder-pat-v1` format, after which the account stops spawning a worker.
- Global-region check-in now authenticates the campaign list/claim calls with the OpenAPI bearer (`/api/v1/jobToken/exchange`), matching the region's token requirement; CN keeps using the COSY `securityOauthToken`. A 401 now also invalidates the cached OpenAPI bearer before the single force-refresh retry.
- Fixed Qoder in-process chat requests losing the model's thinking/reasoning stream. `BuildPlainChatBody` now fills `chat_context` the way the reference Qoder client does: `chat_context.text`, `chat_context.extra.modelConfig` (`key` + `is_reasoning`) and `chat_context.extra.originalContent` carry the latest user prompt and the routed model identity. The gateway resolves the thinking channel from that block, so an empty `extra` made it answer with content only. `source`/`version`/`session_type` were also aligned to the official client (`1` / `"3"` / `"qodercli"`). Reasoning now streams back as `reasoning_content` (and as Anthropic `thinking_delta` on `/v1/messages`).
- Fixed Qoder in-process catalog parsing and model discovery. `ModelInfos` previously expected legacy worker fields (`id`, `mapped_key`) and dropped every entry from raw gateway `/model/list` responses (which only supply `key` and `display_name`). Models are now derived from `key` and `display_name` via `CanonicalModelID`, restoring Qoder model routing for in-process accounts. `catalog_source` now also merges in-process Qoder catalogs, and `manager_probe` triggers background model discovery for prober-less in-process accounts on startup.

### 中文

- Qoder 账号现在可以基于 PAT 完全进程内运行,不再为每个账号 spawn 一个 Node+WASM worker。新增 `qoder-pat-v1` 凭证格式存储 Personal Access Token;新增进程内 `Bridge`(`internal/providers/qoder/bridge.go`)负责 PAT→jobToken 交换、主动与强制 token 刷新、`/user/status` + OpenAPI `/api/v2/quota/usage` 配额解析、`/model/list` 目录拉取——全部经由从 EchoPing07/Qoder-2API-Go 移植的(MIT,见 `internal/providers/qoder/qauth/NOTICE`) `qauth` 包完成。chat 签名仍走既有进程内 COSY 客户端。运行时不再为 PAT 账号 spawn Node worker,从而消除每账号 WASM 进程、liveness 看门狗和重启阶梯。
- 签到(check-in)现在完全在 Go 里跑。OpenAPI 的 `/sash/api/v1/me/campaigns` 列表 + `/claim` 调用直接用 bridge 的 `securityOauthToken` 和从 PAT 确定性派生的机器身份发起(不再 spawn `runtime-info` 子进程)。调用前的 token 刷新和 401 时的强制刷新由 bridge 处理。worker 的 `/admin/checkin` 端点降级为仍是 `qoder-native-v1` blob 的老账号的 legacy fallback。
- 仍带着 legacy `qoder-native-v1` CLI blob(没有 PAT)的 Qoder 老账号在操作员用 PAT 重新登录前继续走 worker 路径。控制台的 PAT 登录路径(`LoginPAT`)现在接受 Qoder PAT 并以 `qoder-pat-v1` 格式存储,之后该账号就不再 spawn worker。
- 国际版签到改用 OpenAPI bearer(`/api/v1/jobToken/exchange`)来发起 campaign 列表/领取请求,符合该区的 token 要求;国内版继续使用 COSY 的 `securityOauthToken`。401 时现在会先清掉缓存的 OpenAPI bearer 再做一次强制刷新重试。
- 修复 Qoder 进程内 chat 请求丢失模型思考(reasoning)流的问题。`BuildPlainChatBody` 现在按官方 Qoder 客户端的方式填充 `chat_context`:`chat_context.text`、`chat_context.extra.modelConfig`(`key` + `is_reasoning`)与 `chat_context.extra.originalContent` 带上最新用户输入和路由到的模型标识。网关依据该块决定是否走思考通道,之前 `extra` 为空导致上游只回内容、不回思考。同时把 `source`/`version`/`session_type` 对齐官方客户端(`1` / `"3"` / `"qodercli"`)。现在思考过程会以 `reasoning_content` 流式返回(`/v1/messages` 入口则编码为 Anthropic `thinking_delta`)。
- 修复 Qoder 进程内模型目录解析与路由丢失问题。`ModelInfos` 之前仅兼容旧版 worker 的 `id` / `mapped_key` 字段,导致直连网关 `/model/list` 的原始数据(仅有 `key` 和 `display_name`)被当成空字段全部跳过,池中 Qoder 账号模型列表为空并报 `model_not_available`。现已自动兼容网关原生 `key` 与 `display_name` 派生 `CanonicalModelID`;`catalog_source` 也同步支持将无 worker 的进程内 Qoder 账号合并入展示目录,且 `manager_probe` 在探针刷新时对这类账号自动触发目录加载。
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-inprocess-quota-aggregate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Qoder PAT accounts: the console quota headline now sums the plan quota, add-on pack, and organization resource package, matching the official client's usage view. Previously only the plan bucket was shown, so an account living on its add-on balance (e.g. after the plan ran out) displayed 0/0.

### 中文

- Qoder PAT 账号:控制台额度总览现在会把套餐内额度、资源包与组织资源包三个桶求和显示,与官方客户端的用量页一致。此前只显示套餐内额度,套餐耗尽后依赖资源包的账号会错误显示为 0/0。
43 changes: 37 additions & 6 deletions internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,12 @@ func TestModelsAPICachesCatalogForFiveMinutes(t *testing.T) {
srv.Pool.Upsert(executor.Item{ID: "wb-cn", Provider: "workbuddy", Runtime: string(providers.RuntimeInProcess)})
srv.Providers.Register(providers.Adapter{ID: "workbuddy", Models: catalog})

// app.New kicks off a background RefreshAll, and prober-less in-process
// adapters now get a one-shot model discovery pass from it. That fetch can
// land at any point during setup (slow CI), so wait for the counter to go
// quiet and assert relative deltas from whatever baseline it left.
baseline := quiesceCatalogHits(t, catalog)

getModels := func(path string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer secret")
Expand All @@ -429,25 +435,50 @@ func TestModelsAPICachesCatalogForFiveMinutes(t *testing.T) {
if second.Code != http.StatusOK {
t.Fatalf("cached /api/models = %d %s", second.Code, second.Body.String())
}
if catalog.hits.Load() != 1 {
t.Fatalf("cached /api/models hits = %d, want 1", catalog.hits.Load())
if got, want := catalog.hits.Load(), baseline+1; got != want {
t.Fatalf("cached /api/models hits = %d, want %d", got, want)
}

overview := getModels("/api/overview")
if overview.Code != http.StatusOK {
t.Fatalf("/api/overview = %d %s", overview.Code, overview.Body.String())
}
if catalog.hits.Load() != 2 {
t.Fatalf("overview must not use /api/models cache, hits = %d", catalog.hits.Load())
if got, want := catalog.hits.Load(), baseline+2; got != want {
t.Fatalf("overview must not use /api/models cache, hits = %d, want %d", got, want)
}

refreshed := getModels("/api/models?account=wb-cn&refresh=1")
if refreshed.Code != http.StatusOK {
t.Fatalf("refresh /api/models = %d %s", refreshed.Code, refreshed.Body.String())
}
if catalog.hits.Load() != 3 {
t.Fatalf("refresh=1 hits = %d, want 3", catalog.hits.Load())
if got, want := catalog.hits.Load(), baseline+3; got != want {
t.Fatalf("refresh=1 hits = %d, want %d", got, want)
}
}

// quiesceCatalogHits waits until the catalog's hit counter stays unchanged
// across several polls (i.e. the boot-time background refresh has finished),
// and returns the stable count to use as the baseline for hit assertions.
func quiesceCatalogHits(t *testing.T, catalog *countingCatalog) int32 {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
last := catalog.hits.Load()
stable := 0
for time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
cur := catalog.hits.Load()
if cur == last {
stable++
if stable >= 10 {
return cur
}
continue
}
last = cur
stable = 0
}
t.Fatalf("catalog hit counter never settled (last = %d)", last)
return last
}

func TestLegacyDiagnosticRoutesAreRemoved(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ func New(cfg config.Config) *App {
}
return account.ProviderRegion, nil
}))
qoderClient.SetPATStore(store, func(ctx context.Context, accountID string) (string, error) {
account, err := store.Get(ctx, accountID)
if err != nil {
return "", err
}
return account.ProviderRegion, nil
})
providerReg.Register(qoderClient.Adapter())
manager.SetProviders(providerReg)
manager.SetWorkBuddy(workbuddyClient)
Expand Down
33 changes: 22 additions & 11 deletions internal/control/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"

"github.com/caigee-cmd/cli2api/internal/accounts"
"github.com/caigee-cmd/cli2api/internal/providers"
Expand Down Expand Up @@ -304,19 +305,29 @@ func (a *Accounts) Admin(ctx context.Context, input AccountAdminAction) (Account
if storeErr != nil {
return AccountAdminResult{}, storeErr
}
if inProcess {
var payload struct {
PAT string `json:"pat"`
}
if err := json.Unmarshal(input.Body, &payload); err != nil {
return AccountAdminResult{}, operationError("invalid_request", err.Error())
}
if err := a.LoginPAT(ctx, input.AccountID, payload.PAT); err != nil {
return AccountAdminResult{}, err
// Qoder PAT login now stores the PAT as qoder-pat-v1 (in-process
// Bridge path) instead of forwarding to the worker. Legacy Qoder
// accounts that lack a CredentialImporter still fall through to the
// workerAdmin path; this mirrors the old behavior for them.
var payload struct {
PAT string `json:"pat"`
}
if err := json.Unmarshal(input.Body, &payload); err != nil {
return AccountAdminResult{}, operationError("invalid_request", err.Error())
}
if err := a.LoginPAT(ctx, input.AccountID, payload.PAT); err != nil {
// Fall back to the worker path only when the provider genuinely has
// no PAT importer (legacy Qoder blob accounts). Any other error —
// invalid_credential, credential_save_failed, a store failure — is a
// real login failure and must be returned as-is; forwarding it to the
// worker would swallow the cause and send the PAT to the daemon.
var opErr *OperationError
if errors.As(err, &opErr) && opErr.Code == "provider_unsupported" {
return a.workerAdmin(ctx, input)
}
return AccountAdminResult{Kind: "login_complete", LoginStatus: "ok", LoginMsg: "login complete"}, nil
return AccountAdminResult{}, err
}
return a.workerAdmin(ctx, input)
return AccountAdminResult{Kind: "login_complete", LoginStatus: "ok", LoginMsg: "login complete"}, nil
case "rewarm":
if inProcess {
return AccountAdminResult{}, operationError("not_found", "unknown account action")
Expand Down
2 changes: 1 addition & 1 deletion internal/control/catalog_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ func (a *CatalogSource) fetchProviderModels(refresh bool, accountID string, mode
if accountID != "" && item.ID != accountID {
continue
}
if item.Provider == "" || item.Provider == "qoder" {
if item.Provider == "" || (item.Provider == "qoder" && item.Worker) {
continue
}
adapter, ok := a.Providers.Get(item.Provider)
Expand Down
74 changes: 74 additions & 0 deletions internal/control/duty_services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,77 @@ func TestSystemPatchPersistsDisabledAccountCheckinSetting(t *testing.T) {
t.Fatal("current setting is disabled")
}
}

// A PAT login that fails for a real reason (invalid credential, save failure)
// must surface that error instead of falling through to the worker path. Only
// provider_unsupported means "this provider has no PAT importer, try the legacy
// worker".
func TestAccountsAdminLoginPATDoesNotSwallowRealErrors(t *testing.T) {
cases := []struct {
name string
adapter providers.Adapter
want string
}{
{
name: "invalid credential",
adapter: providers.Adapter{ID: "qoder", Credential: stubImporter{
format: "qoder-pat-v1",
err: errors.New("bad token"),
}},
want: "invalid_credential",
},
{
name: "save failure",
adapter: providers.Adapter{ID: "qoder", Credential: stubImporter{
format: "qoder-pat-v1", payload: []byte(`{"pat":"x"}`),
}},
// store.payloadErr forces SaveCredentialPayload to fail.
want: "credential_save_failed",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc, _, store, log := newTestServices()
store.accounts["acc-1"] = accounts.Account{ID: "acc-1", Provider: "qoder"}
if tc.name == "save failure" {
store.payloadErr = errors.New("disk full")
}
svc.Accounts.Providers = providers.NewRegistry()
svc.Accounts.Providers.Register(tc.adapter)

_, err := svc.Accounts.Admin(context.Background(), AccountAdminAction{
AccountID: "acc-1", Action: "login/pat", Method: "POST",
Body: []byte(`{"pat":"tok"}`),
})
var op *OperationError
if !errors.As(err, &op) || op.Code != tc.want {
t.Fatalf("err=%v want code %q", err, tc.want)
}
for _, name := range log.names {
if name == "runtime.WorkerAdmin" {
t.Fatalf("real PAT failure must not forward to the worker: %v", log.names)
}
}
})
}
}

// When the provider has no PAT importer, login/pat must fall back to the legacy
// worker path (old qoder-native blob accounts).
func TestAccountsAdminLoginPATFallsBackOnlyOnProviderUnsupported(t *testing.T) {
svc, runtime, store, _ := newTestServices()
store.accounts["acc-1"] = accounts.Account{ID: "acc-1", Provider: "qoder"}
svc.Accounts.Providers = providers.NewRegistry()
svc.Accounts.Providers.Register(providers.Adapter{ID: "qoder"}) // no Credential importer

result, err := svc.Accounts.Admin(context.Background(), AccountAdminAction{
AccountID: "acc-1", Action: "login/pat", Method: "POST",
Body: []byte(`{"pat":"tok"}`),
})
if err != nil || result.Kind != "worker" {
t.Fatalf("result=%+v err=%v", result, err)
}
if runtime.adminReq.Action != "login/pat" {
t.Fatalf("worker action=%q", runtime.adminReq.Action)
}
}
Loading
Loading