diff --git a/AGENTS.md b/AGENTS.md index 04eb22d..e591aa8 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-checkin-machine-headers.md b/changelog/unreleased/qoder-checkin-machine-headers.md new file mode 100644 index 0000000..ce20660 --- /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/changelog/unreleased/qoder-inprocess-chat.md b/changelog/unreleased/qoder-inprocess-chat.md new file mode 100644 index 0000000..a9ba7e4 --- /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 f7128d2..ef434cf 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 ee8c283..1605543 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 f9a01d5..8198573 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 9f610fa..dc05c16 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 e4af885..c7062d7 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 84a0c1b..1533121 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 0000000..0077063 --- /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 0000000..1cc321d --- /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 0000000..608d556 --- /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 0000000..320c623 --- /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) + } +} diff --git a/internal/runtime/manager_test.go b/internal/runtime/manager_test.go index dd40120..1784c76 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 b50002d..5728eb0 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 d9a3e06..68f1834 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 () => {