From 353cb007ce947baeed7f0252e332012740f5df0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Wed, 7 Oct 2026 23:01:57 +0800 Subject: [PATCH 1/3] feat(qoder): sign chat in-process instead of through workers Qoder chat now decrypts the existing CLI credential and calls the gateway directly, so accounts no longer load the CLI bundle on the request path. Login, catalog, and quota stay on the per-account worker. --- AGENTS.md | 4 +- changelog/unreleased/qoder-inprocess-chat.md | 7 + docs/ARCHITECTURE_SUMMARY.md | 5 +- internal/app/app.go | 7 + internal/app/architecture_test.go | 3 - internal/app/regression_test.go | 37 +- internal/executor/chat.go | 81 ++- internal/providers/qoder/adapter.go | 30 + internal/providers/qoder/cosy.go | 426 +++++++++++++ internal/providers/qoder/cosy_test.go | 117 ++++ internal/providers/qoder/direct.go | 599 +++++++++++++++++++ internal/providers/qoder/direct_test.go | 103 ++++ 12 files changed, 1379 insertions(+), 40 deletions(-) create mode 100644 changelog/unreleased/qoder-inprocess-chat.md create mode 100644 internal/providers/qoder/cosy.go create mode 100644 internal/providers/qoder/cosy_test.go create mode 100644 internal/providers/qoder/direct.go create mode 100644 internal/providers/qoder/direct_test.go diff --git a/AGENTS.md b/AGENTS.md index 04eb22d8..e591aa86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,14 +45,14 @@ live-account acceptance, and release status separate. - After console UI changes, run `cd frontend && npm run sync` so `internal/webui/static` matches `frontend/dist`. Do not commit a stale hashed JS/CSS pair. - User-facing changes add one bilingual file in `changelog/unreleased/` (`### English` / `### 中文`). Do not put upcoming notes in `CHANGELOG.md` and do not add `## Unreleased` there. Do not freeze or unfreeze changelog sections by hand. Do not reuse a fragment filename until that tag's archive PR has merged. - When `main` is checked out in another worktree, merge PRs with `gh api` / GitHub; do not `git checkout main` here. -- Keep architecture: auth / endpoint / executor / translate, plus `internal/store` (SQLite), `internal/control` (console facade: accounts, keys, settings, catalog, login, import), `internal/runtime` (Manager lifecycle), `internal/providers/qoder` (Qoder HOME/CLI/worker protocol and Adapter), `internal/gateway` (public protocol HTTP), `internal/console` (operator HTTP), `internal/server` (routes/middleware/webui), and `internal/app` (process assembly). `internal/api` is a test-only compatibility facade (`api.New` → `app.New`); do not add business there. Account entities stay in `accounts`; Pool/Item/RouteQuery/Classify and request Prepare live in `executor`. Display catalog cache lives in `control.Catalog`; catalog aggregation, identity filter, and settings decoration live in `control`, not `app`. Public `/v1/chat/completions`, `/v1/messages`, `/v1/responses`, and `/v1/models` live in `gateway`; console `/api/*` lives in `console`; `internal/server` registers both. Console HTTP decodes and maps errors; persist/apply for system settings and console-key rotation live in `control.System` / `control.KeyRotation`. Update job/maintenance lives in `internal/update.Coordinator`. SQLite lives in `internal/store`; process tables live in runtime. Runtime constructs the one Pool and injects it into executor. Qoder stays `child_process`; the registered Adapter omits Prober. Runtime catalog may use `adapter.Models`; quota/login/chat still use worker HTTP. `cmd/server` constructs `app.New`. `accounts` must not import `runtime` or `executor`. Executor Prepare must not take `*http.Request` or import store. Gateway, console, and server must not import store or runtime Manager. App/server/gateway/console must not import `internal/api`. Provider packages must not receive `http.ResponseWriter` or import executor taxonomy; OAuth loopback HTTP stays in `auth.ServeLoopback`. Adapter error classification and cooldown math live in `executor`; gateway only formats the result. +- Keep architecture: auth / endpoint / executor / translate, plus `internal/store` (SQLite), `internal/control` (console facade: accounts, keys, settings, catalog, login, import), `internal/runtime` (Manager lifecycle), `internal/providers/qoder` (Qoder HOME/CLI/worker protocol and Adapter), `internal/gateway` (public protocol HTTP), `internal/console` (operator HTTP), `internal/server` (routes/middleware/webui), and `internal/app` (process assembly). `internal/api` is a test-only compatibility facade (`api.New` → `app.New`); do not add business there. Account entities stay in `accounts`; Pool/Item/RouteQuery/Classify and request Prepare live in `executor`. Display catalog cache lives in `control.Catalog`; catalog aggregation, identity filter, and settings decoration live in `control`, not `app`. Public `/v1/chat/completions`, `/v1/messages`, `/v1/responses`, and `/v1/models` live in `gateway`; console `/api/*` lives in `console`; `internal/server` registers both. Console HTTP decodes and maps errors; persist/apply for system settings and console-key rotation live in `control.System` / `control.KeyRotation`. Update job/maintenance lives in `internal/update.Coordinator`. SQLite lives in `internal/store`; process tables live in runtime. Runtime constructs the one Pool and injects it into executor. Qoder login, catalog, and quota stay on a per-account child process because the CLI credential exchange still lives there; chat does not. The registered Adapter omits Prober and sends chat through the in-process COSY client. `cmd/server` constructs `app.New`. `accounts` must not import `runtime` or `executor`. Executor Prepare must not take `*http.Request` or import store. Gateway, console, and server must not import store or runtime Manager. App/server/gateway/console must not import `internal/api`. Provider packages must not receive `http.ResponseWriter` or import executor taxonomy; OAuth loopback HTTP stays in `auth.ServeLoopback`. Adapter error classification and cooldown math live in `executor`; gateway only formats the result. - Prefer direct HTTP/SSE to Qoder cloud APIs - Pin qodercli / qoderclicn hooks in `worker/src/compat.mjs`; fail loudly on mismatch. Qoder CN is `provider=qoder` + `region=cn`, not a new family - Reasoning levels are catalog-driven: map client values through `internal/providers/reasoning.go` (`none`/`low`/`medium`/`high`/`xhigh`/`max`), clamp anything the model does not allow back to an allowed level, and treat the console value as a default only (it never locks a call or caps a higher client value) - Console UI: React + Tailwind v4 + **HeroUI only** for components - Follow `docs/DESIGN.md` (taste v1 adapted for this console) - Keep iterating Qoder login, usage, and account routing. Keep scheduling focused on personal-account routing rather than commercial gateway features -- Qoder multi-account = one worker process per HOME; do not share WASM context. WorkBuddy / Trae / Devin use in-process adapters, not one child process per account +- Qoder login still uses one worker process per HOME; do not share that WASM context. Qoder chat is an in-process COSY client and must not import the CLI bundle per request or per account. WorkBuddy / Trae / Devin use in-process adapters, not one child process per account - Schema changes go in a new numbered SQLite migration entry in `internal/store/migrations.go`. Never rewrite shipped SQL ## Don't diff --git a/changelog/unreleased/qoder-inprocess-chat.md b/changelog/unreleased/qoder-inprocess-chat.md new file mode 100644 index 00000000..a9ba7e4c --- /dev/null +++ b/changelog/unreleased/qoder-inprocess-chat.md @@ -0,0 +1,7 @@ +### English + +- Qoder chat now signs requests in the Go process and calls the gateway directly. Per-account Node workers stay for login, model catalog, and quota, and are no longer on the chat path. + +### 中文 + +- Qoder 聊天改为在 Go 进程内签名并直连网关。每个账号的 Node worker 仍负责登录、模型目录和配额,不再参与聊天请求。 diff --git a/docs/ARCHITECTURE_SUMMARY.md b/docs/ARCHITECTURE_SUMMARY.md index f7128d26..ef434cf2 100644 --- a/docs/ARCHITECTURE_SUMMARY.md +++ b/docs/ARCHITECTURE_SUMMARY.md @@ -20,7 +20,7 @@ CLI2API 是一个面向个人部署的 Go + Node 网关: - 对外提供 OpenAI Chat Completions、Anthropic Messages、OpenAI Responses 和 Models 兼容接口。 - 管理多个上游账号,并按 provider、region、model、pin、会话粘性和冷却状态选号。 -- Qoder 使用每账号隔离的 Node child process;WorkBuddy、Trae CN Work 和 Devin 使用 Go 进程内 adapter。 +- Qoder 登录、目录和配额仍使用每账号隔离的 Node child process;Qoder 聊天在 Go 进程内签名直连网关。WorkBuddy、Trae CN Work 和 Devin 使用 Go 进程内 adapter。 - 控制台负责账号、密钥、设置、目录、日志、登录、导入和更新操作。 - 这是个人网关,不实现计费、Redis 槽位、多租户商业网关或公开暴露的管理端口。 @@ -71,8 +71,9 @@ cmd/server ## Provider 边界 - Qoder Global 和 Qoder CN 是同一个 `provider=qoder`,通过 `region` 区分,不创建新的 provider family。 -- Qoder 每个账号使用独立 HOME 和独立 child process;不得为每个请求启动完整 CLI agent。 +- Qoder 登录仍使用独立 HOME 和独立 child process,不共享 WASM context;聊天不加载 CLI bundle。不得为每个请求启动完整 CLI agent。 - Qoder 的 CLI / worker 兼容性版本固定在 `worker/src/compat.mjs`,不兼容时应明确失败。 +- Qoder 聊天改为在 Go 进程内使用 COSY 客户端签名直连网关([`internal/providers/qoder/cosy.go`](../internal/providers/qoder/cosy.go))。COSY 协议版本固定在 [`COSYVersion = "1.1.32"`](cosy.go:23),支持中国区(`gateway.qoder.com.cn`)和全球区(`api1.qoder.sh`)双端点。 - WorkBuddy、Trae CN Work、Devin 使用进程内 adapter,不复制 Qoder worker 生命周期。 - provider 负责上游事实映射;executor 负责是否切号、冷却多久和是否 failover。 - Provider 能力、模型目录和 reasoning level 必须以实际 catalog 声明为准,不凭空增加模型能力。 diff --git a/internal/app/app.go b/internal/app/app.go index ee8c283e..16055438 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -120,6 +120,13 @@ func New(cfg config.Config) *App { providerReg.Register(zhipu.NewClient(store).Adapter()) qoderClient := qoder.NewClient() qoderClient.Bind(manager.AccountURL, manager.ProxyAPIKey) + qoderClient.SetDirect(qoder.NewDirect(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/app/architecture_test.go b/internal/app/architecture_test.go index f9a01d5e..81985738 100644 --- a/internal/app/architecture_test.go +++ b/internal/app/architecture_test.go @@ -29,9 +29,6 @@ var importAllowlist = map[string]map[string]string{ modulePath + "/internal/api": { modulePath + "/internal/app": "S15: api is the remaining test-only compatibility facade (api.New → app.New).", }, - modulePath + "/internal/executor": { - modulePath + "/internal/providers/qoder": "S09 leftover: Qoder chat still uses worker HTTP via qoder.NewChatRequest until production chat switches to Adapter.", - }, modulePath + "/internal/runtime": { modulePath + "/internal/providers/qoder": "S08/S09 leftover: Qoder child spawn, HOME, catalog, and quota still call providers/qoder until remaining capabilities go through Adapter.", }, diff --git a/internal/app/regression_test.go b/internal/app/regression_test.go index 9f610fad..dc05c164 100644 --- a/internal/app/regression_test.go +++ b/internal/app/regression_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "net/http/httptest" "strings" @@ -16,6 +15,7 @@ import ( "github.com/caigee-cmd/cli2api/internal/app" "github.com/caigee-cmd/cli2api/internal/config" "github.com/caigee-cmd/cli2api/internal/executor" + "github.com/caigee-cmd/cli2api/internal/providers/qoder" "github.com/caigee-cmd/cli2api/internal/update" ) @@ -79,36 +79,39 @@ func TestConsoleKeyRotationUpdatesExistingHandlerAndWorkerRequests(t *testing.T) } currentKey = result.Secret } - // A fake ready worker uses the runtime's current key, without spawning a CLI. + // Chat no longer enters the login worker. The gateway sees a COSY bearer, + // never the console key that rotation just changed. var calls atomic.Int32 worker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/chat/completions" { - http.NotFound(w, r) - return - } calls.Add(1) - if r.Header.Get("Authorization") != "Bearer "+currentKey { - t.Error("worker received stale key") + if strings.Contains(r.Header.Get("Authorization"), currentKey) { + t.Error("upstream received the console key") w.WriteHeader(401) return } - var req struct { - Stream bool `json:"stream"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - if req.Stream { - w.Header().Set("Content-Type", "text/event-stream") - fmt.Fprint(w, "data: {\"id\":\"test\",\"model\":\"glm-5.2\",\"choices\":[{\"delta\":{\"content\":\"OK\"}}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer COSY.") { + t.Errorf("authorization = %q", r.Header.Get("Authorization")) + w.WriteHeader(401) return } - io.WriteString(w, `{"model":"glm-5.2","choices":[{"message":{"content":"OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"body\":{\"choices\":[{\"delta\":{\"content\":\"OK\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}}\n\n") })) defer worker.Close() account, err := a.Manager.Store().Create(context.Background(), accounts.CreateAccount{Name: "fake-worker", Enabled: false}) if err != nil { t.Fatal(err) } - a.Pool.Upsert(executor.Item{ID: account.ID, Provider: "qoder", Runtime: "child_process", URL: worker.URL, Models: []string{"glm-5.2"}}) + if err := a.Manager.Store().SaveCredential(context.Background(), account.ID, "oauth", accounts.NativeCredential{ + UserBlob: []byte(`{"uid":"u-rotation","access_token":"dt-rotation"}`), + MachineID: "0123456789abcdef", + }); err != nil { + t.Fatal(err) + } + original := qoder.ChatEndpointHook + qoder.ChatEndpointHook = func(string) string { return worker.URL + "/algo/api/v2/service/pro/sse/agent_chat_generation?Encode=1" } + t.Cleanup(func() { qoder.ChatEndpointHook = original }) + a.Pool.Upsert(executor.Item{ID: account.ID, Provider: "qoder", Runtime: "child_process", URL: "http://127.0.0.1:1", Models: []string{"glm-5.2"}}) // Catalog probing is tested separately; keep the fake worker deterministic. a.Gateway.Catalogs = nil for _, path := range []string{"/v1/chat/completions", "/api/chat", "/v1/messages", "/v1/responses"} { diff --git a/internal/executor/chat.go b/internal/executor/chat.go index e4af8858..c7062d74 100644 --- a/internal/executor/chat.go +++ b/internal/executor/chat.go @@ -1,6 +1,7 @@ package executor import ( + "bytes" "context" "encoding/json" "errors" @@ -14,7 +15,6 @@ import ( "github.com/caigee-cmd/cli2api/internal/accounts" "github.com/caigee-cmd/cli2api/internal/providers" - "github.com/caigee-cmd/cli2api/internal/providers/qoder" "github.com/caigee-cmd/cli2api/internal/translate" ) @@ -486,6 +486,17 @@ func keyGrantedSingleRegion(providerFilter string, allowed []string) (string, bo return regions[0], true } +// qoderDirect reports whether this Qoder account chats through the registered +// adapter instead of its login worker. Tests that stub a worker URL and do not +// register the adapter stay on the worker path. +func (e ChatExecutor) qoderDirect(item Item) bool { + if itemProvider(item) != "qoder" || e.Providers == nil { + return false + } + adapter, ok := e.Providers.Get("qoder") + return ok && adapter.Chat != nil +} + func isInProcessItem(item Item) bool { if item.Runtime == string(providers.RuntimeInProcess) { return true @@ -604,6 +615,34 @@ func (e ChatExecutor) recordAttempt(ctx context.Context, attempt accounts.Reques e.OnAttempt(attempt) } +// buildWorkerChatPayload is the OpenAI-shaped body a non-Qoder worker stub +// still accepts. Qoder production chat does not use it. +func buildWorkerChatPayload(req translate.ChatRequest, stream bool) map[string]any { + payload := map[string]any{ + "model": req.Model, + "messages": req.Messages, + "stream": stream, + } + if len(req.MaxCompletionTokens) > 0 { + payload["max_tokens"] = json.RawMessage(req.MaxCompletionTokens) + } else if len(req.MaxTokens) > 0 { + payload["max_tokens"] = json.RawMessage(req.MaxTokens) + } + if len(req.Temperature) > 0 { + payload["temperature"] = json.RawMessage(req.Temperature) + } + if len(req.Tools) > 0 { + payload["tools"] = json.RawMessage(req.Tools) + } + if len(req.ToolChoice) > 0 { + payload["tool_choice"] = json.RawMessage(req.ToolChoice) + } + if len(req.ReasoningEffort) > 0 { + payload["reasoning_effort"] = json.RawMessage(req.ReasoningEffort) + } + return payload +} + func (e ChatExecutor) newWorkerRequest(ctx context.Context, item Item, payload []byte, prefer string) (*http.Request, error) { account := prefer if account == "" { @@ -613,7 +652,21 @@ func (e ChatExecutor) newWorkerRequest(ctx context.Context, item Item, payload [ if e.WorkerKeySource != nil { key = e.WorkerKeySource() } - return qoder.NewChatRequest(ctx, item.URL, account, RequestIDFromContext(ctx), key, payload) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(item.URL, "/")+"/v1/chat/completions", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + if key != "" { + httpReq.Header.Set("Authorization", "Bearer "+key) + } + if account != "" { + httpReq.Header.Set("X-Qoder-Account", account) + } + if requestID := RequestIDFromContext(ctx); requestID != "" { + httpReq.Header.Set("X-Request-Id", requestID) + } + return httpReq, nil } func classifyWorkerErr(resp *http.Response, body string) Classified { @@ -725,10 +778,6 @@ func (l routeLoop) pickFailure(err error) (int, string, string, error) { func (e ChatExecutor) ChatNonStream(ctx context.Context, req translate.ChatRequest, prefer, providerFilter string) (result ChatResult, returnErr error) { loop := e.newRouteLoop(ctx, prefer, providerFilter, req) defer func() { result.Routing = loop.routing.Source }() - payload, err := json.Marshal(qoder.BuildChatPayload(req, false)) - if err != nil { - return ChatResult{}, err - } for loop.index < loop.attempts { item, i, err := loop.pickNext(e, req.Model) if err != nil { @@ -738,7 +787,7 @@ func (e ChatExecutor) ChatNonStream(ctx context.Context, req translate.ChatReque } return ChatResult{}, pickErr } - if isInProcessItem(item) { + if isInProcessItem(item) || e.qoderDirect(item) { result, classified, err := e.chatInProcessNonStreamAttempt(ctx, item, req, i) if err == nil { result.AttemptCount = i + 1 @@ -756,6 +805,10 @@ func (e ChatExecutor) ChatNonStream(ctx context.Context, req translate.ChatReque } return ChatResult{AttemptCount: i + 1, AccountID: item.ID, Provider: item.Provider}, err } + payload, err := json.Marshal(buildWorkerChatPayload(req, false)) + if err != nil { + return ChatResult{}, err + } headerAccount := loop.headerAccount(item, i) httpReq, err := e.newWorkerRequest(ctx, item, payload, headerAccount) if err != nil { @@ -1140,14 +1193,6 @@ func (e ChatExecutor) chatStreamProxy(ctx context.Context, req translate.ChatReq loop.attempts = e.attemptsFor(loop.providerFilter, loop.regionFilter, req.Model, loop.allowed, loop.eligible) } defer func() { result.Routing = loop.routing.Source }() - var payload []byte - if !preferNativeResponses || native == nil { - var err error - payload, err = json.Marshal(qoder.BuildChatPayload(req, true)) - if err != nil { - return StreamResult{}, err - } - } startedAll := time.Now() for loop.index < loop.attempts { item, i, err := loop.pickNext(e, req.Model) @@ -1158,7 +1203,7 @@ func (e ChatExecutor) chatStreamProxy(ctx context.Context, req translate.ChatReq } return StreamResult{}, pickErr } - if isInProcessItem(item) { + if isInProcessItem(item) || (e.qoderDirect(item) && !(preferNativeResponses && native != nil)) { result, classified, err := e.chatInProcessStreamAttempt(ctx, item, req, native, i, preferNativeResponses) if err == nil { @@ -1176,6 +1221,10 @@ func (e ChatExecutor) chatStreamProxy(ctx context.Context, req translate.ChatReq } return StreamResult{AttemptCount: i + 1, AccountID: item.ID, Provider: item.Provider}, err } + payload, err := json.Marshal(buildWorkerChatPayload(req, true)) + if err != nil { + return StreamResult{}, err + } headerAccount := loop.headerAccount(item, i) httpReq, err := e.newWorkerRequest(ctx, item, payload, headerAccount) if err != nil { diff --git a/internal/providers/qoder/adapter.go b/internal/providers/qoder/adapter.go index 84a0c1bd..1533121f 100644 --- a/internal/providers/qoder/adapter.go +++ b/internal/providers/qoder/adapter.go @@ -34,6 +34,10 @@ type Client struct { loginTimeout time.Duration loginInterval time.Duration + + // 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 } func NewClient() *Client { @@ -48,6 +52,17 @@ func NewClient() *Client { } } +// SetDirect switches chat onto the in-process COSY client. A nil direct keeps +// the worker HTTP path used by tests that stub a worker URL. +func (c *Client) SetDirect(direct *Direct) { + if c == nil { + return + } + c.mu.Lock() + c.direct = direct + c.mu.Unlock() +} + func (c *Client) Bind(locate func(string) (string, bool), proxyAPIKey func() string) { if c == nil { return @@ -94,6 +109,15 @@ func (c *Client) Adapter() providers.Adapter { } } +func (c *Client) directClient() *Direct { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + return c.direct +} + func (c *Client) lookup(accountID string) (string, error) { c.mu.RLock() locate := c.locate @@ -438,6 +462,9 @@ func (c *Client) PollLogin(ctx context.Context, accountID string) (bool, string, } func (c *Client) ChatNonStream(ctx context.Context, accountID string, req translate.ChatRequest) (providers.ChatOutcome, error) { + if direct := c.directClient(); direct != nil { + return direct.ChatNonStream(ctx, accountID, req) + } httpReq, resolved, err := c.newChatRequest(ctx, accountID, req, false) if err != nil { return providers.ChatOutcome{}, err @@ -466,6 +493,9 @@ func (c *Client) ChatNonStream(ctx context.Context, accountID string, req transl } func (c *Client) ChatStream(ctx context.Context, accountID string, req translate.ChatRequest) (*http.Response, providers.ResolvedChat, error) { + if direct := c.directClient(); direct != nil { + return direct.ChatStream(ctx, accountID, req) + } httpReq, resolved, err := c.newChatRequest(ctx, accountID, req, true) if err != nil { return nil, providers.ResolvedChat{}, err diff --git a/internal/providers/qoder/cosy.go b/internal/providers/qoder/cosy.go new file mode 100644 index 00000000..0077063e --- /dev/null +++ b/internal/providers/qoder/cosy.go @@ -0,0 +1,426 @@ +package qoder + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/rand" + "crypto/rsa" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "net/url" + "strings" + "time" +) + +// COSYVersion is the CLI protocol version sent on chat. It matches the pinned +// worker CLI, not a newer desktop SDK constant. +const COSYVersion = "1.1.32" + +const cosyServerPubKeyPEM = `-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc +4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l +6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17 +XcW+ML9FoCI6AOvOzwIDAQAB +-----END PUBLIC KEY-----` + +const ( + cosyStdAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + cosyCustomAlphabet = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!" +) + +// CLIUser is the plaintext of ~/.qoder*/.auth/user. Field names follow the +// CLI JSON; missing tokens mean the account still needs a worker login. +type CLIUser struct { + Name string `json:"name"` + UID string `json:"uid"` + Aid string `json:"aid"` + YxUID string `json:"yx_uid"` + OrganizationID string `json:"organization_id"` + OrganizationName string `json:"organization_name"` + UserType string `json:"user_type"` + AccessToken string `json:"access_token"` + SecurityOauthToken string `json:"security_oauth_token"` + RefreshToken string `json:"refresh_token"` +} + +func (u CLIUser) token() string { + if strings.TrimSpace(u.SecurityOauthToken) != "" { + return strings.TrimSpace(u.SecurityOauthToken) + } + return strings.TrimSpace(u.AccessToken) +} + +func (u CLIUser) uid() string { + if strings.TrimSpace(u.UID) != "" { + return strings.TrimSpace(u.UID) + } + return strings.TrimSpace(u.Aid) +} + +// DecryptCLIUser opens a CLI user blob. Plain JSON is accepted. Ciphertext is +// standard base64 of AES-128-CBC, with key and IV equal to the first 16 bytes +// of machine_id. +func DecryptCLIUser(blob []byte, machineID string) (CLIUser, error) { + raw := strings.TrimSpace(string(blob)) + if raw == "" { + return CLIUser{}, fmt.Errorf("qoder user credential is empty") + } + plain := []byte(raw) + if !strings.HasPrefix(raw, "{") { + key := machineKey(machineID) + if key == nil { + return CLIUser{}, fmt.Errorf("qoder machine id is shorter than 16 bytes") + } + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return CLIUser{}, fmt.Errorf("decode qoder user credential: %w", err) + } + plain, err = aesCBCDecrypt(decoded, key, key) + if err != nil { + return CLIUser{}, err + } + } + var user CLIUser + if err := json.Unmarshal(plain, &user); err != nil { + return CLIUser{}, fmt.Errorf("parse qoder user credential: %w", err) + } + if user.token() == "" || user.uid() == "" { + return CLIUser{}, fmt.Errorf("qoder user credential has no access token") + } + return user, nil +} + +func machineKey(machineID string) []byte { + machineID = strings.TrimSpace(machineID) + if len(machineID) < 16 { + return nil + } + return []byte(machineID[:16]) +} + +// Session is one account's COSY signing state. Machine fields are derived +// from the uid so a restart does not mint a new device. +type Session struct { + User CLIUser + MachineID string + MachineToken string + MachineType string + tempKey []byte + cosyKey string + info string +} + +func NewSession(user CLIUser) (*Session, error) { + uid := user.uid() + if uid == "" || user.token() == "" { + return nil, fmt.Errorf("qoder session requires uid and access token") + } + tempKey, err := randomTempKey() + if err != nil { + return nil, err + } + wrapped, err := rsaEncryptPKCS1(tempKey) + if err != nil { + return nil, err + } + identity, err := identityJSON(user) + if err != nil { + return nil, err + } + enc, err := aesCBCEncrypt(identity, tempKey, tempKey) + if err != nil { + return nil, err + } + return &Session{ + User: user, + MachineID: deriveID(uid, "machine"), + MachineToken: deriveMachineToken(uid), + MachineType: deriveMachineType(uid), + tempKey: tempKey, + cosyKey: base64.StdEncoding.EncodeToString(wrapped), + info: base64.StdEncoding.EncodeToString(enc), + }, nil +} + +func (s *Session) Sign(body, rawURL string, now time.Time) (headers map[string]string, err error) { + if s == nil { + return nil, fmt.Errorf("qoder session is nil") + } + path := cosySignPath(rawURL) + payload, err := payloadB64(s.info) + if err != nil { + return nil, err + } + date := fmt.Sprintf("%d", now.Unix()) + raw := payload + "\n" + s.cosyKey + "\n" + date + "\n" + body + "\n" + path + sum := md5.Sum([]byte(raw)) + bearer := "Bearer COSY." + payload + "." + hex.EncodeToString(sum[:]) + return map[string]string{ + "Accept": "text/event-stream", + "Authorization": bearer, + "Cache-Control": "no-cache", + "Content-Type": "application/json", + "Cosy-ClientType": "5", + "Cosy-Data-Policy": "AGREE", + "Cosy-Date": date, + "Cosy-Key": s.cosyKey, + "Cosy-MachineId": s.MachineID, + "Cosy-MachineToken": s.MachineToken, + "Cosy-MachineType": s.MachineType, + "Cosy-User": s.User.uid(), + "Cosy-Version": COSYVersion, + "Login-Version": "v2", + "Accept-Encoding": "identity", + }, nil +} + +// EncodeBody is the custom base64 used when the chat URL carries Encode=1. +func EncodeBody(plain []byte) string { + std := base64.StdEncoding.EncodeToString(plain) + n := len(std) + a := n / 3 + rearranged := std[n-a:] + std[a:n-a] + std[:a] + var b strings.Builder + b.Grow(len(rearranged)) + for i := 0; i < len(rearranged); i++ { + c := rearranged[i] + if c == '=' { + b.WriteByte('$') + continue + } + if idx := strings.IndexByte(cosyStdAlphabet, c); idx >= 0 { + b.WriteByte(cosyCustomAlphabet[idx]) + continue + } + b.WriteByte(c) + } + return b.String() +} + +func DecodeBody(encoded string) ([]byte, error) { + var b strings.Builder + b.Grow(len(encoded)) + for i := 0; i < len(encoded); i++ { + c := encoded[i] + if c == '$' { + b.WriteByte('=') + continue + } + if idx := strings.IndexByte(cosyCustomAlphabet, c); idx >= 0 { + b.WriteByte(cosyStdAlphabet[idx]) + continue + } + b.WriteByte(c) + } + std := b.String() + n := len(std) + a := n / 3 + r1, r2, r3 := std[:a], std[a:n-a], std[n-a:] + original := r3 + r2 + r1 + return base64.StdEncoding.DecodeString(original) +} + +// ChatEndpointHook replaces the signed chat URL in tests. Production leaves it nil. +var ChatEndpointHook func(region string) string + +func chatEndpoint(region string) string { + if ChatEndpointHook != nil { + return ChatEndpointHook(region) + } + return ChatEndpoint(region) +} + +func ChatEndpoint(region string) string { + if strings.EqualFold(strings.TrimSpace(region), "cn") { + return "https://gateway.qoder.com.cn/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1" + } + return "https://api1.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1" +} + +func cosySignPath(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Path == "" { + return "/" + } + path := parsed.Path + if strings.HasPrefix(path, "/algo") { + path = path[len("/algo"):] + } + if path == "" { + return "/" + } + return path +} + +func identityJSON(user CLIUser) ([]byte, error) { + aid := strings.TrimSpace(user.Aid) + if aid == "" { + aid = user.uid() + } + token := user.token() + fields := [][2]string{ + {"aid", aid}, + {"name", strings.TrimSpace(user.Name)}, + {"organization_id", strings.TrimSpace(user.OrganizationID)}, + {"organization_name", strings.TrimSpace(user.OrganizationName)}, + {"refresh_token", strings.TrimSpace(user.RefreshToken)}, + {"security_oauth_token", token}, + {"uid", user.uid()}, + {"user_type", strings.TrimSpace(user.UserType)}, + {"yx_uid", strings.TrimSpace(user.YxUID)}, + } + return sortedCompact(fields) +} + +func payloadB64(info string) (string, error) { + id, err := randomUUID() + if err != nil { + return "", err + } + fields := [][2]string{ + {"cosyVersion", COSYVersion}, + {"ideVersion", ""}, + {"info", info}, + {"requestId", id}, + {"version", "v1"}, + } + raw, err := sortedCompact(fields) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(raw), nil +} + +func sortedCompact(fields [][2]string) ([]byte, error) { + var b strings.Builder + b.WriteByte('{') + for i, field := range fields { + if i > 0 { + b.WriteByte(',') + } + key, err := json.Marshal(field[0]) + if err != nil { + return nil, err + } + value, err := json.Marshal(field[1]) + if err != nil { + return nil, err + } + b.Write(key) + b.WriteByte(':') + b.Write(value) + } + b.WriteByte('}') + return []byte(b.String()), nil +} + +func deriveID(uid, salt string) string { + if uid == "" { + uid = "anonymous" + } + sum := md5.Sum([]byte(salt + ":" + uid)) + return hex.EncodeToString(sum[:]) +} + +func deriveMachineType(uid string) string { + id := strings.ReplaceAll(deriveID(uid, "machinetype"), "-", "") + if len(id) > 18 { + return id[:18] + } + return id +} + +func deriveMachineToken(uid string) string { + sum := sha512.Sum512([]byte("machinetoken:" + uid)) + return strings.TrimRight(base64.RawURLEncoding.EncodeToString(sum[:]), "=")[:43] +} + +func randomTempKey() ([]byte, error) { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return nil, err + } + return []byte(hex.EncodeToString(buf)), nil +} + +func randomUUID() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + buf[6] = (buf[6] & 0x0f) | 0x40 + buf[8] = (buf[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]), nil +} + +func rsaEncryptPKCS1(plain []byte) ([]byte, error) { + block, _ := pem.Decode([]byte(cosyServerPubKeyPEM)) + if block == nil { + return nil, fmt.Errorf("decode qoder cosy public key") + } + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + key, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("qoder cosy public key is not RSA") + } + return rsa.EncryptPKCS1v15(rand.Reader, key, plain) +} + +func aesCBCEncrypt(plain, key, iv []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + padded := pkcs7Pad(plain, block.BlockSize()) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv[:block.BlockSize()]).CryptBlocks(out, padded) + return out, nil +} + +func aesCBCDecrypt(data, key, iv []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + if len(data) == 0 || len(data)%block.BlockSize() != 0 { + return nil, fmt.Errorf("qoder user credential length is not a full AES block") + } + out := make([]byte, len(data)) + cipher.NewCBCDecrypter(block, iv[:block.BlockSize()]).CryptBlocks(out, data) + return pkcs7Unpad(out, block.BlockSize()) +} + +func pkcs7Pad(plain []byte, size int) []byte { + pad := size - len(plain)%size + out := make([]byte, len(plain)+pad) + copy(out, plain) + for i := len(plain); i < len(out); i++ { + out[i] = byte(pad) + } + return out +} + +func pkcs7Unpad(padded []byte, size int) ([]byte, error) { + if len(padded) == 0 || len(padded)%size != 0 { + return nil, fmt.Errorf("invalid pkcs7 padding") + } + pad := int(padded[len(padded)-1]) + if pad == 0 || pad > size || pad > len(padded) { + return nil, fmt.Errorf("invalid pkcs7 padding") + } + for _, b := range padded[len(padded)-pad:] { + if int(b) != pad { + return nil, fmt.Errorf("invalid pkcs7 padding") + } + } + return padded[:len(padded)-pad], nil +} diff --git a/internal/providers/qoder/cosy_test.go b/internal/providers/qoder/cosy_test.go new file mode 100644 index 00000000..1cc321dc --- /dev/null +++ b/internal/providers/qoder/cosy_test.go @@ -0,0 +1,117 @@ +package qoder + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "strings" + "testing" + "time" +) + +func TestEncodeBodyRoundTrip(t *testing.T) { + plain := []byte(`{"hello":"qoder","n":1}`) // length forces base64 padding + encoded := EncodeBody(plain) + if strings.Contains(encoded, "A") && strings.Contains(encoded, "B") && encoded == base64.StdEncoding.EncodeToString(plain) { + t.Fatal("encoded body must not be standard base64") + } + if strings.Contains(encoded, "=") || !strings.Contains(encoded, "$") { + t.Fatalf("padding must use $ not =: %s", encoded) + } + got, err := DecodeBody(encoded) + if err != nil { + t.Fatal(err) + } + if string(got) != string(plain) { + t.Fatalf("round trip = %q", got) + } +} + +func TestDecryptCLIUserPlainAndCipher(t *testing.T) { + plain := []byte(`{"uid":"u-1","name":"Ada","access_token":"dt-token","refresh_token":"drt-token"}`) + user, err := DecryptCLIUser(plain, "machine-id-is-long-enough") + if err != nil { + t.Fatal(err) + } + if user.uid() != "u-1" || user.token() != "dt-token" { + t.Fatalf("plain user = %+v", user) + } + + machineID := "0123456789abcdef-rest" + encrypted, err := aesCBCEncrypt(plain, []byte(machineID[:16]), []byte(machineID[:16])) + if err != nil { + t.Fatal(err) + } + blob := []byte(base64.StdEncoding.EncodeToString(encrypted)) + user, err = DecryptCLIUser(blob, machineID) + if err != nil { + t.Fatal(err) + } + if user.Name != "Ada" || user.RefreshToken != "drt-token" { + t.Fatalf("cipher user = %+v", user) + } + if _, err := DecryptCLIUser(blob, "short"); err == nil { + t.Fatal("short machine id must fail") + } +} + +func TestSessionSignIsStableForMachineAndCoversBody(t *testing.T) { + user := CLIUser{UID: "uid-9", Name: "Ada", AccessToken: "dt-1", RefreshToken: "drt-1"} + a, err := NewSession(user) + if err != nil { + t.Fatal(err) + } + b, err := NewSession(user) + if err != nil { + t.Fatal(err) + } + if a.MachineID != b.MachineID || a.MachineToken != b.MachineToken || a.MachineType != b.MachineType { + t.Fatalf("machine drift a=%+v b=%+v", a, b) + } + if a.MachineID == "" || len(a.MachineType) != 18 || len(a.MachineToken) != 43 { + t.Fatalf("machine shape id=%q type=%q token=%q", a.MachineID, a.MachineType, a.MachineToken) + } + endpoint := ChatEndpoint("global") + left, err := a.Sign("body-one", endpoint, time.Unix(100, 0)) + if err != nil { + t.Fatal(err) + } + right, err := a.Sign("body-two", endpoint, time.Unix(100, 0)) + if err != nil { + t.Fatal(err) + } + if left["Authorization"] == right["Authorization"] { + t.Fatal("signature must cover the body") + } + if !strings.HasPrefix(left["Authorization"], "Bearer COSY.") { + t.Fatalf("authorization = %q", left["Authorization"]) + } + if left["Cosy-Version"] != COSYVersion || left["Cosy-User"] != "uid-9" { + t.Fatalf("headers = %#v", left) + } + if !strings.Contains(endpoint, "api1.qoder.sh") || !strings.Contains(ChatEndpoint("cn"), "gateway.qoder.com.cn") { + t.Fatalf("endpoints global=%s cn=%s", endpoint, ChatEndpoint("cn")) + } +} + +func TestAESBlockRoundTripUsesMachineKey(t *testing.T) { + key := []byte("0123456789abcdef") + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + plain := []byte("hello qoder user") + enc, err := aesCBCEncrypt(plain, key, key) + if err != nil { + t.Fatal(err) + } + out := make([]byte, len(enc)) + cipher.NewCBCDecrypter(block, key).CryptBlocks(out, enc) + got, err := pkcs7Unpad(out, block.BlockSize()) + if err != nil { + t.Fatal(err) + } + if string(got) != string(plain) { + t.Fatalf("got %q", got) + } +} diff --git a/internal/providers/qoder/direct.go b/internal/providers/qoder/direct.go new file mode 100644 index 00000000..608d556f --- /dev/null +++ b/internal/providers/qoder/direct.go @@ -0,0 +1,599 @@ +package qoder + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/caigee-cmd/cli2api/internal/accounts" + "github.com/caigee-cmd/cli2api/internal/providers" + "github.com/caigee-cmd/cli2api/internal/translate" +) + +// 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. +type Direct struct { + store CredentialStore + region func(ctx context.Context, accountID string) (string, error) + http *http.Client + now func() time.Time +} + +func NewDirect(store CredentialStore, region func(context.Context, string) (string, error)) *Direct { + return &Direct{ + store: store, + region: region, + http: &http.Client{}, + now: time.Now, + } +} + +func (d *Direct) SetHTTP(client *http.Client) { + if d == nil || client == nil { + return + } + d.http = client +} + +type directPrepared struct { + request *http.Request + model string + level string +} + +func (d *Direct) prepare(ctx context.Context, accountID string, req translate.ChatRequest) (directPrepared, error) { + if d == nil || 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 + } + region := "global" + if d.region != nil { + region, err = d.region(ctx, accountID) + if err != nil { + return directPrepared{}, err + } + } + plain := BuildPlainChatBody(req, d.now()) + model, _ := plain["model_config"].(map[string]any) + modelKey, _ := model["key"].(string) + body, err := json.Marshal(plain) + if err != nil { + return directPrepared{}, err + } + endpoint := chatEndpoint(region) + encoded := EncodeBody(body) + headers, err := session.Sign(encoded, endpoint, d.now()) + if err != nil { + return directPrepared{}, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(encoded)) + if err != nil { + return directPrepared{}, err + } + for key, value := range headers { + httpReq.Header.Set(key, value) + } + if modelKey != "" { + httpReq.Header.Set("X-Model-Key", modelKey) + httpReq.Header.Set("X-Model-Source", "system") + } + return directPrepared{request: httpReq, model: modelKey, level: resolvedReasoningLevel(req)}, nil +} + +func (d *Direct) ChatNonStream(ctx context.Context, accountID string, req translate.ChatRequest) (providers.ChatOutcome, error) { + prepared, err := d.prepare(ctx, accountID, req) + if err != nil { + return providers.ChatOutcome{}, err + } + resp, err := d.http.Do(prepared.request) + if err != nil { + return providers.ChatOutcome{}, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if err != nil { + return providers.ChatOutcome{}, err + } + if resp.StatusCode >= 300 { + return providers.ChatOutcome{}, upstreamStatusError(resp.StatusCode, raw) + } + outcome, err := outcomeFromUpstreamSSE(prepared.model, raw) + if err != nil { + return providers.ChatOutcome{}, err + } + outcome.ReasoningLevel = prepared.level + return outcome, nil +} + +func (d *Direct) ChatStream(ctx context.Context, accountID string, req translate.ChatRequest) (*http.Response, providers.ResolvedChat, error) { + prepared, err := d.prepare(ctx, accountID, req) + if err != nil { + return nil, providers.ResolvedChat{}, err + } + resp, err := d.http.Do(prepared.request) + if err != nil { + return nil, providers.ResolvedChat{}, err + } + if resp.StatusCode >= 300 { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + return nil, providers.ResolvedChat{}, upstreamStatusError(resp.StatusCode, raw) + } + return rewriteUpstreamSSE(resp, prepared.model), providers.ResolvedChat{ReasoningLevel: prepared.level}, nil +} + +func upstreamStatusError(status int, body []byte) error { + message := strings.TrimSpace(string(body)) + if message == "" { + message = fmt.Sprintf("qoder upstream status %d", status) + } + kind := accounts.KindUnavailable + switch status { + case http.StatusUnauthorized, http.StatusForbidden: + kind = accounts.KindAuth + case http.StatusTooManyRequests: + kind = accounts.KindRateLimit + case http.StatusBadRequest: + kind = accounts.KindInvalidRequest + } + return &providers.Error{Kind: kind, Status: status, Message: message} +} + +// BuildPlainChatBody is the plaintext agent_chat_generation document. The +// gateway expects this shape before Encode=1; it is not the OpenAI body the +// worker used to accept. +func BuildPlainChatBody(req translate.ChatRequest, now time.Time) map[string]any { + if now.IsZero() { + now = time.Now() + } + var systemParts []string + messages := make([]map[string]any, 0, len(req.Messages)) + for _, message := range req.Messages { + text := strings.TrimSpace(translate.ContentToString(message.Content)) + if message.Role == "system" || message.Role == "developer" { + if text != "" { + systemParts = append(systemParts, text) + } + continue + } + item := map[string]any{"role": message.Role, "content": message.Content} + if message.Name != "" { + item["name"] = message.Name + } + if message.ToolCallID != "" { + item["tool_call_id"] = message.ToolCallID + } + if len(message.ToolCalls) > 0 { + item["tool_calls"] = json.RawMessage(message.ToolCalls) + } + messages = append(messages, item) + } + systemText := strings.Join(systemParts, "\n\n") + if systemText != "" { + messages = append([]map[string]any{{"role": "system", "content": systemText}}, messages...) + } + if len(messages) == 0 { + messages = []map[string]any{{"role": "user", "content": "ping"}} + } + modelKey := strings.TrimSpace(req.Model) + if modelKey == "" { + modelKey = "auto" + } + maxTokens := 32000 + if n, ok := rawNumber(req.MaxCompletionTokens); ok && n > 0 { + maxTokens = n + } else if n, ok := rawNumber(req.MaxTokens); ok && n > 0 { + maxTokens = n + } + parameters := map[string]any{"max_tokens": maxTokens} + copyRaw(parameters, "temperature", req.Temperature) + copyRaw(parameters, "top_p", req.TopP) + copyRaw(parameters, "stop", req.Stop) + copyRaw(parameters, "response_format", req.ResponseFormat) + copyRaw(parameters, "reasoning_effort", req.ReasoningEffort) + copyRaw(parameters, "reasoning_budget_tokens", req.ReasoningBudgetTokens) + copyRaw(parameters, "context_length", req.ContextLength) + copyRaw(parameters, "max_input_tokens", req.MaxInputTokens) + copyRaw(parameters, "tool_choice", req.ToolChoice) + if req.ParallelToolCalls != nil { + parameters["parallel_tool_calls"] = *req.ParallelToolCalls + } + level := resolvedReasoningLevel(req) + reasoning := level != "" && level != "none" + if level != "" { + parameters["enable_thinking"] = reasoning + } + maxInput := 180000 + if n, ok := rawNumber(req.MaxInputTokens); ok && n > 0 { + maxInput = n + } + name := "chat" + if text := translate.ContentToString(messages[len(messages)-1]["content"]); strings.TrimSpace(text) != "" { + name = strings.TrimSpace(text) + } + 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{}}, + "is_reply": false, + "is_retry": false, + "source": "cli", + "version": "1.0", + "agent_id": "agent_common", + "task_id": "common", + "session_type": "assistant", + "aliyun_user_type": "", + "model_config": map[string]any{ + "key": modelKey, "display_name": modelKey, "model": "", "format": "openai", + "is_vl": true, "is_reasoning": reasoning, "api_key": "", "url": "", + "source": "system", "max_input_tokens": maxInput, + }, + "custom_model": nil, + "system": systemText, + "messages": messages, + "tools": rawOrEmpty(req.Tools), + "parameters": parameters, + "business": map[string]any{ + "product": "cli", "version": COSYVersion, "type": "agent", + "id": newID(), "name": name, "begin_at": now.UnixMilli(), "stage": "start", + }, + } +} + +func rawNumber(raw json.RawMessage) (int, bool) { + if len(raw) == 0 { + return 0, false + } + var n int + if json.Unmarshal(raw, &n) != nil { + return 0, false + } + return n, true +} + +func copyRaw(dst map[string]any, key string, raw json.RawMessage) { + if len(bytes.TrimSpace(raw)) == 0 || string(raw) == "null" { + return + } + dst[key] = json.RawMessage(raw) +} + +func rawOrEmpty(raw json.RawMessage) any { + if len(bytes.TrimSpace(raw)) == 0 { + return []any{} + } + return json.RawMessage(raw) +} + +func newID() string { + id, err := randomUUID() + if err != nil { + return "00000000-0000-4000-8000-000000000000" + } + return id +} + +type upstreamFrame struct { + content string + reasoning string + tools json.RawMessage + finish string + usage json.RawMessage + errText string + errStatus int + errKind string +} + +func outcomeFromUpstreamSSE(model string, raw []byte) (providers.ChatOutcome, error) { + frame, err := collectUpstream(bytes.NewReader(raw)) + if err != nil { + return providers.ChatOutcome{}, err + } + if frame.errText != "" && frame.content == "" && frame.reasoning == "" && len(frame.tools) == 0 { + kind := frame.errKind + if kind == "" { + kind = accounts.KindUnavailable + } + status := frame.errStatus + if status == 0 { + status = http.StatusBadGateway + } + return providers.ChatOutcome{}, &providers.Error{Kind: kind, Status: status, Message: frame.errText} + } + out := providers.ChatOutcome{ + Model: model, + Content: frame.content, + Reasoning: frame.reasoning, + ToolCalls: frame.tools, + FinishReason: frame.finish, + UsageSource: "upstream", + } + if out.FinishReason == "" { + if len(out.ToolCalls) > 0 { + out.FinishReason = "tool_calls" + } else { + out.FinishReason = "stop" + } + } + applyUsage(&out, frame.usage) + return out, nil +} + +func applyUsage(out *providers.ChatOutcome, raw json.RawMessage) { + if len(raw) == 0 { + out.UsageSource = "estimate" + return + } + var usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + CacheReadTokens *int `json:"cache_read_tokens"` + CacheWriteTokens *int `json:"cache_write_tokens"` + Credits *float64 `json:"credits"` + } + if json.Unmarshal(raw, &usage) != nil { + out.UsageSource = "estimate" + return + } + out.PromptTokens = usage.PromptTokens + out.CompletionTokens = usage.CompletionTokens + out.CacheReadTokens = usage.CacheReadTokens + out.CacheWriteTokens = usage.CacheWriteTokens + out.Credits = usage.Credits +} + +func collectUpstream(r io.Reader) (upstreamFrame, error) { + var frame upstreamFrame + var content, reasoning strings.Builder + err := walkUpstream(r, func(body map[string]any) error { + if errText, status, kind := upstreamError(body); errText != "" { + frame.errText = errText + frame.errStatus = status + frame.errKind = kind + } + if usage, ok := body["usage"]; ok && usage != nil { + raw, _ := json.Marshal(usage) + frame.usage = raw + } + choices, _ := body["choices"].([]any) + if len(choices) == 0 { + return nil + } + choice, _ := choices[0].(map[string]any) + if choice == nil { + return nil + } + if finish, _ := choice["finish_reason"].(string); finish != "" { + frame.finish = finish + } + delta, _ := choice["delta"].(map[string]any) + message, _ := choice["message"].(map[string]any) + part := delta + if part == nil { + part = message + } + if part == nil { + return nil + } + if text, _ := part["content"].(string); text != "" { + content.WriteString(text) + } + if text, _ := part["reasoning_content"].(string); text != "" { + reasoning.WriteString(text) + } + if tools := part["tool_calls"]; tools != nil { + raw, _ := json.Marshal(tools) + frame.tools = raw + } + return nil + }) + frame.content = content.String() + frame.reasoning = reasoning.String() + return frame, err +} + +func upstreamError(body map[string]any) (string, int, string) { + raw, ok := body["error"] + if !ok || raw == nil { + return "", 0, "" + } + encoded, _ := json.Marshal(raw) + var errBody struct { + Message string `json:"message"` + Code any `json:"code"` + Type string `json:"type"` + } + _ = json.Unmarshal(encoded, &errBody) + message := strings.TrimSpace(errBody.Message) + if message == "" { + message = strings.TrimSpace(string(encoded)) + } + kind := accounts.KindUnavailable + status := http.StatusBadGateway + code := fmt.Sprint(errBody.Code) + if code == "insufficient_quota" || strings.Contains(strings.ToLower(message), "quota") { + kind = accounts.KindQuota + status = http.StatusTooManyRequests + } + return message, status, kind +} + +func walkUpstream(r io.Reader, fn func(map[string]any) error) error { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 4<<20) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, ":") { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" || data == "[DONE]" { + continue + } + var outer map[string]any + if json.Unmarshal([]byte(data), &outer) != nil { + continue + } + body := outer + if nested, ok := outer["body"]; ok { + switch typed := nested.(type) { + case string: + var inner map[string]any + if json.Unmarshal([]byte(typed), &inner) == nil { + body = inner + } + case map[string]any: + body = typed + } + } + if err := fn(body); err != nil { + return err + } + } + return scanner.Err() +} + +func rewriteUpstreamSSE(resp *http.Response, model string) *http.Response { + pr, pw := io.Pipe() + go func() { + defer resp.Body.Close() + err := writeOpenAIChunks(pw, resp.Body, model) + _ = pw.CloseWithError(err) + }() + headers := resp.Header.Clone() + headers.Set("Content-Type", "text/event-stream") + headers.Del("Content-Length") + return &http.Response{ + StatusCode: http.StatusOK, + Header: headers, + Body: io.NopCloser(pr), + Request: resp.Request, + } +} + +func writeOpenAIChunks(w io.Writer, src io.Reader, model string) error { + err := walkUpstream(src, func(body map[string]any) error { + frame := frameFromBody(body) + if frame.errText != "" && frame.content == "" && frame.reasoning == "" && len(frame.tools) == 0 { + payload, _ := json.Marshal(map[string]any{"error": map[string]any{"message": frame.errText, "type": "api_error"}}) + _, err := fmt.Fprintf(w, "data: %s\n\n", payload) + return err + } + chunk := map[string]any{ + "id": "chatcmpl-qoder", "object": "chat.completion.chunk", "model": model, + "choices": []any{map[string]any{"index": 0, "delta": deltaOf(frame), "finish_reason": emptyOrNil(frame.finish)}}, + } + if len(frame.usage) > 0 { + var usage any + if json.Unmarshal(frame.usage, &usage) == nil { + chunk["usage"] = usage + } + } + payload, err := json.Marshal(chunk) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "data: %s\n\n", payload) + return err + }) + if err != nil { + return err + } + _, err = io.WriteString(w, "data: [DONE]\n\n") + return err +} + +func frameFromBody(body map[string]any) upstreamFrame { + var frame upstreamFrame + if errText, status, kind := upstreamError(body); errText != "" { + frame.errText = errText + frame.errStatus = status + frame.errKind = kind + } + if usage, ok := body["usage"]; ok && usage != nil { + raw, _ := json.Marshal(usage) + frame.usage = raw + } + choices, _ := body["choices"].([]any) + if len(choices) == 0 { + return frame + } + choice, _ := choices[0].(map[string]any) + if choice == nil { + return frame + } + if finish, _ := choice["finish_reason"].(string); finish != "" { + frame.finish = finish + } + delta, _ := choice["delta"].(map[string]any) + message, _ := choice["message"].(map[string]any) + part := delta + if part == nil { + part = message + } + if part == nil { + return frame + } + if text, _ := part["content"].(string); text != "" { + frame.content = text + } + if text, _ := part["reasoning_content"].(string); text != "" { + frame.reasoning = text + } + if tools := part["tool_calls"]; tools != nil { + raw, _ := json.Marshal(tools) + frame.tools = raw + } + return frame +} + +func deltaOf(frame upstreamFrame) map[string]any { + delta := map[string]any{} + if frame.content != "" { + delta["content"] = frame.content + } + if frame.reasoning != "" { + delta["reasoning_content"] = frame.reasoning + } + if len(frame.tools) > 0 { + var tools any + if json.Unmarshal(frame.tools, &tools) == nil { + delta["tool_calls"] = tools + } + } + return delta +} + +func emptyOrNil(value string) any { + if value == "" { + return nil + } + return value +} diff --git a/internal/providers/qoder/direct_test.go b/internal/providers/qoder/direct_test.go new file mode 100644 index 00000000..320c623a --- /dev/null +++ b/internal/providers/qoder/direct_test.go @@ -0,0 +1,103 @@ +package qoder + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/caigee-cmd/cli2api/internal/accounts" + "github.com/caigee-cmd/cli2api/internal/providers" + "github.com/caigee-cmd/cli2api/internal/translate" +) + +type memoryCredentials struct { + credential accounts.NativeCredential + region string +} + +func (m memoryCredentials) LoadCredential(context.Context, string) (accounts.NativeCredential, error) { + return m.credential, nil +} + +func (m memoryCredentials) SaveCredential(context.Context, string, string, accounts.NativeCredential) error { + return nil +} + +func TestDirectChatPostsEncodedBodyAndReadsNestedSSE(t *testing.T) { + var gotPath, gotAuth, gotModel string + var gotBody string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.RequestURI() + gotAuth = r.Header.Get("Authorization") + gotModel = r.Header.Get("X-Model-Key") + raw, _ := io.ReadAll(r.Body) + gotBody = string(raw) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"body\":{\"choices\":[{\"delta\":{\"content\":\"OK\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":1,\"credits\":0.2}}}\n\n") + })) + defer upstream.Close() + + direct := NewDirect(memoryCredentials{ + credential: accounts.NativeCredential{ + UserBlob: []byte(`{"uid":"u-1","name":"Ada","access_token":"dt-token"}`), + MachineID: "0123456789abcdef", + }, + region: "cn", + }, func(context.Context, string) (string, error) { return "cn", nil }) + direct.now = func() time.Time { return time.Unix(1_700_000_000, 0) } + direct.SetHTTP(upstream.Client()) + + original := ChatEndpointHook + ChatEndpointHook = func(string) string { + return upstream.URL + "/algo/api/v2/service/pro/sse/agent_chat_generation?Encode=1" + } + t.Cleanup(func() { ChatEndpointHook = original }) + + outcome, err := direct.ChatNonStream(context.Background(), "acc", translate.ChatRequest{ + Model: "auto", + Messages: []translate.ChatMessage{{Role: "user", Content: "ping"}}, + }) + if err != nil { + t.Fatal(err) + } + if outcome.Content != "OK" || outcome.PromptTokens != 3 || outcome.Credits == nil || *outcome.Credits != 0.2 { + t.Fatalf("outcome = %+v", outcome) + } + if !strings.HasPrefix(gotAuth, "Bearer COSY.") { + t.Fatalf("auth = %q", gotAuth) + } + if gotModel != "auto" || !strings.Contains(gotPath, "Encode=1") { + t.Fatalf("path=%s model=%s", gotPath, gotModel) + } + plain, err := DecodeBody(gotBody) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(plain), `"content":"ping"`) || !strings.Contains(string(plain), `"agent_id":"agent_common"`) { + t.Fatalf("plain = %s", plain) + } +} + +func TestDirectChatClassifiesUpstreamStatus(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusForbidden) + })) + defer upstream.Close() + direct := NewDirect(memoryCredentials{ + credential: accounts.NativeCredential{UserBlob: []byte(`{"uid":"u","access_token":"dt"}`), MachineID: "0123456789abcdef"}, + }, nil) + direct.SetHTTP(upstream.Client()) + original := ChatEndpointHook + ChatEndpointHook = func(string) string { return upstream.URL } + t.Cleanup(func() { ChatEndpointHook = original }) + + _, err := direct.ChatNonStream(context.Background(), "acc", translate.ChatRequest{Model: "auto"}) + classified, ok := err.(*providers.Error) + if !ok || classified.Kind != accounts.KindAuth || classified.Status != http.StatusForbidden { + t.Fatalf("err = %#v", err) + } +} From c6c640c28d7f1bc5a8a0dcd6258a9f57594794f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Wed, 7 Oct 2026 23:08:21 +0800 Subject: [PATCH 2/3] fix(qoder): omit partial machine headers on check-in A machine id without the official runtime-info identity made the gateway hide the claimable credit campaign. Send the desktop machine headers only when that identity exists. --- .../qoder-checkin-machine-headers.md | 7 ++++ internal/runtime/manager_test.go | 17 ++++++-- worker/src/checkin.mjs | 40 +++++++++---------- worker/test/checkin.test.mjs | 9 ++--- 4 files changed, 44 insertions(+), 29 deletions(-) create mode 100644 changelog/unreleased/qoder-checkin-machine-headers.md diff --git a/changelog/unreleased/qoder-checkin-machine-headers.md b/changelog/unreleased/qoder-checkin-machine-headers.md new file mode 100644 index 00000000..ce206605 --- /dev/null +++ b/changelog/unreleased/qoder-checkin-machine-headers.md @@ -0,0 +1,7 @@ +### English + +- Qoder CN check-in no longer sends a partial machine identity. Without the official runtime-info identity, those headers hid the claimable credit campaign. + +### 中文 + +- Qoder 国内版签到在没有官方 runtime-info 机器身份时不再发送半套机器头。之前这些头会让可领取的积分活动从列表里消失。 diff --git a/internal/runtime/manager_test.go b/internal/runtime/manager_test.go index dd401206..1784c760 100644 --- a/internal/runtime/manager_test.go +++ b/internal/runtime/manager_test.go @@ -769,12 +769,21 @@ func TestManagerRestartsUnexpectedlyExitedEnabledAccount(t *testing.T) { if second == first { t.Fatal("manager reused exited process") } - item, ok := manager.Pool().ByID(starter.accounts[0].ID) - if !ok || item.Restarts != 1 { + case <-time.After(2 * time.Second): + t.Fatal("account process was not restarted") + } + deadline := time.Now().Add(2 * time.Second) + var item executor.Item + var ok bool + for { + item, ok = manager.Pool().ByID(starter.accounts[0].ID) + if ok && item.Restarts == 1 { + break + } + if time.Now().After(deadline) { t.Fatalf("restart state = %+v ok=%v", item, ok) } - case <-time.After(time.Second): - t.Fatal("account process was not restarted") + time.Sleep(5 * time.Millisecond) } } diff --git a/worker/src/checkin.mjs b/worker/src/checkin.mjs index b50002d8..5728eb01 100644 --- a/worker/src/checkin.mjs +++ b/worker/src/checkin.mjs @@ -196,10 +196,17 @@ function readRiskIdentity(executable, account) { } async function machineHeaders(auth, user, options) { - const headers = { - "Cosy-MachineOS": machineOS(), - "Cosy-MachineHostname": machineHostname(), - }; + const executable = options.runtimeInfoPath + ? executableRuntimeInfo(options.runtimeInfoPath(), options.prepareRuntimeInfo) + : executableRuntimeInfo(installedRuntimeInfo()); + const account = accountId(user); + const identity = executable && account + ? await (options.readRiskIdentity ?? readRiskIdentity)(executable, account) + : null; + // A partial machine identity is treated as an unofficial client and the + // claimable credit campaign is removed from the list. Send the full desktop + // set only when runtime-info produced one; otherwise send none. + if (!identity) return {}; let machineId = auth.machineId; if (typeof auth.getMachineId === "function") { try { @@ -208,23 +215,16 @@ async function machineHeaders(auth, user, options) { machineId = ""; } } + const headers = {}; const id = headerValue(machineId); - if (id) { - headers["Cosy-MachineId"] = id; - headers["Cosy-MachineToken"] = id; - } - const executable = options.runtimeInfoPath - ? executableRuntimeInfo(options.runtimeInfoPath(), options.prepareRuntimeInfo) - : executableRuntimeInfo(installedRuntimeInfo()); - const account = accountId(user); - if (executable && account) { - const identity = await (options.readRiskIdentity ?? readRiskIdentity)(executable, account); - if (identity) { - headers["Cosy-MachineToken"] = identity.machineToken; - headers["Cosy-MachineType"] = identity.machineType; - headers["Cosy-MachineCode"] = identity.machineCode; - } - } + if (id) headers["Cosy-MachineId"] = id; + headers["Cosy-MachineToken"] = identity.machineToken; + headers["Cosy-MachineType"] = identity.machineType; + headers["Cosy-MachineCode"] = identity.machineCode; + const osName = machineOS(); + const hostname = machineHostname(); + if (osName) headers["Cosy-MachineOS"] = osName; + if (hostname) headers["Cosy-MachineHostname"] = hostname; return headers; } diff --git a/worker/test/checkin.test.mjs b/worker/test/checkin.test.mjs index d9a3e062..68f18345 100644 --- a/worker/test/checkin.test.mjs +++ b/worker/test/checkin.test.mjs @@ -125,17 +125,16 @@ test("CN check-in uses an executable copy when the account runtime directory can } }); -test("CN check-in keeps the machine id when the risk identity bridge is unavailable", async () => { +test("CN check-in omits machine headers when the risk identity bridge is unavailable", async () => { const { checkin, calls } = fixture([json(listed(credit({ status: "CLAIMED" })))], { machineId: "machine-test-id", accountId: "account-1", runtimeInfoPath: () => "", }); assert.equal((await checkin()).status, "already"); - assert.equal(calls[0].init.headers["Cosy-MachineId"], "machine-test-id"); - assert.equal(calls[0].init.headers["Cosy-MachineToken"], "machine-test-id"); - assert.equal(calls[0].init.headers["Cosy-MachineType"], undefined); - assert.equal(calls[0].init.headers["Cosy-MachineCode"], undefined); + for (const name of ["Cosy-MachineId", "Cosy-MachineToken", "Cosy-MachineType", "Cosy-MachineCode", "Cosy-MachineOS", "Cosy-MachineHostname"]) { + assert.equal(calls[0].init.headers[name], undefined); + } }); test("VIEW_DETAILS campaigns are never claimed", async () => { From 9f207157e6e924f311c09d3a6b2563ca8392282d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Wed, 7 Oct 2026 23:21:08 +0800 Subject: [PATCH 3/3] docs: drop the changelog note already published in v0.6.17 The overview resources order note shipped with v0.6.17. Leaving the fragment would publish it again. --- changelog/unreleased/overview-resources-order.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 changelog/unreleased/overview-resources-order.md diff --git a/changelog/unreleased/overview-resources-order.md b/changelog/unreleased/overview-resources-order.md deleted file mode 100644 index 32b50fe1..00000000 --- a/changelog/unreleased/overview-resources-order.md +++ /dev/null @@ -1,7 +0,0 @@ -### English - -- Move the Overview resources card up beside the account pool, ahead of the traffic rankings. - -### 中文 - -- 概览页的资源占用卡片提前到账号池旁边,排在流量排行之前。