From 00a8feae960dc492eae810c3d6d7872c095cbd28 Mon Sep 17 00:00:00 2001 From: whale Date: Thu, 15 Oct 2026 22:22:50 +0800 Subject: [PATCH 1/9] feat(qwenwork): add in-process QwenWork provider package Device-flow login with PKCE, lazy token refresh with rotation, COSY-signed chat (plain JSON agent_chat_generation), wrapped-SSE translation to OpenAI chunks, live qwork model catalog, account-context quota, error taxonomy incl. quota code 14018 and model_not_available, and credential import for both the canonical and qwenwork2api shapes. Regions: global (qwenwork.ai) and cn (qwenwork.cn). --- internal/providers/qwenwork/classify.go | 176 ++++ internal/providers/qwenwork/client.go | 736 +++++++++++++++++ internal/providers/qwenwork/client_test.go | 881 +++++++++++++++++++++ internal/providers/qwenwork/cosy.go | 249 ++++++ internal/providers/qwenwork/credential.go | 182 +++++ internal/providers/qwenwork/import.go | 30 + internal/providers/qwenwork/payload.go | 147 ++++ internal/providers/qwenwork/pkce.go | 37 + internal/providers/qwenwork/sse.go | 521 ++++++++++++ 9 files changed, 2959 insertions(+) create mode 100644 internal/providers/qwenwork/classify.go create mode 100644 internal/providers/qwenwork/client.go create mode 100644 internal/providers/qwenwork/client_test.go create mode 100644 internal/providers/qwenwork/cosy.go create mode 100644 internal/providers/qwenwork/credential.go create mode 100644 internal/providers/qwenwork/import.go create mode 100644 internal/providers/qwenwork/payload.go create mode 100644 internal/providers/qwenwork/pkce.go create mode 100644 internal/providers/qwenwork/sse.go diff --git a/internal/providers/qwenwork/classify.go b/internal/providers/qwenwork/classify.go new file mode 100644 index 0000000..4dd2d98 --- /dev/null +++ b/internal/providers/qwenwork/classify.go @@ -0,0 +1,176 @@ +package qwenwork + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/caigee-cmd/cli2api/internal/accounts" + "github.com/caigee-cmd/cli2api/internal/providers" +) + +// quotaCode is the account-level quota error the QwenWork gateway embeds in +// error frames. +const quotaCode = "14018" + +// Classify maps a QwenWork HTTP status and error body onto the internal +// taxonomy. Text checks run before plain status checks because the gateway +// reports model and quota failures inside 400-class frames: "Model is not +// available for this user" and quota code 14018 must not be folded into +// invalid_request. Model-level misses stay model_not_available so the +// executor cools the model, never the whole account. +func Classify(status int, body string) providers.ClassifiedError { + text := strings.TrimSpace(body) + message, code := errorTextAndCode(text) + kind := classifyKind(status, message, code) + if kind == "" { + return providers.ClassifiedError{} + } + out := providers.ClassifiedError{Kind: kind, Status: status, Message: message} + switch kind { + case accounts.KindModelNotAvailable: + out.Status = firstStatus(status, http.StatusBadRequest) + case accounts.KindQuota: + out.Status = http.StatusPaymentRequired + case accounts.KindAuth: + if out.Status != http.StatusUnauthorized && out.Status != http.StatusForbidden { + out.Status = http.StatusUnauthorized + } + case accounts.KindRateLimit: + out.Status = http.StatusTooManyRequests + case accounts.KindInvalidRequest, accounts.KindUnavailable: + out.Status = firstStatus(status, http.StatusBadGateway) + } + if out.Message == "" { + out.Message = "qwenwork upstream status " + strconv.Itoa(out.Status) + } + return out +} + +// classifyKind picks the taxonomy kind. The known error texts and body-level +// codes win over the frame status they ride in: "Model is not available for +// this user" arrives in a 400-class frame and must not fold into +// invalid_request. +func classifyKind(status int, message, code string) string { + lower := strings.ToLower(message + " " + code) + if strings.Contains(lower, "model is not available") { + return accounts.KindModelNotAvailable + } + // Body-level 4xx codes: the desktop error convention sends + // {"code":"401","message":"…"} even inside 200-class frames. + switch code { + case "401", "403": + return accounts.KindAuth + case "402": + return accounts.KindQuota + case "429": + return accounts.KindRateLimit + } + switch { + case status == http.StatusPaymentRequired || code == quotaCode || + strings.Contains(lower, quotaCode) || quotaLike(lower): + return accounts.KindQuota + case status == http.StatusUnauthorized || status == http.StatusForbidden: + return accounts.KindAuth + case status == http.StatusTooManyRequests: + return accounts.KindRateLimit + case status == http.StatusBadRequest || status == http.StatusRequestEntityTooLarge || status == http.StatusUnprocessableEntity: + return accounts.KindInvalidRequest + case status >= 500 || status == http.StatusNotFound: + return accounts.KindUnavailable + } + return "" +} + +func quotaLike(lower string) bool { + return strings.Contains(lower, "quota") || + strings.Contains(lower, "insufficient credit") || + strings.Contains(lower, "积分不足") || + strings.Contains(lower, "额度") +} + +// errorTextAndCode extracts the human message and machine code from a +// QwenWork error body. Both the flat gateway shape ({"code":14018,"message": +// "…"}) and the OpenAI error shape ({"error":{"message":"…"}}) are accepted; +// anything else keeps the trimmed body as the message. +func errorTextAndCode(body string) (message, code string) { + var flat struct { + Code any `json:"code"` + Message string `json:"message"` + Msg string `json:"msg"` + } + if json.Unmarshal([]byte(body), &flat) == nil { + code = scalarCode(flat.Code) + message = strings.TrimSpace(flat.Message) + if message == "" { + message = strings.TrimSpace(flat.Msg) + } + } + if message == "" { + var openAI struct { + Error struct { + Message string `json:"message"` + Code any `json:"code"` + } `json:"error"` + } + if json.Unmarshal([]byte(body), &openAI) == nil && openAI.Error.Message != "" { + message = strings.TrimSpace(openAI.Error.Message) + if code == "" { + code = scalarCode(openAI.Error.Code) + } + } + } + if message == "" { + message = body + } + return message, code +} + +// scalarCode renders a JSON code value (number or string) as text. +func scalarCode(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + if typed == float64(int64(typed)) { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', -1, 64) + } + return "" +} + +func firstStatus(status, fallback int) int { + if status >= 400 { + return status + } + return fallback +} + +// classifiedError wraps Classify for the HTTP error path. Bodies that carry +// no known marker still fail over as unavailable instead of passing a raw +// upstream page to the client. +func classifiedError(status int, body []byte) error { + classified := Classify(status, string(body)) + if classified.Kind == "" { + classified = providers.ClassifiedError{ + Kind: accounts.KindUnavailable, + Status: firstStatus(status, http.StatusBadGateway), + Message: truncateBody(body), + } + } + return &providers.Error{Kind: classified.Kind, Status: classified.Status, Message: classified.Message} +} + +// truncateBody keeps error bodies bounded and never empty. +func truncateBody(body []byte) string { + text := strings.TrimSpace(string(body)) + if text == "" { + return "" + } + if len(text) > 240 { + return text[:240] + } + return text +} diff --git a/internal/providers/qwenwork/client.go b/internal/providers/qwenwork/client.go new file mode 100644 index 0000000..53962b8 --- /dev/null +++ b/internal/providers/qwenwork/client.go @@ -0,0 +1,736 @@ +package qwenwork + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/caigee-cmd/cli2api/internal/accounts" + "github.com/caigee-cmd/cli2api/internal/providers" + proxyutil "github.com/caigee-cmd/cli2api/internal/proxy" + "github.com/caigee-cmd/cli2api/internal/translate" +) + +// Store is the persistence surface the adapter needs. It matches the SQLite +// store without importing the concrete type. +type Store interface { + Get(ctx context.Context, id string) (accounts.Account, error) + LoadCredentialPayload(ctx context.Context, accountID string) (string, []byte, error) + SaveCredentialPayload(ctx context.Context, accountID, format string, payload []byte) error + Observe(ctx context.Context, id, remoteUID, status, lastError, lastKind string) error +} + +// SecretReader is optional; missing it means no global proxy, not an error. +type SecretReader interface { + GetSecret(context.Context, string) (string, bool, error) +} + +// Client is the in-process QwenWork adapter. Region comes from the account +// record; every gateway call picks its site from it. +type Client struct { + store Store + http *http.Client + + transports proxyutil.TransportCache + + mu sync.Mutex + loginStates map[string]*loginState + sessions map[string]*session + catalog map[string]providers.ModelInfo +} + +// loginState is one pending device authorization round. +type loginState struct { + verifier string + nonce string + machineID string + createdAt time.Time +} + +const ( + catalogTimeout = 15 * time.Second + loginStateTTL = 10 * time.Minute + refreshMargin = 2 * time.Minute + accountContextOK = "ok" +) + +// modelListEntry is one qwork model from /algo/api/v2/model/list. +type modelListEntry struct { + Key string `json:"key"` + DisplayName string `json:"display_name"` + IsVL bool `json:"is_vl"` + IsReasoning bool `json:"is_reasoning"` + MaxInputTokens int `json:"max_input_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Credits string `json:"credits"` + Enable *bool `json:"enable"` +} + +func (e modelListEntry) modelInfo() providers.ModelInfo { + display := strings.TrimSpace(e.DisplayName) + if display == "" { + display = e.Key + } + return providers.ModelInfo{ + NativeModel: e.Key, + PublicModel: e.Key, + DisplayName: display, + Credits: e.Credits, + Capabilities: providers.ModelCapabilities{ + ContextWindow: e.MaxInputTokens, + MaxOutput: e.MaxOutputTokens, + Tools: true, + Images: e.IsVL, + Reasoning: e.IsReasoning, + }, + Scene: scene, + } +} + +func NewClient(store Store) *Client { + return &Client{ + store: store, + http: &http.Client{Timeout: 120 * time.Second}, + loginStates: map[string]*loginState{}, + sessions: map[string]*session{}, + } +} + +func (c *Client) globalProxy(ctx context.Context) (string, error) { + store, ok := c.store.(SecretReader) + if !ok { + return "", nil + } + value, found, err := store.GetSecret(ctx, "proxy_url") + if err != nil { + return "", fmt.Errorf("load global proxy setting: %w", err) + } + if !found { + return "", nil + } + return strings.TrimSpace(value), nil +} + +func (c *Client) effectiveProxy(ctx context.Context, accountID string) (string, error) { + account, err := c.store.Get(ctx, accountID) + if err != nil { + return "", err + } + if value := strings.TrimSpace(account.ProxyURL); value != "" { + return value, nil + } + return c.globalProxy(ctx) +} + +func (c *Client) httpClient(ctx context.Context, accountID string) (*http.Client, error) { + rawProxy, err := c.effectiveProxy(ctx, accountID) + if err != nil { + return nil, err + } + client := *c.http + transport, err := c.transports.Get(rawProxy) + if err != nil { + return nil, err + } + if transport != nil { + client.Transport = transport + } + return &client, nil +} + +func (c *Client) do(ctx context.Context, accountID, method, rawURL string, body []byte, setHeaders func(http.Header)) ([]byte, int, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, rawURL, reader) + if err != nil { + return nil, 0, err + } + if setHeaders != nil { + setHeaders(req.Header) + } + client, err := c.httpClient(ctx, accountID) + if err != nil { + return nil, 0, err + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, resp.StatusCode, err + } + return payload, resp.StatusCode, nil +} + +// setPlainHeaders carries the desktop identity for unauthenticated gateway +// calls (login, poll, refresh, userinfo, account-context). +func setPlainHeaders(h http.Header, st site, bearer string) { + h.Set("Accept", "application/json, text/plain, */*") + h.Set("Content-Type", "application/json") + h.Set("User-Agent", st.userAgent()) + if bearer != "" { + h.Set("Authorization", "Bearer "+bearer) + } +} + +func applyHeaders(h http.Header, headers map[string]string) { + for key, value := range headers { + h.Set(key, value) + } +} + +// siteFor resolves the site for the account's region. +func (c *Client) siteFor(ctx context.Context, accountID string) (site, error) { + account, err := c.store.Get(ctx, accountID) + if err != nil { + return site{}, err + } + return siteFor(account.ProviderRegion) +} + +// StartLogin builds the device authorization URL. The link is single-use and +// must be opened in a browser already signed in to the target account. +func (c *Client) StartLogin(ctx context.Context, accountID string) (providers.LoginSession, error) { + st, err := c.siteFor(ctx, accountID) + if err != nil { + return providers.LoginSession{}, err + } + verifier, challenge, err := pkcePair() + if err != nil { + return providers.LoginSession{}, err + } + nonce := newID() + machineID := newID() + query := url.Values{ + "challenge": {challenge}, + "challenge_method": {"S256"}, + "nonce": {nonce}, + "machine_id": {machineID}, + "client_id": {st.ClientID}, + "redirect_uri": {st.RedirectURI}, + } + c.mu.Lock() + if c.loginStates == nil { + c.loginStates = map[string]*loginState{} + } + c.loginStates[accountID] = &loginState{ + verifier: verifier, + nonce: nonce, + machineID: machineID, + createdAt: time.Now(), + } + c.mu.Unlock() + return providers.LoginSession{ + AuthURL: st.Gateway + pathDeviceAuth + "?" + query.Encode(), + State: nonce, + }, nil +} + +// PollLogin checks the device token poll endpoint, then completes the +// credential from the grant plus /api/v1/userinfo. +func (c *Client) PollLogin(ctx context.Context, accountID string) (bool, string, error) { + c.mu.Lock() + state := c.loginStates[accountID] + c.mu.Unlock() + if state == nil { + return false, "", fmt.Errorf("login not started for account %s", accountID) + } + if time.Since(state.createdAt) > loginStateTTL { + c.mu.Lock() + delete(c.loginStates, accountID) + c.mu.Unlock() + return false, "", fmt.Errorf("login expired; start again") + } + st, err := c.siteFor(ctx, accountID) + if err != nil { + return false, "", err + } + pollURL := st.Gateway + pathDevicePoll + "?nonce=" + url.QueryEscape(state.nonce) + + "&verifier=" + url.QueryEscape(state.verifier) + "&challenge_method=S256" + body, status, err := c.do(ctx, accountID, http.MethodGet, pollURL, nil, + func(h http.Header) { setPlainHeaders(h, st, "") }) + if err != nil { + return false, "", err + } + if status == http.StatusNotFound || status == http.StatusAccepted { + return false, "waiting for authorization", nil + } + if status >= 300 { + return false, "", fmt.Errorf("poll status=%d: %s", status, truncateBody(body)) + } + var grant grant + if json.Unmarshal(body, &grant) != nil || grant.accessToken() == "" { + return false, "waiting for authorization", nil + } + credential := Credential{ + AccessToken: grant.accessToken(), + RefreshToken: grant.RefreshToken, + ExpiresAt: grant.expiry(time.Now()), + UID: grant.UserID, + Nickname: grant.UserName, + MachineID: state.machineID, + } + if uid, nickname, email := c.fetchUserInfo(ctx, accountID, st, credential.AccessToken); uid != "" || nickname != "" || email != "" { + if uid != "" { + credential.UID = uid + } + if credential.Nickname == "" && nickname != "" { + credential.Nickname = nickname + } + if email != "" { + credential.Email = email + } + } + if credential.UID == "" { + return false, "", fmt.Errorf("login grant missing user identity; try again") + } + payload, err := credential.Encode() + if err != nil { + return false, "", err + } + if err := c.store.SaveCredentialPayload(ctx, accountID, CredentialFormat, payload); err != nil { + return false, "", err + } + _ = c.store.Observe(ctx, accountID, credential.UID, "ready", "", "") + c.mu.Lock() + delete(c.loginStates, accountID) + c.mu.Unlock() + return true, "login complete", nil +} + +// fetchUserInfo fills the profile from /api/v1/userinfo. Failures leave the +// grant identity in place. +func (c *Client) fetchUserInfo(ctx context.Context, accountID string, st site, accessToken string) (uid, nickname, email string) { + body, status, err := c.do(ctx, accountID, http.MethodGet, st.Gateway+pathUserInfo, nil, + func(h http.Header) { setPlainHeaders(h, st, accessToken) }) + if err != nil || status >= 300 { + return "", "", "" + } + var info struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } + if json.Unmarshal(body, &info) != nil { + return "", "", "" + } + return strings.TrimSpace(info.ID), strings.TrimSpace(info.Name), strings.TrimSpace(info.Email) +} + +// credential loads the stored token and refreshes it when less than two +// minutes remain. A failed refresh falls back to the stored credential so a +// flaky refresh endpoint never blocks a possibly-valid token. +func (c *Client) credential(ctx context.Context, accountID string) (Credential, error) { + _, payload, err := c.store.LoadCredentialPayload(ctx, accountID) + if err != nil { + return Credential{}, err + } + credential, err := DecodeCredential(payload) + if err != nil { + return Credential{}, err + } + if credential.ExpiresAt != 0 && time.Now().Add(refreshMargin).Unix() < credential.ExpiresAt { + return credential, nil + } + refreshed, err := c.Refresh(ctx, accountID, credential) + if err != nil { + return credential, nil + } + return refreshed, nil +} + +// resolvedCredential loads the credential and resolves the account's site. +func (c *Client) resolvedCredential(ctx context.Context, accountID string) (Credential, site, error) { + credential, err := c.credential(ctx, accountID) + if err != nil { + return Credential{}, site{}, err + } + st, err := c.siteFor(ctx, accountID) + if err != nil { + return Credential{}, site{}, err + } + return credential, st, nil +} + +// Refresh exchanges the refresh token. The endpoint rotates the refresh +// token, so the response pair replaces the stored one. Session-dead errors +// flip the account to login_required through Observe. +func (c *Client) Refresh(ctx context.Context, accountID string, credential Credential) (Credential, error) { + st, err := c.siteFor(ctx, accountID) + if err != nil { + return credential, err + } + payload, err := json.Marshal(map[string]string{"refresh_token": credential.RefreshToken}) + if err != nil { + return credential, err + } + body, status, err := c.do(ctx, accountID, http.MethodPost, st.Gateway+pathDeviceRefresh, payload, + func(h http.Header) { setPlainHeaders(h, st, "") }) + if err != nil { + return credential, err + } + classified := Classify(status, string(body)) + if classified.Kind == accounts.KindAuth { + _ = c.store.Observe(ctx, accountID, credential.UID, "login_required", "session dead; re-login required", accounts.KindAuth) + return credential, fmt.Errorf("qwenwork session dead: re-login required") + } + if status >= 300 || classified.Kind != "" { + return credential, fmt.Errorf("refresh status=%d kind=%s", status, classified.Kind) + } + var grant grant + if err := json.Unmarshal(body, &grant); err != nil { + return credential, fmt.Errorf("refresh parse: %w", err) + } + if grant.accessToken() == "" || grant.RefreshToken == "" { + return credential, fmt.Errorf("refresh response missing token pair") + } + credential.AccessToken = grant.accessToken() + credential.RefreshToken = grant.RefreshToken + credential.ExpiresAt = grant.expiry(time.Now()) + encoded, err := credential.Encode() + if err != nil { + return credential, err + } + if err := c.store.SaveCredentialPayload(ctx, accountID, CredentialFormat, encoded); err != nil { + return credential, err + } + return credential, nil +} + +// sessionFor caches one COSY envelope per account and rebuilds it whenever +// the access token changes. +func (c *Client) sessionFor(accountID string, credential Credential) (*session, error) { + c.mu.Lock() + cached := c.sessions[accountID] + c.mu.Unlock() + if cached != nil && cached.token == credential.AccessToken { + return cached, nil + } + built, err := newSession(credential) + if err != nil { + return nil, err + } + c.mu.Lock() + if c.sessions == nil { + c.sessions = map[string]*session{} + } + c.sessions[accountID] = built + c.mu.Unlock() + return built, nil +} + +func (c *Client) rememberCatalog(models []providers.ModelInfo) { + catalog := make(map[string]providers.ModelInfo, len(models)) + for _, model := range models { + catalog[model.NativeModel] = model + if model.PublicModel != model.NativeModel { + catalog[model.PublicModel] = model + } + } + c.mu.Lock() + c.catalog = catalog + c.mu.Unlock() +} + +// upstreamModelID resolves the request model against the remembered catalog. +// QwenWork public and native ids are identical, so an unknown id passes +// through unchanged; no alias is invented. +func (c *Client) upstreamModelID(model string) string { + model = strings.TrimSpace(model) + if model == "" || c == nil { + return model + } + canonical := accounts.CanonicalModelID(model) + c.mu.Lock() + defer c.mu.Unlock() + if info, ok := c.catalog[model]; ok { + return info.NativeModel + } + if info, ok := c.catalog[canonical]; ok { + return info.NativeModel + } + return model +} + +// chatRequest builds the signed agent_chat_generation request. +func (c *Client) chatRequest(ctx context.Context, accountID string, credential Credential, st site, req translate.ChatRequest) (*http.Request, string, error) { + modelKey := c.upstreamModelID(req.Model) + if modelKey == "" { + modelKey = st.DefaultModel + } + encoded, err := json.Marshal(buildChatBody(req, modelKey)) + if err != nil { + return nil, "", err + } + sess, err := c.sessionFor(accountID, credential) + if err != nil { + return nil, "", err + } + endpoint := st.Gateway + pathChat + headers, err := sess.sign(st, string(encoded), endpoint, modelKey, "text/event-stream", time.Now()) + if err != nil { + return nil, "", err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(encoded)) + if err != nil { + return nil, "", err + } + applyHeaders(httpReq.Header, headers) + return httpReq, modelKey, nil +} + +func (c *Client) ChatNonStream(ctx context.Context, accountID string, req translate.ChatRequest) (providers.ChatOutcome, error) { + credential, st, err := c.resolvedCredential(ctx, accountID) + if err != nil { + return providers.ChatOutcome{}, err + } + httpReq, model, err := c.chatRequest(ctx, accountID, credential, st, req) + if err != nil { + return providers.ChatOutcome{}, err + } + client, err := c.httpClient(ctx, accountID) + if err != nil { + return providers.ChatOutcome{}, err + } + client.Timeout = 0 + resp, err := client.Do(httpReq) + 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{}, fmt.Errorf("read qwenwork stream: %w", err) + } + if resp.StatusCode >= 300 { + return providers.ChatOutcome{}, classifiedError(resp.StatusCode, raw) + } + return outcomeFromUpstreamSSE(model, raw) +} + +func (c *Client) ChatStream(ctx context.Context, accountID string, req translate.ChatRequest) (*http.Response, providers.ResolvedChat, error) { + credential, st, err := c.resolvedCredential(ctx, accountID) + if err != nil { + return nil, providers.ResolvedChat{}, err + } + httpReq, model, err := c.chatRequest(ctx, accountID, credential, st, req) + if err != nil { + return nil, providers.ResolvedChat{}, err + } + client, err := c.httpClient(ctx, accountID) + if err != nil { + return nil, providers.ResolvedChat{}, err + } + client.Timeout = 0 + resp, err := client.Do(httpReq) + 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{}, classifiedError(resp.StatusCode, raw) + } + guarded, err := ensureMeaningfulUpstream(resp) + if err != nil { + // ensureMeaningfulUpstream closes the body on failure; no client + // bytes have been written yet, so the executor can still fail over. + return nil, providers.ResolvedChat{}, err + } + return rewriteUpstreamSSE(guarded, model), providers.ResolvedChat{}, nil +} + +// Models fetches the live qwork catalog. There is no static fallback list. +func (c *Client) Models(ctx context.Context, accountID string) ([]providers.ModelInfo, error) { + credential, st, err := c.resolvedCredential(ctx, accountID) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, catalogTimeout) + defer cancel() + sess, err := c.sessionFor(accountID, credential) + if err != nil { + return nil, err + } + endpoint := st.Gateway + pathModelList + headers, err := sess.sign(st, "", endpoint, "", "application/json", time.Now()) + if err != nil { + return nil, err + } + body, status, err := c.do(ctx, accountID, http.MethodGet, endpoint, nil, + func(h http.Header) { applyHeaders(h, headers) }) + if err != nil { + return nil, err + } + if status >= 300 { + return nil, fmt.Errorf("models status=%d: %s", status, catalogErrorBody(body)) + } + var parsed struct { + Qwork []modelListEntry `json:"qwork"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("models parse: %s", catalogErrorBody(body)) + } + var out []providers.ModelInfo + for _, entry := range parsed.Qwork { + if entry.Enable != nil && !*entry.Enable { + continue + } + if strings.TrimSpace(entry.Key) == "" { + continue + } + out = append(out, entry.modelInfo()) + } + if len(out) == 0 { + return nil, fmt.Errorf("qwenwork model list returned no models") + } + c.rememberCatalog(out) + return out, nil +} + +// catalogErrorBody keeps model-list errors bounded and free of HTML pages. +func catalogErrorBody(body []byte) string { + text := strings.TrimSpace(string(body)) + if strings.Contains(strings.ToLower(text), " 240 { + return text[:240] + } + return text +} + +// Probe reports whether the stored credential is usable. There is no child +// process; Hot mirrors Ready so the console treats signed-in accounts as +// available. Token death surfaces through the refresh path's login_required +// observation, not through a probe round-trip. +func (c *Client) Probe(ctx context.Context, accountID string) (providers.AccountHealth, error) { + credential, err := c.credential(ctx, accountID) + if err != nil { + return providers.AccountHealth{LastError: err.Error()}, nil + } + if !credential.Ready() { + msg := "qwenwork credential incomplete; re-login required" + return providers.AccountHealth{UID: credential.UID, LastError: msg}, nil + } + return providers.AccountHealth{ + Ready: true, + Hot: true, + UID: credential.UID, + }, nil +} + +// Quota maps the account-context document onto QuotaInfo. +func (c *Client) Quota(ctx context.Context, accountID string) (*providers.QuotaInfo, error) { + credential, st, err := c.resolvedCredential(ctx, accountID) + if err != nil { + return nil, err + } + body, status, err := c.do(ctx, accountID, http.MethodGet, st.Gateway+pathAccountContext, nil, + func(h http.Header) { setPlainHeaders(h, st, credential.AccessToken) }) + if err != nil { + return nil, err + } + if status >= 300 { + return nil, fmt.Errorf("account-context status=%d: %s", status, truncateBody(body)) + } + var env struct { + Code string `json:"code"` + Data struct { + User struct { + Name string `json:"name"` + Email string `json:"email"` + } `json:"user"` + Plan struct { + Name string `json:"name"` + UserType string `json:"user_type"` + IsPersonalVer bool `json:"is_personal_version"` + } `json:"plan"` + Quota struct { + Total *float64 `json:"total"` + Used *float64 `json:"used"` + Remaining *float64 `json:"remaining"` + Exceeded bool `json:"exceeded"` + } `json:"quota"` + } `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil { + return nil, fmt.Errorf("account-context parse: %w", err) + } + if env.Code != "" && env.Code != accountContextOK { + return nil, fmt.Errorf("account-context code=%s", env.Code) + } + quota := env.Data.Quota + if quota.Total == nil && quota.Used == nil && quota.Remaining == nil { + return nil, fmt.Errorf("account-context returned no quota") + } + used, total, remaining := numberOrZero(quota.Used), numberOrZero(quota.Total), numberOrZero(quota.Remaining) + if total <= 0 && remaining > 0 { + total = remaining + } + if used <= 0 && total > remaining { + used = total - remaining + } + percentage := 0.0 + if total > 0 { + percentage = used / total * 100 + if percentage < 0 { + percentage = 0 + } + if percentage > 100 { + percentage = 100 + } + } + return &providers.QuotaInfo{ + Used: used, + Total: total, + Remaining: remaining, + Percentage: percentage, + Unit: "credits", + Exceeded: quota.Exceeded || (total > 0 && remaining <= 0), + FetchedAt: time.Now().UTC().Format(time.RFC3339), + ProviderID: "qwenwork", + Plan: env.Data.Plan.Name, + }, nil +} + +func numberOrZero(value *float64) float64 { + if value == nil { + return 0 + } + return *value +} + +// Adapter returns the provider capability bundle for registration. +func (c *Client) Adapter() providers.Adapter { + return providers.Adapter{ + ID: "qwenwork", + Credential: credentialCodec{}, + Login: c, + Chat: c, + Models: c, + Classifier: classifier{}, + Prober: c, + } +} + +type credentialCodec struct{} + +func (credentialCodec) Validate(payload []byte) error { return ValidateCredential(payload) } + +type classifier struct{} + +func (classifier) Classify(status int, body string) providers.ClassifiedError { + return Classify(status, body) +} diff --git a/internal/providers/qwenwork/client_test.go b/internal/providers/qwenwork/client_test.go new file mode 100644 index 0000000..516a400 --- /dev/null +++ b/internal/providers/qwenwork/client_test.go @@ -0,0 +1,881 @@ +package qwenwork + +import ( + "context" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/caigee-cmd/cli2api/internal/accounts" + "github.com/caigee-cmd/cli2api/internal/providers" + "github.com/caigee-cmd/cli2api/internal/translate" +) + +var ( + _ providers.LoginSessionProvider = (*Client)(nil) + _ providers.ProviderChat = (*Client)(nil) + _ providers.ModelCatalogProvider = (*Client)(nil) + _ providers.AccountProber = (*Client)(nil) + _ providers.CredentialCodec = credentialCodec{} + _ providers.CredentialImporter = credentialCodec{} +) + +type memStore struct { + items map[string][]byte + region string + observed []string + lastStatus string + lastKind string +} + +func (s *memStore) Get(ctx context.Context, id string) (accounts.Account, error) { + region := s.region + if region == "" { + region = "global" + } + return accounts.Account{ID: id, Provider: "qwenwork", ProviderRegion: region}, nil +} +func (s *memStore) LoadCredentialPayload(ctx context.Context, accountID string) (string, []byte, error) { + payload, ok := s.items[accountID] + if !ok { + return "", nil, accounts.ErrAccountNotFound + } + return CredentialFormat, payload, nil +} +func (s *memStore) SaveCredentialPayload(ctx context.Context, accountID, format string, payload []byte) error { + if s.items == nil { + s.items = map[string][]byte{} + } + s.items[accountID] = payload + return nil +} +func (s *memStore) Observe(ctx context.Context, id, remoteUID, status, lastError, lastKind string) error { + s.observed = append(s.observed, id) + s.lastStatus = status + s.lastKind = lastKind + return nil +} + +type rewriteTransport struct { + server string + round http.RoundTripper +} + +func (t rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + req.URL.Host = strings.TrimPrefix(t.server, "http://") + if t.round == nil { + return http.DefaultTransport.RoundTrip(req) + } + return t.round.RoundTrip(req) +} + +func newTestClient(t *testing.T, store *memStore, handler http.Handler) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client := NewClient(store) + client.http = server.Client() + client.http.Transport = rewriteTransport{server: server.URL, round: server.Client().Transport} + return client +} + +// wrapChunk renders one QwenWork wrapper frame around an OpenAI chunk. +func wrapChunk(chunk string, status int) string { + encoded, _ := json.Marshal(chunk) + var frame strings.Builder + frame.WriteString("data:{\"headers\":{},\"body\":") + frame.Write(encoded) + frame.WriteString(",\"statusCodeValue\":") + frame.WriteString(jsonInt(status)) + frame.WriteString("}\n\n") + return frame.String() +} + +func jsonInt(n int) string { + encoded, _ := json.Marshal(n) + return string(encoded) +} + +var chatSSE = wrapChunk(`{"choices":[{"delta":{"role":"assistant","content":"O"}}]}`, 200) + + wrapChunk(`{"choices":[{"delta":{"content":"K","reasoning_content":"think","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_time","arguments":"{}"}}]}}]}`, 200) + + wrapChunk(`{"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}`, 200) + + "data: [DONE]\n\n" + +func readyCredential() Credential { + return Credential{ + AccessToken: "at", + RefreshToken: "rt", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + UID: "u1", + Nickname: "Tester", + Email: "t@example.com", + MachineID: "11111111-2222-4333-8444-555555555555", + } +} + +func TestDecodeCredentialAcceptsCanonicalAndCamelCase(t *testing.T) { + canonical, err := DecodeCredential([]byte(`{"access_token":"at","refresh_token":"rt","expires_at":123,"uid":"u1"}`)) + if err != nil || canonical.AccessToken != "at" || canonical.UID != "u1" { + t.Fatalf("canonical=%+v err=%v", canonical, err) + } + camel, err := DecodeCredential([]byte(`{"accessToken":"at2","refreshToken":"rt2","expiresAt":456,"uid":"u2","nickname":"n","email":"e"}`)) + if err != nil || camel.AccessToken != "at2" || camel.RefreshToken != "rt2" || camel.ExpiresAt != 456 { + t.Fatalf("camel=%+v err=%v", camel, err) + } + if _, err := DecodeCredential([]byte(`{"refresh_token":"rt"}`)); err == nil { + t.Fatal("missing access_token must be rejected") + } +} + +func TestPrepareImportAcceptsTokenPairAndFillsMachineID(t *testing.T) { + // The qwenwork2api account export shape. + imported, err := credentialCodec{}.PrepareImport([]byte(`{"accessToken":"at","refreshToken":"rt","uid":"u1"}`)) + if err != nil { + t.Fatal(err) + } + if !imported.Ready { + t.Fatal("token pair plus uid must import ready") + } + credential, err := DecodeCredential(imported.Payload) + if err != nil { + t.Fatal(err) + } + if credential.MachineID == "" { + t.Fatal("import must fill machine_id when absent") + } + disabled, err := credentialCodec{}.PrepareImport([]byte(`{"access_token":"at","refresh_token":"rt"}`)) + if err != nil { + t.Fatal(err) + } + if disabled.Ready { + t.Fatal("missing uid imports not-ready") + } +} + +func TestLoginAuthURLCarriesSiteParams(t *testing.T) { + tests := []struct { + region string + gateway string + clientID string + redirectURI string + }{ + {region: "global", gateway: "https://gateway.qwenwork.ai", clientID: "cc65e5fc-05bd-4f5d-a4e8-0df19aa3d75a", redirectURI: "qwenwork://"}, + {region: "cn", gateway: "https://gateway.qwenwork.cn", clientID: "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb", redirectURI: "qwenwork-cn://"}, + } + for _, test := range tests { + t.Run(test.region, func(t *testing.T) { + client := NewClient(&memStore{region: test.region}) + session, err := client.StartLogin(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(session.AuthURL, test.gateway+"/device/selectAccounts?") { + t.Fatalf("authURL=%s", session.AuthURL) + } + for _, want := range []string{ + "challenge=", "challenge_method=S256", "nonce=", + "machine_id=", "client_id=" + test.clientID, + } { + if !strings.Contains(session.AuthURL, want) { + t.Fatalf("authURL missing %s: %s", want, session.AuthURL) + } + } + if !strings.Contains(session.AuthURL, "redirect_uri="+url.QueryEscape(test.redirectURI)) { + t.Fatalf("authURL missing redirect_uri: %s", session.AuthURL) + } + if session.State == "" { + t.Fatal("state (nonce) must be returned for polling") + } + }) + } +} + +func TestLoginUnknownRegionErrors(t *testing.T) { + client := NewClient(&memStore{region: "mars"}) + if _, err := client.StartLogin(context.Background(), "acc1"); err == nil || !strings.Contains(err.Error(), "unknown region") { + t.Fatalf("err=%v", err) + } +} + +func TestLoginPollPendingThenGrant(t *testing.T) { + var polls int32 + client := newTestClient(t, &memStore{region: "global"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case pathDevicePoll: + atomic.AddInt32(&polls, 1) + for _, want := range []string{"nonce", "verifier", "challenge_method"} { + if r.URL.Query().Get(want) == "" { + t.Errorf("poll query missing %s: %s", want, r.URL.RawQuery) + } + } + if r.URL.Query().Get("challenge_method") != "S256" { + t.Errorf("challenge_method=%s", r.URL.Query().Get("challenge_method")) + } + if r.Header.Get("User-Agent") != "qoderwork/1.0.4" { + t.Errorf("poll UA=%s", r.Header.Get("User-Agent")) + } + if atomic.LoadInt32(&polls) == 1 { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": "at", "refresh_token": "rt", "expires_in": 3600, + "user_id": "u1", "user_name": "Tester", + }) + case pathUserInfo: + if r.Header.Get("Authorization") != "Bearer at" { + t.Errorf("userinfo auth=%s", r.Header.Get("Authorization")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": "u1", "name": "Tester", "email": "t@example.com"}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + if _, err := client.StartLogin(context.Background(), "acc1"); err != nil { + t.Fatal(err) + } + done, message, err := client.PollLogin(context.Background(), "acc1") + if err != nil || done || message == "" { + t.Fatalf("pending done=%v message=%q err=%v", done, message, err) + } + done, message, err = client.PollLogin(context.Background(), "acc1") + if err != nil || !done { + t.Fatalf("done=%v message=%q err=%v", done, message, err) + } + store := client.store.(*memStore) + if store.lastStatus != "ready" || len(store.observed) != 1 { + t.Fatalf("observe status=%s calls=%v", store.lastStatus, store.observed) + } + _, payload, err := store.LoadCredentialPayload(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + credential, err := DecodeCredential(payload) + if err != nil { + t.Fatal(err) + } + if credential.AccessToken != "at" || credential.RefreshToken != "rt" || credential.UID != "u1" || + credential.Email != "t@example.com" || !credential.Ready() { + t.Fatalf("credential=%+v", credential) + } + if credential.ExpiresAt <= time.Now().Unix() { + t.Fatalf("expires_at=%d", credential.ExpiresAt) + } + if _, err := validateUUID(credential.MachineID); err != nil { + t.Fatalf("machine id: %v", err) + } +} + +func validateUUID(id string) (string, error) { + if len(id) != 36 || strings.Count(id, "-") != 4 { + return "", errFake{message: "not a uuid: " + id} + } + return id, nil +} + +type errFake struct{ message string } + +func (e errFake) Error() string { return e.message } + +func TestRefreshRotatesTokenPairAndSaves(t *testing.T) { + expiring := readyCredential() + expiring.ExpiresAt = time.Now().Add(time.Minute).Unix() + payload, _ := expiring.Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != pathDeviceRefresh { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Fatalf("method=%s", r.Method) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["refresh_token"] != "rt" { + t.Fatalf("refresh body=%v err=%v", body, err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"token": "at2", "refresh_token": "rt2", "expires_in": 7200}) + })) + // Lazy path: near-expiry credential refreshes before use. + credential, err := client.credential(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if credential.AccessToken != "at2" || credential.RefreshToken != "rt2" { + t.Fatalf("refreshed=%+v", credential) + } + _, saved, err := store.LoadCredentialPayload(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + savedCredential, err := DecodeCredential(saved) + if err != nil { + t.Fatal(err) + } + if savedCredential.RefreshToken != "rt2" || savedCredential.AccessToken != "at2" { + t.Fatalf("saved=%+v", savedCredential) + } +} + +func TestRefreshSessionDeadObservesLoginRequired(t *testing.T) { + credential := readyCredential() + payload, _ := credential.Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"code":"401","message":"token expired"}`)) + })) + if _, err := client.Refresh(context.Background(), "acc1", credential); err == nil { + t.Fatal("expected refresh error") + } + if store.lastStatus != "login_required" || store.lastKind != accounts.KindAuth { + t.Fatalf("observe status=%s kind=%s", store.lastStatus, store.lastKind) + } +} + +func chatHandlerFor(t *testing.T, sse string, verify func(r *http.Request, body []byte)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != pathChatPath { + t.Fatalf("unexpected path %s", r.URL.Path) + } + body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20)) + if err != nil { + t.Fatalf("read body: %v", err) + } + if verify != nil { + verify(r, body) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, sse) + }) +} + +// verifyCosySignature recomputes md5(payload\ncosyKey\ndate\nbody\npath) from +// the request headers and confirms the signed path drops /algo. +func verifyCosySignature(t *testing.T, r *http.Request, body []byte, wantPath string) { + t.Helper() + authorization := r.Header.Get("Authorization") + parts := strings.Split(authorization, " ") + if len(parts) != 2 || parts[0] != "Bearer" || !strings.HasPrefix(parts[1], "COSY.") { + t.Fatalf("authorization=%s", authorization) + } + segments := strings.Split(strings.TrimPrefix(parts[1], "COSY."), ".") + if len(segments) != 2 { + t.Fatalf("authorization=%s", authorization) + } + payloadB64, signature := segments[0], segments[1] + cosyKey := r.Header.Get("Cosy-Key") + date := r.Header.Get("Cosy-Date") + if cosyKey == "" || date == "" { + t.Fatalf("missing cosy headers: %+v", r.Header) + } + stripped := md5.Sum([]byte(payloadB64 + "\n" + cosyKey + "\n" + date + "\n" + string(body) + "\n" + wantPath)) + if signature != hex.EncodeToString(stripped[:]) { + t.Fatalf("signature mismatch: got %s", signature) + } + // The /algo-prefixed path must NOT verify: the gateway signs without it. + fullPath := "/algo" + wantPath + prefixed := md5.Sum([]byte(payloadB64 + "\n" + cosyKey + "\n" + date + "\n" + string(body) + "\n" + fullPath)) + if signature == hex.EncodeToString(prefixed[:]) { + t.Fatal("signature must not verify against the /algo-prefixed path") + } + // The payload itself carries the desktop identity fields. + decoded, err := base64.StdEncoding.DecodeString(payloadB64) + if err != nil { + t.Fatalf("payload b64: %v", err) + } + var payload struct { + CosyVersion string `json:"cosyVersion"` + IdeVersion string `json:"ideVersion"` + Info string `json:"info"` + RequestID string `json:"requestId"` + Version string `json:"version"` + } + if err := json.Unmarshal(decoded, &payload); err != nil { + t.Fatalf("payload json: %v", err) + } + if payload.CosyVersion != "1.1.18" || payload.Version != "v1" || payload.RequestID == "" || payload.Info == "" { + t.Fatalf("payload=%+v", payload) + } +} + +func TestChatSendsSignedRequestWithQwenWorkHeaders(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, chatHandlerFor(t, chatSSE, func(r *http.Request, body []byte) { + verifyCosySignature(t, r, body, "/api/v2/service/pro/sse/agent_chat_generation") + for header, want := range map[string]string{ + "User-Agent": "qoderwork/1.0.4", + "X-Qwenwork-Version": "1.0.4", + "X-Qwenwork-Release-Version": "1.0.4-26090501", + "X-Qwenwork-Build": "26090501", + "X-Qwenwork-Platform": "win32", + "X-Qwenwork-Arch": "x64", + "X-Qwenwork-Channel": "stable", + "Cosy-Business-Product": "qoder_work", + "Cosy-Business-Type": "agent", + "Cosy-Scene": "qwork", + "Cosy-ClientType": "6", + "Cosy-MachineOS": "x86_64_win32", + "Login-Version": "v2", + "Cosy-User": "u1", + "X-Model-Key": "qwork-advanced", + "X-Model-Source": "system", + } { + if got := r.Header.Get(header); got != want { + t.Errorf("%s=%q want %q", header, got, want) + } + } + var sent map[string]any + if err := json.Unmarshal(body, &sent); err != nil { + t.Fatalf("body: %v", err) + } + if sent["session_type"] != businessProduct || sent["source"] != float64(1) || sent["version"] != "3" { + t.Fatalf("body=%v", sent) + } + })) + out, err := client.ChatNonStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "qwork-advanced", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatal(err) + } + if out.Content != "OK" { + t.Fatalf("content=%q", out.Content) + } +} + +func TestChatHeadersFollowRegion(t *testing.T) { + tests := []struct { + region string + userAgent string + version string + releaseVersion string + build string + defaultModel string + }{ + {region: "global", userAgent: "qoderwork/1.0.4", version: "1.0.4", releaseVersion: "1.0.4-26090501", build: "26090501", defaultModel: "qwork-advanced"}, + {region: "cn", userAgent: "qoderwork/1.0.5", version: "1.0.5", releaseVersion: "1.0.5-26090901", build: "26090901", defaultModel: "pro"}, + } + for _, test := range tests { + t.Run(test.region, func(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: test.region, items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, chatHandlerFor(t, chatSSE, func(r *http.Request, body []byte) { + for header, want := range map[string]string{ + "User-Agent": test.userAgent, + "X-Qwenwork-Version": test.version, + "X-Qwenwork-Release-Version": test.releaseVersion, + "X-Qwenwork-Build": test.build, + } { + if got := r.Header.Get(header); got != want { + t.Errorf("%s=%q want %q", header, got, want) + } + } + var sent map[string]any + _ = json.Unmarshal(body, &sent) + model, _ := sent["model_config"].(map[string]any) + if model["key"] != test.defaultModel { + t.Errorf("empty request model must fall back to %s, got %v", test.defaultModel, model["key"]) + } + })) + // No model in the request: the region default is sent. + if _, err := client.ChatNonStream(context.Background(), "acc1", translate.ChatRequest{ + Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + }); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestChatStreamRewritesWrappedFrames(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, chatHandlerFor(t, chatSSE, nil)) + resp, _, err := client.ChatStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "qwork-advanced", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "text/event-stream" { + t.Fatalf("resp status=%d type=%s", resp.StatusCode, resp.Header.Get("Content-Type")) + } + body, _ := io.ReadAll(resp.Body) + text := string(body) + for _, want := range []string{ + `"id":"chatcmpl-qwenwork"`, `"content":"O"`, `"content":"K"`, + `"reasoning_content":"think"`, `"finish_reason":"tool_calls"`, + `"prompt_tokens":3`, "data: [DONE]", + } { + if !strings.Contains(text, want) { + t.Fatalf("stream missing %s:\n%s", want, text) + } + } + if strings.Contains(text, "statusCodeValue") || strings.Contains(text, `"headers":{}`) { + t.Fatalf("wrapper frame leaked: %s", text) + } +} + +func TestChatNonStreamAggregatesContentToolsReasoningUsage(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, chatHandlerFor(t, chatSSE, nil)) + out, err := client.ChatNonStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "qwork-advanced", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + }) + if err != nil { + t.Fatal(err) + } + if out.Content != "OK" || out.Reasoning != "think" || out.FinishReason != "tool_calls" { + t.Fatalf("outcome=%+v", out) + } + if !strings.Contains(string(out.ToolCalls), "get_time") { + t.Fatalf("tool calls=%s", out.ToolCalls) + } + if out.PromptTokens != 3 || out.CompletionTokens != 2 || out.UsageSource != "upstream" { + t.Fatalf("usage=%+v", out) + } + if out.Model != "qwork-advanced" { + t.Fatalf("model=%s", out.Model) + } +} + +func TestChatNonStreamClassifiesErrorFrames(t *testing.T) { + tests := []struct { + name string + sse string + kind string + status int + }{ + { + name: "quota code 14018", + sse: wrapChunk(`{"code":14018,"message":"insufficient quota"}`, 402), + kind: accounts.KindQuota, + status: 402, + }, + { + name: "model not available", + sse: wrapChunk(`Model is not available for this user`, 400), + kind: accounts.KindModelNotAvailable, + status: 400, + }, + { + name: "auth code in body", + sse: wrapChunk(`{"code":"401","message":"token expired"}`, 200), + kind: accounts.KindAuth, + status: 401, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, chatHandlerFor(t, test.sse, nil)) + _, err := client.ChatNonStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "qwork-advanced", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + }) + providerErr, ok := err.(*providers.Error) + if !ok { + t.Fatalf("err=%v type=%T", err, err) + } + if providerErr.Kind != test.kind { + t.Fatalf("kind=%s want=%s", providerErr.Kind, test.kind) + } + if providerErr.Status != test.status { + t.Fatalf("status=%d want=%d", providerErr.Status, test.status) + } + }) + } +} + +func TestChatHTTPStatusErrorsAreClassified(t *testing.T) { + tests := []struct { + status int + body string + kind string + }{ + {http.StatusUnauthorized, `{"code":"401","message":"token expired"}`, accounts.KindAuth}, + {http.StatusPaymentRequired, `{"code":14018,"message":"quota exceeded"}`, accounts.KindQuota}, + {http.StatusTooManyRequests, "too many requests", accounts.KindRateLimit}, + {http.StatusBadRequest, "bad request", accounts.KindInvalidRequest}, + {http.StatusInternalServerError, "boom", accounts.KindUnavailable}, + {http.StatusNotFound, "", accounts.KindUnavailable}, + } + for _, test := range tests { + t.Run(test.body+jsonInt(test.status), func(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(test.status) + _, _ = w.Write([]byte(test.body)) + }) + client := newTestClient(t, store, handler) + _, _, err := client.ChatStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "qwork-advanced", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + }) + providerErr, ok := err.(*providers.Error) + if !ok { + t.Fatalf("err=%v type=%T", err, err) + } + if providerErr.Kind != test.kind { + t.Fatalf("kind=%s want=%s (status=%d body=%s)", providerErr.Kind, test.kind, test.status, test.body) + } + }) + } +} + +func TestClassifyKnownErrorTexts(t *testing.T) { + cases := []struct { + status int + body string + kind string + }{ + {401, `{"code":"401","message":"token expired"}`, accounts.KindAuth}, + {402, `{"code":14018,"message":"quota exceeded"}`, accounts.KindQuota}, + {200, `{"code":14018,"message":"quota exceeded"}`, accounts.KindQuota}, + {429, "too many requests", accounts.KindRateLimit}, + {400, "Model is not available for this user", accounts.KindModelNotAvailable}, + {400, `{"message":"invalid messages"}`, accounts.KindInvalidRequest}, + {413, "payload too large", accounts.KindInvalidRequest}, + {500, "boom", accounts.KindUnavailable}, + {404, "", accounts.KindUnavailable}, + {400, `{"code":"401","message":"token expired"}`, accounts.KindAuth}, + } + for _, c := range cases { + got := Classify(c.status, c.body) + if got.Kind != c.kind { + t.Fatalf("Classify(%d,%s)=%+v want %s", c.status, c.body, got, c.kind) + } + } +} + +func TestModelsParsesQworkKey(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != pathModelList { + t.Fatalf("path=%s", r.URL.Path) + } + if r.Header.Get("X-Qwenwork-Version") != "1.0.4" { + t.Fatalf("models must carry site headers, got %+v", r.Header) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"qwork": []map[string]any{ + { + "key": "qwork-advanced", "display_name": "Advanced", "is_vl": true, + "is_reasoning": false, "max_input_tokens": 180000, "max_output_tokens": 32768, + "credits": "x1.0", + }, + {"key": "hidden", "enable": false}, + {"key": "qwork-auto", "display_name": "Auto", "enable": true}, + {"display_name": "no key"}, + }}) + })) + models, err := client.Models(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if len(models) != 2 { + t.Fatalf("models=%+v", models) + } + advanced := models[0] + if advanced.NativeModel != "qwork-advanced" || advanced.PublicModel != "qwork-advanced" || + advanced.DisplayName != "Advanced" || advanced.Scene != scene || + !advanced.Capabilities.Images || advanced.Capabilities.Reasoning || + advanced.Capabilities.ContextWindow != 180000 || advanced.Capabilities.MaxOutput != 32768 { + t.Fatalf("model=%+v", advanced) + } + // Catalog memory resolves the model for later chat requests. + if got := client.upstreamModelID("qwork-auto"); got != "qwork-auto" { + t.Fatalf("catalog rewrite=%s", got) + } +} + +func TestQuotaMapsAccountContext(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != pathAccountContextPath { + t.Fatalf("path=%s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer at" { + t.Fatalf("auth=%s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "ok", + "data": map[string]any{ + "user": map[string]any{"name": "Tester", "email": "t@example.com"}, + "plan": map[string]any{"name": "Pro", "user_type": "personal", "is_personal_version": true}, + "quota": map[string]any{"total": 100, "used": 25, "remaining": 75, "exceeded": false}, + }, + }) + })) + quota, err := client.Quota(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if quota.ProviderID != "qwenwork" || quota.Used != 25 || quota.Total != 100 || + quota.Remaining != 75 || quota.Percentage != 25 || quota.Plan != "Pro" || quota.Exceeded { + t.Fatalf("quota=%+v", quota) + } + if quota.FetchedAt == "" { + t.Fatal("fetched_at missing") + } +} + +func TestQuotaAbsentFieldsError(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"code": "ok", "data": map[string]any{}}) + })) + if _, err := client.Quota(context.Background(), "acc1"); err == nil { + t.Fatal("missing quota must be an error, not fake zeros") + } +} + +func TestProbeReportsCredentialReadiness(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := NewClient(store) + health, err := client.Probe(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if !health.Ready || !health.Hot || health.UID != "u1" { + t.Fatalf("health=%+v", health) + } + missing, _ := Credential{AccessToken: "at"}.Encode() + store.items = map[string][]byte{"acc2": missing} + health, err = client.Probe(context.Background(), "acc2") + if err != nil { + t.Fatal(err) + } + if health.Ready || health.LastError == "" { + t.Fatalf("health=%+v", health) + } +} + +func TestGrantExpiryParsing(t *testing.T) { + now := time.Now() + tests := []struct { + name string + raw string + check func(t *testing.T, got int64) + }{ + {"rfc3339", `{"token":"t","expires_at":"2026-01-02T03:04:05Z"}`, func(t *testing.T, got int64) { + if got != time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).Unix() { + t.Fatalf("got=%d", got) + } + }}, + {"unix seconds", `{"token":"t","expires_at":1800000000}`, func(t *testing.T, got int64) { + if got != 1800000000 { + t.Fatalf("got=%d", got) + } + }}, + {"unix millis", `{"token":"t","expires_at":1800000000000}`, func(t *testing.T, got int64) { + if got != 1800000000 { + t.Fatalf("got=%d", got) + } + }}, + {"expires_in seconds", `{"token":"t","expires_in":3600}`, func(t *testing.T, got int64) { + if got != now.Add(time.Hour).Unix() { + t.Fatalf("got=%d", got) + } + }}, + {"default lifetime", `{"token":"t"}`, func(t *testing.T, got int64) { + if got != now.Add(defaultTokenLifetime).Unix() { + t.Fatalf("got=%d", got) + } + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var grant grant + if err := json.Unmarshal([]byte(test.raw), &grant); err != nil { + t.Fatal(err) + } + test.check(t, grant.expiry(now)) + }) + } +} + +func TestChatStreamEmptyUpstreamFailsOver(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + })) + _, _, err := client.ChatStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "qwork-advanced", Messages: []translate.ChatMessage{{Role: "user", Content: "hi"}}, + }) + providerErr, ok := err.(*providers.Error) + if !ok { + t.Fatalf("err=%v type=%T", err, err) + } + if providerErr.Kind != accounts.KindUnavailable || providerErr.Code != "upstream_empty" { + t.Fatalf("err=%+v", providerErr) + } +} + +func TestChatRequestSendsSystemAndMessageHistory(t *testing.T) { + payload, _ := readyCredential().Encode() + store := &memStore{region: "global", items: map[string][]byte{"acc1": payload}} + client := newTestClient(t, store, chatHandlerFor(t, chatSSE, func(r *http.Request, body []byte) { + var sent map[string]any + if err := json.Unmarshal(body, &sent); err != nil { + t.Fatalf("body: %v", err) + } + if sent["system"] != "be brief" { + t.Fatalf("system=%v", sent["system"]) + } + messages, _ := sent["messages"].([]any) + if len(messages) != 2 { + t.Fatalf("messages=%v", messages) + } + first, _ := messages[0].(map[string]any) + second, _ := messages[1].(map[string]any) + if first["role"] != "user" || second["role"] != "assistant" { + t.Fatalf("roles=%v %v", first["role"], second["role"]) + } + })) + _, err := client.ChatNonStream(context.Background(), "acc1", translate.ChatRequest{ + Model: "qwork-advanced", + Messages: []translate.ChatMessage{{Role: "system", Content: "be brief"}, {Role: "user", Content: "hi"}, {Role: "assistant", Content: "hello"}}, + }) + if err != nil { + t.Fatal(err) + } +} + +func TestAdapterBundle(t *testing.T) { + adapter := NewClient(&memStore{}).Adapter() + if adapter.ID != "qwenwork" { + t.Fatalf("id=%s", adapter.ID) + } + for _, capability := range []string{"credential", "login", "chat", "models", "classifier", "prober"} { + if !adapter.Supports(capability) { + t.Fatalf("adapter missing %s", capability) + } + } + if adapter.Supports("checkin") { + t.Fatal("qwenwork has no check-in") + } +} diff --git a/internal/providers/qwenwork/cosy.go b/internal/providers/qwenwork/cosy.go new file mode 100644 index 0000000..de4e78d --- /dev/null +++ b/internal/providers/qwenwork/cosy.go @@ -0,0 +1,249 @@ +package qwenwork + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "net/url" + "strings" + "time" +) + +// cosyServerPubKeyPEM is the COSY envelope key shipped in the QwenWork desktop +// bundle. It is the same key Qoder uses; the copy keeps this package free of +// any dependency on internal/providers/qoder. +const cosyServerPubKeyPEM = `-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc +4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l +6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17 +XcW+ML9FoCI6AOvOzwIDAQAB +-----END PUBLIC KEY-----` + +// session is one account's COSY envelope: a random temp key wrapped with the +// server RSA key, and the account identity AES-encrypted under that temp key. +// It is reused until the access token changes. +type session struct { + token string + uid string + machineID string + cosyKey string + info string +} + +func newSession(credential Credential) (*session, error) { + token := strings.TrimSpace(credential.AccessToken) + uid := strings.TrimSpace(credential.UID) + if token == "" { + return nil, fmt.Errorf("qwenwork session requires an access token") + } + tempKey, err := randomTempKey() + if err != nil { + return nil, err + } + wrapped, err := rsaEncryptPKCS1(tempKey) + if err != nil { + return nil, err + } + identity, err := sortedCompact([][2]string{ + {"aid", uid}, + {"email", strings.TrimSpace(credential.Email)}, + {"name", strings.TrimSpace(credential.Nickname)}, + {"security_oauth_token", token}, + {"uid", uid}, + }) + if err != nil { + return nil, err + } + enc, err := aesCBCEncrypt(identity, tempKey, tempKey) + if err != nil { + return nil, err + } + machineID := strings.TrimSpace(credential.MachineID) + if machineID == "" { + machineID = deriveID(uid, "machine") + } + return &session{ + token: token, + uid: uid, + machineID: machineID, + cosyKey: base64.StdEncoding.EncodeToString(wrapped), + info: base64.StdEncoding.EncodeToString(enc), + }, nil +} + +// sign returns the full desktop header set for one request. body is the exact +// bytes sent; modelKey is empty for non-chat calls. +func (s *session) sign(st site, body, rawURL, modelKey, accept string, now time.Time) (map[string]string, error) { + if s == nil { + return nil, fmt.Errorf("qwenwork session is nil") + } + requestID, err := randomUUID() + if err != nil { + return nil, err + } + payloadJSON, err := sortedCompact([][2]string{ + {"cosyVersion", cosyVersion}, + {"ideVersion", st.AppVersion}, + {"info", s.info}, + {"requestId", requestID}, + {"version", "v1"}, + }) + if err != nil { + return nil, err + } + payload := base64.StdEncoding.EncodeToString(payloadJSON) + date := fmt.Sprintf("%d", now.Unix()) + sum := md5.Sum([]byte(payload + "\n" + s.cosyKey + "\n" + date + "\n" + body + "\n" + cosySignPath(rawURL))) + xRequestID, err := randomUUID() + if err != nil { + return nil, err + } + headers := map[string]string{ + "Accept": accept, + "Content-Type": "application/json", + "User-Agent": st.userAgent(), + "X-Request-Id": xRequestID, + "X-Qwenwork-Version": st.AppVersion, + "X-Qwenwork-Release-Version": st.releaseVersion(), + "X-Qwenwork-Build": st.Build, + "X-Qwenwork-Platform": "win32", + "X-Qwenwork-Arch": "x64", + "X-Qwenwork-Channel": "stable", + "Cosy-Version": cosyVersion, + "Cosy-ClientType": clientType, + "Cosy-Business-Product": businessProduct, + "Cosy-Business-Type": businessType, + "Cosy-Scene": scene, + "Cosy-MachineOS": machineOS, + "Login-Version": "v2", + "Authorization": "Bearer COSY." + payload + "." + hex.EncodeToString(sum[:]), + "Cosy-Key": s.cosyKey, + "Cosy-User": s.uid, + "Cosy-Date": date, + "Cosy-MachineId": s.machineID, + "Accept-Encoding": "identity", + "Cache-Control": "no-cache", + } + if modelKey != "" { + headers["X-Model-Key"] = modelKey + headers["X-Model-Source"] = "system" + } + return headers, nil +} + +// cosySignPath is the signed path: the URL path without query and without the +// /algo gateway prefix. +func cosySignPath(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Path == "" { + return "/" + } + path := strings.TrimPrefix(parsed.Path, "/algo") + if path == "" { + return "/" + } + return path +} + +// sortedCompact renders fields, already in key order, as compact JSON. +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 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 newID() string { + id, err := randomUUID() + if err != nil { + return "00000000-0000-4000-8000-000000000000" + } + return id +} + +func rsaEncryptPKCS1(plain []byte) ([]byte, error) { + block, _ := pem.Decode([]byte(cosyServerPubKeyPEM)) + if block == nil { + return nil, fmt.Errorf("decode qwenwork 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("qwenwork 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 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 +} diff --git a/internal/providers/qwenwork/credential.go b/internal/providers/qwenwork/credential.go new file mode 100644 index 0000000..b4903c4 --- /dev/null +++ b/internal/providers/qwenwork/credential.go @@ -0,0 +1,182 @@ +// Package qwenwork implements the QwenWork (千问办公) in-process provider. +// QwenWork is a rebranded QoderWork: device-flow login plus COSY-signed +// agent_chat_generation, sharing Qoder's RSA key but not its CLI, client type, +// or business fields. Protocol constants live only in this package. +package qwenwork + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +const ( + CredentialFormat = "qwenwork-oauth-v1" + + pathDeviceAuth = "/device/selectAccounts" + pathDevicePoll = "/api/v1/deviceToken/poll" + pathDeviceRefresh = "/api/v1/deviceToken/refresh" + pathUserInfo = "/api/v1/userinfo" + pathAccountContext = pathAccountContextPath + "?include=user,plan,quota" + pathModelList = "/algo/api/v2/model/list" + // Plain JSON body: unlike Qoder's CLI route, QwenWork does not use Encode=1. + pathChat = pathChatPath + "?FetchKeys=llm_model_result&AgentId=agent_common" + + // Path-only constants for handlers that match r.URL.Path. + pathChatPath = "/algo/api/v2/service/pro/sse/agent_chat_generation" + pathAccountContextPath = "/api/v1/adapter/user/account-context" + + cosyVersion = "1.1.18" + clientType = "6" + businessProduct = "qoder_work" + businessType = "agent" + scene = "qwork" + machineOS = "x86_64_win32" + + // defaultTokenLifetime applies when a grant carries no expiry. + defaultTokenLifetime = 30 * 24 * time.Hour +) + +// site is the per-region desktop identity. Both regions share the device flow, +// COSY key, paths, and scene; only the gateway, OAuth client, app version, +// and default model differ. Values come from the QwenWork desktop bundles +// (intl 1.0.4, CN 1.0.5). +type site struct { + Gateway string + ClientID string + RedirectURI string + AppVersion string + Build string + DefaultModel string +} + +var sites = map[string]site{ + "global": { + Gateway: "https://gateway.qwenwork.ai", + ClientID: "cc65e5fc-05bd-4f5d-a4e8-0df19aa3d75a", + RedirectURI: "qwenwork://", + AppVersion: "1.0.4", + Build: "26090501", + DefaultModel: "qwork-advanced", + }, + "cn": { + Gateway: "https://gateway.qwenwork.cn", + ClientID: "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb", + RedirectURI: "qwenwork-cn://", + AppVersion: "1.0.5", + Build: "26090901", + DefaultModel: "pro", + }, +} + +// siteFor resolves a provider region. Empty means the descriptor default. +func siteFor(region string) (site, error) { + region = strings.ToLower(strings.TrimSpace(region)) + if region == "" { + region = "global" + } + s, ok := sites[region] + if !ok { + return site{}, fmt.Errorf("qwenwork: unknown region %q", region) + } + return s, nil +} + +func (s site) userAgent() string { return "qoderwork/" + s.AppVersion } +func (s site) releaseVersion() string { return s.AppVersion + "-" + s.Build } + +// Credential is the canonical storage payload shape. +type Credential struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt int64 `json:"expires_at"` + UID string `json:"uid"` + Nickname string `json:"nickname"` + Email string `json:"email"` + MachineID string `json:"machine_id"` +} + +// DecodeCredential accepts the canonical snake_case payload and the camelCase +// account shape exported by qwenwork2api (accessToken / refreshToken / +// expiresAt in Unix seconds). +func DecodeCredential(payload []byte) (Credential, error) { + var flat Credential + if err := json.Unmarshal(payload, &flat); err == nil && strings.TrimSpace(flat.AccessToken) != "" { + return flat, nil + } + var camel struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + UID string `json:"uid"` + Nickname string `json:"nickname"` + Email string `json:"email"` + MachineID string `json:"machineId"` + } + if err := json.Unmarshal(payload, &camel); err == nil && strings.TrimSpace(camel.AccessToken) != "" { + return Credential(camel), nil + } + return Credential{}, fmt.Errorf("qwenwork credential requires access_token") +} + +func (c Credential) Encode() ([]byte, error) { + return json.Marshal(c) +} + +func (c Credential) Ready() bool { + return strings.TrimSpace(c.AccessToken) != "" && strings.TrimSpace(c.UID) != "" +} + +// ValidateCredential enforces the import contract: a token is required; a +// missing uid is stored but never ready. +func ValidateCredential(payload []byte) error { + _, err := DecodeCredential(payload) + return err +} + +// grant is the token document returned by deviceToken/poll and /refresh. +type grant struct { + Token string `json:"token"` + DeviceToken string `json:"device_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt json.RawMessage `json:"expires_at"` + ExpiresIn int64 `json:"expires_in"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` +} + +func (g grant) accessToken() string { + if strings.TrimSpace(g.Token) != "" { + return strings.TrimSpace(g.Token) + } + return strings.TrimSpace(g.DeviceToken) +} + +// expiry returns the grant's absolute expiry in Unix seconds. expires_at may be +// an RFC 3339 string or a Unix number; expires_in may be seconds or, when +// implausibly large, milliseconds. +func (g grant) expiry(now time.Time) int64 { + if raw := strings.TrimSpace(string(g.ExpiresAt)); raw != "" && raw != "null" { + var text string + if json.Unmarshal(g.ExpiresAt, &text) == nil { + if t, err := time.Parse(time.RFC3339, text); err == nil { + return t.Unix() + } + } + var n int64 + if json.Unmarshal(g.ExpiresAt, &n) == nil && n > 0 { + if n > 1e12 { + return n / 1000 + } + return n + } + } + if g.ExpiresIn > 0 { + if g.ExpiresIn > 10_000_000 { + return now.Add(time.Duration(g.ExpiresIn) * time.Millisecond).Unix() + } + return now.Add(time.Duration(g.ExpiresIn) * time.Second).Unix() + } + return now.Add(defaultTokenLifetime).Unix() +} diff --git a/internal/providers/qwenwork/import.go b/internal/providers/qwenwork/import.go new file mode 100644 index 0000000..c7a209d --- /dev/null +++ b/internal/providers/qwenwork/import.go @@ -0,0 +1,30 @@ +package qwenwork + +import ( + "strings" + + "github.com/caigee-cmd/cli2api/internal/providers" +) + +func (credentialCodec) Format() string { return CredentialFormat } + +// PrepareImport accepts the canonical snake_case payload and the camelCase +// account shape exported by qwenwork2api. A token pair is required; a +// missing uid imports disabled until the first refresh fills it. +func (credentialCodec) PrepareImport(payload []byte) (providers.CredentialImport, error) { + if err := ValidateCredential(payload); err != nil { + return providers.CredentialImport{}, err + } + credential, err := DecodeCredential(payload) + if err != nil { + return providers.CredentialImport{}, err + } + if strings.TrimSpace(credential.MachineID) == "" { + credential.MachineID = newID() + } + encoded, err := credential.Encode() + if err != nil { + return providers.CredentialImport{}, err + } + return providers.CredentialImport{Payload: encoded, Ready: credential.Ready()}, nil +} diff --git a/internal/providers/qwenwork/payload.go b/internal/providers/qwenwork/payload.go new file mode 100644 index 0000000..25387ee --- /dev/null +++ b/internal/providers/qwenwork/payload.go @@ -0,0 +1,147 @@ +package qwenwork + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/caigee-cmd/cli2api/internal/translate" +) + +const ( + defaultMaxTokens = 32000 + defaultMaxInputTokens = 180000 +) + +// buildChatBody is the plaintext agent_chat_generation document the QwenWork +// desktop sends. System and developer turns are lifted into "system"; the +// remaining turns go upstream unchanged, so multi-turn history is carried by +// the caller on every request. +func buildChatBody(req translate.ChatRequest, modelKey string) map[string]any { + var systemParts []string + messages := make([]map[string]any, 0, len(req.Messages)) + lastUser := "" + for _, message := range req.Messages { + if message.Role == "system" || message.Role == "developer" { + if text := strings.TrimSpace(translate.ContentToString(message.Content)); text != "" { + systemParts = append(systemParts, text) + } + continue + } + item := map[string]any{"role": message.Role, "content": normalizeMessageContent(message)} + 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) + } + if message.Role == "user" { + lastUser = translate.ContentToString(message.Content) + } + messages = append(messages, item) + } + if len(messages) == 0 { + messages = []map[string]any{{"role": "user", "content": "ping"}} + } + if strings.TrimSpace(lastUser) == "" { + lastUser = "ping" + } + systemText := strings.Join(systemParts, "\n\n") + + maxTokens := defaultMaxTokens + 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) + + tools := rawOrEmpty(req.Tools) + if strings.Trim(string(bytes.TrimSpace(req.ToolChoice)), `"`) == "none" { + tools = []any{} + } + + requestID := newID() + return map[string]any{ + "request_id": requestID, + "request_set_id": requestID, + "chat_record_id": requestID, + "session_id": newID(), + "stream": true, + "chat_task": "FREE_INPUT", + "chat_context": map[string]any{ + "text": lastUser, + "features": []any{}, + "extra": map[string]any{ + "context": []any{}, + "modelConfig": map[string]any{"key": modelKey, "is_reasoning": false}, + "originalContent": lastUser, + }, + "chatPrompt": "", + "imageUrls": nil, + }, + "is_reply": true, + "is_retry": false, + "source": 1, + "version": "3", + "agent_id": "agent_common", + "task_id": "common", + "session_type": businessProduct, + "aliyun_user_type": "", + "model_config": map[string]any{ + "key": modelKey, "display_name": modelKey, "model": "", "format": "openai", + "is_vl": true, "is_reasoning": false, "api_key": "", "url": "", + "source": "system", "max_input_tokens": defaultMaxInputTokens, + }, + "system": systemText, + "messages": messages, + "tools": tools, + "parameters": parameters, + "business": map[string]any{ + "product": businessProduct, "type": businessType, "version": "1", + "feature_switches": map[string]any{}, + }, + } +} + +// normalizeMessageContent sends "" instead of null for an assistant turn that +// is pure tool calls; the gateway only pairs the following role:"tool" +// message when content is a string. +func normalizeMessageContent(message translate.ChatMessage) any { + if len(message.ToolCalls) > 0 && message.Content == nil { + return "" + } + return message.Content +} + +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 { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || string(trimmed) == "null" { + return []any{} + } + return json.RawMessage(raw) +} diff --git a/internal/providers/qwenwork/pkce.go b/internal/providers/qwenwork/pkce.go new file mode 100644 index 0000000..44eae0b --- /dev/null +++ b/internal/providers/qwenwork/pkce.go @@ -0,0 +1,37 @@ +package qwenwork + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" +) + +// pkceVerifier returns a fresh RFC 7636 code verifier. +func pkceVerifier() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// pkceChallenge derives the S256 challenge for a verifier. +func pkceChallenge(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// pkcePair returns a fresh PKCE verifier and its S256 challenge. The verifier +// is replayed at the deviceToken poll while the challenge is embedded in the +// browser authorization URL. +func pkcePair() (verifier, challenge string, err error) { + verifier, err = pkceVerifier() + if err != nil { + return "", "", err + } + if verifier == "" { + return "", "", errors.New("empty pkce verifier") + } + return verifier, pkceChallenge(verifier), nil +} diff --git a/internal/providers/qwenwork/sse.go b/internal/providers/qwenwork/sse.go new file mode 100644 index 0000000..9e279ce --- /dev/null +++ b/internal/providers/qwenwork/sse.go @@ -0,0 +1,521 @@ +package qwenwork + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/caigee-cmd/cli2api/internal/accounts" + "github.com/caigee-cmd/cli2api/internal/providers" +) + +// The QwenWork gateway wraps every OpenAI chunk in an envelope frame: +// +// data:{"headers":{},"body":"{\"choices\":…}","statusCodeValue":200} +// +// Errors are signaled by statusCodeValue >= 400 or by a body-level 4xx code +// (the desktop convention, e.g. {"code":"401","message":"…"}). The wrapper is +// stripped here; the gateway relays plain OpenAI chat-completion chunks. + +// upstreamFrame is one unwrapped upstream event. +type upstreamFrame struct { + content string + reasoning string + tools json.RawMessage + finish string + usage json.RawMessage + errText string + errStatus int + errKind string +} + +// parseUpstreamLine decodes one SSE data payload into the inner OpenAI chunk +// it wraps. ok is false for blanks, [DONE], heartbeat frames, and empty +// bodies. A frame status of 400+ survives the unwrap so the walker can +// classify the frame as an error. +func parseUpstreamLine(data string) (body map[string]any, frameStatus int, ok bool) { + var outer map[string]any + if json.Unmarshal([]byte(data), &outer) != nil { + return nil, 0, false + } + if status, isNumber := numberField(outer["statusCodeValue"]); isNumber { + frameStatus = status + } + body = outer + if nested, present := outer["body"]; present { + switch typed := nested.(type) { + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" || trimmed == "[DONE]" || trimmed == "null" || trimmed == "{}" { + return nil, 0, false + } + var inner map[string]any + if json.Unmarshal([]byte(trimmed), &inner) == nil { + if len(inner) == 0 { + return nil, 0, false + } + body = inner + } else if frameStatus >= 400 { + // Bare text body only carries meaning as an error message. + body = map[string]any{"message": trimmed} + } else { + return nil, 0, false + } + case map[string]any: + if len(typed) == 0 { + return nil, 0, false + } + body = typed + } + } + return body, frameStatus, true +} + +func numberField(value any) (int, bool) { + number, ok := value.(float64) + if !ok { + return 0, false + } + return int(number), true +} + +// walkUpstream walks the wrapped SSE body and calls fn with each unwrapped +// chunk plus the frame status it arrived with. +func walkUpstream(r io.Reader, fn func(body map[string]any, frameStatus int) 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, ":") || !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" || data == "[DONE]" { + continue + } + body, frameStatus, ok := parseUpstreamLine(data) + if !ok { + continue + } + if err := fn(body, frameStatus); err != nil { + return err + } + } + return scanner.Err() +} + +// upstreamError extracts the error one frame carries: an OpenAI-style error +// object, a wrapper statusCodeValue >= 400, or a body-level 4xx code with a +// message. Quota code 14018 and "Model is not available for this user" are +// classified through the shared taxonomy. +func upstreamError(body map[string]any, frameStatus int) (text string, status int, kind string) { + if len(body) == 0 { + return "", 0, "" + } + if raw, ok := body["error"]; ok && raw != nil { + encoded, _ := json.Marshal(raw) + var parsed struct { + Message string `json:"message"` + Code any `json:"code"` + } + _ = json.Unmarshal(encoded, &parsed) + text = strings.TrimSpace(parsed.Message) + if text == "" { + text = strings.TrimSpace(string(encoded)) + } + status = firstStatus(frameStatus, http.StatusBadGateway) + return text, status, classifiedKind(status, text, scalarCode(parsed.Code)) + } + code := scalarCode(body["code"]) + message, _ := body["message"].(string) + message = strings.TrimSpace(message) + if frameStatus < 400 && !errorCodeString(code) { + return "", 0, "" + } + status = frameStatus + if status < 400 { + if n, err := strconv.Atoi(code); err == nil && n >= 400 { + status = n + } + } + status = firstStatus(status, http.StatusBadGateway) + if message == "" { + message = "qwenwork upstream error" + if code != "" { + message += " code=" + code + } + } + return message, status, classifiedKind(status, message, code) +} + +// errorCodeString reports whether a body-level code marks the frame as an +// error regardless of the wrapper status. +func errorCodeString(code string) bool { + switch code { + case "400", "401", "402", "403", "429": + return true + } + return false +} + +// classifiedKind maps an extracted frame error onto the taxonomy; an empty +// kind means "no classification", which callers treat as unavailable. +func classifiedKind(status int, message, code string) string { + kind := classifyKind(status, message, code) + if kind == "" { + return accounts.KindUnavailable + } + return kind +} + +func frameFromBody(body map[string]any, frameStatus int) upstreamFrame { + var frame upstreamFrame + if errText, status, kind := upstreamError(body, frameStatus); 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 +} + +// collectUpstream folds a full wrapped SSE body into one frame for the +// non-stream path. +func collectUpstream(r io.Reader) (upstreamFrame, error) { + var frame upstreamFrame + var content, reasoning strings.Builder + err := walkUpstream(r, func(body map[string]any, frameStatus int) error { + if errText, status, kind := upstreamError(body, frameStatus); 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 +} + +// outcomeFromUpstreamSSE aggregates a wrapped upstream SSE body into the +// provider-neutral outcome. An error frame without any payload is a +// classified provider error; a body without content, reasoning, tools, a +// terminal finish_reason, or an error is a silent upstream reject. +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} + } + if frame.finish == "" && frame.content == "" && frame.reasoning == "" && len(frame.tools) == 0 { + return providers.ChatOutcome{}, emptyUpstreamError() + } + 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 +} + +// rewriteUpstreamSSE pipes the wrapped upstream body through an OpenAI +// chat-completion chunk rewriter. +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 { + var sawFinish, sawTools, sawPayload, sawError bool + err := walkUpstream(src, func(body map[string]any, frameStatus int) error { + frame := frameFromBody(body, frameStatus) + if frame.finish != "" { + sawFinish = true + } + if frame.content != "" || frame.reasoning != "" || len(frame.tools) > 0 || frame.usage != nil { + sawPayload = true + } + if len(frame.tools) > 0 { + sawTools = true + } + if frame.errText != "" && frame.content == "" && frame.reasoning == "" && len(frame.tools) == 0 { + sawError = true + 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-qwenwork", "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 + } + if !sawFinish { + if sawPayload { + // Upstream streamed real content but was cut before the terminal + // finish_reason. The bytes are already on the wire, so salvage the + // completion with a synthesized terminal chunk instead of leaving + // strict OpenAI clients with "Stream ended without finish_reason". + finish := "stop" + if sawTools { + finish = "tool_calls" + } + chunk := map[string]any{ + "id": "chatcmpl-qwenwork", "object": "chat.completion.chunk", "model": model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": finish}}, + } + payload, err := json.Marshal(chunk) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", payload); err != nil { + return err + } + } else if !sawError { + // Empty upstream stream that slipped past the pre-relay guard: + // surface it as an error frame so the gateway classifies and logs + // the failure instead of reporting a successful empty answer. + payload, _ := json.Marshal(map[string]any{"error": map[string]any{"message": emptyUpstreamError().Error(), "type": "api_error"}}) + if _, err := fmt.Fprintf(w, "data: %s\n\n", payload); err != nil { + return err + } + } + } + _, err = io.WriteString(w, "data: [DONE]\n\n") + return err +} + +// emptyUpstreamError mirrors the worker path's upstream_empty classification. +// The executor cools the account down and fails over instead of relaying a +// spec-invalid completion (no content, no finish_reason) that strict OpenAI +// clients reject with "Stream ended without finish_reason". +func emptyUpstreamError() error { + return &providers.Error{ + Kind: accounts.KindUnavailable, + Status: http.StatusBadGateway, + Code: "upstream_empty", + Message: "upstream returned empty content (possible context overflow / silent reject)", + } +} + +// ensureMeaningfulUpstream peeks the upstream SSE body before anything is +// relayed to the client, so an empty or error-only upstream can still fail +// over to another account. On success the buffered bytes are replayed ahead +// of the remaining body, preserving real-time streaming. +func ensureMeaningfulUpstream(resp *http.Response) (*http.Response, error) { + br := bufio.NewReader(resp.Body) + var pending bytes.Buffer + for { + line, readErr := br.ReadString('\n') + if line != "" { + pending.WriteString(line) + if data, ok := strings.CutPrefix(strings.TrimSpace(line), "data:"); ok && meaningfulDataLine(strings.TrimSpace(data)) { + resp.Body = peekedBody{Reader: io.MultiReader(&pending, br), Closer: resp.Body} + return resp, nil + } + } + if readErr != nil { + resp.Body.Close() + if errors.Is(readErr, io.EOF) { + return nil, emptyUpstreamError() + } + return nil, &providers.Error{ + Kind: accounts.KindUnavailable, + Status: http.StatusBadGateway, + Code: "upstream_stream_interrupted", + Message: readErr.Error(), + } + } + } +} + +// peekedBody keeps the original body's Close wired to the upstream response +// while prepending the peeked bytes. +type peekedBody struct { + io.Reader + io.Closer +} + +// meaningfulDataLine reports whether a single upstream SSE data line carries +// any payload a relayed completion needs: content, reasoning, tool calls, a +// terminal finish_reason, usage, or an error. Heartbeat and empty-delta +// frames are not meaningful. +func meaningfulDataLine(payload string) bool { + if payload == "" || payload == "[DONE]" { + return false + } + body, frameStatus, ok := parseUpstreamLine(payload) + if !ok { + return false + } + frame := frameFromBody(body, frameStatus) + return frame.content != "" || frame.reasoning != "" || len(frame.tools) > 0 || frame.finish != "" || frame.usage != nil || frame.errText != "" +} From 4849b8c166fe0a3d4989bcea73b304f9c63d053d Mon Sep 17 00:00:00 2001 From: whale Date: Thu, 15 Oct 2026 22:23:44 +0800 Subject: [PATCH 2/9] feat(qwenwork): register provider descriptor and adapter Adds the QwenWork descriptor (global/cn regions, in-process, OAuth) to the provider registry, registers the adapter in app assembly, and admits the qwenwork/ model prefix in executor prepare and console model settings. Architecture guard now lists qwenwork as a concrete provider. --- internal/app/app.go | 2 ++ internal/app/architecture_test.go | 2 +- internal/console/models.go | 2 +- internal/executor/prepare.go | 2 +- internal/providers/registry.go | 32 ++++++++++++++++++++++++++++++- 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 1605543..ab201e4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -26,6 +26,7 @@ import ( "github.com/caigee-cmd/cli2api/internal/providers/command" "github.com/caigee-cmd/cli2api/internal/providers/devin" "github.com/caigee-cmd/cli2api/internal/providers/qoder" + "github.com/caigee-cmd/cli2api/internal/providers/qwenwork" "github.com/caigee-cmd/cli2api/internal/providers/trae" "github.com/caigee-cmd/cli2api/internal/providers/workbuddy" "github.com/caigee-cmd/cli2api/internal/providers/zhipu" @@ -118,6 +119,7 @@ func New(cfg config.Config) *App { providerReg.Register(command.NewClient(store).Adapter()) providerReg.Register(codex.NewClient(store).Adapter()) providerReg.Register(zhipu.NewClient(store).Adapter()) + providerReg.Register(qwenwork.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) { diff --git a/internal/app/architecture_test.go b/internal/app/architecture_test.go index 8198573..5a45108 100644 --- a/internal/app/architecture_test.go +++ b/internal/app/architecture_test.go @@ -173,7 +173,7 @@ func isConcreteProvider(imp string) bool { rest := strings.TrimPrefix(imp, prefix) root, _, _ := strings.Cut(rest, "/") switch root { - case "qoder", "workbuddy", "trae", "devin", "command", "codex": + case "qoder", "workbuddy", "trae", "devin", "command", "codex", "qwenwork": return true default: return false diff --git a/internal/console/models.go b/internal/console/models.go index a8e359a..8fffb27 100644 --- a/internal/console/models.go +++ b/internal/console/models.go @@ -11,7 +11,7 @@ import ( func splitModelSettingPath(raw, queryProvider string) (provider, modelKey string) { raw = strings.TrimPrefix(raw, "/api/models/") provider = strings.ToLower(strings.TrimSpace(queryProvider)) - for _, prefix := range []string{"trae/", "workbuddy/", "qoder/", "devin/", "command/", "codex/", "zhipu/"} { + for _, prefix := range []string{"trae/", "workbuddy/", "qoder/", "devin/", "command/", "codex/", "zhipu/", "qwenwork/"} { if strings.HasPrefix(strings.ToLower(raw), prefix) { provider = strings.TrimSuffix(prefix, "/") raw = raw[len(prefix):] diff --git a/internal/executor/prepare.go b/internal/executor/prepare.go index 68a369b..ffb1b95 100644 --- a/internal/executor/prepare.go +++ b/internal/executor/prepare.go @@ -160,7 +160,7 @@ func (e ChatExecutor) Prepare(in PrepareInput) (PreparedRequest, error) { // "/" model id. Command Code's own model ids are org-namespaced // ("deepseek/…", "moonshotai/…"), so its provider prefix sits in front of an // already-namespaced id ("command/deepseek/…"). -var providerPrefixes = []string{"qoder/", "workbuddy/", "trae/", "devin/", "command/", "codex/", "zhipu/"} +var providerPrefixes = []string{"qoder/", "workbuddy/", "trae/", "devin/", "command/", "codex/", "zhipu/", "qwenwork/"} func ProviderPrefix(model string) string { model = strings.TrimSpace(model) diff --git a/internal/providers/registry.go b/internal/providers/registry.go index b6ed443..cc4abc0 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -264,6 +264,35 @@ var Zhipu = ProviderDescriptor{ DefaultRegion: "cn", } +// QwenWork descriptor. Protocol constants stay in internal/providers/qwenwork; +// the registry only carries control-plane metadata. QwenWork is the rebranded +// QoderWork desktop product: global is qwenwork.ai, cn is 千问办公 qwenwork.cn. +var QwenWork = ProviderDescriptor{ + ID: "qwenwork", + Label: "QwenWork", + Runtime: RuntimeInProcess, + AuthTypes: []AuthType{AuthOAuth}, + CredentialFormats: []string{"qwenwork-oauth-v1"}, + Capabilities: ProviderCapabilities{ + Chat: true, Stream: true, Tools: true, Images: true, Reasoning: true, + ModelCatalog: true, Usage: true, Login: true, BrowserLogin: true, + PATLogin: false, ImportExport: true, + }, + Regions: []RegionDescriptor{ + { + ID: "global", Label: "International", ChatBase: "https://gateway.qwenwork.ai", + BillingBase: "https://gateway.qwenwork.ai", AuthBase: "https://gateway.qwenwork.ai", + DefaultDomain: "qwenwork.ai", + }, + { + ID: "cn", Label: "CN", ChatBase: "https://gateway.qwenwork.cn", + BillingBase: "https://gateway.qwenwork.cn", AuthBase: "https://gateway.qwenwork.cn", + DefaultDomain: "qwenwork.cn", + }, + }, + DefaultRegion: "global", +} + var registry = map[string]ProviderDescriptor{ Qoder.ID: Qoder, WorkBuddy.ID: WorkBuddy, @@ -272,6 +301,7 @@ var registry = map[string]ProviderDescriptor{ Command.ID: Command, Codex.ID: Codex, Zhipu.ID: Zhipu, + QwenWork.ID: QwenWork, } func Get(id string) (ProviderDescriptor, bool) { @@ -280,7 +310,7 @@ func Get(id string) (ProviderDescriptor, bool) { } func List() []ProviderDescriptor { - return []ProviderDescriptor{Qoder, WorkBuddy, Trae, Devin, Command, Codex, Zhipu} + return []ProviderDescriptor{Qoder, WorkBuddy, Trae, Devin, Command, Codex, Zhipu, QwenWork} } // Resolve validates a provider/region pair. Empty values fall back to the From 8c9de7725a4c29c8920299db4ffc63703951f13f Mon Sep 17 00:00:00 2001 From: whale Date: Thu, 15 Oct 2026 22:26:15 +0800 Subject: [PATCH 3/9] feat(console): add QwenWork account type to the frontend Provider family label, geometric QwenworkMark (no trademark art), global/cn account types with single-use-link hints, qwenwork-oauth-v1 import default, and the qwenwork/ prefix hint. Static bundle resynced. --- frontend/src/components/AddAccountModal.tsx | 5 ++++ frontend/src/components/ProviderMark.tsx | 4 +++ frontend/src/components/QwenworkMark.tsx | 29 +++++++++++++++++++ frontend/src/i18n/messages.ts | 12 ++++++-- frontend/src/lib/provider.ts | 8 +++++ ...t-204RRZzP.js => TrafficChart-Cu4wMr7T.js} | 2 +- .../{index-BmBq_G52.js => index-B2E3UrDA.js} | 12 ++++---- ...{index-CmKR3T8N.css => index-DUre_bfC.css} | 2 +- internal/webui/static/index.html | 4 +-- 9 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/QwenworkMark.tsx rename internal/webui/static/assets/{TrafficChart-204RRZzP.js => TrafficChart-Cu4wMr7T.js} (99%) rename internal/webui/static/assets/{index-BmBq_G52.js => index-B2E3UrDA.js} (80%) rename internal/webui/static/assets/{index-CmKR3T8N.css => index-DUre_bfC.css} (93%) diff --git a/frontend/src/components/AddAccountModal.tsx b/frontend/src/components/AddAccountModal.tsx index 97a4432..b8523d0 100644 --- a/frontend/src/components/AddAccountModal.tsx +++ b/frontend/src/components/AddAccountModal.tsx @@ -56,6 +56,8 @@ const labelKeys: Record = { 'command-global': 'accountTypeCommandGlobal', 'codex-global': 'accountTypeCodexGlobal', 'zhipu-cn': 'accountTypeZhipuCN', + 'qwenwork-global': 'accountTypeQwenworkGlobal', + 'qwenwork-cn': 'accountTypeQwenworkCN', } const hintKeys: Record = { @@ -68,6 +70,8 @@ const hintKeys: Record = { 'command-global': 'accountTypeCommandGlobalHint', 'codex-global': 'accountTypeCodexGlobalHint', 'zhipu-cn': 'accountTypeZhipuCNHint', + 'qwenwork-global': 'accountTypeQwenworkGlobalHint', + 'qwenwork-cn': 'accountTypeQwenworkCNHint', } function AccountTypeSkeleton({ ariaLabel }: { ariaLabel: string }) { @@ -437,6 +441,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) { else if (activeOption.descriptor.id === 'trae') bundle.format = 'trae-oauth-v1' else if (activeOption.descriptor.id === 'devin') bundle.format = 'devin-session-v1' else if (activeOption.descriptor.id === 'command') bundle.format = 'command-key-v1' + else if (activeOption.descriptor.id === 'qwenwork') bundle.format = 'qwenwork-oauth-v1' else bundle.format = 'qoder-native-v1' } try { diff --git a/frontend/src/components/ProviderMark.tsx b/frontend/src/components/ProviderMark.tsx index 0025bca..20892d3 100644 --- a/frontend/src/components/ProviderMark.tsx +++ b/frontend/src/components/ProviderMark.tsx @@ -2,6 +2,7 @@ import { CommandMark } from '@/components/CommandMark' import { CodexMark } from '@/components/CodexMark' import { DevinMark } from '@/components/DevinMark' import { QoderMark } from '@/components/QoderMark' +import { QwenworkMark } from '@/components/QwenworkMark' import { TraeMark } from '@/components/TraeMark' import { WorkBuddyMark } from '@/components/WorkBuddyMark' import { ZhipuMark } from '@/components/ZhipuMark' @@ -33,5 +34,8 @@ export function ProviderMark({ provider, size = 16, className = '' }: Props) { if (id === 'zhipu') { return } + if (id === 'qwenwork') { + return + } return } diff --git a/frontend/src/components/QwenworkMark.tsx b/frontend/src/components/QwenworkMark.tsx new file mode 100644 index 0000000..4a0edc2 --- /dev/null +++ b/frontend/src/components/QwenworkMark.tsx @@ -0,0 +1,29 @@ +type Props = { + size?: number + className?: string +} + +// QwenWork mark: an ink tile, a Q glyph, and a Signal Cyan terminal dot — +// a simple geometric placeholder, deliberately not the official trademark. +export function QwenworkMark({ size = 16, className = '' }: Props) { + const dot = Math.max(2, Math.round(size * 0.14)) + return ( +