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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-checkin-machine-headers.md
Original file line number Diff line number Diff line change
@@ -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 机器身份时不再发送半套机器头。之前这些头会让可领取的积分活动从列表里消失。
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-inprocess-chat.md
Original file line number Diff line number Diff line change
@@ -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 仍负责登录、模型目录和配额,不再参与聊天请求。
5 changes: 3 additions & 2 deletions docs/ARCHITECTURE_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 槽位、多租户商业网关或公开暴露的管理端口。

Expand Down Expand Up @@ -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 声明为准,不凭空增加模型能力。
Expand Down
7 changes: 7 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 0 additions & 3 deletions internal/app/architecture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
Expand Down
37 changes: 20 additions & 17 deletions internal/app/regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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"} {
Expand Down
81 changes: 65 additions & 16 deletions internal/executor/chat.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package executor

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading