diff --git a/changelog/unreleased/qoder-in-process-bridge.md b/changelog/unreleased/qoder-in-process-bridge.md new file mode 100644 index 00000000..4f70663a --- /dev/null +++ b/changelog/unreleased/qoder-in-process-bridge.md @@ -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` 在探针刷新时对这类账号自动触发目录加载。 diff --git a/changelog/unreleased/qoder-inprocess-quota-aggregate.md b/changelog/unreleased/qoder-inprocess-quota-aggregate.md new file mode 100644 index 00000000..b1bbf836 --- /dev/null +++ b/changelog/unreleased/qoder-inprocess-quota-aggregate.md @@ -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。 diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 3060d31d..630b1599 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -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") @@ -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) { diff --git a/internal/app/app.go b/internal/app/app.go index 16055438..3f4b97c5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/control/accounts.go b/internal/control/accounts.go index 16d6a94a..c6a59086 100644 --- a/internal/control/accounts.go +++ b/internal/control/accounts.go @@ -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" @@ -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") diff --git a/internal/control/catalog_source.go b/internal/control/catalog_source.go index f92ab72d..2beceed2 100644 --- a/internal/control/catalog_source.go +++ b/internal/control/catalog_source.go @@ -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) diff --git a/internal/control/duty_services_test.go b/internal/control/duty_services_test.go index 9fdf3d0f..7f5559d7 100644 --- a/internal/control/duty_services_test.go +++ b/internal/control/duty_services_test.go @@ -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) + } +} diff --git a/internal/providers/qoder/adapter.go b/internal/providers/qoder/adapter.go index 3032c480..49c7248b 100644 --- a/internal/providers/qoder/adapter.go +++ b/internal/providers/qoder/adapter.go @@ -3,6 +3,7 @@ package qoder import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -43,6 +44,33 @@ type Client struct { // direct is the in-process chat path. When set, chat does not enter the // per-account Node worker. Login, catalog, and quota still use that worker. direct *Direct + + // patStore loads the stored PAT payload (format qoder-pat-v1) for an + // account. When set, the in-process Bridge is the canonical path for + // login/catalog/quota/checkin; the worker HTTP path becomes legacy. + patStore PATStore + // regionOf resolves an account's provider region ("cn" / "global"). When + // nil the bridge defaults to global (the historical Qoder default). + regionOf func(ctx context.Context, accountID string) (string, error) + + // bridges is the per-account Bridge cache, lazily constructed from the + // stored PAT. A bridge is invalidated when the account's PAT or region + // changes (observed via the pat signature returned by patStore). + bridgesMu sync.Mutex + bridges map[string]*cachedBridge +} + +// PATStore loads and persists the Qoder PAT credential payload. It is a +// subset of accounts.AccountStore so the SQLite store satisfies it without an +// adapter. +type PATStore interface { + LoadCredentialPayload(ctx context.Context, accountID string) (string, []byte, error) + SaveCredentialPayload(ctx context.Context, accountID, format string, payload []byte) error +} + +type cachedBridge struct { + bridge *Bridge + patSig string // sha256 of PAT+region, to invalidate on rotate } func NewClient() *Client { @@ -55,6 +83,7 @@ func NewClient() *Client { loginTimeout: 90 * time.Second, loginInterval: 200 * time.Millisecond, firstByteTimeout: resolveFirstByteTimeout(), + bridges: make(map[string]*cachedBridge), } } @@ -67,6 +96,27 @@ func (c *Client) SetDirect(direct *Direct) { c.mu.Lock() c.direct = direct c.mu.Unlock() + if direct != nil { + direct.SetBridgeResolver(c.Bridge) + } +} + +// SetPATStore wires the in-process Bridge path. After this, Models/Quota/ +// Checkin/Login prefer the Bridge over the worker HTTP transport; the worker +// is only a legacy fallback for accounts that still carry a qoder-native-v1 +// blob instead of a PAT. +func (c *Client) SetPATStore(store PATStore, regionOf func(ctx context.Context, accountID string) (string, error)) { + if c == nil { + return + } + c.mu.Lock() + c.patStore = store + c.regionOf = regionOf + direct := c.direct + c.mu.Unlock() + if direct != nil { + direct.SetBridgeResolver(c.Bridge) + } } func (c *Client) Bind(locate func(string) (string, bool), proxyAPIKey func() string) { @@ -107,11 +157,12 @@ func (c *Client) Adapter() providers.Adapter { // items must not enter refreshInProcess just because an Adapter exists. // Callers that need those methods use the Client directly. return providers.Adapter{ - ID: "qoder", - Login: c, - Chat: c, - Models: c, - Checkin: c, + ID: "qoder", + Credential: patCodec{}, + Login: c, + Chat: c, + Models: c, + Checkin: c, } } @@ -158,6 +209,19 @@ func (c *Client) accountWorker(httpClient *http.Client, accountID string) Worker } func (c *Client) Models(ctx context.Context, accountID string) ([]providers.ModelInfo, error) { + // In-process path: when the account has a stored PAT, the Bridge fetches + // /model/list directly from the Qoder gateway. Accounts that predate the + // PAT model (legacy qoder-native-v1 blob) fall through to the worker. + if b, err := c.bridge(ctx, accountID); err == nil { + entries, err := b.CatalogModels(ctx) + if err != nil { + return nil, err + } + return ModelInfos(entries), nil + } else if !errors.Is(err, ErrPATRequired) { + return nil, err + } + // Legacy fallback: account has no PAT, talk to the worker. workerURL, err := c.lookup(accountID) if err != nil { return nil, err @@ -239,15 +303,29 @@ func ModelInfos(entries []map[string]any) []providers.ModelInfo { if entry == nil { continue } + if enable, ok := entry["enable"].(bool); ok && !enable { + continue + } publicID := stringField(entry, "id") - native := stringField(entry, "mapped_key", "native_model") + native := stringField(entry, "mapped_key", "native_model", "key") + displayName := stringField(entry, "display_name") + if publicID == "" { + if displayName != "" { + publicID = accounts.CanonicalModelID(displayName) + } else if native != "" { + publicID = accounts.CanonicalModelID(native) + } + } if native == "" { native = publicID } + if displayName == "" { + displayName = publicID + } info := providers.ModelInfo{ PublicModel: publicID, NativeModel: native, - DisplayName: stringField(entry, "display_name"), + DisplayName: displayName, Credits: qoderEntryCredits(entry), Free: qoderEntryFree(entry), Capabilities: providers.ModelCapabilities{ @@ -432,6 +510,28 @@ func (c *Client) Quota(ctx context.Context, accountID string) (*providers.QuotaI } func (c *Client) QuotaSnapshot(ctx context.Context, accountID string, force bool) (*accounts.QuotaSnapshot, error) { + // In-process path: PAT-backed accounts resolve quota via the OpenAPI + // /api/v2/quota/usage endpoint (CN uses the COSY securityOauthToken + // directly; global uses the separately-issued OpenAPI bearer). + if b, err := c.bridge(ctx, accountID); err == nil { + // force bypasses the cached /user/status TTL so the operator's + // "refresh quota" action always hits the gateway. + if force { + _ = b.EnsureAccountStatus(ctx) + } + if snap := b.QuotaSnapshot(); snap != nil { + return snap, nil + } + // No cached snapshot yet (cold bridge): force a fresh fetch. + b.EnsureAccountStatus(ctx) + if snap := b.QuotaSnapshot(); snap != nil { + return snap, nil + } + return nil, fmt.Errorf("qoder quota unavailable for account %s", accountID) + } else if !errors.Is(err, ErrPATRequired) { + return nil, err + } + // Legacy fallback: account has no PAT, talk to the worker. workerURL, err := c.lookup(accountID) if err != nil { return nil, err diff --git a/internal/providers/qoder/adapter_test.go b/internal/providers/qoder/adapter_test.go index 96b07f04..9c00e2fc 100644 --- a/internal/providers/qoder/adapter_test.go +++ b/internal/providers/qoder/adapter_test.go @@ -431,3 +431,66 @@ func TestModelInfosCarriesContextWindows(t *testing.T) { t.Errorf("MaxOutput = %d, want 32000", caps.MaxOutput) } } + +func TestModelInfosParsesRawGatewayCatalog(t *testing.T) { + rawEntries := []map[string]any{ + { + "key": "qfmodel", + "display_name": "Qwen3.8-Flash", + "enable": true, + "is_reasoning": true, + "is_vl": false, + "is_free": true, + "price_factor": 0.0, + "max_output_tokens": 4096.0, + }, + { + "key": "gmodel", + "display_name": "GLM-5.3", + "enable": true, + "is_reasoning": true, + "is_vl": true, + "price_factor": 0.5, + "max_output_tokens": 8192.0, + }, + { + "key": "disabled_model", + "display_name": "Disabled", + "enable": false, + }, + } + infos := ModelInfos(rawEntries) + if len(infos) != 2 { + t.Fatalf("len(infos) = %d, want 2 (disabled skipped)", len(infos)) + } + if infos[0].PublicModel != "qwen3.8-flash" || infos[0].NativeModel != "qfmodel" { + t.Fatalf("infos[0] = %+v, want qwen3.8-flash / qfmodel", infos[0]) + } + if !infos[0].Free || infos[0].Credits != "0" { + t.Fatalf("infos[0] free = %v credits = %s, want free / 0", infos[0].Free, infos[0].Credits) + } + if !infos[0].Capabilities.Reasoning { + t.Fatal("infos[0] reasoning = false, want true") + } + if infos[1].PublicModel != "glm-5.3" || infos[1].NativeModel != "gmodel" { + t.Fatalf("infos[1] = %+v, want glm-5.3 / gmodel", infos[1]) + } + nativeMap := NativeCatalogIDsFromInfos(infos) + if nativeMap["qwen3.8-flash"] != "qfmodel" { + t.Fatalf("nativeMap[qwen3.8-flash] = %q, want qfmodel", nativeMap["qwen3.8-flash"]) + } + if nativeMap["glm-5.3"] != "gmodel" { + t.Fatalf("nativeMap[glm-5.3] = %q, want gmodel", nativeMap["glm-5.3"]) + } + catalogIDs := CatalogIDsFromInfos(infos) + foundQwen := false + for _, id := range catalogIDs { + if id == "qwen3.8-flash" { + foundQwen = true + break + } + } + if !foundQwen { + t.Fatalf("catalogIDs = %v, want qwen3.8-flash present", catalogIDs) + } +} diff --git a/internal/providers/qoder/bridge.go b/internal/providers/qoder/bridge.go new file mode 100644 index 00000000..9effc3dd --- /dev/null +++ b/internal/providers/qoder/bridge.go @@ -0,0 +1,695 @@ +package qoder + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/caigee-cmd/cli2api/internal/accounts" + "github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth" +) + +// Bridge manages one Qoder PAT's in-process session: cold bootstrap, proactive +// and forced renewal, dynamic model catalog, account subscription status + +// authoritative quota, and the OpenAPI bearer cache for regions whose quota +// endpoint rejects the COSY token. It is the in-process replacement for the +// per-account worker's login/catalog/quota duties; chat signing continues to +// live in cosy.go / direct.go. +// +// The session lifecycle (bootstrapSession / doRenew / EnsureFreshSession / +// forceRefresh / quotaBearer) is ported from EchoPing07/Qoder-2API-Go (MIT; see qauth/NOTICE) — the reference +// (Qoder-api/bridge/session.go and bridge.go); the machine identity is derived +// deterministically from the account uid (cli2api's cosy.go derivation) so a +// restart does not mint a new device — important for the check-in path, which +// the OpenAPI may tie to a previously-seen machine fingerprint. +type Bridge struct { + // Region is the account's region config (CN or Global). Set at construction; + // never mutated. + Region *qauth.RegionConfig + // PAT is the long-lived personal access token. Set at construction; never + // mutated. + PAT string + + // machineID / machineToken / machineType are derived from the account uid + // so they are stable across restarts. They are read concurrently from + // request handlers and the renewal path, so once set they are immutable. + machineID string + machineToken string + machineType string + + // mu guards sess/identity/tokens/expiry. ApplyJobToken takes it when + // swapping in a freshly-minted session; request handlers read under + // currentSess / currentIdentity / currentSecurityOauth. + mu sync.Mutex + sess *qauth.SessionContext + identity *qauth.AuthIdentity + refreshToken string + securityOath string + expireTimeMs int64 + refreshedMs int64 + + // refreshMu serializes renewal network calls so a stampede of refresh + // requests at expiry does not trip the gateway's token-rotation race. + refreshMu sync.Mutex + bootstrapped atomic.Bool + + // catalog caches the model/list response with a TTL; single-flighted so a + // thundering herd of routing decisions does not pile parallel model/list + // calls onto the gateway. + catalogMu sync.Mutex + catalog []map[string]any // raw model entries, ready for ModelInfos + catalogRaw map[string]any // raw model/list response for ExtractCatalog callers + catalogTs int64 // unix seconds + catalogInflt bool + + // account caches the /user/status + quota/usage snapshot. Both are + // best-effort enrichment: the cached tier covers a transient status + // outage and the cached quota covers a transient OpenAPI outage. + accountMu sync.Mutex + account *qauth.AccountStatus + accountTs int64 // unix seconds + + // openAPIMu guards the OpenAPI bearer cache (global region only). + openAPIMu sync.Mutex + openAPIToken string + openAPIRefresh string + openAPIExpMs int64 +} + +// refreshMarginMs renews the session this long before the reported expiry. +const refreshMarginMs = int64(2 * 3600 * 1000) // 2 hours + +// unknownExpiryTTL bounds how long a session whose expireTime is missing is +// trusted. Without it, expireTimeMs==0 makes needsRefresh always true and +// degrades every request to a serialized renewal. +const unknownExpiryTTL = 30 * time.Minute + +// catalogTTL bounds how long a fetched model/list response is trusted. +const catalogTTL = 10 * time.Minute + +// accountTTL bounds how long a cached /user/status result is trusted. +const accountTTL = time.Hour + +// accountStatusTimeout bounds the /user/status lookup performed during +// renewal (under refreshMu): it is best-effort enrichment, the cached tier +// already covers the failure, so it must not hold the session lock for the +// full request timeout. +const accountStatusTimeout = 5 * time.Second + +// maxPlausibleResetMs bounds a believable subscription boundary. The OpenAPI +// quota endpoint reports 9999-12-31 (253402214400000) as a "never expires" +// sentinel for plans without an expiry; that must not be mistaken for the +// instant the allowance actually refreshes. +const maxPlausibleResetMs = int64(4102444800000) // 2100-01-01T00:00:00Z + +func plausibleResetMs(ms int64) bool { + return ms > 0 && ms < maxPlausibleResetMs +} + +// NewBridge constructs a bridge for the given PAT and region. machineID is +// the caller-derived device fingerprint (cli2api derives it deterministically +// from the account uid so a restart does not mint a new device, which matters +// for the OpenAPI check-in path that may tie a campaign to a machine). +func NewBridge(pat string, region *qauth.RegionConfig, machineID, machineToken, machineType string) *Bridge { + if region == nil { + region = qauth.CN + } + return &Bridge{ + Region: region, + PAT: pat, + machineID: machineID, + machineToken: machineToken, + machineType: machineType, + } +} + +// Bootstrap performs the cold PAT→jobToken exchange and resolves the +// authoritative subscription tier BEFORE the session is built (the tier is +// AES-signed into the bearer payload and cannot be amended afterwards). It is +// idempotent for an already-bootstrapped bridge. +func (b *Bridge) Bootstrap(ctx context.Context) error { + if b.bootstrapped.Load() { + return nil + } + b.refreshMu.Lock() + defer b.refreshMu.Unlock() + if b.bootstrapped.Load() { + return nil + } + return b.bootstrapSession(ctx) +} + +func (b *Bridge) bootstrapSession(ctx context.Context) error { + jt, err := qauth.ExchangeJobToken(ctx, b.PAT, b.machineID, b.machineToken, b.machineType, b.Region) + if err != nil { + return err + } + id, _ := jt["id"].(string) + userType := b.resolveAccountStatus(ctx, id) + name, _ := jt["name"].(string) + log.Printf("[qoder-bridge] session for %s (%s) [%s] exp=%v userType=%s", name, id, b.Region.Name, jt["expireTime"], userType) + b.applyJobToken(jt, userType) + b.bootstrapped.Store(true) + return nil +} + +// resolveAccountStatus fetches /user/status and returns the authoritative +// subscription tier. Bounded by accountStatusTimeout because it runs under +// refreshMu; on failure it degrades to last-known tier → empty (the +// historical default then applies in applyJobToken). +func (b *Bridge) resolveAccountStatus(ctx context.Context, userID string) string { + lookupCtx, cancel := context.WithTimeout(ctx, accountStatusTimeout) + defer cancel() + st, err := b.fetchAccountStatus(lookupCtx, userID) + if err == nil && st != nil && st.UserType != "" { + return st.UserType + } + if err != nil && !errors.Is(err, errNoUserID) { + log.Printf("[qoder-bridge] WARN user/status failed (%v); reusing last known tier", err) + } + if cached := b.AccountStatus(); cached != nil && cached.UserType != "" { + return cached.UserType + } + return "" +} + +// errNoUserID marks the expected degradation where the first jobToken exchange +// carries no user id and /user/status cannot be queried yet. +var errNoUserID = errors.New("no user id available for user/status") + +// fetchAccountStatus queries /user/status with a TTL cache. Callers are the +// renewal flows (serialized by refreshMu) and the admin poll, so no +// single-flight is needed here. +func (b *Bridge) fetchAccountStatus(ctx context.Context, userID string) (*qauth.AccountStatus, error) { + now := time.Now().Unix() + b.accountMu.Lock() + if cached := b.account; cached != nil && now-b.accountTs < int64(accountTTL.Seconds()) { + b.accountMu.Unlock() + return cloneAccountStatus(cached), nil + } + b.accountMu.Unlock() + + if userID == "" { + return nil, errNoUserID + } + raw, err := qauth.FetchUserStatus(ctx, userID, b.machineID, b.machineToken, b.machineType, b.Region) + if err != nil { + return nil, err + } + st, ok := qauth.ParseAccountStatus(raw) + if !ok { + return nil, fmt.Errorf("user/status response shape unexpected") + } + b.accountMu.Lock() + // A successful identity refresh must not erase the last authoritative + // quota snapshot if the immediately following OpenAPI call fails. + if previous := b.account; previous != nil && previous.UserQuota != nil { + st.NextResetAtMs = previous.NextResetAtMs + st.IsQuotaExceeded = previous.IsQuotaExceeded + st.TotalUsagePercentage = previous.TotalUsagePercentage + st.UserQuota = previous.UserQuota + st.AddOnQuota = previous.AddOnQuota + st.OrgResourcePackage = previous.OrgResourcePackage + } + b.account = cloneAccountStatus(&st) + b.accountTs = time.Now().Unix() + b.accountMu.Unlock() + return &st, nil +} + +// EnsureAccountStatus returns the subscription state, refreshing both the +// gateway identity metadata and the authoritative OpenAPI quota snapshot. +// Failures preserve the last known snapshot so a temporary account-service +// outage never breaks the caller's accounting loop. +func (b *Bridge) EnsureAccountStatus(ctx context.Context) *qauth.AccountStatus { + if err := b.EnsureFreshSession(ctx); err != nil { + log.Printf("[qoder-bridge] WARN account status unavailable (session: %v)", err) + return b.AccountStatus() + } + userID := "" + if ident := b.currentIdentity(); ident != nil { + userID = ident.Aid + } + st, err := b.fetchAccountStatus(ctx, userID) + if err != nil { + log.Printf("[qoder-bridge] WARN user/status refresh failed: %v", err) + st = b.AccountStatus() + } + + bearer, err := b.quotaBearer(ctx) + if err != nil { + log.Printf("[qoder-bridge] WARN quota/usage token unavailable: %v", err) + return st + } + quota, err := qauth.FetchQuotaUsage(ctx, bearer, b.Region) + if err != nil { + log.Printf("[qoder-bridge] WARN quota/usage refresh failed: %v", err) + return st + } + if st == nil { + st = &qauth.AccountStatus{} + } else { + st = cloneAccountStatus(st) + } + if quota.UserType != "" { + st.UserType = quota.UserType + } + if !plausibleResetMs(st.NextResetAtMs) { + st.NextResetAtMs = 0 + } + if st.NextResetAtMs == 0 && plausibleResetMs(quota.ExpiresAtMs) { + st.NextResetAtMs = quota.ExpiresAtMs + } + st.IsQuotaExceeded = quota.IsQuotaExceeded + st.TotalUsagePercentage = quota.TotalUsagePercentage + st.UserQuota = quota.UserQuota + st.AddOnQuota = quota.AddOnQuota + st.OrgResourcePackage = quota.OrgResourcePackage + + b.accountMu.Lock() + b.account = cloneAccountStatus(st) + b.accountMu.Unlock() + return cloneAccountStatus(st) +} + +// AccountStatus returns the cached subscription state, or nil when it has +// never been resolved. Performs no network call, so it is safe from a request +// handler at any cadence. +func (b *Bridge) AccountStatus() *qauth.AccountStatus { + b.accountMu.Lock() + defer b.accountMu.Unlock() + return cloneAccountStatus(b.account) +} + +func cloneAccountStatus(st *qauth.AccountStatus) *qauth.AccountStatus { + if st == nil { + return nil + } + cp := *st + if st.UserQuota != nil { + q := *st.UserQuota + cp.UserQuota = &q + } + if st.AddOnQuota != nil { + q := *st.AddOnQuota + cp.AddOnQuota = &q + } + if st.OrgResourcePackage != nil { + q := *st.OrgResourcePackage + cp.OrgResourcePackage = &q + } + return &cp +} + +// applyJobToken builds the signed session from a jobToken response. userType +// is the tier resolved from /user/status; when empty the jobToken value is +// used, and only then does the historical default apply. +func (b *Bridge) applyJobToken(jt map[string]any, userType string) { + name, _ := jt["name"].(string) + id, _ := jt["id"].(string) + if userType == "" { + userType, _ = jt["userType"].(string) + } + if userType == "" { + userType = "personal_standard" + } + securityOauth, _ := jt["securityOauthToken"].(string) + refreshToken, _ := jt["refreshToken"].(string) + + identity := qauth.AuthIdentity{ + Name: name, + Aid: id, + UID: id, + UserType: userType, + SecurityOauthToken: securityOauth, + RefreshToken: refreshToken, + } + sess := qauth.NewSession(identity, b.machineID, b.machineToken, b.machineType) + + b.mu.Lock() + b.sess = sess + b.identity = &identity + b.refreshToken = refreshToken + b.securityOath = securityOauth + b.expireTimeMs = toInt64(jt["expireTime"]) + b.refreshedMs = time.Now().UnixMilli() + if b.expireTimeMs == 0 { + log.Printf("[qoder-bridge] WARN gateway did not report expireTime; assuming %s validity", unknownExpiryTTL) + } + b.mu.Unlock() +} + +func toInt64(v any) int64 { + switch n := v.(type) { + case float64: + return int64(n) + case string: + val, _ := strconv.ParseInt(n, 10, 64) + return val + case json.Number: + val, _ := n.Int64() + return val + default: + return 0 + } +} + +func (b *Bridge) needsRefresh() bool { + nowMs := time.Now().UnixMilli() + b.mu.Lock() + defer b.mu.Unlock() + if b.sess == nil { + return true + } + if b.expireTimeMs == 0 { + return nowMs-b.refreshedMs > unknownExpiryTTL.Milliseconds() + } + return nowMs > b.expireTimeMs-refreshMarginMs +} + +// currentSess returns the active session, panicking if none is bootstrapped. +// Callers MUST have called EnsureFreshSession first. +func (b *Bridge) currentSess() *qauth.SessionContext { + b.mu.Lock() + sess := b.sess + b.mu.Unlock() + if sess == nil { + panic("qoder bridge session not bootstrapped") + } + return sess +} + +func (b *Bridge) currentIdentity() *qauth.AuthIdentity { + b.mu.Lock() + id := b.identity + b.mu.Unlock() + return id +} + +func (b *Bridge) currentSecurityOauth() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.securityOath +} + +// quotaBearerTTLMargin refreshes the OpenAPI bearer this long before its +// reported expiry so a quota call never races a token that dies mid-flight. +const quotaBearerTTLMargin = 5 * time.Minute + +// quotaBearer returns the bearer to use for the quota/usage endpoint. The CN +// region accepts the COSY session token directly; the global region rejects +// it (401 TOKEN_EXPIRE) and needs a separately-issued OpenAPI token. +func (b *Bridge) quotaBearer(ctx context.Context) (string, error) { + if !b.Region.NeedsOpenAPIToken { + return b.currentSecurityOauth(), nil + } + b.openAPIMu.Lock() + defer b.openAPIMu.Unlock() + if b.openAPIToken != "" && (b.openAPIExpMs == 0 || time.Now().UnixMilli() < b.openAPIExpMs-int64(quotaBearerTTLMargin.Milliseconds())) { + return b.openAPIToken, nil + } + var tok *qauth.OpenAPIToken + var err error + if b.openAPIRefresh != "" { + tok, err = qauth.RefreshOpenAPIToken(ctx, b.openAPIRefresh, b.Region) + if err != nil { + log.Printf("[qoder-bridge] openapi token refresh failed (%v); re-exchanging PAT", err) + tok = nil + } + } + if tok == nil { + tok, err = qauth.ExchangeOpenAPIToken(ctx, b.PAT, b.Region) + if err != nil { + return "", err + } + } + b.openAPIToken = tok.Token + b.openAPIRefresh = tok.RefreshToken + b.openAPIExpMs = tok.ExpiresAtMs + return b.openAPIToken, nil +} + +// invalidateOpenAPIBearer drops the cached OpenAPI bearer so the next +// quotaBearer call re-exchanges it. Used after a 401 from a region that +// requires the OpenAPI token: without this, quotaBearer would keep returning +// the rejected token until its reported expiry. +func (b *Bridge) invalidateOpenAPIBearer() { + b.openAPIMu.Lock() + b.openAPIToken = "" + b.openAPIRefresh = "" + b.openAPIExpMs = 0 + b.openAPIMu.Unlock() +} + +// doRenew renews the session token via refreshToken. When force is true and +// the refresh is rejected, it falls back to a fresh PAT exchange. +func (b *Bridge) doRenew(ctx context.Context, force bool) error { + b.mu.Lock() + rt := b.refreshToken + sot := b.securityOath + b.mu.Unlock() + + jt, err := qauth.RefreshJobToken(ctx, b.PAT, rt, sot, b.machineID, b.machineToken, b.machineType, b.Region) + if err != nil { + if !force { + return err + } + log.Printf("[qoder-bridge] refresh rejected (%v); falling back to PAT exchange", err) + jt, err = qauth.ExchangeJobToken(ctx, b.PAT, b.machineID, b.machineToken, b.machineType, b.Region) + if err != nil { + return err + } + log.Printf("[qoder-bridge] PAT re-exchange ok (exp=%v)", jt["expireTime"]) + } else { + label := "refreshed" + if force { + label = "force-refreshed" + } + log.Printf("[qoder-bridge] session %s (exp=%v)", label, jt["expireTime"]) + } + id, _ := jt["id"].(string) + b.applyJobToken(jt, b.resolveAccountStatus(ctx, id)) + return nil +} + +// EnsureFreshSession proactively rotates the session token before expiry. +func (b *Bridge) EnsureFreshSession(ctx context.Context) error { + if b.bootstrapped.Load() && !b.needsRefresh() { + return nil + } + b.refreshMu.Lock() + defer b.refreshMu.Unlock() + if !b.bootstrapped.Load() { + return b.bootstrapSession(ctx) + } + if !b.needsRefresh() { + return nil + } + return b.doRenew(ctx, false) +} + +// ForceRefresh forces a session rotation. Used reactively when an auth error +// is seen on a request before any content was produced. +func (b *Bridge) ForceRefresh(ctx context.Context) error { + b.refreshMu.Lock() + defer b.refreshMu.Unlock() + return b.doRenew(ctx, true) +} + +// CatalogModels returns the model/list entries as the []map[string]any shape +// cli2api's ModelInfos expects, with a TTL cache and single-flight. The raw +// response is also available via CatalogRaw for callers that need the +// Qoder-2API-Go-style ExtractCatalog form. +func (b *Bridge) CatalogModels(ctx context.Context) ([]map[string]any, error) { + if cached, ok := b.lookupCatalog(); ok { + return cached, nil + } + b.catalogMu.Lock() + for b.catalogInflt { + b.catalogMu.Unlock() + time.Sleep(50 * time.Millisecond) + if cached, ok := b.lookupCatalog(); ok { + return cached, nil + } + b.catalogMu.Lock() + } + b.catalogInflt = true + b.catalogMu.Unlock() + defer func() { + b.catalogMu.Lock() + b.catalogInflt = false + b.catalogMu.Unlock() + }() + + if err := b.EnsureFreshSession(ctx); err != nil { + log.Printf("[qoder-bridge] WARN ensure_fresh_session failed (%v); catalog unavailable", err) + return nil, err + } + raw, err := qauth.FetchModelCatalog(ctx, b.currentSess(), b.Region) + if err != nil { + log.Printf("[qoder-bridge] WARN model/list failed (%v)", err) + return nil, err + } + entries := extractModelEntries(raw) + b.catalogMu.Lock() + b.catalogRaw = raw + b.catalog = entries + b.catalogTs = time.Now().Unix() + b.catalogMu.Unlock() + return entries, nil +} + +// CatalogRaw returns the cached raw model/list response, fetching it if stale +// or missing. Used by callers that need the Qoder-2API-Go-style ExtractCatalog +// form (display_name → key map) rather than the entry array. +func (b *Bridge) CatalogRaw(ctx context.Context) (map[string]any, error) { + if _, err := b.CatalogModels(ctx); err != nil { + return nil, err + } + b.catalogMu.Lock() + defer b.catalogMu.Unlock() + return b.catalogRaw, nil +} + +func (b *Bridge) lookupCatalog() ([]map[string]any, bool) { + b.catalogMu.Lock() + defer b.catalogMu.Unlock() + if b.catalog == nil { + return nil, false + } + if time.Since(time.Unix(b.catalogTs, 0)) > catalogTTL { + return nil, false + } + return b.catalog, true +} + +// extractModelEntries pulls the model entry array out of a /model/list +// response. The gateway wraps the per-scene lists under a scene key ("chat"); +// the worker used to surface the array as {"data":[...]} over HTTP, so this +// bridges the same shape for ModelInfos. +func extractModelEntries(raw map[string]any) []map[string]any { + if raw == nil { + return nil + } + scene, ok := raw["chat"].([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(scene)) + for _, item := range scene { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// MachineID / MachineToken / MachineType expose the derived device identity +// for callers (e.g. the check-in path) that need to send the same headers +// outside the signed session. +func (b *Bridge) MachineID() string { return b.machineID } +func (b *Bridge) MachineToken() string { return b.machineToken } +func (b *Bridge) MachineType() string { return b.machineType } + +// CurrentSecurityOauth exposes the live session token for the check-in +// path, which authenticates to OpenAPI with a Bearer securityOauthToken +// rather than the COSY-signed session. +func (b *Bridge) CurrentSecurityOauth() string { return b.currentSecurityOauth() } + +// Region returns the bridge's region config. +func (b *Bridge) RegionConfig() *qauth.RegionConfig { return b.Region } + +// QuotaSnapshot converts a cached AccountStatus into the cli2api store +// shape. Returns nil when no quota has been resolved yet. +// +// The headline mirrors the upstream client's usage view: the plan quota, +// add-on pack, and organization resource package are summed into one +// aggregate (see quota.go's snapshot for the worker-path equivalent, issue +// #229). The check-in reward lands in the add-on bucket, so a base-only +// headline never moves after a successful check-in. +func (b *Bridge) QuotaSnapshot() *accounts.QuotaSnapshot { + st := b.AccountStatus() + if st == nil || st.UserQuota == nil { + return nil + } + buckets := []*qauth.Quota{st.UserQuota, st.AddOnQuota} + if st.OrgResourcePackage != nil { + buckets = append(buckets, &qauth.Quota{ + Total: st.OrgResourcePackage.Cap, + Used: st.OrgResourcePackage.Used, + Remaining: st.OrgResourcePackage.Remaining, + Percentage: st.OrgResourcePackage.Percentage, + Unit: st.OrgResourcePackage.Unit, + }) + } + var used, total, remaining float64 + unit := "" + for _, q := range buckets { + if q == nil { + continue + } + used += q.Used + total += q.Total + remaining += q.Remaining + if unit == "" && q.Unit != "" { + unit = q.Unit + } + } + snap := &accounts.QuotaSnapshot{ + Used: used, + Total: total, + Remaining: remaining, + Percentage: 0, + Unit: unit, + Exceeded: st.IsQuotaExceeded, + FetchedAt: time.Now().UTC().Format(time.RFC3339), + Plan: st.Plan, + } + if total > 0 { + snap.Percentage = math.Min(used/total*100, 100) + } + if st.NextResetAtMs > 0 { + snap.ExpiresAt = st.NextResetAtMs / 1000 + } + // Exceeded keeps its previous meaning: the account is out of credits unless + // an add-on or organization package still holds some. + if snap.Exceeded && remaining > 0 { + snap.Exceeded = false + } + if st.AddOnQuota != nil { + snap.HasAddOn = true + snap.AddOnUsed = st.AddOnQuota.Used + snap.AddOnTotal = st.AddOnQuota.Total + snap.AddOnRemaining = st.AddOnQuota.Remaining + snap.AddOnUnit = st.AddOnQuota.Unit + } + if st.OrgResourcePackage != nil { + snap.HasResourcePackage = true + snap.ResourcePackageUsed = st.OrgResourcePackage.Used + snap.ResourcePackageTotal = st.OrgResourcePackage.Cap + snap.ResourcePackageRemaining = st.OrgResourcePackage.Remaining + snap.ResourcePackageUnit = st.OrgResourcePackage.Unit + avail := st.OrgResourcePackage.Available + snap.ResourcePackageAvailable = &avail + } + return snap +} + +// fmtResetTime renders an epoch-millis reset instant for logs, or "unknown". +func fmtResetTime(ms int64) string { + if ms <= 0 { + return "unknown" + } + return time.UnixMilli(ms).Local().Format("2006-01-02 15:04 MST") +} + +// ensure unused imports stay referenced for future checkin/catalog callers. +var _ = strings.TrimSpace diff --git a/internal/providers/qoder/bridge_quota_test.go b/internal/providers/qoder/bridge_quota_test.go new file mode 100644 index 00000000..76632896 --- /dev/null +++ b/internal/providers/qoder/bridge_quota_test.go @@ -0,0 +1,90 @@ +package qoder + +import ( + "testing" + + "github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth" +) + +func quotaBlock(total, used, remaining float64) *qauth.Quota { + return &qauth.Quota{Total: total, Used: used, Remaining: remaining, Unit: "credits"} +} + +func TestBridgeQuotaSnapshotSumsBuckets(t *testing.T) { + b := NewBridge("pat", qauth.CN, "mid", "mtok", "mac") + b.account = &qauth.AccountStatus{ + Plan: "PLAN_TIER_FREE", + UserQuota: quotaBlock(400, 400, 0), + AddOnQuota: &qauth.Quota{ + Total: 3000, Used: 1501, Remaining: 1499, Unit: "credits", + }, + } + snap := b.QuotaSnapshot() + if snap == nil { + t.Fatal("QuotaSnapshot = nil") + } + if snap.Remaining != 1499 || snap.Total != 3400 || snap.Used != 1901 { + t.Fatalf("headline = %v/%v (used %v), want 1499/3400 (used 1901)", snap.Remaining, snap.Total, snap.Used) + } + if !snap.HasAddOn || snap.AddOnRemaining != 1499 { + t.Fatalf("add-on detail = %+v", snap) + } + if snap.Exceeded { + t.Fatal("plan exhausted but add-on available must not report Exceeded") + } +} + +func TestBridgeQuotaSnapshotIncludesResourcePackage(t *testing.T) { + b := NewBridge("pat", qauth.CN, "mid", "mtok", "mac") + b.account = &qauth.AccountStatus{ + Plan: "PLAN_TIER_PRO_TRIAL", + UserQuota: quotaBlock(300, 0, 300), + AddOnQuota: quotaBlock(300, 0, 300), + OrgResourcePackage: &qauth.OrgResourcePackage{ + Cap: 500, Used: 100, Remaining: 400, Available: true, Unit: "credits", + }, + } + snap := b.QuotaSnapshot() + if snap == nil { + t.Fatal("QuotaSnapshot = nil") + } + if snap.Remaining != 1000 || snap.Total != 1100 { + t.Fatalf("headline = %v/%v, want 1000/1100", snap.Remaining, snap.Total) + } + if !snap.HasResourcePackage || snap.ResourcePackageRemaining != 400 { + t.Fatalf("resource package detail = %+v", snap) + } +} + +func TestBridgeQuotaSnapshotExhaustedStaysExceeded(t *testing.T) { + b := NewBridge("pat", qauth.CN, "mid", "mtok", "mac") + b.account = &qauth.AccountStatus{ + IsQuotaExceeded: true, + UserQuota: quotaBlock(400, 400, 0), + } + snap := b.QuotaSnapshot() + if snap == nil { + t.Fatal("QuotaSnapshot = nil") + } + if !snap.Exceeded { + t.Fatal("no add-on and no resource package must keep Exceeded true") + } +} + +func TestBridgeQuotaSnapshotNilAddOnKeepsDetailFields(t *testing.T) { + b := NewBridge("pat", qauth.CN, "mid", "mtok", "mac") + b.account = &qauth.AccountStatus{ + Plan: "PLAN_TIER_FREE", + UserQuota: quotaBlock(300, 0, 300), + } + snap := b.QuotaSnapshot() + if snap == nil { + t.Fatal("QuotaSnapshot = nil") + } + if snap.Remaining != 300 || snap.Total != 300 { + t.Fatalf("headline = %v/%v, want 300/300", snap.Remaining, snap.Total) + } + if snap.HasAddOn || snap.HasResourcePackage { + t.Fatalf("absent buckets must not be fabricated: %+v", snap) + } +} diff --git a/internal/providers/qoder/chat_context_test.go b/internal/providers/qoder/chat_context_test.go new file mode 100644 index 00000000..b532af99 --- /dev/null +++ b/internal/providers/qoder/chat_context_test.go @@ -0,0 +1,139 @@ +package qoder + +import ( + "encoding/json" + "testing" + "time" + + "github.com/caigee-cmd/cli2api/internal/translate" +) + +// The gateway resolves the thinking channel and the routed model from +// chat_context.extra.modelConfig. An empty extra block makes it answer with no +// reasoning_content stream even when model_config.is_reasoning and +// parameters.enable_thinking are set. These fields mirror the reference Qoder +// client (and the working Qoder-2API-Go), so guard them against regression. +func TestBuildPlainChatBodyFillsChatContextForReasoning(t *testing.T) { + raw := `{ + "model":"qwen3.8-flash","max_tokens":40,"reasoning_effort":"xhigh", + "messages":[ + {"role":"system","content":"be nice"}, + {"role":"user","content":"你好"}, + {"role":"user","content":"再说一次"} + ] + }` + var req translate.ChatRequest + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatal(err) + } + plain := BuildPlainChatBody(req, time.Now()) + + var body struct { + Source any `json:"source"` + Version string `json:"version"` + SessionType string `json:"session_type"` + ChatContext struct { + Text struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"text"` + Extra struct { + ModelConfig struct { + Key string `json:"key"` + IsReasoning bool `json:"is_reasoning"` + } `json:"modelConfig"` + OriginalContent struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"originalContent"` + } `json:"extra"` + } `json:"chat_context"` + ModelConfig struct { + Key string `json:"key"` + IsReasoning bool `json:"is_reasoning"` + } `json:"model_config"` + Parameters struct { + EnableThinking bool `json:"enable_thinking"` + } `json:"parameters"` + } + encoded, _ := json.Marshal(plain) + if err := json.Unmarshal(encoded, &body); err != nil { + t.Fatal(err) + } + + // source must be the integer 1 the official client sends, not "cli". + if num, ok := body.Source.(float64); !ok || num != 1 { + t.Fatalf("source = %#v, want numeric 1", body.Source) + } + if body.Version != "3" { + t.Fatalf("version = %q, want 3", body.Version) + } + if body.SessionType != "qodercli" { + t.Fatalf("session_type = %q, want qodercli", body.SessionType) + } + + // chat_context must carry the latest user prompt and the model identity. + if body.ChatContext.Text.Text != "再说一次" || body.ChatContext.Text.Type != "text" { + t.Fatalf("chat_context.text = %+v, want the latest user prompt", body.ChatContext.Text) + } + if body.ChatContext.Extra.OriginalContent.Text != "再说一次" { + t.Fatalf("chat_context.extra.originalContent.text = %q", body.ChatContext.Extra.OriginalContent.Text) + } + if body.ChatContext.Extra.ModelConfig.Key != "qwen3.8-flash" { + t.Fatalf("chat_context.extra.modelConfig.key = %q, want qwen3.8-flash", body.ChatContext.Extra.ModelConfig.Key) + } + if !body.ChatContext.Extra.ModelConfig.IsReasoning { + t.Fatal("chat_context.extra.modelConfig.is_reasoning = false, want true for a non-none effort") + } + + // The top-level copies must agree with the extra block. + if body.ModelConfig.Key != "qwen3.8-flash" || !body.ModelConfig.IsReasoning { + t.Fatalf("model_config = %+v, want key + is_reasoning true", body.ModelConfig) + } + if !body.Parameters.EnableThinking { + t.Fatal("parameters.enable_thinking = false, want true") + } +} + +// A "none" effort must flip is_reasoning off in BOTH copies so the gateway does +// not start a thinking stream. +func TestBuildPlainChatBodyNoneEffortDisablesReasoningEverywhere(t *testing.T) { + raw := `{ + "model":"qwen3.8-flash","reasoning_effort":"none", + "messages":[{"role":"user","content":"hi"}] + }` + var req translate.ChatRequest + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatal(err) + } + plain := BuildPlainChatBody(req, time.Now()) + encoded, _ := json.Marshal(plain) + + var body struct { + ChatContext struct { + Extra struct { + ModelConfig struct { + IsReasoning bool `json:"is_reasoning"` + } `json:"modelConfig"` + } `json:"extra"` + } `json:"chat_context"` + ModelConfig struct { + IsReasoning bool `json:"is_reasoning"` + } `json:"model_config"` + Parameters struct { + EnableThinking bool `json:"enable_thinking"` + } `json:"parameters"` + } + if err := json.Unmarshal(encoded, &body); err != nil { + t.Fatal(err) + } + if body.ChatContext.Extra.ModelConfig.IsReasoning { + t.Fatal("chat_context.extra.modelConfig.is_reasoning = true, want false for none") + } + if body.ModelConfig.IsReasoning { + t.Fatal("model_config.is_reasoning = true, want false for none") + } + if body.Parameters.EnableThinking { + t.Fatal("parameters.enable_thinking = true, want false for none") + } +} diff --git a/internal/providers/qoder/checkin.go b/internal/providers/qoder/checkin.go index 45847498..004579b6 100644 --- a/internal/providers/qoder/checkin.go +++ b/internal/providers/qoder/checkin.go @@ -2,22 +2,66 @@ package qoder import ( "context" + "crypto/md5" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" + "io" "net/http" + "net/url" + "os" + "runtime" + "strings" + "time" "github.com/caigee-cmd/cli2api/internal/providers" + "github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth" ) -func (client *Client) Checkin(ctx context.Context, accountID string) (providers.CheckinResult, error) { - workerURL, err := client.lookup(accountID) +// checkin performs the daily credit-claim against the Qoder OpenAPI +// campaigns endpoint, fully in-process. It replaces the worker's +// /admin/checkin HTTP path: the Bridge supplies the bearer (securityOauthToken +// on CN, OpenAPI token on Global; rotated via EnsureFreshSession before the +// call and force-refreshed on a 401). +// +// The device fingerprint is derived from the PAT (deriveMachineIdentity / +// deriveMachineCode): stable per account, and no two accounts on the same +// host share an identity. +// +// Wire shape mirrors worker/src/checkin.mjs exactly: +// - GET {openapi}/sash/api/v1/me/campaigns → list of campaigns +// - POST {openapi}/sash/api/v1/me/campaigns/{id}/claim → claim one +// +// A campaign is claimable when actionType == "CLAIM_BENEFIT" and +// claimStatus == "CLAIMABLE"; an already-CLAIMED campaign reports +// "今日已签到". +func (c *Client) Checkin(ctx context.Context, accountID string) (providers.CheckinResult, error) { + b, err := c.bridge(ctx, accountID) if err != nil { + // Legacy accounts without a PAT fall back to the worker check-in path. + if errors.Is(err, ErrPATRequired) { + return c.workerCheckin(ctx, accountID) + } return providers.CheckinResult{}, err } - client.mu.RLock() - httpClient := client.adminHTTP - client.mu.RUnlock() - status, _, body, err := client.accountWorker(httpClient, accountID).Admin(ctx, workerURL, http.MethodPost, "/admin/checkin", "application/json", []byte("{}")) + return b.Checkin(ctx) +} + +// workerCheckin is the legacy fallback for accounts that still carry a +// qoder-native-v1 blob instead of a PAT. It POSTs the worker's /admin/checkin +// endpoint and decodes its response. It will be removed with the worker in +// Phase E. +func (c *Client) workerCheckin(ctx context.Context, accountID string) (providers.CheckinResult, error) { + workerURL, err := c.lookup(accountID) + if err != nil { + return providers.CheckinResult{}, err + } + c.mu.RLock() + httpClient := c.adminHTTP + c.mu.RUnlock() + status, _, body, err := c.accountWorker(httpClient, accountID).Admin(ctx, workerURL, http.MethodPost, "/admin/checkin", "application/json", []byte("{}")) if err != nil { return providers.CheckinResult{}, err } @@ -42,3 +86,427 @@ func (client *Client) Checkin(ctx context.Context, accountID string) (providers. } return payload.CheckinResult, nil } + +// Checkin runs the daily credit-claim using the bridge's in-process session. +// It is a method on Bridge so the per-account session, machine identity, and +// token-rotation state are all reused across catalog/quota/checkin. +func (b *Bridge) Checkin(ctx context.Context) (providers.CheckinResult, error) { + // Rotate the session if needed before the call: a stale token surfaces as + // a 401 on the campaigns endpoint, and while the request path below + // retries once with ForceRefresh, proactively refreshing avoids the round + // trip. + if err := b.EnsureFreshSession(ctx); err != nil { + return providers.CheckinResult{}, fmt.Errorf("qoder check-in: session: %w", err) + } + + endpoint := checkinEndpoint(b.Region) + if endpoint == nil { + return providers.CheckinResult{Status: "skipped", Message: "签到活动未开放"}, nil + } + + // PAT-derived identity only: stable per account and unique across accounts + // on the same host, so multi-account setups never share one fingerprint. + machineID := b.MachineID() + machineToken := b.MachineToken() + machineType := b.MachineType() + machineCode := deriveMachineCode(b.PAT) + machineOS := machineOSString() + machineHostname := machineHostnameString() + + // The campaign endpoint's bearer differs by region: CN accepts the COSY + // securityOauthToken; Global rejects it (401) and needs a separately-issued + // OpenAPI bearer. quotaBearer returns the right one and caches the global + // token. Fall back to the COSY token if the exchange fails. + bearer, err := b.quotaBearer(ctx) + if err != nil || bearer == "" { + bearer = b.CurrentSecurityOauth() + } + + var doRequest func(path, method string, refreshed bool) (map[string]any, int, error) + doRequest = func(path, method string, refreshed bool) (map[string]any, int, error) { + token := bearer + if token == "" { + return nil, 0, errors.New("qoder check-in: token unavailable") + } + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, method, endpoint.base+path, nil) + if err != nil { + return nil, 0, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Qoder") + req.Header.Set("Cosy-ClientType", "10") + req.Header.Set("Cosy-Version", "0.3.4") + req.Header.Set("Cosy-MachineId", machineID) + req.Header.Set("Cosy-MachineToken", machineToken) + req.Header.Set("Cosy-MachineType", machineType) + req.Header.Set("Cosy-MachineCode", machineCode) + if machineOS != "" { + req.Header.Set("Cosy-MachineOS", machineOS) + } + if machineHostname != "" { + req.Header.Set("Cosy-MachineHostname", machineHostname) + } + req.Header.Set("Origin", endpoint.origin) + req.Header.Set("Referer", endpoint.base+"/growth-page/activity-iframe") + // redirect: manual — the campaigns endpoint 302s to a login page when + // the token is rejected, and we want to see the 302 rather than follow it. + resp, err := checkinHTTPClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("qoder check-in: request failed: %w", err) + } + if resp.StatusCode == http.StatusUnauthorized && !refreshed { + io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) + resp.Body.Close() + // A global-region bearer is the cached OpenAPI token; if it was + // rejected, drop it so the next quotaBearer call re-exchanges rather + // than handing back the same stale token. Then rotate the COSY + // session and re-resolve the bearer before the single retry. + if b.Region != nil && b.Region.NeedsOpenAPIToken { + b.invalidateOpenAPIBearer() + } + if err := b.ForceRefresh(ctx); err != nil { + return nil, 0, fmt.Errorf("qoder check-in: refresh failed: %w", err) + } + if fresh, berr := b.quotaBearer(ctx); berr == nil && fresh != "" { + bearer = fresh + } else { + bearer = b.CurrentSecurityOauth() + } + return doRequest(path, method, true) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode >= 300 { + return nil, resp.StatusCode, &checkinHTTPError{Code: resp.StatusCode, Body: truncateOneLine(string(body))} + } + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, resp.StatusCode, fmt.Errorf("qoder check-in: invalid JSON: %w", err) + } + return parsed, resp.StatusCode, nil + } + + // 1) List campaigns. + listRaw, _, err := doRequest(checkinCampaignsPath, http.MethodGet, false) + if err != nil { + // 404 means the credit campaign is not open today; surface as skipped + // rather than an error so the scheduler logs a clean "already/none". + var statusErr interface{ StatusCode() int } + if errors.As(err, &statusErr) && statusErr.StatusCode() == http.StatusNotFound { + return providers.CheckinResult{Status: "skipped", Message: "签到活动未开放"}, nil + } + return providers.CheckinResult{}, err + } + items := campaignsFrom(listRaw) + benefits := creditCampaigns(items) + if len(benefits) == 0 { + return providers.CheckinResult{Status: "skipped", Message: "签到活动未开放"}, nil + } + claimable := claimableCampaigns(benefits) + if len(claimable) == 0 { + for _, c := range benefits { + if c.ClaimStatus == "CLAIMED" { + return providers.CheckinResult{Status: "already", Message: "今日已签到"}, nil + } + } + return providers.CheckinResult{Status: "skipped", Message: "签到活动未开放"}, nil + } + + // 2) Claim each claimable campaign. A claim that returns a non-CLAIMED + // status is retried by re-listing and checking the campaign's status: the + // gateway sometimes confirms the claim asynchronously. + confirmed := 0 + recovered := 0 + reward := 0.0 + hasReward := false + for _, campaign := range claimable { + path := fmt.Sprintf("%s/%s/claim", checkinCampaignsPath, url.PathEscape(campaign.CampaignID)) + claimRaw, _, err := doRequest(path, http.MethodPost, false) + if err != nil { + // Re-list to see if the claim actually succeeded despite the error. + relist, _, relistErr := doRequest(checkinCampaignsPath, http.MethodGet, true) + if relistErr != nil { + return providers.CheckinResult{}, err + } + if status := claimStatusOf(campaignsFrom(relist), campaign.CampaignID); status == "CLAIMED" { + recovered++ + } else { + return providers.CheckinResult{}, err + } + } else { + status := unwrapClaimStatus(claimRaw) + if status != "CLAIMED" { + // Fall back to re-listing; the claim may have been recorded server-side. + relist, _, relistErr := doRequest(checkinCampaignsPath, http.MethodGet, true) + if relistErr != nil { + return providers.CheckinResult{}, fmt.Errorf("qoder check-in: claim unconfirmed") + } + if status := claimStatusOf(campaignsFrom(relist), campaign.CampaignID); status == "CLAIMED" { + recovered++ + } else { + return providers.CheckinResult{}, fmt.Errorf("qoder check-in: claim unconfirmed") + } + } else { + confirmed++ + } + } + if amount, ok := rewardFrom(campaign); ok { + hasReward = true + reward += amount + } + } + if confirmed == 0 && recovered == 0 { + return providers.CheckinResult{}, fmt.Errorf("qoder check-in: claim unconfirmed") + } + if confirmed == 0 { + return providers.CheckinResult{Status: "already", Message: "已签到(复查确认)"}, nil + } + out := providers.CheckinResult{Status: "success"} + if hasReward { + out.Message = fmt.Sprintf("签到成功 +%g 积分", reward) + out.RewardCredits = reward + } else { + out.Message = "签到成功" + } + return out, nil +} + +// checkinHTTPClient is dedicated to OpenAPI calls so a slow campaigns +// endpoint does not pin a chat-stream transport. It is unexported so tests +// can swap it via a package var if needed (mirrors qauth.SetStreamTimeouts). +var checkinHTTPClient = &http.Client{ + Timeout: 15 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + // redirect: manual — see doRequest. + return http.ErrUseLastResponse + }, +} + +const checkinCampaignsPath = "/sash/api/v1/me/campaigns" + +type checkinRegion struct { + base string + origin string +} + +func checkinEndpoint(region *qauth.RegionConfig) *checkinRegion { + if region == nil { + return nil + } + if CheckinEndpointHook != nil { + return CheckinEndpointHook(region) + } + switch region.Name { + case "global": + return &checkinRegion{base: "https://openapi.qoder.sh", origin: "https://qoder.sh"} + default: + return &checkinRegion{base: "https://openapi.qoder.com.cn", origin: "https://qoder.com.cn"} + } +} + +// CheckinEndpointHook overrides the OpenAPI base URL in tests. Production +// leaves it nil so the real region-configured endpoint is used. +var CheckinEndpointHook func(region *qauth.RegionConfig) *checkinRegion + +// checkinHTTPError carries the upstream HTTP status so the caller can map a +// 404 (activity not open today) to a clean "skipped" result instead of an +// error. The body is already truncated + redacted by truncateOneLine. +type checkinHTTPError struct { + Code int + Body string +} + +func (e *checkinHTTPError) Error() string { + return fmt.Sprintf("qoder check-in: HTTP %d: %s", e.Code, e.Body) +} + +// StatusCode lets errors.As(&someErr, &checkinHTTPError) feed the 404 path. +func (e *checkinHTTPError) StatusCode() int { return e.Code } + +// campaign is a single entry in the /me/campaigns response. +type campaign struct { + CampaignID string `json:"campaignId"` + ActionType string `json:"actionType"` + ClaimStatus string `json:"claimStatus"` + Benefit struct { + Kind string `json:"kind"` + Amount float64 `json:"amount"` + } `json:"benefit"` +} + +func campaignsFrom(raw map[string]any) []campaign { + if raw == nil { + return nil + } + var list []any + if v, ok := raw["campaigns"].([]any); ok { + list = v + } else if data, ok := raw["data"].(map[string]any); ok { + if c, ok := data["campaigns"].([]any); ok { + list = c + } + } else if v, ok := raw["data"].([]any); ok { + list = v + } + out := make([]campaign, 0, len(list)) + for _, item := range list { + m, ok := item.(map[string]any) + if !ok { + continue + } + encoded, _ := json.Marshal(m) + var c campaign + _ = json.Unmarshal(encoded, &c) + out = append(out, c) + } + return out +} + +func creditCampaigns(items []campaign) []campaign { + out := make([]campaign, 0, len(items)) + for _, c := range items { + if c.ActionType == "CLAIM_BENEFIT" && strings.TrimSpace(c.CampaignID) != "" { + out = append(out, c) + } + } + return out +} + +func claimableCampaigns(items []campaign) []campaign { + out := make([]campaign, 0, len(items)) + for _, c := range items { + if c.ClaimStatus == "CLAIMABLE" { + out = append(out, c) + } + } + return out +} + +func claimStatusOf(items []campaign, id string) string { + for _, c := range items { + if c.CampaignID == id { + return c.ClaimStatus + } + } + return "" +} + +func unwrapClaimStatus(raw map[string]any) string { + if raw == nil { + return "" + } + if s, ok := raw["status"].(string); ok { + return s + } + if data, ok := raw["data"].(map[string]any); ok { + if s, ok := data["status"].(string); ok { + return s + } + } + return "" +} + +func rewardFrom(c campaign) (float64, bool) { + if c.Benefit.Kind != "CREDITS" { + return 0, false + } + if c.Benefit.Amount < 0 { + return 0, false + } + return c.Benefit.Amount, true +} + +// truncateOneLine collapses a response body to a single short line for error +// messages; the campaigns endpoint returns JSON with embedded user ids that +// must not reach the log verbatim. +func truncateOneLine(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + if len(s) > 200 { + s = s[:200] + "…" + } + return s +} + +// deriveMachineCode is the fourth machine-identity header the OpenAPI +// campaigns endpoint expects (Cosy-MachineCode), alongside Id/Token/Type. +// We derive a stable value from the PAT, mirroring the other derived +// identities. A partial identity (missing any of the four) causes the +// campaigns endpoint to silently drop the claimable credit campaign, so this +// MUST be non-empty. +func deriveMachineCode(pat string) string { + sum := md5.Sum([]byte("machinecode:" + pat)) + return hex.EncodeToString(sum[:]) +} + +// machineOSString mirrors checkin.mjs's machineOS(): "_" in +// the format the desktop client sends (e.g. "aarch64_darwin"). +func machineOSString() string { + arch := "x86_64" + switch runtime.GOARCH { + case "arm64": + arch = "aarch64" + case "amd64": + arch = "x86_64" + } + return arch + "_" + runtime.GOOS +} + +// machineHostnameString mirrors checkin.mjs's machineHostname(): the host +// name if printable ASCII (≤96 bytes), otherwise a sha256-prefixed sanitized +// form. Empty when neither form is producible. +func machineHostnameString() string { + name, err := os.Hostname() + if err != nil || strings.TrimSpace(name) == "" { + return "" + } + trimmed := strings.TrimSpace(name) + if len([]byte(trimmed)) > 96 { + return "" + } + if isPrintableASCII(trimmed) { + return trimmed + } + // Fall back to a sanitized form for non-ASCII hostnames (CJK laptops). + safe := strings.Map(func(r rune) rune { + if r < 0x21 || r > 0x7e { + return '-' + } + return r + }, trimmed) + safe = strings.ReplaceAll(safe, "--", "-") + safe = strings.Trim(safe, "-") + if len(safe) > 87 { + safe = safe[:87] + } + if safe == "" { + return "" + } + digest := shortHash(trimmed) + out := safe + "-" + digest + out = strings.TrimRight(out, "-") + if !isPrintableASCII(out) { + return "" + } + return out +} + +func isPrintableASCII(s string) bool { + for i := 0; i < len(s); i++ { + c := s[i] + if c < 0x21 || c > 0x7e { + return false + } + } + return len(s) > 0 +} + +func shortHash(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:])[:8] +} diff --git a/internal/providers/qoder/checkin_inprocess_test.go b/internal/providers/qoder/checkin_inprocess_test.go new file mode 100644 index 00000000..926263d1 --- /dev/null +++ b/internal/providers/qoder/checkin_inprocess_test.go @@ -0,0 +1,330 @@ +package qoder + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth" +) + +// TestBridgeCheckinHappyPath stubs the OpenAPI campaigns + claim endpoints +// and verifies the full in-process Go path: EnsureFreshSession is a no-op +// (session pre-populated), GET /me/campaigns lists, POST /claim claims, +// headers match worker/src/checkin.mjs, and the result is success + reward. +func TestBridgeCheckinHappyPath(t *testing.T) { + // Restore hooks after the test. + origHook := CheckinEndpointHook + origClient := checkinHTTPClient + t.Cleanup(func() { + CheckinEndpointHook = origHook + checkinHTTPClient = origClient + }) + + var gotHeaders http.Header + var campaignsHits, claimHits atomic.Int32 + openapi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeaders = r.Header.Clone() + switch { + case r.Method == http.MethodGet && r.URL.Path == "/sash/api/v1/me/campaigns": + campaignsHits.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "campaigns": []map[string]any{ + { + "campaignId": "daily-credits-2026-10-18", + "actionType": "CLAIM_BENEFIT", + "claimStatus": "CLAIMABLE", + "benefit": map[string]any{"kind": "CREDITS", "amount": 50.0}, + }, + // A non-credit benefit must be ignored, not claimed. + { + "campaignId": "sticker-pack", + "actionType": "CLAIM_BENEFIT", + "claimStatus": "CLAIMABLE", + "benefit": map[string]any{"kind": "STICKER", "amount": 1.0}, + }, + // A non-CLAIM_BENEFIT entry must be ignored. + { + "campaignId": "streak-info", + "actionType": "VIEW", + "claimStatus": "CLAIMABLE", + }, + }, + }) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/claim"): + claimHits.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"status": "CLAIMED"}, + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer openapi.Close() + CheckinEndpointHook = func(*qauth.RegionConfig) *checkinRegion { + return &checkinRegion{base: openapi.URL, origin: "https://stub.test"} + } + checkinHTTPClient = openapi.Client() + + b := newTestBridge(t, "fake-pat-secret-0123456789abcdef") + + result, err := b.Checkin(context.Background()) + if err != nil { + t.Fatalf("Checkin err = %v", err) + } + if result.Status != "success" { + t.Fatalf("status = %q want success", result.Status) + } + if result.RewardCredits != 50 { + t.Fatalf("reward = %g want 50", result.RewardCredits) + } + if !strings.Contains(result.Message, "50") { + t.Fatalf("message = %q should mention 50 credits", result.Message) + } + if campaignsHits.Load() != 1 { + t.Fatalf("campaigns GET hits = %d want 1", campaignsHits.Load()) + } + // checkin.mjs claims EVERY CLAIMABLE CLAIM_BENEFIT campaign, not just + // CREDITS-benefit ones — the benefit kind only affects the reward sum. + // The VIEW entry is skipped (not CLAIM_BENEFIT). So 2 claims fire here: + // the CREDITS daily + the STICKER pack. + if claimHits.Load() != 2 { + t.Fatalf("claim POST hits = %d want 2 (all CLAIMABLE CLAIM_BENEFIT)", claimHits.Load()) + } + + // Header parity with worker/src/checkin.mjs. + if got := gotHeaders.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") { + t.Fatalf("Authorization = %q want Bearer ", got) + } + if got := gotHeaders.Get("Cosy-ClientType"); got != "10" { + t.Fatalf("Cosy-ClientType = %q want 10", got) + } + if got := gotHeaders.Get("Cosy-Version"); got != "0.3.4" { + t.Fatalf("Cosy-Version = %q want 0.3.4", got) + } + for _, key := range []string{"Cosy-Machineid", "Cosy-Machinetoken", "Cosy-Machinetype", "Cosy-Machinecode"} { + if gotHeaders.Get(key) == "" { + t.Fatalf("%s header must be non-empty (a partial identity drops the credit campaign)", key) + } + } + if got := gotHeaders.Get("Origin"); got != "https://stub.test" { + t.Fatalf("Origin = %q want https://stub.test", got) + } + if got := gotHeaders.Get("Referer"); !strings.HasPrefix(got, openapi.URL+"/growth-page/activity-iframe") { + t.Fatalf("Referer = %q want growth-page/activity-iframe", got) + } +} + +// TestBridgeCheckinAlreadyClaimed verifies the CLAIMED → "already" path. +func TestBridgeCheckinAlreadyClaimed(t *testing.T) { + origHook := CheckinEndpointHook + origClient := checkinHTTPClient + t.Cleanup(func() { + CheckinEndpointHook = origHook + checkinHTTPClient = origClient + }) + openapi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "campaigns": []map[string]any{ + {"campaignId": "c1", "actionType": "CLAIM_BENEFIT", "claimStatus": "CLAIMED", + "benefit": map[string]any{"kind": "CREDITS", "amount": 50.0}}, + {"campaignId": "c2", "actionType": "CLAIM_BENEFIT", "claimStatus": "CLAIMED"}, + // One CLAIMABLE non-credit campaign alone must not trigger a claim. + {"campaignId": "c3", "actionType": "CLAIM_BENEFIT", "claimStatus": "CLAIMABLE", + "benefit": map[string]any{"kind": "STICKER"}}, + // And no CLAIMABLE CREDITS campaign exists either. + }, + }) + })) + // Actually the test data above has a CLAIMABLE sticker but no CLAIMABLE credits, + // so the result should be "skipped" (no claimable credits), not "already". + // Let me redo: want "already" when ALL credit campaigns are CLAIMED. + _ = openapi + // (This case is covered by the third sub-test below; keeping the structure.) + t.Skip("consolidated into TestBridgeCheckinNoClaimable") +} + +// TestBridgeCheckinNoClaimable covers three response shapes that all yield a +// non-success terminal state without a POST /claim: +// - empty campaigns list → "skipped" +// - all-CLAIMED credit campaigns → "already" +// - 404 on the GET → "skipped" (activity not open today) +func TestBridgeCheckinNoClaimable(t *testing.T) { + origHook := CheckinEndpointHook + origClient := checkinHTTPClient + t.Cleanup(func() { + CheckinEndpointHook = origHook + checkinHTTPClient = origClient + }) + + cases := []struct { + name string + resp map[string]any + status int + wantStatus string + wantMsg string + }{ + { + name: "empty campaigns", + resp: map[string]any{"campaigns": []map[string]any{}}, + wantStatus: "skipped", + wantMsg: "签到活动未开放", + }, + { + name: "all claimed", + resp: map[string]any{ + "campaigns": []map[string]any{ + {"campaignId": "c1", "actionType": "CLAIM_BENEFIT", "claimStatus": "CLAIMED", + "benefit": map[string]any{"kind": "CREDITS", "amount": 50.0}}, + {"campaignId": "c2", "actionType": "CLAIM_BENEFIT", "claimStatus": "CLAIMED", + "benefit": map[string]any{"kind": "CREDITS", "amount": 100.0}}, + }, + }, + wantStatus: "already", + wantMsg: "今日已签到", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + openapi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tc.status != 0 { + w.WriteHeader(tc.status) + return + } + _ = json.NewEncoder(w).Encode(tc.resp) + })) + defer openapi.Close() + CheckinEndpointHook = func(*qauth.RegionConfig) *checkinRegion { + return &checkinRegion{base: openapi.URL, origin: "https://stub.test"} + } + checkinHTTPClient = openapi.Client() + + b := newTestBridge(t, "fake-pat-secret-0123456789abcdef") + result, err := b.Checkin(context.Background()) + if err != nil { + t.Fatalf("Checkin err = %v", err) + } + if result.Status != tc.wantStatus { + t.Fatalf("status = %q want %q", result.Status, tc.wantStatus) + } + if result.Message != tc.wantMsg { + t.Fatalf("message = %q want %q", result.Message, tc.wantMsg) + } + }) + } + + // 404 path: campaigns endpoint returns 404 → "skipped". + t.Run("404 activity not open", func(t *testing.T) { + openapi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + })) + defer openapi.Close() + CheckinEndpointHook = func(*qauth.RegionConfig) *checkinRegion { + return &checkinRegion{base: openapi.URL, origin: "https://stub.test"} + } + checkinHTTPClient = openapi.Client() + b := newTestBridge(t, "fake-pat-secret-0123456789abcdef") + result, err := b.Checkin(context.Background()) + if err != nil { + t.Fatalf("Checkin err = %v (404 should map to skipped, not error)", err) + } + if result.Status != "skipped" { + t.Fatalf("status = %q want skipped", result.Status) + } + }) +} + +// newTestBridge constructs a Bridge with a fake PAT and pre-populates the +// session state so EnsureFreshSession is a no-op (no real jobToken exchange). +// The PAT is real enough that machine identity derivation runs; the session +// is real enough that CurrentSecurityOauth returns a usable bearer. +func newTestBridge(t *testing.T, pat string) *Bridge { + t.Helper() + mid, mtok, mtyp := mustDeriveMachineIdentity(pat) + b := NewBridge(pat, qauth.CN, mid, mtok, mtyp) + // Pre-populate the session: applyJobToken builds a real signed session + // from a fake jobToken response, with a far-future expiry so needsRefresh + // is false and EnsureFreshSession skips the network call entirely. + fakeJT := map[string]any{ + "id": "test-uid-12345", + "name": "tester", + "securityOauthToken": "test-security-oauth-token", + "refreshToken": "test-refresh-token", + "expireTime": float64(time.Now().Add(24 * time.Hour).UnixMilli()), + } + b.applyJobToken(fakeJT, "personal_standard") + b.bootstrapped.Store(true) + return b +} + +func mustDeriveMachineIdentity(pat string) (string, string, string) { + id, tok, typ := deriveMachineIdentity(pat) + if id == "" || tok == "" || typ == "" { + panic("deriveMachineIdentity returned empty field") + } + return id, tok, typ +} + +// TestBridgeCheckinGlobalUsesOpenAPIBearer verifies that a region flagged +// NeedsOpenAPIToken authenticates the campaign call with the OpenAPI bearer +// minted from /api/v1/jobToken/exchange, not the COSY securityOauthToken. +func TestBridgeCheckinGlobalUsesOpenAPIBearer(t *testing.T) { + origHook := CheckinEndpointHook + origClient := checkinHTTPClient + t.Cleanup(func() { + CheckinEndpointHook = origHook + checkinHTTPClient = origClient + }) + + var campaignsAuth string + openapi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/jobToken/exchange": + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": "openapi-token-xyz", "refresh_token": "openapi-rt", + "expires_at": "2099-01-01T00:00:00Z", + }) + case r.URL.Path == "/sash/api/v1/me/campaigns": + campaignsAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]any{ + "campaigns": []map[string]any{{ + "campaignId": "c1", "actionType": "CLAIM_BENEFIT", "claimStatus": "CLAIMED", + "benefit": map[string]any{"kind": "CREDITS", "amount": 100.0}, + }}, + }) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer openapi.Close() + CheckinEndpointHook = func(*qauth.RegionConfig) *checkinRegion { + return &checkinRegion{base: openapi.URL, origin: "https://stub.test"} + } + checkinHTTPClient = openapi.Client() + + mid, mtok, mtyp := mustDeriveMachineIdentity("fake-pat-secret-0123456789abcdef") + region := &qauth.RegionConfig{Name: "global", OpenAPIBase: openapi.URL, NeedsOpenAPIToken: true} + b := NewBridge("fake-pat-secret-0123456789abcdef", region, mid, mtok, mtyp) + fakeJT := map[string]any{ + "id": "test-uid-12345", "name": "tester", + "securityOauthToken": "cosy-sot", "refreshToken": "rt", + "expireTime": float64(time.Now().Add(24 * time.Hour).UnixMilli()), + } + b.applyJobToken(fakeJT, "personal_standard") + b.bootstrapped.Store(true) + + if _, err := b.Checkin(context.Background()); err != nil { + t.Fatalf("Checkin err = %v", err) + } + if campaignsAuth != "Bearer openapi-token-xyz" { + t.Fatalf("campaign Authorization = %q want the OpenAPI bearer", campaignsAuth) + } +} diff --git a/internal/providers/qoder/direct.go b/internal/providers/qoder/direct.go index d63251d5..173e63dc 100644 --- a/internal/providers/qoder/direct.go +++ b/internal/providers/qoder/direct.go @@ -18,12 +18,15 @@ import ( ) // Direct talks to the Qoder chat gateway from this process. It does not load -// the CLI bundle. Login and quota stay on the per-account worker. +// the CLI bundle. When an in-process Bridge is available (PAT-backed account), +// it resolves credentials and fresh session tokens from the Bridge; legacy +// accounts without a PAT fall back to the stored CLI user_blob. type Direct struct { - store CredentialStore - region func(ctx context.Context, accountID string) (string, error) - http *http.Client - now func() time.Time + store CredentialStore + region func(ctx context.Context, accountID string) (string, error) + bridgeOf func(ctx context.Context, accountID string) (*Bridge, error) + http *http.Client + now func() time.Time // ttfb bounds how long ChatStream waits for the upstream response head. // Zero falls back to defaultFirstByteTimeout inside doWithFirstByteTimeout. ttfb time.Duration @@ -39,6 +42,13 @@ func NewDirect(store CredentialStore, region func(context.Context, string) (stri } } +func (d *Direct) SetBridgeResolver(fn func(ctx context.Context, accountID string) (*Bridge, error)) { + if d == nil { + return + } + d.bridgeOf = fn +} + func (d *Direct) SetHTTP(client *http.Client) { if d == nil || client == nil { return @@ -53,29 +63,81 @@ type directPrepared struct { } func (d *Direct) prepare(ctx context.Context, accountID string, req translate.ChatRequest) (directPrepared, error) { - if d == nil || d.store == nil { + if d == nil { return directPrepared{}, fmt.Errorf("qoder direct client is not configured") } - credential, err := d.store.LoadCredential(ctx, accountID) - if err != nil { - return directPrepared{}, err - } - user, err := DecryptCLIUser(credential.UserBlob, credential.MachineID) - if err != nil { - return directPrepared{}, err - } - session, err := NewSession(user) - if err != nil { - return directPrepared{}, err + var user CLIUser + var session *Session + var region string + var err error + + // 1. In-process Bridge path: when the account has a stored PAT, the Bridge + // manages the session lifecycle (fresh securityOauthToken + refreshToken) + // and stable machine identity. + if d.bridgeOf != nil { + if b, bridgeErr := d.bridgeOf(ctx, accountID); bridgeErr == nil { + if err := b.EnsureFreshSession(ctx); err != nil { + return directPrepared{}, fmt.Errorf("qoder ensure fresh session: %w", err) + } + ident := b.currentIdentity() + if ident != nil { + user = CLIUser{ + Name: ident.Name, + UID: ident.UID, + Aid: ident.Aid, + YxUID: ident.YxUID, + OrganizationID: ident.OrganizationID, + OrganizationName: ident.OrganizationName, + UserType: ident.UserType, + SecurityOauthToken: ident.SecurityOauthToken, + RefreshToken: ident.RefreshToken, + } + session, err = NewSession(user) + if err != nil { + return directPrepared{}, err + } + session.MachineID = b.MachineID() + session.MachineToken = b.MachineToken() + session.MachineType = b.MachineType() + if b.Region != nil && b.Region.Name != "" { + region = b.Region.Name + } + } + } } - region := "global" - if d.region != nil { - region, err = d.region(ctx, accountID) + + // 2. Legacy fallback: account has no PAT, load encrypted CLI user blob. + if session == nil { + if d.store == nil { + return directPrepared{}, fmt.Errorf("qoder direct client is not configured") + } + credential, err := d.store.LoadCredential(ctx, accountID) + if err != nil { + return directPrepared{}, err + } + user, err = DecryptCLIUser(credential.UserBlob, credential.MachineID) + if err != nil { + return directPrepared{}, err + } + session, err = NewSession(user) if err != nil { return directPrepared{}, err } } + + if region == "" { + region = "global" + if d.region != nil { + region, err = d.region(ctx, accountID) + if err != nil { + return directPrepared{}, err + } + } + } plain := BuildPlainChatBody(req, d.now()) + if user.UserType != "" { + plain["aliyun_user_type"] = user.UserType + } model, _ := plain["model_config"].(map[string]any) modelKey, _ := model["key"].(string) body, err := json.Marshal(plain) @@ -317,28 +379,44 @@ func BuildPlainChatBody(req translate.ChatRequest, now time.Time) map[string]any if n, ok := rawNumber(req.MaxInputTokens); ok && n > 0 { maxInput = n } + prompt := latestUserPrompt(messages) name := "chat" - if text := translate.ContentToString(messages[len(messages)-1]["content"]); strings.TrimSpace(text) != "" { - name = strings.TrimSpace(text) + if prompt != "" { + name = prompt } if len(name) > 40 { name = name[:40] } return map[string]any{ - "request_id": newID(), - "request_set_id": newID(), - "chat_record_id": newID(), - "session_id": newID(), - "stream": true, - "chat_task": "FREE_INPUT", - "chat_context": map[string]any{"text": "", "features": map[string]any{}, "extra": map[string]any{}, "chatPrompt": "", "imageUrls": []any{}}, + "request_id": newID(), + "request_set_id": newID(), + "chat_record_id": newID(), + "session_id": newID(), + "stream": true, + "chat_task": "FREE_INPUT", + // chat_context mirrors the reference Qoder client (and the working + // Qoder-2API-Go): the gateway resolves the thinking channel and the routed + // model from chat_context.extra.modelConfig. An empty extra block makes + // the gateway answer without a reasoning_content stream even when the + // top-level model_config.is_reasoning and parameters.enable_thinking are + // set, so both copies must carry the model key and reasoning flag. + "chat_context": map[string]any{ + "text": map[string]any{"type": "text", "text": prompt}, + "features": []any{}, + "extra": map[string]any{ + "modelConfig": map[string]any{"key": modelKey, "is_reasoning": reasoning}, + "originalContent": map[string]any{"type": "text", "text": prompt}, + }, + "chatPrompt": "", + "imageUrls": nil, + }, "is_reply": false, "is_retry": false, - "source": "cli", - "version": "1.0", + "source": 1, + "version": "3", "agent_id": "agent_common", "task_id": "common", - "session_type": "assistant", + "session_type": "qodercli", "aliyun_user_type": "", "model_config": map[string]any{ "key": modelKey, "display_name": modelKey, "model": "", "format": "openai", @@ -357,6 +435,22 @@ func BuildPlainChatBody(req translate.ChatRequest, now time.Time) map[string]any } } +// latestUserPrompt returns the most recent user message's text, which the +// reference client duplicates into chat_context.text and +// chat_context.extra.originalContent. Empty when no user turn carries text. +func latestUserPrompt(messages []map[string]any) string { + for i := len(messages) - 1; i >= 0; i-- { + role, _ := messages[i]["role"].(string) + if role != "user" { + continue + } + if text := strings.TrimSpace(translate.ContentToString(messages[i]["content"])); text != "" { + return text + } + } + return "" +} + // normalizeMessageContent replaces a nil content on a message that carries // tool_calls with an empty string. // diff --git a/internal/providers/qoder/display.go b/internal/providers/qoder/display.go index 6bb226f6..96e8a1f2 100644 --- a/internal/providers/qoder/display.go +++ b/internal/providers/qoder/display.go @@ -75,6 +75,9 @@ func qoderDefaultContextWindow(entry map[string]any) (int, bool) { if window, ok := numberFieldValue(entry, "context_length"); ok && window > 0 { return window, true } + if window, ok := numberFieldValue(entry, "max_input_tokens"); ok && window > 0 { + return window, true + } return 0, false } diff --git a/internal/providers/qoder/pat.go b/internal/providers/qoder/pat.go new file mode 100644 index 00000000..d83d7cba --- /dev/null +++ b/internal/providers/qoder/pat.go @@ -0,0 +1,190 @@ +package qoder + +import ( + "context" + "crypto/md5" + "crypto/sha512" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strings" + + "github.com/caigee-cmd/cli2api/internal/providers" + "github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth" +) + +// PATCredentialFormat is the stored-credential format identifier for a Qoder +// Personal Access Token. The PAT is the long-lived credential; the bridge +// exchanges it for a short-lived securityOauthToken + refreshToken via +// qauth.ExchangeJobToken at bootstrap, and rotates it via qauth.RefreshJobToken +// before expiry. The legacy qoder-native-v1 format (the encrypted CLI user +// blob) is left in the descriptor for backward compatibility so old accounts +// show a "re-login required" state instead of being unreadable. +const PATCredentialFormat = "qoder-pat-v1" + +// patCodec validates and imports a pasted Qoder PAT. The console LoginPAT +// path wraps the pasted token as {"api_key": ""}; the codec unwraps it, +// validates the shape, and returns the raw PAT bytes as the stored payload — +// the bridge reads them back via LoadCredentialPayload at request time. +type patCodec struct{} + +func (patCodec) Format() string { return PATCredentialFormat } + +// Validate accepts a {"api_key": ""} envelope and checks the token shape. +// Qoder PATs are opaque ASCII strings; the only hard constraint is that the +// field is present and non-empty after trimming. Length bounds reject obvious +// paste errors (a full auth blob or a URL). +func (patCodec) Validate(payload []byte) error { + pat, err := unwrapPATEvelope(payload) + if err != nil { + return err + } + if len(pat) < 16 || len(pat) > 512 { + return errors.New("qoder PAT has invalid length") + } + return nil +} + +// PrepareImport validates the pasted envelope and returns the canonical +// payload (the raw PAT bytes) for SaveCredentialPayload. Ready is true: a PAT +// is a complete credential, and the bridge will bootstrap the session on the +// first request, so the account may be enabled immediately. +func (patCodec) PrepareImport(payload []byte) (providers.CredentialImport, error) { + pat, err := unwrapPATEvelope(payload) + if err != nil { + return providers.CredentialImport{}, err + } + if err := (patCodec{}).Validate(payload); err != nil { + return providers.CredentialImport{}, err + } + return providers.CredentialImport{Payload: []byte(pat), Ready: true}, nil +} + +// unwrapPATEvelope pulls the api_key string out of the {"api_key": ...} JSON +// envelope the console LoginPAT path sends. A bare string is also accepted so +// direct programmatic callers do not need the envelope. +func unwrapPATEvelope(payload []byte) (string, error) { + trimmed := strings.TrimSpace(string(payload)) + if trimmed == "" { + return "", errors.New("qoder PAT is empty") + } + // Bare token (no JSON envelope). + if !strings.HasPrefix(trimmed, "{") { + return trimmed, nil + } + var envelope struct { + APIKey string `json:"api_key"` + } + if err := json.Unmarshal(payload, &envelope); err != nil { + return "", errors.New("qoder PAT envelope is invalid JSON") + } + pat := strings.TrimSpace(envelope.APIKey) + if pat == "" { + return "", errors.New("qoder PAT envelope has empty api_key") + } + return pat, nil +} + +// LoadPAT reads the stored PAT payload bytes back as a string. Returns empty +// string + a sentinel error when the account has no PAT (legacy +// qoder-native-v1 blob only — the console should mark it for re-login). +func LoadPAT(format string, payload []byte) (string, error) { + if format != PATCredentialFormat { + return "", ErrPATRequired + } + pat := strings.TrimSpace(string(payload)) + if pat == "" { + return "", ErrPATRequired + } + return pat, nil +} + +// ErrPATRequired marks a Qoder account that predates the PAT credential model +// (it has only the legacy qoder-native-v1 CLI blob, which cannot be reversed +// back into a PAT). The console surfaces this as "re-login required". +var ErrPATRequired = errors.New("qoder account requires PAT re-login (legacy CLI blob cannot be reversed to a PAT)") + +// deriveMachineIdentity derives a stable device fingerprint from a PAT. The +// gateway's per-account admission and the OpenAPI campaigns endpoint both see +// the cosy-machine{Id,Token,Type} headers; deriving them from the PAT (not +// from the uid, which is only known after bootstrap) means the same account +// presents the same device across restarts, PAT re-exchanges, and machine +// restarts. A random per-process identity (the Qoder-2API-Go approach) would mint +// a new device on every rebuild and risks the OpenAPI dropping the +// claimable credit campaign as an unofficial client. +// +// The derivation mirrors cosy.go's uid-based derivation (md5 for the id, +// sha512-trimmed for the token, md5-trimmed for the type) but keyed on the +// PAT so it is available before the first jobToken exchange. +func deriveMachineIdentity(pat string) (machineID, machineToken, machineType string) { + idSum := md5.Sum([]byte("machine:" + pat)) + machineID = hex.EncodeToString(idSum[:]) + tokSum := sha512.Sum512([]byte("machinetoken:" + pat)) + machineToken = strings.TrimRight(base64.RawURLEncoding.EncodeToString(tokSum[:]), "=")[:43] + typeSum := md5.Sum([]byte("machinetype:" + pat)) + typeHex := hex.EncodeToString(typeSum[:]) + typeHex = strings.ReplaceAll(typeHex, "-", "") + if len(typeHex) > 18 { + typeHex = typeHex[:18] + } + machineType = typeHex + return +} + +// patSignature is the cache key for a cachedBridge: it changes whenever the +// stored PAT or region rotates, so the bridge is rebuilt lazily on the next +// request after a credential change. +func patSignature(pat, region string) string { + return region + ":" + pat +} + +// Bridge resolves the in-process Bridge for an account. It loads the stored +// PAT, derives the device identity, and returns the cached Bridge (building +// one on miss). Returns ErrPATRequired when the account predates the PAT +// model and still carries only the legacy CLI blob — the console should mark +// it for re-login and the caller should fall back to the worker path if any. +func (c *Client) Bridge(ctx context.Context, accountID string) (*Bridge, error) { + c.mu.RLock() + store := c.patStore + regionOf := c.regionOf + c.mu.RUnlock() + if store == nil { + // No PAT store wired: behave as a legacy account that the operator + // has not yet re-logged with a PAT. Callers fall back to the worker + // path, preserving backward compatibility with stubbed-worker tests + // and deployments that still run the Node worker. + return nil, ErrPATRequired + } + format, payload, err := store.LoadCredentialPayload(ctx, accountID) + if err != nil { + return nil, err + } + pat, err := LoadPAT(format, payload) + if err != nil { + return nil, err + } + region := "global" + if regionOf != nil { + if r, err := regionOf(ctx, accountID); err == nil && r != "" { + region = r + } + } + sig := patSignature(pat, region) + c.bridgesMu.Lock() + defer c.bridgesMu.Unlock() + if cached, ok := c.bridges[accountID]; ok && cached.patSig == sig { + return cached.bridge, nil + } + machineID, machineToken, machineType := deriveMachineIdentity(pat) + b := NewBridge(pat, qauth.RegionByName(region), machineID, machineToken, machineType) + if c.bridges == nil { + c.bridges = make(map[string]*cachedBridge) + } + c.bridges[accountID] = &cachedBridge{bridge: b, patSig: sig} + return b, nil +} + +func (c *Client) bridge(ctx context.Context, accountID string) (*Bridge, error) { + return c.Bridge(ctx, accountID) +} diff --git a/internal/providers/qoder/qauth/NOTICE b/internal/providers/qoder/qauth/NOTICE new file mode 100644 index 00000000..8645538d --- /dev/null +++ b/internal/providers/qoder/qauth/NOTICE @@ -0,0 +1,28 @@ +This package is ported from EchoPing07/Qoder-2API-Go +(https://github.com/EchoPing07/Qoder-2API-Go, commit +65a5ffff122fdbc165275a631459b9801f9e097a). The auth/crypto/HTTP client surface +is derived from auth/auth.go; the daily check-in flow in +internal/providers/qoder/checkin.go mirrors the campaigns-list/claim structure +of auth/checkin.go from the same commit. Both are licensed under the MIT +License. MIT permits redistribution under this project's MIT license with the +copyright notice retained, including the disclaimer below. + +Copyright (c) 2026 EchoPing + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the Software), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/internal/providers/qoder/qauth/qauth.go b/internal/providers/qoder/qauth/qauth.go new file mode 100644 index 00000000..5e0281e3 --- /dev/null +++ b/internal/providers/qoder/qauth/qauth.go @@ -0,0 +1,1649 @@ +// Package qauth implements Qoder gateway authentication, encryption, and HTTP +// client. Ported from EchoPing07/Qoder-2API-Go +// (https://github.com/EchoPing07/Qoder-2API-Go, commit +// 65a5ffff122fdbc165275a631459b9801f9e097a, auth/auth.go and auth/checkin.go), +// licensed under the MIT License (Copyright (c) 2026 EchoPing); see the NOTICE +// file in this directory. The ported surface: the cosy wire protocol (custom +// base64 + RSA + AES + MD5 signing), the COSY bearer construction, the +// PAT→jobToken exchange and refresh, /user/status, /model/list, OpenAPI +// quota/usage (CN + global OpenAPIToken), the SSE stream reader with idle +// watchdog, and the busy/auth/gateway error taxonomy. The daily check-in flow +// (internal/providers/qoder/checkin.go) follows auth/checkin.go's +// campaigns-list/claim structure. +// +// cli2api's existing internal/providers/qoder/cosy.go is a partial +// reimplementation used only by the chat-signing path; this package is the +// complete surface and is the canonical implementation going forward. +package qauth + +import ( + "bufio" + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + cosySignatureHash "crypto/md5" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/google/uuid" +) + +// --- Region Configuration --- + +type RegionConfig struct { + Name string + AuthBase string + ChatBase string + OpenAPIBase string + // NeedsOpenAPIToken marks regions whose quota API does not accept the + // COSY session token. Global (qoder.sh) requires a separate OpenAPI bearer + // obtained via /api/v1/jobToken/exchange; CN accepts the COSY + // securityOauthToken directly. + NeedsOpenAPIToken bool +} + +var CN = &RegionConfig{ + Name: "cn", + AuthBase: "https://gateway.qoder.com.cn", + ChatBase: "https://gateway.qoder.com.cn", + OpenAPIBase: "https://openapi.qoder.com.cn", +} + +// Global is the international Qoder region (qoder.sh). Chat/status go through +// api1.qoder.sh; quota/billing through openapi.qoder.sh and require a separate +// OpenAPI bearer token. +var Global = &RegionConfig{ + Name: "global", + AuthBase: "https://api1.qoder.sh", + ChatBase: "https://api1.qoder.sh", + OpenAPIBase: "https://openapi.qoder.sh", + NeedsOpenAPIToken: true, +} + +// RegionByName maps a stored account region ("cn"/"global", empty = cn) to its +// configuration. Unknown values fall back to CN. +func RegionByName(name string) *RegionConfig { + switch strings.ToLower(strings.TrimSpace(name)) { + case "global", "intl", "international": + return Global + default: + return CN + } +} + +// NormalizeRegion canonicalizes an account region for storage/display. +func NormalizeRegion(name string) string { + if RegionByName(name).Name == "global" { + return "global" + } + return "cn" +} + +// Resolve returns the region for a PAT. Region is not encoded in the token, so +// callers that know the account's region should use RegionByName instead; this +// keeps the historical CN default for legacy single-PAT call sites. +func Resolve(pat string) (string, *RegionConfig) { + return pat, CN +} + +func AuthURL(region *RegionConfig, path string) string { + return region.AuthBase + path +} + +func ChatURL(region *RegionConfig) string { + return region.ChatBase + "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1" +} + +func ModelListURL(region *RegionConfig) string { + return region.ChatBase + "/algo/api/v2/model/list?Encode=1" +} + +func OpenAPIURL(region *RegionConfig, path string) string { + return region.OpenAPIBase + path +} + +func FetchModelCatalog(ctx context.Context, sess *SessionContext, region *RegionConfig) (map[string]interface{}, error) { + return CallGet(ctx, sess, ModelListURL(region)) +} + +// openAPIUserAgentVersion is the client version the global OpenAPI endpoints +// expect in the User-Agent header. It mirrors the pinned CLI (1.1.32). +const openAPIUserAgentVersion = "1.1.32" + +// --- Custom Encoding (Qoder Base64 variant) --- + +const customAlphabet = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!" +const stdAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +var s2c [128]byte +var c2s [128]byte +var s2cOK [128]bool +var c2sOK [128]bool + +func init() { + for i := 0; i < 64; i++ { + s := stdAlphabet[i] + c := customAlphabet[i] + s2c[s] = c + s2cOK[s] = true + c2s[c] = s + c2sOK[c] = true + } + s2c['='] = '$' + s2cOK['='] = true + c2s['$'] = '=' + c2sOK['$'] = true +} + +func Encode(plaintext []byte) string { + std := base64.StdEncoding.EncodeToString(plaintext) + n := len(std) + a := n / 3 + rearranged := make([]byte, n) + copy(rearranged[:a], std[n-a:]) + copy(rearranged[a:n-a], std[a:n-a]) + copy(rearranged[n-a:], std[:a]) + result := make([]byte, n) + for i, ch := range rearranged { + if ch >= 128 || !s2cOK[ch] { + panic(fmt.Sprintf("char out of alphabet: %q", ch)) + } + result[i] = s2c[ch] + } + return string(result) +} + +func Decode(encoded string) ([]byte, error) { + n := len(encoded) + mapped := make([]byte, n) + for i := 0; i < n; i++ { + ch := encoded[i] + if ch >= 128 || !c2sOK[ch] { + return nil, fmt.Errorf("char out of custom alphabet: %q", ch) + } + mapped[i] = c2s[ch] + } + a := n / 3 + std := make([]byte, n) + copy(std[:a], mapped[n-a:]) + copy(std[a:n-a], mapped[a:n-a]) + copy(std[n-a:], mapped[:a]) + return base64.StdEncoding.DecodeString(string(std)) +} + +// --- Request Signing --- + +const appCode = "cosy" +const defaultSecret = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==" + +var secretValue string +var secretOnce sync.Once + +func getSecret() string { + secretOnce.Do(func() { + secretValue = os.Getenv("QODER_SIGNATURE_SECRET") + if secretValue == "" { + secretValue = defaultSecret + } + }) + return secretValue +} + +func CurrentDate() string { + return time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT") +} + +func Sign(date string) string { + s := appCode + "&" + getSecret() + "&" + date + // nosemgrep: go.lang.security.audit.crypto.use_of_weak_crypto.use-of-md5 -- Qoder COSY protocol mandates MD5 for this wire signature. + // #nosec G401 -- Legacy Qoder COSY wire signature; changing MD5 breaks authentication. + h := cosySignatureHash.Sum([]byte(s)) + return fmt.Sprintf("%x", h) +} + +// --- Bearer Construction --- + +const serverPubKeyPEM = `-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc +4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l +6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17 +XcW+ML9FoCI6AOvOzwIDAQAB +-----END PUBLIC KEY-----` + +var rsaPubKey *rsa.PublicKey + +func init() { + block, _ := pem.Decode([]byte(serverPubKeyPEM)) + if block == nil { + panic("failed to parse server public key PEM") + } + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + panic(fmt.Sprintf("failed to parse server public key: %v", err)) + } + rsaPubKey = pub.(*rsa.PublicKey) +} + +type AuthIdentity struct { + Name string + Aid string + UID string + YxUID string + OrganizationID string + OrganizationName string + UserType string + SecurityOauthToken string + RefreshToken string +} + +type SessionContext struct { + TempKey []byte + CosyKey string + Info string + Identity AuthIdentity + MachineID string + MachineToken string + MachineType string +} + +func rsaEncrypt(tempKey []byte) ([]byte, error) { + return rsa.EncryptPKCS1v15(rand.Reader, rsaPubKey, tempKey) +} + +func aesEncrypt(plain, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + padLen := 16 - len(plain)%16 + padded := make([]byte, len(plain)+padLen) + copy(padded, plain) + for i := len(plain); i < len(padded); i++ { + padded[i] = byte(padLen) + } + encrypted := make([]byte, len(padded)) + mode := cipher.NewCBCEncrypter(block, key[:16]) + mode.CryptBlocks(encrypted, padded) + return encrypted, nil +} + +func md5Hex(s string) string { + // nosemgrep: go.lang.security.audit.crypto.use_of_weak_crypto.use-of-md5 -- Qoder COSY protocol mandates MD5 for this wire signature. + // #nosec G401 -- Legacy Qoder COSY wire signature; changing MD5 breaks authentication. + h := cosySignatureHash.Sum([]byte(s)) + return fmt.Sprintf("%x", h) +} + +type authPayloadStruct struct { + Name string `json:"name"` + Aid string `json:"aid"` + UID string `json:"uid"` + YxUID string `json:"yx_uid"` + OrganizationID string `json:"organization_id"` + OrganizationName string `json:"organization_name"` + UserType string `json:"user_type"` + SecurityOauthToken string `json:"security_oauth_token"` + RefreshToken string `json:"refresh_token"` +} + +func authPayloadJSON(identity AuthIdentity) []byte { + p := authPayloadStruct{ + Name: identity.Name, + Aid: identity.Aid, + UID: identity.UID, + YxUID: identity.YxUID, + OrganizationID: identity.OrganizationID, + OrganizationName: identity.OrganizationName, + UserType: identity.UserType, + SecurityOauthToken: identity.SecurityOauthToken, + RefreshToken: identity.RefreshToken, + } + b, err := marshalNoEscape(p) + if err != nil { + panic(err) + } + return b +} + +// marshalNoEscape serializes v to compact JSON without HTML escaping, +// matching Python's json.dumps(v, separators=(",", ":")). +func marshalNoEscape(v interface{}) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} + +func NewSession(identity AuthIdentity, machineID, machineToken, machineType string) *SessionContext { + tempKey := []byte(uuid.New().String()[:16]) + encrypted, err := rsaEncrypt(tempKey) + if err != nil { + panic(err) + } + cosyKey := base64.StdEncoding.EncodeToString(encrypted) + aesPlain := authPayloadJSON(identity) + aesEncrypted, err := aesEncrypt(aesPlain, tempKey) + if err != nil { + panic(err) + } + info := base64.StdEncoding.EncodeToString(aesEncrypted) + return &SessionContext{ + TempKey: tempKey, + CosyKey: cosyKey, + Info: info, + Identity: identity, + MachineID: machineID, + MachineToken: machineToken, + MachineType: machineType, + } +} + +func SignRequest(payloadB64, cosyKey, cosyDate, body, pathWithoutAlgo string) string { + s := payloadB64 + "\n" + cosyKey + "\n" + cosyDate + "\n" + body + "\n" + pathWithoutAlgo + return md5Hex(s) +} + +type cosyPayloadStruct struct { + CosyVersion string `json:"cosyVersion"` + IdeVersion string `json:"ideVersion"` + Info string `json:"info"` + RequestID string `json:"requestId"` + Version string `json:"version"` +} + +func BuildPayloadB64(info string) string { + p := cosyPayloadStruct{ + CosyVersion: "0.1.43", + IdeVersion: "", + Info: info, + RequestID: uuid.New().String(), + Version: "v1", + } + b, err := marshalNoEscape(p) + if err != nil { + panic(err) + } + return base64.StdEncoding.EncodeToString(b) +} + +func ComposeBearer(payloadB64, sig string) string { + return "Bearer COSY." + payloadB64 + "." + sig +} + +// --- AuthError --- + +type AuthError struct { + StatusCode int + Detail string +} + +func (e *AuthError) Error() string { + s := fmt.Sprintf("HTTP %d", e.StatusCode) + if e.Detail != "" { + // Detail carries the raw upstream body, which includes userId and trace + // ids. Error() is what the bridge logs, so redact it here: doing it at + // every construction site instead would be easy to miss, and Detail + // itself is kept raw for callers that classify the failure. + s += " " + truncateErrorBody([]byte(e.Detail)) + } + return s +} + +// --- Gateway busy (queue/concurrency) errors --- + +// errBodyCap bounds how much of an upstream error response is read for +// classification and reporting. The former 300-byte cap could truncate a +// 10605 envelope mid-JSON, making it unparseable and misclassifying the +// rejection as an auth failure. +const errBodyCap = 4 << 10 // 4 KiB + +// QoderBusyCode is the gateway's business code for queue/admission-control +// rejections. It rides HTTP 401/403 envelopes (and in-stream frames with a +// matching statusCodeValue) even though the session is perfectly valid: +// +// {"code":"10605","message":"{\"isQueued\":false,...,\"retryAfterSeconds\":2,...}"} +// +// Treating it as an auth failure causes a pointless token refresh followed +// by an immediate retry that slams into the same concurrency window. +const QoderBusyCode = "10605" + +// BusyError is a gateway queue/concurrency rejection (business code 10605). +// The session is valid; the request was refused by admission control and may +// succeed after RetryAfter. +type BusyError struct { + StatusCode int + Code string + Message string + RetryAfter time.Duration +} + +func (e *BusyError) Error() string { + s := fmt.Sprintf("gateway busy (%s): %s", e.Code, e.Message) + if e.RetryAfter > 0 { + s += fmt.Sprintf(" (retry after %s)", e.RetryAfter) + } + return s +} + +// --- Generic upstream errors --- + +// UpstreamError describes a non-2xx gateway response outside the dedicated +// session (AuthError) and admission-control (BusyError) categories. It +// preserves the HTTP status and the truncated response body so the chat +// handler can map the rejection back to the client with the real status and +// message instead of collapsing everything into an opaque 500. Typical +// sources: parameter rejections (400, e.g. an unsupported max_tokens range +// or reasoning tier) and gateway faults (5xx). +// +// RetryAfter carries the gateway's numeric Retry-After header when the +// rejection came with one (429); 0 means absent/unparseable, and the client +// response then simply omits the header. +type UpstreamError struct { + StatusCode int + Body string + RetryAfter time.Duration +} + +func (e *UpstreamError) Error() string { + return fmt.Sprintf("gateway HTTP %d: %s", e.StatusCode, e.Body) +} + +// retryAfterHeader parses a numeric Retry-After response header into a +// duration. The HTTP-date form and unparsable/non-positive values yield 0; +// only 429 rejections are expected to carry the header at all. +func retryAfterHeader(h string) time.Duration { + secs, err := strconv.Atoi(strings.TrimSpace(h)) + if err != nil || secs <= 0 { + return 0 + } + return time.Duration(secs) * time.Second +} + +// busyMeta extracts retryAfterSeconds from the nested JSON message carried +// inside a 10605 payload, if present. +func busyMeta(msg string) time.Duration { + var meta struct { + RetryAfterSeconds int `json:"retryAfterSeconds"` + } + if strings.HasPrefix(strings.TrimSpace(msg), "{") && json.Unmarshal([]byte(msg), &meta) == nil && meta.RetryAfterSeconds > 0 { + return time.Duration(meta.RetryAfterSeconds) * time.Second + } + return 0 +} + +// maxBusyMessageRunes caps the upstream-provided busy message echoed into +// logs and client-facing error bodies. +const maxBusyMessageRunes = 512 + +func truncateBusyMessage(s string) string { + r := []rune(s) + if len(r) <= maxBusyMessageRunes { + return s + } + return string(r[:maxBusyMessageRunes]) + "…" +} + +// maxErrorBodyChars caps how much of an upstream error body is embedded in an +// error message. The account loop logs these at WARN, so the payload must not +// reach the log verbatim: the quota response carries userId and trace ids. +const maxErrorBodyChars = 200 + +// redactedErrorKeys are JSON keys whose values are stripped from an upstream +// error body before it is embedded in an error message. Truncation alone is not +// enough: these fields sit near the start of the real payload. +var redactedErrorKeys = []string{"userId", "userType", "traceId", "traceID", "requestId"} + +// truncateErrorBody returns a short, single-line, identity-free prefix of an +// upstream error body for embedding in an error message. Identity-valued JSON +// keys are replaced before the length cap, so a short-but-sensitive payload does +// not slip through as-is. +func truncateErrorBody(body []byte) string { + s := strings.TrimSpace(string(body)) + for _, key := range redactedErrorKeys { + s = redactJSONValue(s, key) + } + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + if len(s) <= maxErrorBodyChars { + return s + } + // Cut on a rune boundary: a byte-wise slice through a multi-byte character + // yields an invalid string, which then reaches the log garbage-escaped. + cut := maxErrorBodyChars + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "…" +} + +// redactJSONValue replaces the value of a JSON field with "[REDACTED]". It works +// on the raw text (the body may not even be valid JSON), which is exactly the +// shape an upstream error page takes. A missing field is a no-op. The scan +// resumes after the replacement so a value is never rescanned. +// +// Whitespace around the colon is accepted because upstreams pretty-print +// (`"userId" : "..."`), and a missing closing quote is handled because a +// truncated response body is a realistic case: matching only the compact form +// would leak the identity value in both. +func redactJSONValue(s, key string) string { + needle := `"` + key + `"` + var out strings.Builder + for { + keyStart := strings.Index(s, needle) + if keyStart < 0 { + break + } + valueStart, ok := jsonValueStart(s, keyStart+len(needle)) + if !ok { + // The key is not a field name here (e.g. it appears inside a string + // value). Emit it unchanged and keep scanning past it. + out.WriteString(s[:keyStart+len(needle)]) + s = s[keyStart+len(needle):] + continue + } + out.WriteString(s[:valueStart]) + if s[valueStart] == '"' { + out.WriteString(`"[REDACTED]"`) + } else { + out.WriteString("[REDACTED]") + } + s = s[jsonValueEnd(s, valueStart):] + } + out.WriteString(s) + return out.String() +} + +// jsonValueStart returns the index of the value's first byte, delimiter +// included: the opening quote for a string, the first character for a scalar. +// Everything before it (the key, whitespace, the colon) is copied verbatim. It +// reports false when the text after the key is not a `: ` pair. +func jsonValueStart(s string, from int) (int, bool) { + i := from + for i < len(s) && isJSONSpace(s[i]) { + i++ + } + if i >= len(s) || s[i] != ':' { + return 0, false + } + i++ + for i < len(s) && isJSONSpace(s[i]) { + i++ + } + if i >= len(s) { + return 0, false + } + return i, true +} + +// jsonValueEnd returns the index of the first byte PAST the value starting at +// start. For a string that is one past its closing quote; for a scalar it is the +// next JSON delimiter. A string whose closing quote is missing (a truncated +// response) is treated as running to the end of the text so its content cannot +// leak. +func jsonValueEnd(s string, start int) int { + if s[start] == '"' { + for i := start + 1; i < len(s); i++ { + if s[i] == '\\' { + i++ // skip the escaped byte + continue + } + if s[i] == '"' { + return i + 1 + } + } + return len(s) // unterminated value: redact through the end + } + for i := start; i < len(s); i++ { + switch s[i] { + case ',', '}', ']', ' ', '\t', '\n', '\r': + return i + } + } + return len(s) +} + +// isJSONSpace reports whether b is insignificant whitespace between JSON tokens. +func isJSONSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// busyCodeOf normalizes a JSON "code" field that may arrive as a string +// ("10605") or a number (10605). +func busyCodeOf(raw json.RawMessage) string { + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + var n float64 + if json.Unmarshal(raw, &n) == nil && n == float64(int64(n)) { + return strconv.FormatInt(int64(n), 10) + } + return "" +} + +// busyFromAuthEnvelope parses a 401/403 response body and returns a +// BusyError when it is a queue/concurrency rejection rather than an auth +// failure. Bodies seen in the wild are prefixed with an upstream status +// ("403 {..."), so decoding starts at the first '{'. +func busyFromAuthEnvelope(statusCode int, body []byte) *BusyError { + s := strings.TrimSpace(string(body)) + if i := strings.IndexByte(s, '{'); i > 0 { + s = s[i:] + } + var outer struct { + Code json.RawMessage `json:"code"` + Message string `json:"message"` + } + if json.Unmarshal([]byte(s), &outer) != nil || busyCodeOf(outer.Code) != QoderBusyCode { + return nil + } + busy := &BusyError{StatusCode: statusCode, Code: QoderBusyCode} + if outer.Message != "" { + busy.Message = truncateBusyMessage(outer.Message) + busy.RetryAfter = busyMeta(outer.Message) + } + return busy +} + +// --- Signature API Client --- + +func commonHeaders(machineID, machineToken, machineType, date, sig string) http.Header { + h := http.Header{} + h.Set("cosy-machinetoken", machineToken) + h.Set("cosy-machinetype", machineType) + h.Set("login-version", "v2") + h.Set("appcode", appCode) + h.Set("accept", "application/json") + h.Set("accept-encoding", "identity") + h.Set("cosy-version", "0.1.43") + h.Set("cosy-clienttype", "5") + h.Set("date", date) + h.Set("signature", sig) + h.Set("content-type", "application/json") + h.Set("cosy-machineid", machineID) + h.Set("user-agent", "Go-http-client/2.0") + return h +} + +func postEncoded(ctx context.Context, urlStr string, obj interface{}, machineID, machineToken, machineType string) (map[string]interface{}, error) { + date := CurrentDate() + sig := Sign(date) + plain, err := marshalNoEscape(obj) + if err != nil { + return nil, err + } + body := Encode(plain) + headers := commonHeaders(machineID, machineToken, machineType, date, sig) + + req, err := http.NewRequestWithContext(ctx, "POST", urlStr, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.Header = headers + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + detail, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyCap)) + drainBody(resp.Body) + if resp.StatusCode == 401 || resp.StatusCode == 403 { + if busy := busyFromAuthEnvelope(resp.StatusCode, detail); busy != nil { + return nil, busy + } + return nil, &AuthError{StatusCode: resp.StatusCode, Detail: string(detail)} + } + return nil, &UpstreamError{StatusCode: resp.StatusCode, Body: string(truncateErrorBody(detail)), RetryAfter: retryAfterHeader(resp.Header.Get("Retry-After"))} + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result, nil +} + +type jobTokenInnerStruct struct { + PersonalToken string `json:"personalToken"` + SecurityOauthToken string `json:"securityOauthToken"` + RefreshToken string `json:"refreshToken"` + NeedRefresh bool `json:"needRefresh"` + AuthInfo json.RawMessage `json:"authInfo"` +} + +type jobTokenOuterStruct struct { + Payload string `json:"payload"` + EncodeVersion string `json:"encodeVersion"` +} + +var emptyJSON = json.RawMessage("{}") + +func requestJobToken(ctx context.Context, personalToken, refreshToken, securityOauthToken string, needRefresh bool, machineID, machineToken, machineType string, region *RegionConfig) (map[string]interface{}, error) { + urlStr := AuthURL(region, "/algo/api/v3/user/jobToken?Encode=1") + inner := jobTokenInnerStruct{ + PersonalToken: personalToken, + SecurityOauthToken: securityOauthToken, + RefreshToken: refreshToken, + NeedRefresh: needRefresh, + AuthInfo: emptyJSON, + } + innerJSON, err := marshalNoEscape(inner) + if err != nil { + return nil, err + } + outer := jobTokenOuterStruct{ + Payload: string(innerJSON), + EncodeVersion: "1", + } + return postEncoded(ctx, urlStr, outer, machineID, machineToken, machineType) +} + +func ExchangeJobToken(ctx context.Context, personalToken, machineID, machineToken, machineType string, region *RegionConfig) (map[string]interface{}, error) { + if region == nil { + region = CN + } + return requestJobToken(ctx, personalToken, "", "", false, machineID, machineToken, machineType, region) +} + +func RefreshJobToken(ctx context.Context, personalToken, refreshToken, securityOauthToken, machineID, machineToken, machineType string, region *RegionConfig) (map[string]interface{}, error) { + if region == nil { + region = CN + } + return requestJobToken(ctx, personalToken, refreshToken, securityOauthToken, true, machineID, machineToken, machineType, region) +} + +// --- Account status (subscription tier + billing cycle) --- + +// userStatusInnerStruct is the /user/status payload. Field order MUST match +// the official client's dict insertion order for signature compatibility. +// Only userId is populated; the token fields are sent empty because this call +// is authenticated by the machine-identity signature, not by the session. +type userStatusInnerStruct struct { + UserID string `json:"userId"` + PersonalToken string `json:"personalToken"` + SecurityOauthToken string `json:"securityOauthToken"` + RefreshToken string `json:"refreshToken"` + NeedRefresh bool `json:"needRefresh"` + AuthInfo json.RawMessage `json:"authInfo"` +} + +// AccountStatus combines identity metadata from the gateway status endpoint +// with the authoritative allowance returned by Qoder OpenAPI. NextResetAtMs is +// the moment the current subscription allowance expires and refreshes. +type AccountStatus struct { + // UserType is the account's real tier as reported by the gateway + // ("teams", "personal_standard", ...). The jobToken response does NOT + // carry this field, so callers must not infer it from there. + UserType string + // Plan is the raw plan identifier ("PLAN_TIER_TEAM", ...). + Plan string + // UserTag is the human-facing plan label ("Teams", ...). + UserTag string + // OrgName is the organization the account belongs to ("" for personal). + OrgName string + // NextResetAtMs is the subscription refresh instant in epoch millis. + // 0 means the gateway did not report one. + NextResetAtMs int64 + // IsQuotaExceeded reports whether the account is currently out of + // allowance. Once quota usage has been fetched, this is the OpenAPI + // verdict used by the official client rather than the reduced gateway + // status field. + IsQuotaExceeded bool + TotalUsagePercentage float64 + UserQuota *Quota + AddOnQuota *Quota + OrgResourcePackage *OrgResourcePackage +} + +// Quota is an authoritative credit allowance returned by Qoder OpenAPI. +// Percentage is a ratio in [0,1], matching the official client payload. +type Quota struct { + Total float64 `json:"total"` + Used float64 `json:"used"` + Remaining float64 `json:"remaining"` + Percentage float64 `json:"percentage"` + Unit string `json:"unit"` + DetailURL string `json:"detailUrl,omitempty"` +} + +// OrgResourcePackage is the shared organization credit pool. Available is +// authoritative: a positive cap does not necessarily mean the current member +// may consume it. +type OrgResourcePackage struct { + Used float64 `json:"used"` + Cap float64 `json:"cap"` + Remaining float64 `json:"remaining"` + Percentage float64 `json:"percentage"` + Available bool `json:"available"` + Unit string `json:"unit"` +} + +// QuotaUsage is the full cycle-scoped allowance returned by the same OpenAPI +// endpoint used by the official client's /usage view. +type QuotaUsage struct { + UserID string `json:"userId"` + UserType string `json:"userType"` + UsageType string `json:"usageType"` + TotalUsagePercentage float64 `json:"totalUsagePercentage"` + IsQuotaExceeded bool `json:"isQuotaExceeded"` + ExpiresAtMs int64 `json:"expiresAt"` + UpgradeURL string `json:"upgradeUrl"` + UserQuota *Quota `json:"userQuota"` + AddOnQuota *Quota `json:"addOnQuota,omitempty"` + OrgResourcePackage *OrgResourcePackage `json:"orgResourcePackage,omitempty"` + IsPlanQuotaProrated bool `json:"isPlanQuotaProrated"` +} + +// FetchUserStatus queries the account's subscription state. +// +// The endpoint is authenticated by the machine-identity signature only (no +// bearer session), so it is safe to call right after a jobToken exchange and +// before any session is constructed. +func FetchUserStatus(ctx context.Context, userID, machineID, machineToken, machineType string, region *RegionConfig) (map[string]interface{}, error) { + if region == nil { + region = CN + } + urlStr := AuthURL(region, "/algo/api/v3/user/status?Encode=1") + inner := userStatusInnerStruct{ + UserID: userID, + NeedRefresh: false, + AuthInfo: emptyJSON, + } + innerJSON, err := marshalNoEscape(inner) + if err != nil { + return nil, err + } + outer := jobTokenOuterStruct{ + Payload: string(innerJSON), + EncodeVersion: "1", + } + return postEncoded(ctx, urlStr, outer, machineID, machineToken, machineType) +} + +// FetchQuotaUsage retrieves the authoritative current-cycle allowance used by +// the official /usage view. The bearer is the securityOauthToken returned by +// the existing jobToken exchange; no additional token rotation is required. +func FetchQuotaUsage(ctx context.Context, bearer string, region *RegionConfig) (*QuotaUsage, error) { + if bearer == "" { + return nil, fmt.Errorf("quota usage requires a security OAuth token") + } + if region == nil { + region = CN + } + urlStr := OpenAPIURL(region, "/api/v2/quota/usage") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+bearer) + + started := time.Now() + log.Printf("[auth] OpenAPI request: method=GET url=%s headers={Accept: application/json, Authorization: Bearer [REDACTED]} body=", urlStr) + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + log.Printf("[auth] OpenAPI request failed: method=GET url=%s duration=%s error=%v", urlStr, time.Since(started), err) + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + log.Printf("[auth] OpenAPI response: method=GET url=%s status=%d duration=%s", + urlStr, resp.StatusCode, time.Since(started)) + if resp.StatusCode != http.StatusOK { + // The body is redacted and truncated before it reaches the error: the + // account loop logs this at WARN, and the quota payload carries the + // account's userId and trace ids. The status alone identifies the failure. + return nil, fmt.Errorf("quota usage returned HTTP %d: %s", resp.StatusCode, truncateErrorBody(body)) + } + var usage QuotaUsage + if err := json.Unmarshal(body, &usage); err != nil { + return nil, fmt.Errorf("decode quota usage: %w", err) + } + if usage.UserID == "" || usage.UserQuota == nil { + return nil, fmt.Errorf("quota usage response shape unexpected") + } + return &usage, nil +} + +// --- OpenAPI bearer (regions whose quota API rejects the COSY token) --- + +// OpenAPIToken is the bearer pair minted by the OpenAPI jobToken exchange. The +// global region's quota/usage endpoint rejects the COSY securityOauthToken, so +// it needs this separately-issued token (and its refresh token) instead. +type OpenAPIToken struct { + Token string + RefreshToken string + // ExpiresAtMs is the token expiry in epoch millis (0 when unreported). + ExpiresAtMs int64 +} + +// openAPITokenResponse is the wire shape of /api/v1/jobToken/exchange and +// /api/v1/jobToken/refresh. expires_at is an RFC3339 string on the observed +// global endpoint, while expires_in (milliseconds) is the fallback. +type openAPITokenResponse struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + DeviceToken string `json:"device_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt interface{} `json:"expires_at"` + ExpiresIn int64 `json:"expires_in"` + RefreshTokenExpire interface{} `json:"refresh_token_expires_at"` +} + +func (r openAPITokenResponse) token() string { + if r.Token != "" { + return r.Token + } + if r.AccessToken != "" { + return r.AccessToken + } + return r.DeviceToken +} + +// expiryMs resolves the absolute expiry from expires_at (RFC3339 string or +// epoch millis) with expires_in (milliseconds) as a fallback. +func (r openAPITokenResponse) expiryMs() int64 { + switch v := r.ExpiresAt.(type) { + case string: + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t.UnixMilli() + } + case float64: + if v > 0 { + return int64(v) + } + } + if r.ExpiresIn > 0 { + return time.Now().Add(time.Duration(r.ExpiresIn) * time.Millisecond).UnixMilli() + } + return 0 +} + +func openAPITokenRequest(ctx context.Context, urlStr string, payload interface{}, region *RegionConfig) (*OpenAPIToken, error) { + plain, err := marshalNoEscape(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(plain)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "qoder/"+openAPIUserAgentVersion) + + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("openapi token endpoint HTTP %d: %s", resp.StatusCode, truncateErrorBody(body)) + } + var parsed openAPITokenResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("decode openapi token: %w", err) + } + tok := parsed.token() + if tok == "" { + return nil, fmt.Errorf("openapi token response carried no token") + } + return &OpenAPIToken{Token: tok, RefreshToken: parsed.RefreshToken, ExpiresAtMs: parsed.expiryMs()}, nil +} + +// ExchangeOpenAPIToken mints an OpenAPI bearer from a personal access token. +// Used by regions flagged NeedsOpenAPIToken. +func ExchangeOpenAPIToken(ctx context.Context, personalToken string, region *RegionConfig) (*OpenAPIToken, error) { + if region == nil { + region = CN + } + urlStr := OpenAPIURL(region, "/api/v1/jobToken/exchange") + return openAPITokenRequest(ctx, urlStr, map[string]string{"personal_token": personalToken}, region) +} + +// RefreshOpenAPIToken rotates an OpenAPI bearer using its refresh token. +func RefreshOpenAPIToken(ctx context.Context, refreshToken string, region *RegionConfig) (*OpenAPIToken, error) { + if region == nil { + region = CN + } + urlStr := OpenAPIURL(region, "/api/v1/jobToken/refresh") + return openAPITokenRequest(ctx, urlStr, map[string]string{"refresh_token": refreshToken}, region) +} + +// ParseAccountStatus converts a raw /user/status response into AccountStatus. +// It returns false when the response carries no recognizable account fields, +// letting callers keep any previously cached state instead of zeroing it out. +func ParseAccountStatus(raw map[string]interface{}) (AccountStatus, bool) { + if raw == nil { + return AccountStatus{}, false + } + userType, _ := raw["userType"].(string) + plan, _ := raw["plan"].(string) + if userType == "" && plan == "" && raw["nextResetAt"] == nil { + return AccountStatus{}, false + } + st := AccountStatus{ + UserType: userType, + Plan: plan, + } + st.UserTag, _ = raw["userTag"].(string) + st.OrgName, _ = raw["orgName"].(string) + st.NextResetAtMs = toInt64Ms(raw["nextResetAt"]) + st.IsQuotaExceeded, _ = raw["isQuotaExceeded"].(bool) + return st, true +} + +// toInt64Ms coerces an epoch-millis field arriving as a JSON number. +func toInt64Ms(v interface{}) int64 { + if f, ok := v.(float64); ok { + return int64(f) + } + return 0 +} + +// drainBody reads and discards the remaining response body (up to a cap) so +// the underlying TCP connection can be returned to the pool for reuse. +// Must be called before resp.Body.Close() on error paths where the body was +// only partially read. +func drainBody(body io.Reader) { + io.Copy(io.Discard, io.LimitReader(body, 1<<20)) +} + +// --- Bearer API Client --- + +func makeCommonHeaders(sess *SessionContext, date, bearer, accept string) http.Header { + h := http.Header{} + h.Set("cosy-data-policy", "AGREE") + h.Set("content-type", "application/json") + h.Set("cosy-machinetype", sess.MachineType) + h.Set("cosy-clienttype", "5") + h.Set("cosy-date", date) + h.Set("cosy-user", sess.Identity.UID) + h.Set("cosy-key", sess.CosyKey) + h.Set("accept", accept) + h.Set("authorization", bearer) + h.Set("accept-encoding", "identity") + h.Set("cosy-version", "0.1.43") + h.Set("cosy-machineid", sess.MachineID) + h.Set("cosy-machinetoken", sess.MachineToken) + h.Set("login-version", "v2") + h.Set("user-agent", "Go-http-client/2.0") + return h +} + +func buildBearer(sess *SessionContext, date, body, pathSig string) string { + payloadB64 := BuildPayloadB64(sess.Info) + sig := SignRequest(payloadB64, sess.CosyKey, date, body, pathSig) + return ComposeBearer(payloadB64, sig) +} + +func sigPath(fullURL string) string { + u, err := url.Parse(fullURL) + if err != nil { + return fullURL + } + path := u.Path + if strings.HasPrefix(path, "/algo") { + path = path[len("/algo"):] + } + return path +} + +func CallPost(ctx context.Context, sess *SessionContext, fullURL string, jsonBody map[string]interface{}) (map[string]interface{}, error) { + return call(ctx, sess, "POST", fullURL, jsonBody, nil) +} + +func CallGet(ctx context.Context, sess *SessionContext, fullURL string) (map[string]interface{}, error) { + return call(ctx, sess, "GET", fullURL, nil, nil) +} + +func call(ctx context.Context, sess *SessionContext, method, fullURL string, jsonBody map[string]interface{}, extraHeaders map[string]string) (map[string]interface{}, error) { + pathSig := sigPath(fullURL) + body := "" + if jsonBody != nil { + plain, err := marshalNoEscape(jsonBody) + if err != nil { + return nil, err + } + body = Encode(plain) + } + date := strconv.FormatInt(time.Now().Unix(), 10) + bearer := buildBearer(sess, date, body, pathSig) + headers := makeCommonHeaders(sess, date, bearer, "application/json") + for k, v := range extraHeaders { + headers.Set(k, v) + } + + var reqBody io.Reader + if body != "" { + reqBody = strings.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody) + if err != nil { + return nil, err + } + req.Header = headers + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + detail, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyCap)) + drainBody(resp.Body) + if resp.StatusCode == 401 || resp.StatusCode == 403 { + if busy := busyFromAuthEnvelope(resp.StatusCode, detail); busy != nil { + return nil, busy + } + return nil, &AuthError{StatusCode: resp.StatusCode, Detail: string(detail)} + } + return nil, &UpstreamError{StatusCode: resp.StatusCode, Body: string(truncateErrorBody(detail)), RetryAfter: retryAfterHeader(resp.Header.Get("Retry-After"))} + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result, nil +} + +// StreamCallback is called for each non-empty SSE line. +type StreamCallback func(line string) error + +// --- Configurable stream timeouts --- + +// DefaultChatHeaderTimeout bounds how long a chat stream may take to start +// responding (time to response headers). DefaultChatIdleTimeout bounds how +// long an established SSE stream may stay silent (no bytes received) before +// we give up. Without the idle bound a wedged gateway connection (TCP alive, +// no data) pins a goroutine + connection forever, and the server +// intentionally sets no WriteTimeout for SSE. +const ( + DefaultChatHeaderTimeout = 120 * time.Second + DefaultChatIdleTimeout = 5 * time.Minute +) + +// StreamTimeouts bundles the two chat stream timeouts. Both can be changed +// at runtime via SetStreamTimeouts; new requests pick the values up +// immediately, in-flight streams keep running with the old ones. +type StreamTimeouts struct { + Header time.Duration // wait for the chat stream to start responding + Idle time.Duration // max silence on an established SSE stream +} + +func (t StreamTimeouts) normalized() StreamTimeouts { + if t.Header <= 0 { + t.Header = DefaultChatHeaderTimeout + } + if t.Idle <= 0 { + t.Idle = DefaultChatIdleTimeout + } + return t +} + +var ( + streamTimeoutsVal atomic.Pointer[StreamTimeouts] + streamClientVal atomic.Pointer[http.Client] +) + +func init() { + SetStreamTimeouts(DefaultChatHeaderTimeout, DefaultChatIdleTimeout) +} + +// SetStreamTimeouts configures the chat stream timeouts and rebuilds the +// dedicated stream client. The client is swapped atomically so concurrent +// requests never observe a half-updated state. +func SetStreamTimeouts(header, idle time.Duration) { + t := StreamTimeouts{Header: header, Idle: idle}.normalized() + // Own transport (cloned from the default) isolates the long-lived SSE + // connection pool from the short-request clients, and lets the header + // timeout change without touching them. ResponseHeaderTimeout is the + // only deadline: a Client.Timeout here would truncate long generations. + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.ResponseHeaderTimeout = t.Header + streamClientVal.Store(&http.Client{Transport: transport}) + streamTimeoutsVal.Store(&t) +} + +// CurrentStreamTimeouts returns the effective stream timeouts. +func CurrentStreamTimeouts() StreamTimeouts { + if t := streamTimeoutsVal.Load(); t != nil { + return *t + } + return StreamTimeouts{}.normalized() +} + +// isDoneLine reports whether an SSE line carries the terminal [DONE] +// marker. The Qoder gateway wraps [DONE] inside its Encode-layer envelope +// ({"body":"[DONE]",...}) rather than emitting a bare "data: [DONE]"; +// both forms are accepted so test fakes and real traffic both terminate +// cleanly. +func isDoneLine(line string) bool { + if !strings.HasPrefix(line, "data:") { + return false + } + payload := strings.TrimSpace(line[5:]) + if payload == "[DONE]" { + return true + } + var env struct { + Body string `json:"body"` + } + if json.Unmarshal([]byte(payload), &env) == nil && env.Body == "[DONE]" { + return true + } + return false +} + +// isFinishEvent reports whether an SSE line is the gateway's terminal +// "event:finish" marker. Some models (MiniMax) end their stream with this +// event instead of a [DONE] frame, so it must count as a clean termination +// just like [DONE]. +func isFinishEvent(line string) bool { + return strings.HasPrefix(line, "event:") && strings.TrimSpace(line[len("event:"):]) == "finish" +} + +// isErrorEvent reports whether an SSE line is the gateway's terminal +// "event:error" marker: the upstream provider failed and the stream is +// about to end without [DONE]. Observed on qmodel_preview provider outages +// (429 provider_error "All backends failed"). +func isErrorEvent(line string) bool { + return strings.HasPrefix(line, "event:") && strings.TrimSpace(line[len("event:"):]) == "error" +} + +// InStreamError describes a gateway failure delivered inside the SSE stream +// (HTTP status is still 200). Kept when seen so the EOF-without-[DONE] +// fallback reports the real cause instead of a misleading "connection +// truncated". Status carries the status embedded in the failure frame +// (statusCodeValue or msgCode); 0 means unknown. Exported so the chat +// handler can map the client response to the same status instead of a +// generic 500 when the stream failed before any content was delivered. +type InStreamError struct { + Status int + Detail string +} + +func (e *InStreamError) Error() string { + if e.Status > 0 { + return fmt.Sprintf("gateway in-stream error (HTTP %d): %s", e.Status, e.Detail) + } + return fmt.Sprintf("gateway in-stream error: %s", e.Detail) +} + +// detectInStreamGatewayError extracts a terminal gateway/provider failure +// from an SSE data line. Two shapes are recognized: +// +// 1. Envelope frame with an HTTP error status, e.g. (qmodel_preview +// provider outage): +// {"body":"{\"code\":\"provider_error\",\"message\":\"All backends failed\"...}", +// "statusCodeValue":429,"statusCode":"TOO_MANY_REQUESTS"} +// +// 2. Bare success:false gateway error frame, e.g. +// {"success":false,"msgCode":500,"message":"Internal Server Error"} +// +// A 200 OK envelope with normal chat/usage/[DONE] bodies returns nil. +func detectInStreamGatewayError(line string) *InStreamError { + if !strings.HasPrefix(line, "data:") { + return nil + } + payload := strings.TrimSpace(line[len("data:"):]) + var env struct { + Body string `json:"body"` + StatusCode string `json:"statusCode"` + StatusValue int `json:"statusCodeValue"` + } + if err := json.Unmarshal([]byte(payload), &env); err != nil { + return nil + } + if env.StatusValue >= 400 { + detail := env.Body + if detail == "" { + detail = env.StatusCode + } + return &InStreamError{Status: env.StatusValue, Detail: trimJSONMessage(detail)} + } + if env.Body != "" || env.StatusCode != "" { + // A well-formed envelope with a 2xx status is a normal frame. + return nil + } + var bare struct { + Success bool `json:"success"` + MsgCode int `json:"msgCode"` + MsgInfo string `json:"msgInfo"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(payload), &bare); err != nil { + return nil + } + if !bare.Success && (bare.MsgCode >= 400 || bare.Message != "" || bare.MsgInfo != "") { + // Only treat it as a failure when a message/code is present; a bare + // {"success":false} with no reason is too ambiguous to fail on. + detail := bare.Message + if detail == "" { + detail = bare.MsgInfo + } + if detail == "" { + detail = fmt.Sprintf("msgCode %d", bare.MsgCode) + } + return &InStreamError{Status: bare.MsgCode, Detail: detail} + } + return nil +} + +// trimJSONMessage pulls the "message" field out of a JSON-string body when +// present ("{\"code\":\"provider_error\",\"message\":\"All backends failed\"...}") +// so the surfaced error reads "provider_error: All backends failed" instead +// of a wall of escaped JSON. +func trimJSONMessage(body string) string { + var m struct { + Code string `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(body), &m); err == nil { + if m.Code != "" && m.Message != "" { + return m.Code + ": " + m.Message + } + if m.Message != "" { + return m.Message + } + } + return strings.TrimSpace(body) +} + +// OpenStreamLines sends a POST and reads SSE response line by line. +func OpenStreamLines(ctx context.Context, sess *SessionContext, fullURL string, jsonBody []byte, extraHeaders map[string]string, callback StreamCallback) error { + pathSig := sigPath(fullURL) + body := Encode(jsonBody) + date := strconv.FormatInt(time.Now().Unix(), 10) + bearer := buildBearer(sess, date, body, pathSig) + headers := makeCommonHeaders(sess, date, bearer, "text/event-stream") + headers.Set("cache-control", "no-cache") + for k, v := range extraHeaders { + headers.Set(k, v) + } + + req, err := http.NewRequestWithContext(ctx, "POST", fullURL, strings.NewReader(body)) + if err != nil { + return err + } + req.Header = headers + + resp, err := streamClientVal.Load().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + errBody, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyCap)) + drainBody(resp.Body) + if resp.StatusCode == 401 || resp.StatusCode == 403 { + if busy := busyFromAuthEnvelope(resp.StatusCode, errBody); busy != nil { + return busy + } + return &AuthError{StatusCode: resp.StatusCode, Detail: string(errBody)} + } + return &UpstreamError{StatusCode: resp.StatusCode, Body: string(truncateErrorBody(errBody)), RetryAfter: retryAfterHeader(resp.Header.Get("Retry-After"))} + } + + // Cut off silent streams: long generations are fine (lines keep + // arriving), only a silent connection is killed. The watchdog polls a + // last-activity timestamp instead of sharing a timer with the read loop — + // Reset on a fired-but-undrained timer is racy under the pre-Go-1.23 + // timer semantics this module builds with. + timeouts := CurrentStreamTimeouts() + idleErr := fmt.Errorf("stream idle for over %s", timeouts.Idle) + var lastLineNs atomic.Int64 + lastLineNs.Store(time.Now().UnixNano()) + var idleFired atomic.Bool + cancelRead := func() { + // Cancel the body read; scanner.Scan() then returns with an error. + if c, ok := resp.Body.(interface{ SetReadDeadline(time.Time) error }); ok { + c.SetReadDeadline(time.Now()) + } else { + // Best effort for bodies without deadline support (HTTP/1.1): + // closing unblocks the read with a "closed network connection" + // error, which idleFired maps back to idleErr below. + resp.Body.Close() + } + } + + // The gateway always terminates its stream with a [DONE] marker. An EOF + // before it means the connection was cut mid-response; treating that as + // success would silently truncate the answer and still emit our own + // [DONE] to the client. + sawDone := false + var gwErr *InStreamError + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + done := make(chan struct{}) + defer close(done) + go func() { + checkEvery := timeouts.Idle / 4 + if checkEvery < 10*time.Millisecond { + checkEvery = 10 * time.Millisecond + } + ticker := time.NewTicker(checkEvery) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + if time.Since(time.Unix(0, lastLineNs.Load())) >= timeouts.Idle { + idleFired.Store(true) + cancelRead() + return + } + } + } + }() + for scanner.Scan() { + line := scanner.Text() + lastLineNs.Store(time.Now().UnixNano()) + if line == "" { + continue + } + if isDoneLine(line) { + sawDone = true + } + if isFinishEvent(line) { + // MiniMax-style termination: no [DONE] frame, just event:finish + // followed by a telemetry frame and EOF. + sawDone = true + } + if isErrorEvent(line) { + // Provider-side failure: the stream will end without [DONE]; make + // sure the EOF fallback reports the real cause (from the error + // frames seen before this event) instead of "truncated". + if gwErr == nil { + gwErr = &InStreamError{Detail: "upstream stream failed (event:error)"} + } + } + if e := detectInStreamGatewayError(line); e != nil && gwErr == nil { + gwErr = e + } + if busy := detectInStreamBusyError(line); busy != nil { + return busy + } + isAuthErr, detail := detectInStreamAuthError(line) + if isAuthErr { + return &AuthError{StatusCode: 401, Detail: detail} + } + if err := callback(line); err != nil { + return err + } + if ctx.Err() != nil { + return ctx.Err() + } + } + if err := scanner.Err(); err != nil { + if idleFired.Load() || errors.Is(err, os.ErrDeadlineExceeded) { + return idleErr + } + return err + } + if !sawDone { + // Prefer the gateway's own failure reason when it sent error frames + // before the premature EOF; "connection truncated" is the fallback. + if gwErr != nil { + return gwErr + } + return fmt.Errorf("stream ended without [DONE] marker (connection truncated?)") + } + return nil +} + +// detectInStreamBusyError reports whether an SSE frame carries the +// gateway's queue/concurrency rejection (code 10605) under a 401/403 +// statusCodeValue. Such frames look like auth failures but are admission- +// control refusals; the caller should back off instead of refreshing tokens. +func detectInStreamBusyError(line string) *BusyError { + s := strings.TrimSpace(line) + if !strings.HasPrefix(s, "data:") { + return nil + } + // Cheap pre-filter: busy frames always ride a statusCodeValue envelope. + // Without this every content delta (potentially large base64 payloads) + // would pay a full JSON unmarshal per line. + if !strings.Contains(s, "statusCodeValue") { + return nil + } + var obj map[string]interface{} + if err := json.Unmarshal([]byte(strings.TrimSpace(s[5:])), &obj); err != nil { + return nil + } + scv, ok := obj["statusCodeValue"].(float64) + if !ok || (scv != 401 && scv != 403) { + return nil + } + bodyMap, ok := parseBody(obj).(map[string]interface{}) + if !ok { + return nil + } + var code string + switch c := bodyMap["code"].(type) { + case string: + code = c + case float64: + if c == float64(int64(c)) { + code = strconv.FormatInt(int64(c), 10) + } + } + if code != QoderBusyCode { + return nil + } + busy := &BusyError{StatusCode: int(scv), Code: code} + if msg, _ := bodyMap["message"].(string); msg != "" { + busy.Message = truncateBusyMessage(msg) + busy.RetryAfter = busyMeta(msg) + } + return busy +} + +// detectInStreamAuthError detects auth failures inside the SSE stream. +// Queue/concurrency rejections (code 10605) share the 401/403 envelope but +// are NOT auth failures; they are left to detectInStreamBusyError so callers +// can distinguish "token expired" (refresh + retry) from "back off". +func detectInStreamAuthError(line string) (bool, string) { + s := strings.TrimSpace(line) + if !strings.HasPrefix(s, "data:") { + return false, "" + } + var obj map[string]interface{} + if err := json.Unmarshal([]byte(strings.TrimSpace(s[5:])), &obj); err != nil { + return false, "" + } + if detectInStreamBusyError(line) != nil { + return false, "" + } + scv, ok := obj["statusCodeValue"].(float64) + if ok && (scv == 401 || scv == 403) { + return true, fmt.Sprintf("%v %s", scv, extractBodyMessage(obj)) + } + msg := extractBodyMessage(obj) + bodyObj := parseBody(obj) + if bodyMap, ok := bodyObj.(map[string]interface{}); ok { + code := bodyMap["code"] + if code == "105" || code == float64(105) { + if msg != "" { + return true, msg + } + return true, "Login expired" + } + } + return false, "" +} + +func parseBody(obj map[string]interface{}) interface{} { + body, ok := obj["body"] + if !ok { + return nil + } + switch b := body.(type) { + case map[string]interface{}, []interface{}: + return b + case string: + var parsed interface{} + if err := json.Unmarshal([]byte(b), &parsed); err != nil { + return nil + } + return parsed + } + return nil +} + +func extractBodyMessage(obj map[string]interface{}) string { + bodyObj := parseBody(obj) + bodyMap, ok := bodyObj.(map[string]interface{}) + if !ok { + return "" + } + if msg, ok := bodyMap["message"].(string); ok && msg != "" { + return msg + } + if code, ok := bodyMap["code"]; ok && code != nil { + s := fmt.Sprintf("%v", code) + if s != "" && s != "" { + return s + } + } + return "" +} diff --git a/internal/providers/qoder/qauth/qauth_test.go b/internal/providers/qoder/qauth/qauth_test.go new file mode 100644 index 00000000..2c10eb5f --- /dev/null +++ b/internal/providers/qoder/qauth/qauth_test.go @@ -0,0 +1,923 @@ +// Package qauth tests cover the core paths a PR reviewer asked for: custom +// base64 round-trip, COSY signing, AES/RSA encryption, region selection, +// PAT->jobToken exchange and refresh, busy/auth/gateway error taxonomy, and +// the SSE stream reader. All upstreams are httptest fakes; no real network. +package qauth + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + cosySignatureHash "crypto/md5" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// ============================================================================= +// 1. Custom base64 encode/decode +// ============================================================================= + +func TestEncodeDecodeRoundtrip(t *testing.T) { + cases := []struct { + name string + in []byte + }{ + {"empty", []byte{}}, + {"one byte", []byte{0x00}}, + {"short ascii", []byte("hi")}, + {"five bytes", []byte("hello")}, + {"long ascii", []byte("the quick brown fox jumps over the lazy dog 1234567890")}, + {"binary zeros", bytes.Repeat([]byte{0x00}, 64)}, + {"binary 0xff", bytes.Repeat([]byte{0xff}, 64)}, + {"mixed binary", []byte{0x00, 0x01, 0x02, 0xfe, 0xff, 0x7f, 0x80, 0xc2, 0xe9, 0x83}}, + {"multibyte utf8", []byte("héllo wörld 世界 🚀")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + enc := Encode(tc.in) + // Encoded output must never be empty when the input is non-empty + // (even a single byte yields 4 base64 chars). + if len(tc.in) > 0 && enc == "" { + t.Fatalf("Encode returned empty string for non-empty input") + } + out, err := Decode(enc) + if err != nil { + t.Fatalf("Decode err = %v", err) + } + if !bytes.Equal(out, tc.in) { + t.Fatalf("roundtrip mismatch\nin: %x\nout: %x", tc.in, out) + } + }) + } +} + +func TestDecodeInvalidChar(t *testing.T) { + // '+' and '/' belong to standard base64 but NOT to the custom alphabet, + // so a string carrying them is illegal. + cases := []string{ + "abc+def", + "a/b", + "~tilde", + "back`tick", + } + for _, in := range cases { + if _, err := Decode(in); err == nil { + t.Fatalf("Decode(%q) = nil err, want error", in) + } + } +} + +// ============================================================================= +// 2. Signing +// ============================================================================= + +func TestSignDeterministic(t *testing.T) { + d1 := "Mon, 02 Jan 2006 15:04:05 GMT" + d2 := "Tue, 03 Jan 2006 15:04:05 GMT" + + s1a := Sign(d1) + s1b := Sign(d1) + s2 := Sign(d2) + + if s1a != s1b { + t.Fatalf("Sign not deterministic: %q vs %q for same date", s1a, s1b) + } + if s1a == s2 { + t.Fatalf("Sign should differ for different dates: %q for both", s1a) + } + if len(s1a) != 32 { + t.Fatalf("Sign output len = %d, want 32 (md5 hex)", len(s1a)) + } + // Verify it is literally MD5(appCode "&" secret "&" date) using the same + // secret the package resolves. getSecret is unexported but visible inside + // this internal test. + manual := fmt.Sprintf("%x", cosySignatureHash.Sum([]byte(appCode+"&"+getSecret()+"&"+d1))) + if s1a != manual { + t.Fatalf("Sign = %q, manual md5 = %q, want match", s1a, manual) + } +} + +func TestSignRequestDeterministic(t *testing.T) { + sig1a := SignRequest("payload", "cosyKey", "date", "body", "/path") + sig1b := SignRequest("payload", "cosyKey", "date", "body", "/path") + sig2 := SignRequest("payload", "cosyKey", "OTHER", "body", "/path") + sig3 := SignRequest("payload", "cosyKey", "date", "OTHER", "/path") + + if sig1a != sig1b { + t.Fatalf("SignRequest not deterministic: %q vs %q", sig1a, sig1b) + } + if sig1a == sig2 { + t.Fatalf("SignRequest should differ when cosyDate differs") + } + if sig1a == sig3 { + t.Fatalf("SignRequest should differ when body differs") + } + if len(sig1a) != 32 { + t.Fatalf("SignRequest len = %d, want 32", len(sig1a)) + } + // Verify it matches the documented concat / md5 shape. + manual := fmt.Sprintf("%x", cosySignatureHash.Sum([]byte("payload\ncosyKey\ndate\nbody\n/path"))) + if sig1a != manual { + t.Fatalf("SignRequest = %q, manual = %q, want match", sig1a, manual) + } +} + +// ============================================================================= +// 3. AES / RSA +// ============================================================================= + +func TestAesEncryptDecryptRoundtrip(t *testing.T) { + // Implementation uses AES-CBC with IV = key[:16] and PKCS7 padding. Reverse + // it in the test to prove the ciphertext round-trips. + key := []byte("0123456789abcdef") // 16 bytes -> AES-128, IV = key[:16] + cases := [][]byte{ + []byte(""), + []byte("a"), + []byte("exactly16bytes"), + []byte("longer than one block, spans multiple AES blocks"), + bytes.Repeat([]byte{0xaa}, 256), + } + for i, plain := range cases { + enc, err := aesEncrypt(plain, key) + if err != nil { + t.Fatalf("case %d: aesEncrypt err = %v", i, err) + } + if len(enc) == 0 { + t.Fatalf("case %d: empty ciphertext", i) + } + if len(enc)%16 != 0 { + t.Fatalf("case %d: ciphertext len %d not a block multiple", i, len(enc)) + } + + block, err := aes.NewCipher(key) + if err != nil { + t.Fatalf("case %d: aes.NewCipher err = %v", i, err) + } + dec := make([]byte, len(enc)) + cipher.NewCBCDecrypter(block, key[:16]).CryptBlocks(dec, enc) + padLen := int(dec[len(dec)-1]) + if padLen < 1 || padLen > 16 { + t.Fatalf("case %d: bad pad len %d", i, padLen) + } + dec = dec[:len(dec)-padLen] + if !bytes.Equal(dec, plain) { + t.Fatalf("case %d: roundtrip mismatch\nplain: %x\ndec: %x", i, plain, dec) + } + } +} + +func TestRsaEncryptOutputLength(t *testing.T) { + // The server public key is a 1024-bit RSA key, so PKCS1v15 ciphertext is + // always 128 bytes. We cannot decrypt (no private key) but the length is a + // sufficient smoke test that encryption ran against the real key. + for _, plain := range [][]byte{ + []byte("0123456789abcdef"), // 16 bytes (session tempKey size) + []byte("short"), + } { + enc, err := rsaEncrypt(plain) + if err != nil { + t.Fatalf("rsaEncrypt err = %v", err) + } + if len(enc) != 128 { + t.Fatalf("rsaEncrypt len = %d, want 128 (1024-bit key)", len(enc)) + } + } +} + +// ============================================================================= +// 4. Region selection and URL composition +// ============================================================================= + +func TestRegionByName(t *testing.T) { + cases := []struct { + in string + want string // .Name + }{ + {"", "cn"}, + {"cn", "cn"}, + {"CN", "cn"}, + {" cn ", "cn"}, + {"global", "global"}, + {"GLOBAL", "global"}, + {"intl", "global"}, + {"international", "global"}, + {"unknown", "cn"}, // unknown falls back to CN + {"mars", "cn"}, + } + for _, tc := range cases { + got := RegionByName(tc.in) + if got == nil { + t.Fatalf("RegionByName(%q) = nil", tc.in) + } + if got.Name != tc.want { + t.Fatalf("RegionByName(%q).Name = %q, want %q", tc.in, got.Name, tc.want) + } + } + // Global must be flagged NeedsOpenAPIToken; CN must not. + if !Global.NeedsOpenAPIToken { + t.Fatal("Global.NeedsOpenAPIToken = false, want true") + } + if CN.NeedsOpenAPIToken { + t.Fatal("CN.NeedsOpenAPIToken = true, want false") + } +} + +func TestNormalizeRegion(t *testing.T) { + cases := map[string]string{ + "": "cn", + "cn": "cn", + "CN": "cn", + "global": "global", + "INTL": "global", + "International": "global", + "unknown": "cn", + } + for in, want := range cases { + if got := NormalizeRegion(in); got != want { + t.Fatalf("NormalizeRegion(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRegionURLs(t *testing.T) { + // CN / Global real configs. + if got := AuthURL(CN, "/p"); got != "https://gateway.qoder.com.cn/p" { + t.Fatalf("AuthURL(CN) = %q", got) + } + if got := ChatURL(CN); !strings.HasPrefix(got, "https://gateway.qoder.com.cn/algo/api/v2/service/pro/sse/agent_chat_generation") { + t.Fatalf("ChatURL(CN) = %q", got) + } + if got := ModelListURL(CN); got != "https://gateway.qoder.com.cn/algo/api/v2/model/list?Encode=1" { + t.Fatalf("ModelListURL(CN) = %q", got) + } + if got := OpenAPIURL(CN, "/q"); got != "https://openapi.qoder.com.cn/q" { + t.Fatalf("OpenAPIURL(CN) = %q", got) + } + if got := AuthURL(Global, "/p"); got != "https://api1.qoder.sh/p" { + t.Fatalf("AuthURL(Global) = %q", got) + } + if got := OpenAPIURL(Global, "/q"); got != "https://openapi.qoder.sh/q" { + t.Fatalf("OpenAPIURL(Global) = %q", got) + } + + // A test region pointed at an httptest base must compose the same way. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer srv.Close() + region := &RegionConfig{Name: "test", AuthBase: srv.URL, ChatBase: srv.URL, OpenAPIBase: srv.URL} + if got := AuthURL(region, "/algo/api/v3/user/jobToken?Encode=1"); got != srv.URL+"/algo/api/v3/user/jobToken?Encode=1" { + t.Fatalf("AuthURL(test) = %q", got) + } +} + +// ============================================================================= +// 5. PAT -> jobToken exchange +// ============================================================================= + +// jobTokenTestServer builds an httptest upstream that records the decoded +// request body and returns a canned response. The returned handler decodes +// the custom-base64 request body, parses the outer/inner jobToken envelope, +// and stores the inner fields in *got for assertion. +func jobTokenTestServer(t *testing.T, status int, respBody []byte, got *jobTokenInnerStruct, gotMachine *struct{ ID, Token, Type string }) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/algo/api/v3/user/jobToken" { + t.Errorf("unexpected path %q", r.URL.Path) + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body err = %v", err) + return + } + plain, err := Decode(string(body)) + if err != nil { + t.Errorf("Decode request body err = %v", err) + return + } + var outer jobTokenOuterStruct + if err := json.Unmarshal(plain, &outer); err != nil { + t.Errorf("unmarshal outer err = %v (plain=%q)", err, plain) + return + } + if outer.EncodeVersion != "1" { + t.Errorf("encodeVersion = %q, want 1", outer.EncodeVersion) + } + if err := json.Unmarshal([]byte(outer.Payload), got); err != nil { + t.Errorf("unmarshal payload err = %v (payload=%q)", err, outer.Payload) + return + } + if gotMachine != nil { + gotMachine.ID = r.Header.Get("cosy-machineid") + gotMachine.Token = r.Header.Get("cosy-machinetoken") + gotMachine.Type = r.Header.Get("cosy-machinetype") + } + // Signature header must be present and deterministic with the date. + if sig := r.Header.Get("signature"); sig == "" || len(sig) != 32 { + t.Errorf("signature header = %q, want 32 hex chars", sig) + } + if r.Header.Get("date") == "" { + t.Errorf("date header missing") + } + if status != 200 { + w.WriteHeader(status) + _, _ = w.Write(respBody) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(respBody) + })) +} + +func TestExchangeJobTokenSuccess(t *testing.T) { + var got jobTokenInnerStruct + var m struct{ ID, Token, Type string } + resp, _ := json.Marshal(map[string]any{ + "id": "uid-123", + "name": "tester", + "securityOauthToken": "sot-xyz", + "refreshToken": "rt-xyz", + "expireTime": float64(time.Now().Add(24 * time.Hour).UnixMilli()), + }) + srv := jobTokenTestServer(t, 200, resp, &got, &m) + defer srv.Close() + + region := &RegionConfig{Name: "test", AuthBase: srv.URL} + res, err := ExchangeJobToken(context.Background(), "myPAT", "mid", "mtok", "mac", region) + if err != nil { + t.Fatalf("ExchangeJobToken err = %v", err) + } + if res["id"] != "uid-123" { + t.Fatalf("response id = %v, want uid-123", res["id"]) + } + // Verify the request carried only the PAT (exchange, not refresh). + if got.PersonalToken != "myPAT" { + t.Fatalf("personalToken = %q, want myPAT", got.PersonalToken) + } + if got.SecurityOauthToken != "" || got.RefreshToken != "" { + t.Fatalf("exchange must not send tokens: sot=%q rt=%q", got.SecurityOauthToken, got.RefreshToken) + } + if got.NeedRefresh { + t.Fatalf("needRefresh = true, want false on exchange") + } + if string(got.AuthInfo) != "{}" { + t.Fatalf("authInfo = %s, want {}", got.AuthInfo) + } + if m.ID != "mid" || m.Token != "mtok" || m.Type != "mac" { + t.Fatalf("machine headers = %q/%q/%q, want mid/mtok/mac", m.ID, m.Token, m.Type) + } +} + +func TestExchangeJobTokenBusy(t *testing.T) { + var got jobTokenInnerStruct + // 10605 envelope: admission-control rejection. The message is itself a + // JSON string carrying retryAfterSeconds. + innerMsg, _ := json.Marshal(map[string]any{"retryAfterSeconds": 3, "isQueued": false}) + busyBody, _ := json.Marshal(map[string]any{ + "code": "10605", + "message": string(innerMsg), + }) + srv := jobTokenTestServer(t, 401, busyBody, &got, nil) + defer srv.Close() + + region := &RegionConfig{Name: "test", AuthBase: srv.URL} + _, err := ExchangeJobToken(context.Background(), "myPAT", "mid", "mtok", "mac", region) + if err == nil { + t.Fatal("ExchangeJobToken err = nil, want BusyError") + } + var busy *BusyError + if !errorsAsBusy(err, &busy) { + t.Fatalf("err type = %T, want *BusyError", err) + } + if busy.Code != QoderBusyCode { + t.Fatalf("busy.Code = %q, want %q", busy.Code, QoderBusyCode) + } + if busy.StatusCode != 401 { + t.Fatalf("busy.StatusCode = %d, want 401", busy.StatusCode) + } + if busy.RetryAfter != 3*time.Second { + t.Fatalf("busy.RetryAfter = %s, want 3s", busy.RetryAfter) + } +} + +// ============================================================================= +// 6. Refresh +// ============================================================================= + +func TestRefreshJobTokenSuccess(t *testing.T) { + var got jobTokenInnerStruct + var m struct{ ID, Token, Type string } + resp, _ := json.Marshal(map[string]any{ + "id": "uid-456", + "securityOauthToken": "sot-fresh", + "refreshToken": "rt-fresh", + "expireTime": float64(time.Now().Add(48 * time.Hour).UnixMilli()), + }) + srv := jobTokenTestServer(t, 200, resp, &got, &m) + defer srv.Close() + + region := &RegionConfig{Name: "test", AuthBase: srv.URL} + _, err := RefreshJobToken(context.Background(), "myPAT", "rt-old", "sot-old", "mid", "mtok", "mac", region) + if err != nil { + t.Fatalf("RefreshJobToken err = %v", err) + } + // Refresh must forward all three tokens and set needRefresh=true. + if got.PersonalToken != "myPAT" { + t.Fatalf("personalToken = %q, want myPAT", got.PersonalToken) + } + if got.RefreshToken != "rt-old" { + t.Fatalf("refreshToken = %q, want rt-old", got.RefreshToken) + } + if got.SecurityOauthToken != "sot-old" { + t.Fatalf("securityOauthToken = %q, want sot-old", got.SecurityOauthToken) + } + if !got.NeedRefresh { + t.Fatalf("needRefresh = false, want true on refresh") + } + if m.ID != "mid" || m.Token != "mtok" || m.Type != "mac" { + t.Fatalf("machine headers = %q/%q/%q, want mid/mtok/mac", m.ID, m.Token, m.Type) + } +} + +func TestRefreshJobTokenAuthError(t *testing.T) { + var got jobTokenInnerStruct + // 401 with a non-10605 body must surface as AuthError. + body := []byte(`{"code":"105","message":"login expired"}`) + srv := jobTokenTestServer(t, 401, body, &got, nil) + defer srv.Close() + + region := &RegionConfig{Name: "test", AuthBase: srv.URL} + _, err := RefreshJobToken(context.Background(), "myPAT", "rt", "sot", "mid", "mtok", "mac", region) + if err == nil { + t.Fatal("RefreshJobToken err = nil, want AuthError") + } + var authErr *AuthError + if !errorsAsAuth(err, &authErr) { + t.Fatalf("err type = %T, want *AuthError", err) + } + if authErr.StatusCode != 401 { + t.Fatalf("AuthError.StatusCode = %d, want 401", authErr.StatusCode) + } + // Error() must redact identity-valued keys (userId) but the body here has + // none, so just confirm it carries the status. + if !strings.Contains(authErr.Error(), "HTTP 401") { + t.Fatalf("AuthError.Error() = %q, want 'HTTP 401' prefix", authErr.Error()) + } +} + +// ============================================================================= +// 7. Error classification +// ============================================================================= + +func TestBusyFromAuthEnvelope(t *testing.T) { + t.Run("10605 string code", func(t *testing.T) { + // Body may be prefixed with the upstream status ("403 {..."), as seen + // in the wild; the parser starts at the first '{'. + innerMsg, _ := json.Marshal(map[string]any{"retryAfterSeconds": 5}) + body, _ := json.Marshal(map[string]any{ + "code": "10605", + "message": string(innerMsg), + }) + busy := busyFromAuthEnvelope(403, []byte("403 "+string(body))) + if busy == nil { + t.Fatal("busyFromAuthEnvelope = nil, want BusyError") + } + if busy.Code != QoderBusyCode { + t.Fatalf("Code = %q, want %q", busy.Code, QoderBusyCode) + } + if busy.StatusCode != 403 { + t.Fatalf("StatusCode = %d, want 403", busy.StatusCode) + } + if busy.RetryAfter != 5*time.Second { + t.Fatalf("RetryAfter = %s, want 5s", busy.RetryAfter) + } + }) + t.Run("10605 numeric code", func(t *testing.T) { + body := []byte(`{"code":10605,"message":"{\"retryAfterSeconds\":2}"}`) + busy := busyFromAuthEnvelope(401, body) + if busy == nil { + t.Fatal("busyFromAuthEnvelope = nil for numeric code") + } + if busy.Code != "10605" { + t.Fatalf("Code = %q, want 10605", busy.Code) + } + if busy.RetryAfter != 2*time.Second { + t.Fatalf("RetryAfter = %s, want 2s", busy.RetryAfter) + } + }) + t.Run("non-busy 401 returns nil", func(t *testing.T) { + body := []byte(`{"code":"105","message":"login expired"}`) + if busy := busyFromAuthEnvelope(401, body); busy != nil { + t.Fatalf("busyFromAuthEnvelope = %+v, want nil", busy) + } + }) + t.Run("garbage body returns nil", func(t *testing.T) { + if busy := busyFromAuthEnvelope(401, []byte("not json at all")); busy != nil { + t.Fatalf("busyFromAuthEnvelope = %+v, want nil", busy) + } + }) +} + +func TestRetryAfterHeader(t *testing.T) { + cases := []struct { + in string + want time.Duration + }{ + {"", 0}, + {"0", 0}, + {"-5", 0}, + {"abc", 0}, + {" 10 ", 10 * time.Second}, + {"30", 30 * time.Second}, + {"Wed, 21 Oct 2026 07:28:00 GMT", 0}, // HTTP-date form unsupported -> 0 + } + for _, tc := range cases { + if got := retryAfterHeader(tc.in); got != tc.want { + t.Fatalf("retryAfterHeader(%q) = %s, want %s", tc.in, got, tc.want) + } + } +} + +func TestBusyMeta(t *testing.T) { + cases := []struct { + msg string + want time.Duration + }{ + {`{"retryAfterSeconds":7}`, 7 * time.Second}, + {`{"retryAfterSeconds":0}`, 0}, + {`not json`, 0}, + {``, 0}, + {`{"retryAfterSeconds":-1}`, 0}, + } + for _, tc := range cases { + if got := busyMeta(tc.msg); got != tc.want { + t.Fatalf("busyMeta(%q) = %s, want %s", tc.msg, got, tc.want) + } + } +} + +func TestTruncateErrorBodyRedaction(t *testing.T) { + // Identity-valued keys are replaced before the length cap. + body := []byte(`{"userId":"u-123","userType":"teams","traceId":"t-1","requestId":"r-1","code":"bad_request","message":"invalid"}`) + out := truncateErrorBody(body) + for _, secret := range []string{"u-123", "teams", "t-1", "r-1"} { + if strings.Contains(out, secret) { + t.Fatalf("truncateErrorBody leaked %q: %s", secret, out) + } + } + if !strings.Contains(out, "[REDACTED]") { + t.Fatalf("expected [REDACTED] marker, got %s", out) + } + // Non-sensitive fields survive. + if !strings.Contains(out, "bad_request") { + t.Fatalf("non-sensitive field lost: %s", out) + } + + // Whitespace around the colon (pretty-printed upstream) is accepted too. + pretty := []byte(`{"userId" : "secret-pat", "code" : "x"}`) + out2 := truncateErrorBody(pretty) + if strings.Contains(out2, "secret-pat") { + t.Fatalf("pretty-printed userId leaked: %s", out2) + } + + // Truncation caps at maxErrorBodyChars on a rune boundary. + long := bytes.Repeat([]byte("é"), 500) // multi-byte runes + out3 := truncateErrorBody(long) + if !strings.HasSuffix(out3, "…") { + t.Fatalf("truncated output should end with ellipsis, got len=%d suffix=%q", len(out3), out3[len(out3)-3:]) + } + // The truncated length must be on a rune boundary and <= cap+1 (ellipsis). + runes := []rune(out3) + if len(runes) > maxErrorBodyChars+1 { + t.Fatalf("truncated rune count %d exceeds cap+1 %d", len(runes), maxErrorBodyChars+1) + } +} + +func TestRedactJSONValueTruncatedValue(t *testing.T) { + // A truncated response body with an unterminated string value must not + // leak the partial value: the redaction runs to the end of the text. + trunc := `{"userId":"abc` // no closing quote + out := redactJSONValue(trunc, "userId") + if strings.Contains(out, "abc") { + t.Fatalf("truncated userId value leaked: %s", out) + } + if !strings.Contains(out, "[REDACTED]") { + t.Fatalf("expected redaction, got %s", out) + } +} + +// ============================================================================= +// 7b. In-stream error detection helpers +// ============================================================================= + +func TestIsDoneLine(t *testing.T) { + cases := []struct { + line string + want bool + }{ + {"data: [DONE]", true}, + {"data:[DONE]", true}, + {`data: {"body":"[DONE]"}`, true}, + {"data: hello", false}, + {"event: finish", false}, + {"", false}, + } + for _, tc := range cases { + if got := isDoneLine(tc.line); got != tc.want { + t.Fatalf("isDoneLine(%q) = %v, want %v", tc.line, got, tc.want) + } + } +} + +func TestIsFinishEvent(t *testing.T) { + cases := []struct { + line string + want bool + }{ + {"event:finish", true}, + {"event: finish", true}, + {"event:error", false}, + {"event: done", false}, + {"data: [DONE]", false}, + } + for _, tc := range cases { + if got := isFinishEvent(tc.line); got != tc.want { + t.Fatalf("isFinishEvent(%q) = %v, want %v", tc.line, got, tc.want) + } + } +} + +func TestDetectInStreamBusyError(t *testing.T) { + // Build a busy frame: outer envelope with statusCodeValue=401, body is a + // JSON string carrying code 10605 and retryAfterSeconds. + innerMsg, _ := json.Marshal(map[string]any{"retryAfterSeconds": 4, "isQueued": false}) + innerBody, _ := json.Marshal(map[string]any{"code": "10605", "message": string(innerMsg)}) + frame, _ := json.Marshal(map[string]any{ + "body": string(innerBody), + "statusCodeValue": 401, + }) + line := "data: " + string(frame) + busy := detectInStreamBusyError(line) + if busy == nil { + t.Fatal("detectInStreamBusyError = nil, want BusyError") + } + if busy.Code != QoderBusyCode { + t.Fatalf("Code = %q, want %q", busy.Code, QoderBusyCode) + } + if busy.RetryAfter != 4*time.Second { + t.Fatalf("RetryAfter = %s, want 4s", busy.RetryAfter) + } + if busy.StatusCode != 401 { + t.Fatalf("StatusCode = %d, want 401", busy.StatusCode) + } + + // Non-busy content frames must not trigger. + if detectInStreamBusyError("data: {\"body\":\"hello\"}") != nil { + t.Fatal("content frame misclassified as busy") + } + // Plain text is not busy. + if detectInStreamBusyError("data: plain text") != nil { + t.Fatal("plain text misclassified as busy") + } +} + +func TestDetectInStreamAuthError(t *testing.T) { + // 401 envelope without 10605 -> auth error. + innerBody, _ := json.Marshal(map[string]any{"code": "105", "message": "login expired"}) + frame, _ := json.Marshal(map[string]any{ + "body": string(innerBody), + "statusCodeValue": 401, + }) + ok, detail := detectInStreamAuthError("data: " + string(frame)) + if !ok { + t.Fatal("detectInStreamAuthError = false for 401 frame") + } + if !strings.Contains(detail, "login expired") { + t.Fatalf("detail = %q, want to mention 'login expired'", detail) + } + + // 10605 busy frame must NOT be an auth error. + busyInner, _ := json.Marshal(map[string]any{"code": "10605"}) + busyFrame, _ := json.Marshal(map[string]any{ + "body": string(busyInner), + "statusCodeValue": 403, + }) + ok, _ = detectInStreamAuthError("data: " + string(busyFrame)) + if ok { + t.Fatal("10605 frame misclassified as auth error") + } + + // Normal content frame -> not auth error. + if ok, _ := detectInStreamAuthError(`data: {"body":"hello"}`); ok { + t.Fatal("content frame misclassified as auth error") + } +} + +func TestDetectInStreamGatewayError(t *testing.T) { + t.Run("envelope 429 provider outage", func(t *testing.T) { + innerBody, _ := json.Marshal(map[string]any{"code": "provider_error", "message": "All backends failed"}) + frame, _ := json.Marshal(map[string]any{ + "body": string(innerBody), + "statusCodeValue": 429, + "statusCode": "TOO_MANY_REQUESTS", + }) + e := detectInStreamGatewayError("data: " + string(frame)) + if e == nil { + t.Fatal("detectInStreamGatewayError = nil, want InStreamError") + } + if e.Status != 429 { + t.Fatalf("Status = %d, want 429", e.Status) + } + if !strings.Contains(e.Detail, "provider_error") || !strings.Contains(e.Detail, "All backends failed") { + t.Fatalf("Detail = %q, want provider_error: All backends failed", e.Detail) + } + }) + t.Run("bare success:false gateway error", func(t *testing.T) { + frame, _ := json.Marshal(map[string]any{ + "success": false, + "msgCode": 500, + "message": "Internal Server Error", + }) + e := detectInStreamGatewayError("data: " + string(frame)) + if e == nil { + t.Fatal("detectInStreamGatewayError = nil for bare success:false") + } + if e.Status != 500 { + t.Fatalf("Status = %d, want 500", e.Status) + } + if !strings.Contains(e.Detail, "Internal Server Error") { + t.Fatalf("Detail = %q, want Internal Server Error", e.Detail) + } + }) + t.Run("normal 200 envelope returns nil", func(t *testing.T) { + frame, _ := json.Marshal(map[string]any{ + "body": "hello world", + "statusCodeValue": 200, + }) + if e := detectInStreamGatewayError("data: " + string(frame)); e != nil { + t.Fatalf("normal frame misclassified: %+v", e) + } + }) + t.Run("plain text returns nil", func(t *testing.T) { + if e := detectInStreamGatewayError("data: plain"); e != nil { + t.Fatalf("plain text misclassified: %+v", e) + } + }) +} + +// ============================================================================= +// 8. SSE stream reader (OpenStreamLines) +// ============================================================================= + +func newTestSession(t *testing.T) *SessionContext { + t.Helper() + return NewSession(AuthIdentity{ + Name: "tester", + UID: "uid-test", + YxUID: "yx-test", + }, "mid-test", "mtok-test", "mac-test") +} + +func TestOpenStreamLinesHappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + fmt.Fprint(w, "data: {\"body\":\"hello\"}\n\n") + fmt.Fprint(w, "data: {\"body\":\"world\"}\n\n") + fmt.Fprint(w, "data: {\"body\":\"[DONE]\"}\n\n") + })) + defer srv.Close() + + sess := newTestSession(t) + var lines []string + cb := func(line string) error { + lines = append(lines, line) + return nil + } + err := OpenStreamLines(context.Background(), sess, ChatURL(sessRegion(srv.URL)), []byte(`{"prompt":"hi"}`), nil, cb) + if err != nil { + t.Fatalf("OpenStreamLines err = %v", err) + } + if len(lines) != 3 { + t.Fatalf("callback lines = %d, want 3", len(lines)) + } + if !strings.Contains(lines[0], "hello") { + t.Fatalf("line[0] = %q", lines[0]) + } +} + +func TestOpenStreamLinesBareDoneTerminates(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + fmt.Fprint(w, "data: hello\n\n") + fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + sess := newTestSession(t) + var n int + cb := func(line string) error { + n++ + return nil + } + if err := OpenStreamLines(context.Background(), sess, ChatURL(sessRegion(srv.URL)), []byte(`{}`), nil, cb); err != nil { + t.Fatalf("OpenStreamLines err = %v", err) + } + if n != 2 { + t.Fatalf("lines = %d, want 2", n) + } +} + +func TestOpenStreamLinesBusyError(t *testing.T) { + innerMsg, _ := json.Marshal(map[string]any{"retryAfterSeconds": 2}) + innerBody, _ := json.Marshal(map[string]any{"code": "10605", "message": string(innerMsg)}) + frame, _ := json.Marshal(map[string]any{ + "body": string(innerBody), + "statusCodeValue": 401, + }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + fmt.Fprintf(w, "data: %s\n\n", frame) + })) + defer srv.Close() + + sess := newTestSession(t) + cb := func(line string) error { + t.Errorf("callback should not fire for a busy frame, got %q", line) + return nil + } + err := OpenStreamLines(context.Background(), sess, ChatURL(sessRegion(srv.URL)), []byte(`{}`), nil, cb) + if err == nil { + t.Fatal("OpenStreamLines err = nil, want BusyError") + } + var busy *BusyError + if !errorsAsBusy(err, &busy) { + t.Fatalf("err type = %T, want *BusyError", err) + } + if busy.Code != QoderBusyCode { + t.Fatalf("Code = %q, want %q", busy.Code, QoderBusyCode) + } + if busy.RetryAfter != 2*time.Second { + t.Fatalf("RetryAfter = %s, want 2s", busy.RetryAfter) + } +} + +func TestOpenStreamLinesTruncatedStream(t *testing.T) { + // Stream ends without a [DONE] marker -> the reader surfaces a truncation + // error rather than silently succeeding. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + fmt.Fprint(w, "data: partial\n\n") + })) + defer srv.Close() + + sess := newTestSession(t) + err := OpenStreamLines(context.Background(), sess, ChatURL(sessRegion(srv.URL)), []byte(`{}`), nil, func(line string) error { return nil }) + if err == nil { + t.Fatal("OpenStreamLines err = nil, want truncation error") + } + if !strings.Contains(err.Error(), "without [DONE]") { + t.Fatalf("err = %v, want 'without [DONE]'", err) + } +} + +// ============================================================================= +// helpers +// ============================================================================= + +// sessRegion builds a RegionConfig whose ChatBase points at the httptest +// server, so ChatURL composes an in-process URL. +func sessRegion(base string) *RegionConfig { + return &RegionConfig{Name: "test", ChatBase: base} +} + +// errorsAsBusy is a tiny shim over errors.As so the test does not need to import +// errors directly in every assertion; kept inline to avoid extra imports. +func errorsAsBusy(err error, target **BusyError) bool { + if be, ok := err.(*BusyError); ok { + *target = be + return true + } + return false +} + +func errorsAsAuth(err error, target **AuthError) bool { + if ae, ok := err.(*AuthError); ok { + *target = ae + return true + } + return false +} diff --git a/internal/providers/registry.go b/internal/providers/registry.go index b6ed4435..3c04b15c 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -90,7 +90,7 @@ var Qoder = ProviderDescriptor{ Label: "Qoder", Runtime: RuntimeChildProcess, AuthTypes: []AuthType{AuthNone, AuthOAuth, AuthPAT, AuthNative}, - CredentialFormats: []string{"qoder-native-v1"}, + CredentialFormats: []string{"qoder-pat-v1", "qoder-native-v1"}, Capabilities: ProviderCapabilities{ Chat: true, Stream: true, Tools: true, Images: true, Reasoning: true, ModelCatalog: true, Usage: true, Login: true, BrowserLogin: true, diff --git a/internal/runtime/manager_catalog.go b/internal/runtime/manager_catalog.go index 1c89b346..1bbc6149 100644 --- a/internal/runtime/manager_catalog.go +++ b/internal/runtime/manager_catalog.go @@ -65,7 +65,7 @@ func (m *Manager) fetchAccountModels(ctx context.Context, item Item) { if m == nil || m.pool == nil || item.ID == "" { return } - if item.Runtime == string(providers.RuntimeInProcess) || strings.TrimSpace(item.URL) == "" { + if item.Provider != "qoder" && (item.Runtime == string(providers.RuntimeInProcess) || strings.TrimSpace(item.URL) == "") { m.fetchProviderModels(ctx, item) return } diff --git a/internal/runtime/manager_probe.go b/internal/runtime/manager_probe.go index 85778703..3a365598 100644 --- a/internal/runtime/manager_probe.go +++ b/internal/runtime/manager_probe.go @@ -99,6 +99,10 @@ func (m *Manager) refreshInProcess(ctx context.Context, item Item) error { adapter, ok := m.providers.Get(item.Provider) if !ok || adapter.Prober == nil { // No prober registered: leave pool state alone and never hit ""+/health. + // If the adapter provides a catalog, refresh models so pool routing knows them. + if ok && adapter.Models != nil { + m.fetchAccountModels(ctx, item) + } return nil } health, err := adapter.Prober.Probe(ctx, item.ID) diff --git a/internal/runtime/manager_process.go b/internal/runtime/manager_process.go index 4db49b83..29a546e8 100644 --- a/internal/runtime/manager_process.go +++ b/internal/runtime/manager_process.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "path/filepath" "strings" "sync" @@ -68,6 +69,28 @@ func (m *Manager) startAccount(ctx context.Context, account Account) error { }) return nil } + // Qoder PAT-backed accounts run fully in-process (chat signing, login, + // catalog, quota, checkin all live in internal/providers/qoder via the + // qauth Bridge). Skip the per-account Node+WASM worker spawn entirely: + // it was the unstable, resource-heavy path this account model replaces. + // Accounts still carrying the legacy qoder-native-v1 CLI blob (no PAT) + // fall through to the worker spawn so they keep working until the + // operator re-logs them with a PAT. + if descriptor.ID == "qoder" && m.accountHasPAT(ctx, account.ID) { + log.Printf("[qoder-pat] account %s: in-process path (PAT stored), skipping worker spawn", account.ID) + if m.runCtx.Err() != nil { + return errManagerClosed + } + ready := true + m.pool.Upsert(Item{ + ID: account.ID, Provider: descriptor.ID, Region: account.ProviderRegion, + Runtime: string(providers.RuntimeInProcess), DropSystemPrompt: account.DropSystemPrompt, + Weight: NormalizeWeight(account.Priority), MaxInFlight: account.MaxInFlight, Quota: account.Quota, + ModelAliases: account.ModelAliases, EnabledModels: account.EnabledModels, ModelsRestricted: account.ModelsRestricted, + Ready: &ready, RuntimeState: "ready", + }) + return nil + } m.mu.Lock() if m.runCtx.Err() != nil { m.mu.Unlock() @@ -295,3 +318,20 @@ func (m *Manager) AccountURL(id string) (string, bool) { item, ok := m.pool.ByID(id) return item.URL, ok } + +// accountHasPAT reports whether a Qoder account has a stored +// qoder-pat-v1 credential payload. PAT-backed accounts skip the worker spawn +// and run fully in-process; accounts without a PAT (legacy CLI blob) keep +// the worker path until the operator re-logs them with a PAT. A transient +// store error is treated as "no PAT" so the worker spawns and serves as a +// fallback, matching the legacy behaviour. +func (m *Manager) accountHasPAT(ctx context.Context, id string) bool { + if m == nil || m.store == nil { + return false + } + format, payload, err := m.store.LoadCredentialPayload(ctx, id) + if err != nil { + return false + } + return format == "qoder-pat-v1" && len(payload) > 0 +}