diff --git a/README.md b/README.md index ebdffc5c..d8bf0769 100644 --- a/README.md +++ b/README.md @@ -49,18 +49,20 @@ VisionCoder is also offering our users a limited-time -req +// qoder_replay -auth -req -bisect +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/google/uuid" + qoderauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/qoder" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" +) + +func main() { + authFile := flag.String("auth", "", "Path to qoder auth JSON file") + reqFile := flag.String("req", "", "Path to captured request JSON file") + bisect := flag.Bool("bisect", false, "Binary search for the offending message") + chatURL := flag.String("url", "", "Override chat URL (default: auto based on -encode flag)") + encode := flag.Bool("encode", true, "Wrap and encode body (Encode=1 mode)") + flag.Parse() + + if *chatURL == "" { + if *encode { + *chatURL = qoderauth.QoderChatURLEncoded + } else { + *chatURL = qoderauth.QoderChatURL + } + } + + if *authFile == "" || *reqFile == "" { + fmt.Fprintln(os.Stderr, "usage: qoder_replay -auth -req [-bisect]") + os.Exit(1) + } + + // Load auth + authData, err := os.ReadFile(*authFile) + if err != nil { + fatalf("read auth: %v", err) + } + var storage qoderauth.QoderTokenStorage + if err := json.Unmarshal(authData, &storage); err != nil { + fatalf("parse auth: %v", err) + } + + // Load request + reqData, err := os.ReadFile(*reqFile) + if err != nil { + fatalf("read req: %v", err) + } + var reqBody map[string]interface{} + if err := json.Unmarshal(reqData, &reqBody); err != nil { + fatalf("parse req: %v", err) + } + + msgs, _ := reqBody["messages"].([]interface{}) + fmt.Printf("Loaded request with %d messages\n", len(msgs)) + + if !*bisect { + status, body := send(&storage, reqBody, *chatURL, *encode) + fmt.Printf("Status: %d\n", status) + fmt.Printf("Body: %s\n", truncate(string(body), 500)) + return + } + + // Binary search + fmt.Printf("\nBisecting %d messages...\n\n", len(msgs)) + + // Verify full set fails + status, _ := send(&storage, withMessages(reqBody, msgs), *chatURL, *encode) + if status != 405 { + fmt.Printf("WARNING: full request returned %d (not 405), bisect may be unreliable\n", status) + } + + lo, hi := 0, len(msgs) + for hi-lo > 1 { + mid := (lo + hi) / 2 + subset := msgs[:mid] + fmt.Printf("Testing msgs[0:%d]... ", mid) + status, _ := send(&storage, withMessages(reqBody, subset), *chatURL, *encode) + fmt.Printf("-> %d\n", status) + if status == 405 { + hi = mid + } else { + lo = mid + } + } + + fmt.Printf("\nOffending message index: %d\n", lo) + m, _ := msgs[lo].(map[string]interface{}) + if m == nil { + fmt.Println("(could not parse message)") + return + } + fmt.Printf("role: %s\n", m["role"]) + content, _ := m["content"].(string) + fmt.Printf("content: %s\n", truncate(content, 300)) + if tcs, ok := m["tool_calls"].([]interface{}); ok { + for _, tc := range tcs { + tcMap, _ := tc.(map[string]interface{}) + if tcMap == nil { + continue + } + fn, _ := tcMap["function"].(map[string]interface{}) + if fn == nil { + continue + } + fmt.Printf("tool_call: %s\n args: %s\n", fn["name"], truncate(fmt.Sprintf("%v", fn["arguments"]), 500)) + } + } +} + +func withMessages(base map[string]interface{}, msgs []interface{}) map[string]interface{} { + clone := make(map[string]interface{}, len(base)) + for k, v := range base { + clone[k] = v + } + clone["messages"] = msgs + // Fresh IDs so each bisect call is independent + clone["request_id"] = uuid.New().String() + clone["request_set_id"] = uuid.New().String() + clone["chat_record_id"] = uuid.New().String() + clone["session_id"] = uuid.New().String() + if biz, ok := clone["business"].(map[string]interface{}); ok { + bizClone := make(map[string]interface{}, len(biz)) + for k, v := range biz { + bizClone[k] = v + } + bizClone["id"] = uuid.New().String() + bizClone["begin_at"] = time.Now().UnixMilli() + clone["business"] = bizClone + } + return clone +} + +func send(storage *qoderauth.QoderTokenStorage, reqBody map[string]interface{}, chatURL string, encode bool) (int, []byte) { + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + fatalf("marshal: %v", err) + } + + var sendBytes []byte + if encode { + sendBytes = []byte(helps.QoderEncodeBody(bodyBytes)) + } else { + sendBytes = bodyBytes + } + + req, err := http.NewRequest("POST", chatURL, bytes.NewReader(sendBytes)) + if err != nil { + fatalf("new request: %v", err) + } + + headers, err := qoderauth.BuildAuthHeaders( + sendBytes, + chatURL, + qoderauth.CosyCredentials{ + UserID: storage.UserID, + AuthToken: storage.Token, + Name: storage.Name, + Email: storage.Email, + MachineID: storage.MachineID, + }, + ) + if err != nil { + fatalf("build auth headers: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + headers.Apply(req) + // Extract model key from request body for X-Model-Key header + if modelKey, ok := reqBody["model_config"].(map[string]interface{}); ok { + if key, ok := modelKey["key"].(string); ok && key != "" { + req.Header.Set("X-Model-Key", key) + } + if src, ok := modelKey["source"].(string); ok && src != "" { + req.Header.Set("X-Model-Source", src) + } else { + req.Header.Set("X-Model-Source", "system") + } + } + req.Header.Set("Accept-Encoding", "identity") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "request error: %v\n", err) + return 0, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, body + } + // For 200 SSE responses, just read the first chunk to confirm success. + buf := make([]byte, 512) + n, _ := resp.Body.Read(buf) + return resp.StatusCode, buf[:n] +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func fatalf(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index eeabda21..027a48e7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -99,6 +99,7 @@ func main() { var githubCopilotLogin bool var codeBuddyLogin bool var xaiLogin bool + var qoderLogin bool var projectID string var vertexImport string var vertexImportPrefix string @@ -141,6 +142,7 @@ func main() { flag.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow") flag.BoolVar(&codeBuddyLogin, "codebuddy-login", false, "Login to CodeBuddy using browser OAuth flow") flag.BoolVar(&xaiLogin, "xai-login", false, "Login to xAI using OAuth") + flag.BoolVar(&qoderLogin, "qoder-login", false, "Login to Qoder using OAuth device flow") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file") @@ -680,6 +682,8 @@ func main() { cmd.DoKiroIDCLogin(cfg, options, kiroIDCStartURL, kiroIDCRegion, kiroIDCFlow) } else if xaiLogin { cmd.DoXAILogin(cfg, options) + } else if qoderLogin { + cmd.DoQoderLogin(cfg, options) } else { // In cloud deploy mode without config file, just wait for shutdown signals if isCloudDeploy && !configFileExists { diff --git a/config.example.yaml b/config.example.yaml index 783a121c..49d91518 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -399,7 +399,7 @@ nonstream-keepalive-interval: 0 # Global OAuth model name aliases (per channel) # These aliases rename model IDs for both model listing and request routing. -# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, kimi, xai. +# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, kimi, xai, qoder. # NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode. # NOTE: Because aliases affect the merged /v1 model list and merged request routing, overlapping # client-visible names can become ambiguous across providers. /api/provider/{provider}/... helps @@ -444,9 +444,12 @@ nonstream-keepalive-interval: 0 # xai: # - name: "grok-4.3" # alias: "grok-latest" +# qoder: +# - name: "auto" +# alias: "qoder-auto" # OAuth provider excluded models -# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot. +# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, qoder. # oauth-excluded-models: # gemini-cli: # - "gemini-2.5-pro" # exclude specific models (exact match) @@ -467,6 +470,8 @@ nonstream-keepalive-interval: 0 # - "kimi-k2-thinking" # xai: # - "grok-3-mini" +# qoder: +# - "auto" # Optional payload configuration # payload: diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 8003fc0b..9e8bb280 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -36,6 +36,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kilo" "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi" kiroauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kiro" + qoderauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/qoder" xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" @@ -522,6 +523,22 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { } } } + // Expose Qoder credit usage if available. + if auth.Provider == "qoder" { + if storage, ok := auth.Storage.(*qoderauth.QoderTokenStorage); ok && storage != nil && storage.GetUsageInfo() != nil { + u := storage.GetUsageInfo() + entry["usage"] = gin.H{ + "used": u.UserQuota.Used, + "total": u.UserQuota.Total, + "remaining": u.UserQuota.Remaining, + "percentage": u.TotalUsagePercentage, + "unit": u.UserQuota.Unit, + "is_quota_exceeded": u.IsQuotaExceeded, + "expires_at": u.ExpiresAt, + "org_resource_remaining": u.OrgResourcePackage.Remaining, + } + } + } if websockets, ok := authWebsocketsValue(auth); ok { entry["websockets"] = websockets } @@ -2976,6 +2993,80 @@ func (h *Handler) RequestXAIToken(c *gin.Context) { c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state}) } +func (h *Handler) RequestQoderToken(c *gin.Context) { + ctx := context.Background() + ctx = PopulateAuthContext(ctx, c) + + fmt.Println("Initializing Qoder authentication...") + + state := fmt.Sprintf("qod-%d", time.Now().UnixNano()) + qoderAuth := qoderauth.NewQoderAuth(h.cfg) + + deviceFlow, err := qoderAuth.InitiateDeviceFlow(ctx) + if err != nil { + log.Errorf("Failed to generate authorization URL: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"}) + return + } + + RegisterOAuthSession(state, "qoder") + + go func() { + fmt.Println("Waiting for authentication...") + tokenData, errPollForToken := qoderAuth.PollForToken(ctx, deviceFlow) + if errPollForToken != nil { + SetOAuthSessionError(state, "Authentication failed") + fmt.Printf("Authentication failed: %v\n", errPollForToken) + return + } + + storage := qoderAuth.CreateTokenStorage(tokenData, deviceFlow.MachineID) + // Resolve a human-readable label: prefer the email from /userinfo, + // fall back to user_id, then to a timestamp so the auth file always + // gets a unique, non-empty name without prompting the operator. + name, email := qoderAuth.SaveUserInfo(ctx, tokenData.AccessToken, tokenData.UserID, "", "") + storage.Name = name + switch { + case strings.TrimSpace(email) != "": + storage.Email = strings.TrimSpace(email) + case strings.TrimSpace(tokenData.UserID) != "": + storage.Email = strings.TrimSpace(tokenData.UserID) + default: + storage.Email = fmt.Sprintf("user-%d", time.Now().UnixMilli()) + } + fileName := fmt.Sprintf("qoder-%s.json", storage.Email) + record := &coreauth.Auth{ + ID: fileName, + Provider: "qoder", + FileName: fileName, + Label: func() string { + if storage.Name != "" { + return storage.Name + } + if storage.Email != "" { + return storage.Email + } + return "Qoder User" + }(), + Storage: storage, + Metadata: map[string]any{"email": storage.Email}, + } + savedPath, errSave := h.saveTokenRecord(ctx, record) + if errSave != nil { + log.Errorf("Failed to save authentication tokens: %v", errSave) + SetOAuthSessionError(state, "Failed to save authentication tokens") + return + } + + fmt.Printf("Authentication successful! Token saved to %s\n", savedPath) + fmt.Println("You can now use Qoder services through this CLI") + CompleteOAuthSession(state) + CompleteOAuthSessionsByProvider("qoder") + }() + + c.JSON(200, gin.H{"status": "ok", "url": deviceFlow.VerificationURIComplete, "state": state}) +} + func (h *Handler) RequestKimiToken(c *gin.Context) { ctx := context.Background() ctx = PopulateAuthContext(ctx, c) diff --git a/internal/api/server.go b/internal/api/server.go index 1aebf2fc..bb73cabe 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -773,6 +773,7 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken) mgmt.GET("/cursor-auth-url", s.mgmt.RequestCursorToken) mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken) + mgmt.GET("/qoder-auth-url", s.mgmt.RequestQoderToken) mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback) mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) } diff --git a/internal/auth/qoder/api.go b/internal/auth/qoder/api.go new file mode 100644 index 00000000..aa6e0522 --- /dev/null +++ b/internal/auth/qoder/api.go @@ -0,0 +1,90 @@ +package qoder + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +const ( + // QoderInferURL is the base URL for Qoder inference (chat / model list). + // Aligned with Veria v1.3.7's reverse-engineering: api3.qoder.sh. + QoderInferURL = QoderChatBase + // QoderSigPath is the relative path of the streaming chat endpoint + // without the /algo prefix; used both for URL construction and for + // the Cosy-Sigpath header. + QoderSigPath = "/api/v2/service/pro/sse/agent_chat_generation" + // QoderChatURL is the full URL for the streaming chat endpoint. + QoderChatURL = QoderInferURL + "/algo" + QoderSigPath + "?FetchKeys=llm_model_result&AgentId=agent_common" + // QoderChatURLEncoded is the chat URL with Encode=1, used when the request + // body is encoded with QoderEncodeBody to bypass WAF pattern matching. + QoderChatURLEncoded = QoderChatURL + "&Encode=1" + // QoderModelListURL is the full URL for /algo/api/v2/model/list on the + // inference host. The endpoint uses COSY signing; pass an empty body. + QoderModelListURL = QoderInferURL + "/algo/api/v2/model/list" +) + +// ModelMap is the canonical set of model identifiers Qoder accepts. Based on +// Ve-ria/CLIProxyAPIPlus v1.3.7 (commit a97cd101) — five tier models plus six +// "frontier" backing-model identifiers. The map is identity (key == value); +// kept as a map so callers can cheaply test "is this a known qoder model?" +// before sending the request. +var ModelMap = map[string]string{ + // Tier models — pick a quality/cost tradeoff + "auto": "auto", + "ultimate": "ultimate", + "performance": "performance", + "efficient": "efficient", + "lite": "lite", + // Frontier models — pin a specific backing model + "qmodel": "qmodel", // Qwen 3.6 Plus + "dmodel": "dmodel", // DeepSeek V4 Pro + "dfmodel": "dfmodel", // DeepSeek V4 Flash + "gm51model": "gm51model", // GLM 5.1 + "kmodel": "kmodel", // Kimi K2.6 + "mmodel": "mmodel", // MiniMax M2.7 +} + +// doRefreshToken performs a token refresh and persists the result to authFilePath. +// When authFilePath is empty, it falls back to AuthDir/qoder-.json for +// backward compatibility with auth records that lack a recorded path. +func doRefreshToken(ctx context.Context, cfg *config.Config, storage *QoderTokenStorage, authFilePath string) error { + auth := NewQoderAuth(cfg) + + tokenData, err := auth.RefreshTokens(ctx, storage.Token, storage.RefreshToken) + if err != nil { + return fmt.Errorf("failed to refresh token: %w", err) + } + + auth.UpdateTokenStorage(storage, tokenData) + + if authFilePath == "" { + if storage.Email == "" { + return fmt.Errorf("cannot save token: email is empty and no file path provided") + } + fileName := fmt.Sprintf("qoder-%s.json", storage.Email) + authFilePath = filepath.Join(cfg.AuthDir, fileName) + } + return storage.SaveTokenToFile(authFilePath) +} + +// RefreshTokenIfNeeded refreshes the access token when the remaining lifetime +// drops below bufferSeconds. authFilePath is the on-disk location of the auth +// record; an empty value triggers the email-derived fallback path. +func RefreshTokenIfNeeded(ctx context.Context, cfg *config.Config, storage *QoderTokenStorage, bufferSeconds int64, authFilePath string) error { + if storage.ExpireTime == 0 { + return nil + } + + now := time.Now().UnixMilli() + bufferMs := bufferSeconds * 1000 + + if storage.ExpireTime-now-bufferMs <= 0 { + return doRefreshToken(ctx, cfg, storage, authFilePath) + } + + return nil +} diff --git a/internal/auth/qoder/cosy.go b/internal/auth/qoder/cosy.go new file mode 100644 index 00000000..f8163490 --- /dev/null +++ b/internal/auth/qoder/cosy.go @@ -0,0 +1,407 @@ +// Package qoder provides authentication and API client functionality +// for Qoder AI services. It handles OAuth2 device flow authentication, +// COSY hybrid-encryption signing, and direct API calls to the Qoder cloud. +package qoder + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +// RSA public key for COSY encryption (extracted from Qoder IDE v0.9) +const qoderRSAPublicKey = `-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc +4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l +6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17 +XcW+ML9FoCI6AOvOzwIDAQAB +-----END PUBLIC KEY-----` + +// UserInfo represents the encrypted user information payload +type UserInfo struct { + UID string `json:"uid"` + SecurityOAuthToken string `json:"security_oauth_token"` + Name string `json:"name"` + AID string `json:"aid"` + Email string `json:"email"` +} + +// CosyPayload represents the payload structure for COSY authentication +type CosyPayload struct { + Version string `json:"version"` + RequestID string `json:"requestId"` + Info string `json:"info"` + CosyVersion string `json:"cosyVersion"` + IdeVersion string `json:"ideVersion"` +} + +// CosyHeaders holds the generated COSY authentication headers, matching the +// header set the official qodercli sends. The fields named with snake/camel +// quirks reflect the on-the-wire header keys. +type CosyHeaders struct { + Authorization string + + // Cosy-* headers + CosyKey string + CosyUser string + CosyDate string + CosyVersion string + CosyMachineID string + CosyMachineToken string + CosyMachineType string + CosyMachineOS string + CosyClientType string + CosyClientIP string + CosyBodyHash string + CosyBodyLength string + CosySigPath string + CosyDataPolicy string + CosyOrganizationID string + CosyOrgTags string + + // X-* and Login-* auxiliary headers + XRequestID string + LoginVersion string +} + +// Apply writes the COSY headers onto an HTTP request. Caller is responsible for +// setting Content-Type and any non-auth headers (Accept, etc.). +func (h *CosyHeaders) Apply(req *http.Request) { + if h == nil || req == nil { + return + } + req.Header.Set("Authorization", h.Authorization) + req.Header.Set("Cosy-Key", h.CosyKey) + req.Header.Set("Cosy-User", h.CosyUser) + req.Header.Set("Cosy-Date", h.CosyDate) + req.Header.Set("Cosy-Version", h.CosyVersion) + req.Header.Set("Cosy-Machineid", h.CosyMachineID) + req.Header.Set("Cosy-Machinetoken", h.CosyMachineToken) + req.Header.Set("Cosy-Machinetype", h.CosyMachineType) + req.Header.Set("Cosy-Machineos", h.CosyMachineOS) + req.Header.Set("Cosy-Clienttype", h.CosyClientType) + req.Header.Set("Cosy-Clientip", h.CosyClientIP) + req.Header.Set("Cosy-Bodyhash", h.CosyBodyHash) + req.Header.Set("Cosy-Bodylength", h.CosyBodyLength) + req.Header.Set("Cosy-Sigpath", h.CosySigPath) + req.Header.Set("Cosy-Data-Policy", h.CosyDataPolicy) + req.Header.Set("Cosy-Organization-Id", h.CosyOrganizationID) + req.Header.Set("Cosy-Organization-Tags", h.CosyOrgTags) + req.Header.Set("Login-Version", h.LoginVersion) + req.Header.Set("X-Request-Id", h.XRequestID) +} + +// parseRSAPublicKey parses the PEM-encoded RSA public key. +func parseRSAPublicKey(pemString string) (*rsa.PublicKey, error) { + block, _ := pem.Decode([]byte(pemString)) + if block == nil { + return nil, fmt.Errorf("failed to decode RSA public key PEM") + } + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse RSA public key: %w", err) + } + pubKey, ok := parsed.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("not an RSA public key") + } + return pubKey, nil +} + +// cosyPublicKey lazily parses qoderRSAPublicKey once and caches the result. +// The PEM bytes are a compile-time constant so the parse is deterministic; +// caching avoids repeating PEM decode + ASN.1 parse on every signed request. +var ( + cosyPublicKeyOnce sync.Once + cosyPublicKey *rsa.PublicKey + cosyPublicKeyErr error +) + +func getCosyPublicKey() (*rsa.PublicKey, error) { + cosyPublicKeyOnce.Do(func() { + cosyPublicKey, cosyPublicKeyErr = parseRSAPublicKey(qoderRSAPublicKey) + }) + return cosyPublicKey, cosyPublicKeyErr +} + +// generateAESKey returns a 16-byte AES-128 key derived from a fresh UUID. +// Matches Veria/qodercli convention: uuid.New().String()[:16] — the first 16 +// chars of the canonical UUID string include hyphens (e.g. "ad24345f-1a3e-4"). +func generateAESKey() string { + id := uuid.New().String() + return id[:16] +} + +// aesEncryptCBCBase64 encrypts plaintext with AES-128-CBC. The IV reuses the +// raw 16-byte key (matches qodercli) — produces deterministic IV but the key +// is fresh per request, so each request still uses a unique IV. +func aesEncryptCBCBase64(plaintext, keyStr string) (string, error) { + keyBytes := []byte(keyStr) + if len(keyBytes) != 16 { + return "", fmt.Errorf("aes key must be 16 bytes, got %d", len(keyBytes)) + } + block, err := aes.NewCipher(keyBytes) + if err != nil { + return "", fmt.Errorf("aes cipher: %w", err) + } + padded, err := pkcs7Pad([]byte(plaintext), block.BlockSize()) + if err != nil { + return "", fmt.Errorf("pkcs7 pad: %w", err) + } + iv := keyBytes[:16] + ciphertext := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, padded) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// rsaEncryptBase64 RSA-PKCS1v15 encrypts data with the cached server public key +// and returns the base64-encoded ciphertext. +func rsaEncryptBase64(data []byte) (string, error) { + pubKey, err := getCosyPublicKey() + if err != nil { + return "", err + } + encrypted, err := rsa.EncryptPKCS1v15(rand.Reader, pubKey, data) + if err != nil { + return "", fmt.Errorf("rsa encrypt: %w", err) + } + return base64.StdEncoding.EncodeToString(encrypted), nil +} + +// encryptUserInfo serializes the user info, AES-encrypts it, and RSA-encrypts +// the AES key. Returns (cosyKey, info) where: +// - cosyKey = base64(RSA(aes_key)) +// - info = base64(AES-128-CBC(json(user_info))) +func encryptUserInfo(userInfo *UserInfo) (string, string, error) { + aesKey := generateAESKey() + plaintext, err := json.Marshal(userInfo) + if err != nil { + return "", "", fmt.Errorf("marshal user info: %w", err) + } + infoB64, err := aesEncryptCBCBase64(string(plaintext), aesKey) + if err != nil { + return "", "", err + } + cosyKeyB64, err := rsaEncryptBase64([]byte(aesKey)) + if err != nil { + return "", "", err + } + return cosyKeyB64, infoB64, nil +} + +// pkcs7Pad applies PKCS7 padding to data +func pkcs7Pad(data []byte, blockSize int) ([]byte, error) { + if blockSize < 1 || blockSize > 255 { + return nil, fmt.Errorf("invalid block size: %d", blockSize) + } + + padding := blockSize - len(data)%blockSize + padText := bytesRepeat(byte(padding), padding) + return append(data, padText...), nil +} + +// bytesRepeat creates a byte slice with the given byte repeated count times +func bytesRepeat(b byte, count int) []byte { + result := make([]byte, count) + for i := range result { + result[i] = b + } + return result +} + +// CosyCredentials holds the per-account inputs needed to sign a COSY request. +// Build it once per call from the live token storage and pass it into +// BuildAuthHeaders. +type CosyCredentials struct { + UserID string + AuthToken string + Name string + Email string + MachineID string +} + +// FromStorage populates CosyCredentials from the persisted QoderTokenStorage. +func (c *CosyCredentials) FromStorage(s *QoderTokenStorage) { + if c == nil || s == nil { + return + } + c.UserID = s.UserID + c.AuthToken = s.Token + c.Name = s.Name + c.Email = s.Email + c.MachineID = s.MachineID +} + +// computeSigPath extracts the signing path from a request URL by: +// 1. taking the path portion (drops scheme, host, query) +// 2. stripping the leading "/algo" prefix if present +// +// Matches qodercli convention: sigPath = path_after_host, with leading /algo +// removed. Empty input returns empty string. +func computeSigPath(requestURL string) (string, error) { + parsed, err := url.Parse(requestURL) + if err != nil { + return "", fmt.Errorf("parse request URL: %w", err) + } + sigPath := parsed.Path + if strings.HasPrefix(sigPath, "/algo") { + sigPath = sigPath[len("/algo"):] + } + return sigPath, nil +} + +// BuildAuthHeaders signs a single Qoder request using the COSY scheme used by +// the official qodercli (v0.14+). The body argument MUST be the exact bytes +// the request will send — both sigInput and Cosy-Bodyhash are computed from +// it. For GET requests pass nil or empty. +// +// Reference: github.com/Ve-ria/CLIProxyAPIPlus internal/runtime/executor/qoder_executor.go +// (commits e0f1c968 + d72fa22b — MD5 hash, full Cosy-* header set). +func BuildAuthHeaders(body []byte, requestURL string, creds CosyCredentials) (*CosyHeaders, error) { + if creds.UserID == "" { + return nil, fmt.Errorf("cosy: user id is empty") + } + if creds.AuthToken == "" { + return nil, fmt.Errorf("cosy: auth token is empty") + } + + cosyKey, infoB64, err := encryptUserInfo(&UserInfo{ + UID: creds.UserID, + SecurityOAuthToken: creds.AuthToken, + Name: creds.Name, + AID: "", + Email: creds.Email, + }) + if err != nil { + return nil, fmt.Errorf("encrypt user info: %w", err) + } + + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + requestID := uuid.New().String() + + payloadJSON, err := json.Marshal(&CosyPayload{ + Version: "v1", + RequestID: requestID, + Info: infoB64, + CosyVersion: QoderIDEVersion, + IdeVersion: "", + }) + if err != nil { + return nil, fmt.Errorf("marshal cosy payload: %w", err) + } + payloadB64 := base64.StdEncoding.EncodeToString(payloadJSON) + + sigPath, err := computeSigPath(requestURL) + if err != nil { + return nil, err + } + + bodyForSig := string(body) + sigInput := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", payloadB64, cosyKey, timestamp, bodyForSig, sigPath) + sig := fmt.Sprintf("%x", md5.Sum([]byte(sigInput))) + + bodyHash := fmt.Sprintf("%x", md5.Sum(body)) + bodyLen := strconv.Itoa(len(body)) + + machineID := creds.MachineID + if machineID == "" { + machineID = generateMachineID() + } + + return &CosyHeaders{ + Authorization: fmt.Sprintf("Bearer COSY.%s.%s", payloadB64, sig), + CosyKey: cosyKey, + CosyUser: creds.UserID, + CosyDate: timestamp, + CosyVersion: QoderIDEVersion, + CosyMachineID: machineID, + CosyMachineToken: machineID, + CosyMachineType: QoderMachineTypeMagic, + CosyMachineOS: QoderMachineOS, + CosyClientType: QoderClientType, + CosyClientIP: "127.0.0.1", + CosyBodyHash: bodyHash, + CosyBodyLength: bodyLen, + CosySigPath: sigPath, + CosyDataPolicy: QoderDataPolicy, + CosyOrganizationID: "", + CosyOrgTags: "", + LoginVersion: QoderLoginVersion, + XRequestID: uuid.New().String(), + }, nil +} + +// generateDeviceCodeVerifier generates a PKCE code verifier +func generateDeviceCodeVerifier() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +// generateDeviceCodeChallenge creates a SHA-256 hash of the code verifier +func generateDeviceCodeChallenge(codeVerifier string) string { + hash := sha256.Sum256([]byte(codeVerifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +// generateDevicePKCEPair creates a new code verifier and its corresponding code challenge +func generateDevicePKCEPair() (string, string, error) { + codeVerifier, err := generateDeviceCodeVerifier() + if err != nil { + return "", "", err + } + codeChallenge := generateDeviceCodeChallenge(codeVerifier) + return codeVerifier, codeChallenge, nil +} + +// generateMachineID generates a persistent machine UUID +func generateMachineID() string { + return uuid.New().String() +} + +// formatExpiresAt converts milliseconds epoch to RFC3339 format +func formatExpiresAt(expireMs int64) string { + return time.Unix(0, expireMs*int64(time.Millisecond)).Format(time.RFC3339) +} + +// parseExpiresAt converts a Qoder upstream expiry hint into Unix +// milliseconds. The hint can be: +// +// - an RFC3339 timestamp (e.g. "2026-06-16T07:15:04Z") +// - a Unix-millisecond integer string (e.g. "1781594470000") +// - empty / unparseable, in which case it falls back to a positive +// expiresInSeconds (seconds from now), and finally to "now + 30 days" +// when neither is provided. +func parseExpiresAt(s string, expiresInSeconds int64) int64 { + s = strings.TrimSpace(s) + if s != "" { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.UnixMilli() + } + if ms, err := strconv.ParseInt(s, 10, 64); err == nil && ms > 0 { + return ms + } + } + if expiresInSeconds > 0 { + return time.Now().Add(time.Duration(expiresInSeconds) * time.Second).UnixMilli() + } + return time.Now().Add(30 * 24 * time.Hour).UnixMilli() +} diff --git a/internal/auth/qoder/qoder_auth.go b/internal/auth/qoder/qoder_auth.go new file mode 100644 index 00000000..607a10ad --- /dev/null +++ b/internal/auth/qoder/qoder_auth.go @@ -0,0 +1,438 @@ +package qoder + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + log "github.com/sirupsen/logrus" +) + +const ( + // QoderOpenAPIBase is the base URL for Qoder OpenAPI + QoderOpenAPIBase = "https://openapi.qoder.sh" + // QoderCenterBase is the base URL for Qoder Center API + QoderCenterBase = "https://center.qoder.sh" + // QoderChatBase is the inference host used for chat / model list / + // other algo-prefixed endpoints. Veria's reverse-engineering put this + // at api3.qoder.sh; older IDE builds used api1. + QoderChatBase = "https://api3.qoder.sh" + // QoderLoginURL is the URL for user authentication + QoderLoginURL = "https://qoder.com/device/selectAccounts" + // QoderOAuthTokenEndpoint is the URL for polling device code token + QoderOAuthTokenEndpoint = "https://openapi.qoder.sh/api/v1/deviceToken/poll" + // QoderRefreshTokenEndpoint is the URL for refreshing access tokens + QoderRefreshTokenEndpoint = "https://center.qoder.sh/algo/api/v3/user/refresh_token" + // QoderUserInfoEndpoint is the URL for fetching user information + QoderUserInfoEndpoint = "https://openapi.qoder.sh/api/v1/userinfo" + // QoderIDEVersion is the upstream client version that the COSY signature + // scheme expects in payload.cosyVersion and the Cosy-Version header. + // 1.0.0 = what qodercli 0.2.16 actually sends in the COSY payload and + // Cosy-Version header (captured from live traffic). Earlier builds sent + // 0.14.2 (IDE) and qoder2api sends 0.1.43 — server accepts any of these + // as long as headers are consistent. Bump cautiously. + QoderIDEVersion = "1.0.0" + // QoderClientType is the client type advertised in the Cosy-Clienttype + // header. NPM qodercli (0.2.16) sends "5" (CLI). IDE/web sends "0". + QoderClientType = "5" + // QoderDataPolicy is the value sent in the Cosy-Data-Policy header. + // qodercli sends "disagree" (opt-out of training data collection). + QoderDataPolicy = "disagree" + // QoderLoginVersion is the value sent in the Login-Version header. + // "v2" is what current qodercli/IDE builds advertise. + QoderLoginVersion = "v2" + // QoderMachineOS is the machine OS identifier sent in COSY headers. + // qodercli's signing scheme treats this as a fixed magic string; the + // real client sends "x86_64_windows" regardless of host OS. + QoderMachineOS = "x86_64_windows" + // QoderMachineTypeMagic is sent as Cosy-Machinetype. + // qodercli sends "5" (same as client type). + QoderMachineTypeMagic = "5" +) + +// QoderTokenData represents the OAuth credentials from device flow polling +type QoderTokenData struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpireTime int64 `json:"expire_time"` + UserID string `json:"user_id"` + MachineToken string `json:"machineToken"` + MachineType string `json:"machineType"` +} + +// DeviceFlowResponse represents the response from the device authorization endpoint +type DeviceFlowResponse struct { + // VerificationURIComplete is the full URL with PKCE challenge for user authentication + VerificationURIComplete string `json:"verification_uri_complete"` + // CodeVerifier is the PKCE code verifier (generated locally, not from server) + CodeVerifier string `json:"code_verifier"` + // Nonce is the random nonce for the request + Nonce string `json:"nonce"` + // MachineID is the machine identifier + MachineID string `json:"machine_id"` +} + +// DeviceFlowPollResponse mirrors the actual /api/v1/deviceToken/poll success +// payload, e.g.: +// +// { +// "id": "019e34c9-...", +// "token": "dt-xwVyvraeJKzjDfLbM6ANNy9d", +// "user_id": "019cbc72-...", +// "code_challenge": "...", +// "expires_at": "2026-06-16T07:15:04Z", +// "refresh_token": "drt-AQHr26ttbx1nAZrKit4g7dns", +// "expires_in": 2591999998, +// "refresh_token_expires_in": 31103999999, +// "refresh_token_expires_at": "2027-05-12T07:15:04Z" +// } +// +// The fields are flat (no "data" wrapper). expires_at / refresh_token_expires_at +// are RFC3339 strings; expires_in / refresh_token_expires_in are seconds-from-now. +type DeviceFlowPollResponse struct { + ID string `json:"id"` + Token string `json:"token"` + UserID string `json:"user_id"` + RefreshToken string `json:"refresh_token"` + RefreshTokenID string `json:"refresh_token_id"` + ExpiresAt string `json:"expires_at"` + ExpiresIn int64 `json:"expires_in"` + RefreshTokenExpiresAt string `json:"refresh_token_expires_at"` + RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// RefreshTokenResponse mirrors /algo/api/v3/user/refresh_token. +// Treated as the same shape as the poll response until proven otherwise; if the +// upstream returns a different schema we'll see it via the empty-token error. +type RefreshTokenResponse = DeviceFlowPollResponse + +// UserInfoResponse represents the response from /api/v1/userinfo. The endpoint +// returns a flat JSON object (no "data" wrapper): +// +// {"id":"019cbc72-...", "name":"...", "username":"...", +// "email":"shiminhao964@example.com", "organization_id":"...", ...} +type UserInfoResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Username string `json:"username"` + Email string `json:"email"` + OrganizationID string `json:"organization_id"` +} + +// QoderAuth manages authentication and token handling for the Qoder API +type QoderAuth struct { + httpClient *http.Client +} + +// NewQoderAuth creates a new QoderAuth instance with a proxy-configured HTTP client +func NewQoderAuth(cfg *config.Config) *QoderAuth { + return &QoderAuth{ + httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}), + } +} + +// InitiateDeviceFlow starts the OAuth 2.0 device authorization flow. +// Qoder uses a simplified flow: generate PKCE locally and construct the login URL. +func (qa *QoderAuth) InitiateDeviceFlow(ctx context.Context) (*DeviceFlowResponse, error) { + codeVerifier, codeChallenge, err := generateDevicePKCEPair() + if err != nil { + return nil, fmt.Errorf("failed to generate PKCE pair: %w", err) + } + + nonce := uuid.New().String() + machineID := generateMachineID() + + verificationURI := fmt.Sprintf( + "%s?challenge=%s&challenge_method=S256&machine_id=%s&nonce=%s", + QoderLoginURL, + codeChallenge, + machineID, + nonce, + ) + + return &DeviceFlowResponse{ + VerificationURIComplete: verificationURI, + CodeVerifier: codeVerifier, + Nonce: nonce, + MachineID: machineID, + }, nil +} + +// PollForToken polls the token endpoint with the device code to obtain an access token. +func (qa *QoderAuth) PollForToken(ctx context.Context, deviceFlow *DeviceFlowResponse) (*QoderTokenData, error) { + if deviceFlow == nil || deviceFlow.CodeVerifier == "" || deviceFlow.Nonce == "" { + return nil, fmt.Errorf("device flow is missing code verifier or nonce") + } + + pollURL := fmt.Sprintf( + "%s?nonce=%s&verifier=%s&challenge_method=S256", + QoderOAuthTokenEndpoint, + url.QueryEscape(deviceFlow.Nonce), + url.QueryEscape(deviceFlow.CodeVerifier), + ) + + pollInterval := 2 * time.Second + maxAttempts := 90 // 3 minutes max (180 seconds / 2 seconds per poll) + + for attempt := 0; attempt < maxAttempts; attempt++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + req, err := http.NewRequestWithContext(ctx, "GET", pollURL, nil) + if err != nil { + log.Warnf("Polling attempt %d/%d failed: %v", attempt+1, maxAttempts, err) + time.Sleep(pollInterval) + continue + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Go-http-client/2.0") + + resp, err := qa.httpClient.Do(req) + if err != nil { + log.Warnf("Polling attempt %d/%d failed: %v", attempt+1, maxAttempts, err) + time.Sleep(pollInterval) + continue + } + + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + log.Warnf("Polling attempt %d/%d failed: %v", attempt+1, maxAttempts, err) + time.Sleep(pollInterval) + continue + } + + if resp.StatusCode == http.StatusAccepted { + // Still pending - continue polling + log.Debugf("Polling attempt %d/%d... (pending)", attempt+1, maxAttempts) + time.Sleep(pollInterval) + continue + } + + if resp.StatusCode == http.StatusNotFound { + // Token not created yet - user hasn't authenticated, continue polling + log.Debugf("Polling attempt %d/%d... (token not found, waiting for auth)", attempt+1, maxAttempts) + time.Sleep(pollInterval) + continue + } + + if resp.StatusCode != http.StatusOK { + // Parse error response + var errorData map[string]interface{} + if err = json.Unmarshal(body, &errorData); err == nil { + if errMsg, ok := errorData["message"].(string); ok { + return nil, fmt.Errorf("device token poll failed: %s", errMsg) + } + } + return nil, fmt.Errorf("device token poll failed: %d %s. Response: %s", resp.StatusCode, resp.Status, string(body)) + } + + // Success - parse token data + var response DeviceFlowPollResponse + if err = json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse token response: %w", err) + } + + // Defensive: surface a clear error if the upstream returned 200 but + // the token field is empty. + if response.Token == "" { + return nil, fmt.Errorf("device token poll returned empty access token; raw response keys may have changed") + } + + expireMs := parseExpiresAt(response.ExpiresAt, response.ExpiresIn) + + return &QoderTokenData{ + AccessToken: response.Token, + RefreshToken: response.RefreshToken, + ExpireTime: expireMs, + UserID: response.UserID, + }, nil + } + + return nil, fmt.Errorf("authentication timeout. Please restart the authentication process") +} + +// RefreshTokens exchanges a refresh token for a new access token +func (qa *QoderAuth) RefreshTokens(ctx context.Context, accessToken, refreshToken string) (*QoderTokenData, error) { + reqBody := map[string]string{ + "refreshToken": refreshToken, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal refresh request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", QoderRefreshTokenEndpoint, strings.NewReader(string(bodyBytes))) + if err != nil { + return nil, fmt.Errorf("failed to create refresh request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + + resp, err := qa.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("refresh request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read refresh response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errorData map[string]interface{} + if err = json.Unmarshal(body, &errorData); err == nil { + if errMsg, ok := errorData["message"].(string); ok { + return nil, fmt.Errorf("token refresh failed: %s", errMsg) + } + } + return nil, fmt.Errorf("token refresh failed: %d %s. Response: %s", resp.StatusCode, resp.Status, string(body)) + } + + var response RefreshTokenResponse + if err = json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse refresh response: %w", err) + } + + if response.Token == "" { + return nil, fmt.Errorf("token refresh returned empty access token; raw response keys may have changed") + } + + expireMs := parseExpiresAt(response.ExpiresAt, response.ExpiresIn) + + return &QoderTokenData{ + AccessToken: response.Token, + RefreshToken: response.RefreshToken, + ExpireTime: expireMs, + }, nil +} + +// FetchUserInfo fetches user information from the API +func (qa *QoderAuth) FetchUserInfo(ctx context.Context, accessToken string) (name, email string, err error) { + req, err := http.NewRequestWithContext(ctx, "GET", QoderUserInfoEndpoint, nil) + if err != nil { + return "", "", fmt.Errorf("failed to create user info request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Go-http-client/2.0") + + resp, err := qa.httpClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("user info request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("failed to read user info response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("user info request failed: %d %s", resp.StatusCode, resp.Status) + } + + var response UserInfoResponse + if err = json.Unmarshal(body, &response); err != nil { + return "", "", fmt.Errorf("failed to parse user info response: %w", err) + } + + name = strings.TrimSpace(response.Name) + if name == "" { + name = strings.TrimSpace(response.Username) + } + email = strings.TrimSpace(response.Email) + + return name, email, nil +} + +// SaveUserInfo stores the user info alongside auth metadata for later use. +// This mirrors the behavior in qoder-direct.py where user_id is persisted +// and userinfo fields are updated if available. +func (qa *QoderAuth) SaveUserInfo(ctx context.Context, accessToken, userID, name, email string) (string, string) { + if strings.TrimSpace(accessToken) == "" { + return name, email + } + + if strings.TrimSpace(name) == "" || strings.TrimSpace(email) == "" { + if fetchedName, fetchedEmail, err := qa.FetchUserInfo(ctx, accessToken); err == nil { + if strings.TrimSpace(name) == "" { + name = fetchedName + } + if strings.TrimSpace(email) == "" { + email = fetchedEmail + } + } + } + + return name, email +} + +// CreateTokenStorage creates a QoderTokenStorage object from a QoderTokenData object +func (qa *QoderAuth) CreateTokenStorage(tokenData *QoderTokenData, machineID string) *QoderTokenStorage { + storage := &QoderTokenStorage{ + Token: tokenData.AccessToken, + RefreshToken: tokenData.RefreshToken, + UserID: tokenData.UserID, + ExpireTime: tokenData.ExpireTime, + LastRefresh: time.Now().Format(time.RFC3339), + MachineID: machineID, + MachineToken: tokenData.MachineToken, + MachineType: tokenData.MachineType, + } + + return storage +} + +// UpdateTokenStorage updates an existing token storage with new token data +func (qa *QoderAuth) UpdateTokenStorage(storage *QoderTokenStorage, tokenData *QoderTokenData) { + storage.Token = tokenData.AccessToken + storage.RefreshToken = tokenData.RefreshToken + storage.ExpireTime = tokenData.ExpireTime + storage.LastRefresh = time.Now().Format(time.RFC3339) +} + +// RefreshTokensWithRetry attempts to refresh tokens with a specified number of retries upon failure +func (qa *QoderAuth) RefreshTokensWithRetry(ctx context.Context, accessToken, refreshToken string, maxRetries int) (*QoderTokenData, error) { + var lastErr error + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + // Wait before retry + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * time.Second): + } + } + + tokenData, err := qa.RefreshTokens(ctx, accessToken, refreshToken) + if err == nil { + return tokenData, nil + } + + lastErr = err + log.Warnf("Token refresh attempt %d/%d failed: %v", attempt+1, maxRetries, err) + } + + return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr) +} diff --git a/internal/auth/qoder/qoder_auth_test.go b/internal/auth/qoder/qoder_auth_test.go new file mode 100644 index 00000000..1ef26087 --- /dev/null +++ b/internal/auth/qoder/qoder_auth_test.go @@ -0,0 +1,546 @@ +package qoder + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +// TestNewQoderAuth tests the constructor with proxy configuration +func TestNewQoderAuth(t *testing.T) { + cfg := &config.Config{} + auth := NewQoderAuth(cfg) + if auth == nil { + t.Fatal("NewQoderAuth returned nil") + } + if auth.httpClient == nil { + t.Fatal("NewQoderAuth: httpClient is nil") + } +} + +// TestInitiateDeviceFlow tests device flow initiation +func TestInitiateDeviceFlow(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + resp, err := auth.InitiateDeviceFlow(context.Background()) + if err != nil { + t.Fatalf("InitiateDeviceFlow returned error: %v", err) + } + if resp == nil { + t.Fatal("InitiateDeviceFlow returned nil response") + } + if resp.VerificationURIComplete == "" { + t.Error("VerificationURIComplete is empty") + } + if resp.CodeVerifier == "" { + t.Error("CodeVerifier is empty") + } + if resp.Nonce == "" { + t.Error("Nonce is empty") + } + if resp.MachineID == "" { + t.Error("MachineID is empty") + } + if !strings.Contains(resp.VerificationURIComplete, QoderLoginURL) { + t.Errorf("VerificationURIComplete %q does not contain %q", resp.VerificationURIComplete, QoderLoginURL) + } + if !strings.Contains(resp.VerificationURIComplete, "challenge=") { + t.Errorf("VerificationURIComplete %q missing challenge=", resp.VerificationURIComplete) + } + if strings.Contains(resp.VerificationURIComplete, "verifier=") { + t.Errorf("VerificationURIComplete %q must not leak verifier", resp.VerificationURIComplete) + } +} + +// TestPollForToken_Success tests successful token polling +func TestPollForToken_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{ + "data": { + "token": "test_access_token", + "refresh_token": "test_refresh_token", + "expire_time": 1776902400000, + "expireTime": "2026-02-20T00:00:00Z", + "user_id": "test_user", + "machine_token": "test_machine_token", + "machineType": "personal" + } + }`) + })) + defer server.Close() + + auth := NewQoderAuth(&config.Config{}) + deviceFlow := &DeviceFlowResponse{ + VerificationURIComplete: server.URL + "?verifier=test_verifier&nonce=test_nonce", + CodeVerifier: "test_verifier", + Nonce: "test_nonce", + MachineID: "test_machine", + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + tokenData, err := auth.PollForToken(ctx, deviceFlow) + // This will timeout because we can't override the endpoint URL + // Just verify it doesn't panic + if err == nil { + t.Error("expected error due to non-overridable endpoint, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } +} + +// TestPollForToken_Timeout tests timeout after max attempts +func TestPollForToken_Timeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) // Still pending + })) + defer server.Close() + + auth := NewQoderAuth(&config.Config{}) + deviceFlow := &DeviceFlowResponse{ + VerificationURIComplete: server.URL + "?verifier=test_verifier&nonce=test_nonce", + CodeVerifier: "test_verifier", + Nonce: "test_nonce", + MachineID: "test_machine", + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + tokenData, err := auth.PollForToken(ctx, deviceFlow) + if err == nil { + t.Error("expected timeout error, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } +} + +// TestPollForToken_ContextCancel tests context cancellation +func TestPollForToken_ContextCancel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + time.Sleep(100 * time.Millisecond) + })) + defer server.Close() + + auth := NewQoderAuth(&config.Config{}) + deviceFlow := &DeviceFlowResponse{ + VerificationURIComplete: server.URL + "?verifier=test_verifier&nonce=test_nonce", + CodeVerifier: "test_verifier", + Nonce: "test_nonce", + MachineID: "test_machine", + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + tokenData, err := auth.PollForToken(ctx, deviceFlow) + if err == nil { + t.Error("expected context-cancelled error, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } +} + +// TestPollForToken_HTTPError tests handling of HTTP errors +func TestPollForToken_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"message": "internal server error"}`) + })) + defer server.Close() + + auth := NewQoderAuth(&config.Config{}) + deviceFlow := &DeviceFlowResponse{ + VerificationURIComplete: server.URL + "?verifier=test_verifier&nonce=test_nonce", + CodeVerifier: "test_verifier", + Nonce: "test_nonce", + MachineID: "test_machine", + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + tokenData, err := auth.PollForToken(ctx, deviceFlow) + if err == nil { + t.Error("expected HTTP error, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } +} + +// TestPollForToken_InvalidJSON tests handling of malformed JSON +func TestPollForToken_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `invalid json`) + })) + defer server.Close() + + auth := NewQoderAuth(&config.Config{}) + deviceFlow := &DeviceFlowResponse{ + VerificationURIComplete: server.URL + "?verifier=test_verifier&nonce=test_nonce", + CodeVerifier: "test_verifier", + Nonce: "test_nonce", + MachineID: "test_machine", + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + tokenData, err := auth.PollForToken(ctx, deviceFlow) + if err == nil { + t.Error("expected JSON parse error, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } +} + +// TestPollForToken_NonOKStatus tests handling of non-200 status codes +func TestPollForToken_NonOKStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"message": "bad request"}`) + })) + defer server.Close() + + auth := NewQoderAuth(&config.Config{}) + deviceFlow := &DeviceFlowResponse{ + VerificationURIComplete: server.URL + "?verifier=test_verifier&nonce=test_nonce", + CodeVerifier: "test_verifier", + Nonce: "test_nonce", + MachineID: "test_machine", + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + tokenData, err := auth.PollForToken(ctx, deviceFlow) + if err == nil { + t.Error("expected non-OK status error, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } +} + +// TestRefreshTokens_Success tests successful token refresh +func TestRefreshTokens_Success(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // This test will fail because we can't actually make HTTP requests + // to the real endpoint. We're just testing that the function doesn't panic + // and returns an error (since we're using invalid credentials). + tokenData, err := auth.RefreshTokens(ctx, "old_token", "old_refresh") + if err == nil { + t.Error("expected error from invalid refresh, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } +} + +// TestRefreshTokens_Failure tests token refresh failure +func TestRefreshTokens_Failure(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + tokenData, err := auth.RefreshTokens(ctx, "old_token", "old_refresh") + if err == nil { + t.Error("expected error from invalid refresh, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } +} + +// TestRefreshTokensWithRetry_Success tests successful refresh after retry +func TestRefreshTokensWithRetry_Success(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // This will fail because we can't actually make HTTP requests + // We're just testing that the function doesn't panic + tokenData, err := auth.RefreshTokensWithRetry(ctx, "old_token", "old_refresh", 2) + if err == nil { + t.Error("expected error from invalid retry, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } +} + +// TestRefreshTokensWithRetry_Exhausted tests failure after max retries +func TestRefreshTokensWithRetry_Exhausted(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tokenData, err := auth.RefreshTokensWithRetry(ctx, "old_token", "old_refresh", 2) + if err == nil { + t.Fatal("expected exhausted-retries error, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } + if !strings.Contains(err.Error(), "failed after 2 attempts") { + t.Errorf("error %q does not contain %q", err.Error(), "failed after 2 attempts") + } +} + +// TestRefreshTokensWithRetry_ContextCancel tests context cancellation during retry +func TestRefreshTokensWithRetry_ContextCancel(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + tokenData, err := auth.RefreshTokensWithRetry(ctx, "old_token", "old_refresh", 3) + if err == nil { + t.Error("expected context-cancelled error, got nil") + } + if tokenData != nil { + t.Errorf("expected nil tokenData, got %+v", tokenData) + } +} + +// TestFetchUserInfo_Success tests successful user info fetch +func TestFetchUserInfo_Success(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + name, email, err := auth.FetchUserInfo(ctx, "test_token") + if err == nil { + t.Error("expected error from fake token, got nil") + } + if name != "" { + t.Errorf("expected empty name, got %q", name) + } + if email != "" { + t.Errorf("expected empty email, got %q", email) + } +} + +// TestFetchUserInfo_Failure tests user info fetch failure +func TestFetchUserInfo_Failure(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + name, email, err := auth.FetchUserInfo(ctx, "test_token") + if err == nil { + t.Error("expected error from fake token, got nil") + } + if name != "" { + t.Errorf("expected empty name, got %q", name) + } + if email != "" { + t.Errorf("expected empty email, got %q", email) + } +} + +// TestSaveUserInfo tests saving user info +func TestSaveUserInfo(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + name, email := auth.SaveUserInfo(context.Background(), "token", "user123", "", "") + if name != "" { + t.Errorf("expected empty name, got %q", name) + } + if email != "" { + t.Errorf("expected empty email, got %q", email) + } +} + +// TestCreateTokenStorage tests creating token storage +func TestCreateTokenStorage(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + tokenData := &QoderTokenData{ + AccessToken: "token", + RefreshToken: "refresh", + UserID: "user123", + ExpireTime: 1776902400000, + MachineToken: "machine_token", + MachineType: "personal", + } + storage := auth.CreateTokenStorage(tokenData, "machine123") + if storage == nil { + t.Fatal("CreateTokenStorage returned nil") + } + if storage.Token != "token" { + t.Errorf("Token = %q, want %q", storage.Token, "token") + } + if storage.RefreshToken != "refresh" { + t.Errorf("RefreshToken = %q, want %q", storage.RefreshToken, "refresh") + } + if storage.UserID != "user123" { + t.Errorf("UserID = %q, want %q", storage.UserID, "user123") + } + if storage.MachineID != "machine123" { + t.Errorf("MachineID = %q, want %q", storage.MachineID, "machine123") + } + // Type is set when saving to file, not in CreateTokenStorage + if storage.Type != "" { + t.Errorf("Type = %q, want empty", storage.Type) + } +} + +// TestUpdateTokenStorage tests updating token storage +func TestUpdateTokenStorage(t *testing.T) { + auth := NewQoderAuth(&config.Config{}) + storage := &QoderTokenStorage{ + Token: "old_token", + RefreshToken: "old_refresh", + ExpireTime: 1000, + } + tokenData := &QoderTokenData{ + AccessToken: "new_token", + RefreshToken: "new_refresh", + ExpireTime: 2000, + } + auth.UpdateTokenStorage(storage, tokenData) + if storage.Token != "new_token" { + t.Errorf("Token = %q, want %q", storage.Token, "new_token") + } + if storage.RefreshToken != "new_refresh" { + t.Errorf("RefreshToken = %q, want %q", storage.RefreshToken, "new_refresh") + } + if storage.ExpireTime != 2000 { + t.Errorf("ExpireTime = %d, want %d", storage.ExpireTime, 2000) + } +} + +// TestRefreshTokenIfNeeded_NoRefreshNeeded tests no refresh when token is valid +func TestRefreshTokenIfNeeded_NoRefreshNeeded(t *testing.T) { + storage := &QoderTokenStorage{ + Token: "token", + RefreshToken: "refresh", + ExpireTime: time.Now().Add(1 * time.Hour).UnixMilli(), + } + if err := RefreshTokenIfNeeded(context.Background(), &config.Config{}, storage, 600, ""); err != nil { + t.Errorf("RefreshTokenIfNeeded returned error: %v", err) + } +} + +// TestRefreshTokenIfNeeded_RefreshFails tests refresh failure +func TestRefreshTokenIfNeeded_RefreshFails(t *testing.T) { + storage := &QoderTokenStorage{ + Token: "token", + RefreshToken: "refresh", + ExpireTime: 1000, // Expired + UserID: "user123", + Email: "test@example.com", + } + if err := RefreshTokenIfNeeded(context.Background(), &config.Config{}, storage, 600, ""); err == nil { + t.Error("expected refresh error, got nil") + } +} + +// TestIsExpired tests token expiration check +func TestIsExpired(t *testing.T) { + storage := &QoderTokenStorage{} + if !storage.IsExpired(0) { + t.Error("IsExpired(0) on zero ExpireTime should be true") + } + + storage.ExpireTime = time.Now().Add(1 * time.Hour).UnixMilli() + if storage.IsExpired(0) { + t.Error("IsExpired(0) on +1h token should be false") + } + if !storage.IsExpired(7200000) { // 2 hours in ms + t.Error("IsExpired(2h buffer) on +1h token should be true") + } +} + +// TestParseExpiresAt tests parsing various expire time formats +func TestParseExpiresAt(t *testing.T) { + // RFC3339 format + rfc3339 := "2026-02-20T00:00:00Z" + if got := parseExpiresAt(rfc3339, 0); got <= 0 { + t.Errorf("parseExpiresAt(%q, 0) = %d, want > 0", rfc3339, got) + } + + // Milliseconds format + ms := "1776902400000" + if got := parseExpiresAt(ms, 0); got <= 0 { + t.Errorf("parseExpiresAt(%q, 0) = %d, want > 0", ms, got) + } + + // Invalid format - should return default (now + 30 days) + invalid := "invalid" + if got := parseExpiresAt(invalid, 0); got <= time.Now().UnixMilli() { + t.Errorf("parseExpiresAt(%q, 0) = %d, expected > now", invalid, got) + } +} + +// TestGenerateDeviceCodeVerifier tests verifier generation +func TestGenerateDeviceCodeVerifier(t *testing.T) { + verifier, err := generateDeviceCodeVerifier() + if err != nil { + t.Fatalf("generateDeviceCodeVerifier returned error: %v", err) + } + if verifier == "" { + t.Fatal("verifier is empty") + } + if len(verifier) != 43 { // base64url encoded 32 bytes + t.Errorf("len(verifier) = %d, want 43", len(verifier)) + } +} + +// TestGenerateDeviceCodeChallenge tests challenge generation +func TestGenerateDeviceCodeChallenge(t *testing.T) { + verifier := "test_verifier_string_for_testing" + challenge := generateDeviceCodeChallenge(verifier) + if challenge == "" { + t.Fatal("challenge is empty") + } + if len(challenge) != 43 { // base64url encoded 32 bytes + t.Errorf("len(challenge) = %d, want 43", len(challenge)) + } +} + +// TestGenerateMachineID tests machine ID generation +func TestGenerateMachineID(t *testing.T) { + id := generateMachineID() + if id == "" { + t.Fatal("machine ID is empty") + } + // Should be a valid UUID + if len(id) != 36 { + t.Errorf("len(machineID) = %d, want 36", len(id)) + } +} + +// TestFormatExpiresAt tests expire time formatting +func TestFormatExpiresAt(t *testing.T) { + expireMs := int64(1776902400000) + result := formatExpiresAt(expireMs) + // The exact format depends on the local timezone, so just check it's not empty + if result == "" { + t.Fatal("formatted expire time is empty") + } + if !strings.Contains(result, "2026") { + t.Errorf("formatted expire %q does not contain 2026", result) + } +} diff --git a/internal/auth/qoder/qoder_token.go b/internal/auth/qoder/qoder_token.go new file mode 100644 index 00000000..07439d2e --- /dev/null +++ b/internal/auth/qoder/qoder_token.go @@ -0,0 +1,226 @@ +// Package qoder provides authentication and token handling for Qoder API. +package qoder + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" +) + +// QoderTokenStorage stores OAuth2 token information for Qoder API authentication. +// It maintains compatibility with the existing auth system while adding Qoder-specific fields. +type QoderTokenStorage struct { + // Token is the OAuth2 access token used for authenticating API requests. + Token string `json:"token"` + // RefreshToken is used to obtain new access tokens when the current one expires. + RefreshToken string `json:"refresh_token"` + // UserID is the unique identifier for the Qoder user. + UserID string `json:"user_id"` + // Name is the user's display name. + Name string `json:"name"` + // Email is the Qoder account email address associated with this token. + Email string `json:"email"` + // ExpireTime is the timestamp when the current access token expires (milliseconds epoch). + ExpireTime int64 `json:"expire_time"` + // Type indicates the authentication provider type, always "qoder" for this storage. + Type string `json:"type"` + // LastRefresh is the timestamp of the last token refresh operation. + LastRefresh string `json:"last_refresh"` + // MachineID is the persistent machine identifier for this installation. + MachineID string `json:"machine_id,omitempty"` + // MachineToken is the machine-specific token (if returned by auth server). + MachineToken string `json:"machine_token,omitempty"` + // MachineType is the type of machine registration. + MachineType string `json:"machine_type,omitempty"` + // ModelConfigs caches the raw upstream model_config entries from the most + // recent /algo/api/v2/model/list response, keyed by model id (e.g. + // "dfmodel" -> {"key":"dfmodel","format":"openai","is_vl":true, ...}). + // Persisted to disk so per-model fields survive restarts; mutated through + // SetModelConfigs / GetModelConfig only so concurrent FetchQoderModels + + // chat traffic never race on the underlying map. + ModelConfigs map[string]json.RawMessage `json:"model_configs,omitempty"` + + // UsageInfo caches the most recent /api/v2/quota/usage response. + // Populated by FetchQoderUsage; not persisted to disk (in-memory only). + // Access must go through SetUsageInfo / GetUsageInfo. + UsageInfo *QoderUsageInfo `json:"-"` + + // usageMu guards UsageInfo against concurrent FetchQoderUsage writes + // vs management-listing reads (buildAuthFileEntry). + usageMu sync.RWMutex `json:"-"` + + // Metadata holds arbitrary key-value pairs injected via hooks. + // It is not exported to JSON directly to allow flattening during serialization. + Metadata map[string]any `json:"-"` + + // modelConfigMu guards ModelConfigs against concurrent + // FetchQoderModels writes vs ExecuteStream reads. The map fetched + // from /algo/api/v2/model/list is replaced wholesale, but the lookup + // path (GetModelConfig) still reads it during chat requests. + modelConfigMu sync.RWMutex `json:"-"` +} + +// QoderUsageInfo holds the parsed /api/v2/quota/usage response. +type QoderUsageInfo struct { + // UserQuota is the personal credit quota. + UserQuota QoderQuota `json:"userQuota"` + // OrgResourcePackage is the org-level resource package. + OrgResourcePackage QoderQuota `json:"orgResourcePackage"` + // TotalUsagePercentage is the combined usage percentage (0–1). + TotalUsagePercentage float64 `json:"totalUsagePercentage"` + // IsQuotaExceeded indicates whether the quota is exhausted. + IsQuotaExceeded bool `json:"isQuotaExceeded"` + // ExpiresAt is the quota reset timestamp in milliseconds epoch. + ExpiresAt int64 `json:"expiresAt"` +} + +// QoderQuota holds a single quota bucket (user or org). +type QoderQuota struct { + Total float64 `json:"total"` + Used float64 `json:"used"` + Remaining float64 `json:"remaining"` + Percentage float64 `json:"percentage"` + Unit string `json:"unit"` +} + +// SetMetadata allows external callers to inject metadata into the storage before saving. +func (ts *QoderTokenStorage) SetMetadata(meta map[string]any) { + ts.Metadata = meta +} + +// SetModelConfigs replaces the cached per-model configs atomically. +// Callers (FetchQoderModels) hand in the freshly-fetched table; readers +// (ExecuteStream via GetModelConfig) see either the previous full set or +// the new full set, never a half-built map. +func (ts *QoderTokenStorage) SetModelConfigs(configs map[string]json.RawMessage) { + if ts == nil { + return + } + ts.modelConfigMu.Lock() + ts.ModelConfigs = configs + ts.modelConfigMu.Unlock() +} + +// GetModelConfig returns the cached upstream model entry for the given key +// (or false if no fetch has populated the cache yet / the key is unknown). +// The returned RawMessage is safe to read; callers must not mutate it. +func (ts *QoderTokenStorage) GetModelConfig(key string) (json.RawMessage, bool) { + if ts == nil { + return nil, false + } + ts.modelConfigMu.RLock() + defer ts.modelConfigMu.RUnlock() + raw, ok := ts.ModelConfigs[key] + return raw, ok +} + +// SetUsageInfo replaces the cached quota-usage snapshot atomically. +// Callers (FetchQoderUsage background goroutine) hand in the freshly-fetched +// info; readers (buildAuthFileEntry on the management listing path) see +// either the previous full snapshot or the new one, never a torn pointer. +func (ts *QoderTokenStorage) SetUsageInfo(info *QoderUsageInfo) { + if ts == nil { + return + } + ts.usageMu.Lock() + ts.UsageInfo = info + ts.usageMu.Unlock() +} + +// GetUsageInfo returns the cached quota-usage snapshot (or nil if none has +// been fetched yet). Safe for concurrent use with SetUsageInfo. +func (ts *QoderTokenStorage) GetUsageInfo() *QoderUsageInfo { + if ts == nil { + return nil + } + ts.usageMu.RLock() + defer ts.usageMu.RUnlock() + return ts.UsageInfo +} + +// ModelConfigKeys returns the sorted list of cached model keys (used in +// error messages). Locks ModelConfigs while building the slice. +func (ts *QoderTokenStorage) ModelConfigKeys() []string { + if ts == nil { + return nil + } + ts.modelConfigMu.RLock() + defer ts.modelConfigMu.RUnlock() + if len(ts.ModelConfigs) == 0 { + return nil + } + keys := make([]string, 0, len(ts.ModelConfigs)) + for k := range ts.ModelConfigs { + keys = append(keys, k) + } + return keys +} + +// SaveTokenToFile serializes the Qoder token storage to a JSON file. +// This method creates the necessary directory structure and writes the token +// data in JSON format to the specified file path for persistent storage. +// It merges any injected metadata into the top-level JSON object. +// +// Parameters: +// - authFilePath: The full path where the token file should be saved +// +// Returns: +// - error: An error if the operation fails, nil otherwise +func (ts *QoderTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) + ts.Type = "qoder" + + if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { + return fmt.Errorf("failed to create directory: %v", err) + } + + data, errMerge := misc.MergeMetadata(ts, ts.Metadata) + if errMerge != nil { + return fmt.Errorf("failed to merge metadata: %w", errMerge) + } + + // Write to a temp file and atomically rename onto the target path. + // os.Create + Encode leaves a TOCTOU window where the file watcher + // sees an empty (just-truncated) or partially-written file; a temp + // write eliminates that window because the rename is atomic on the + // same filesystem. + tmp, err := os.CreateTemp(filepath.Dir(authFilePath), ".tmp-qoder-*") + if err != nil { + return fmt.Errorf("failed to create temp token file: %w", err) + } + tmpName := tmp.Name() + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = os.Remove(tmpName) + } + }() + + if err = json.NewEncoder(tmp).Encode(data); err != nil { + return fmt.Errorf("failed to write token to temp file: %w", err) + } + if err = tmp.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + if err = os.Rename(tmpName, authFilePath); err != nil { + return fmt.Errorf("failed to commit token file: %w", err) + } + cleanup = false // rename succeeded, don't remove + return nil +} + +// IsExpired checks if the token has expired or will expire within the given duration +func (ts *QoderTokenStorage) IsExpired(bufferDuration int64) bool { + if ts.ExpireTime == 0 { + return true + } + now := time.Now().UnixMilli() + return ts.ExpireTime-now-bufferDuration <= 0 +} diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index 6fe5c888..46532414 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -26,6 +26,7 @@ func newAuthManager() *sdkAuth.Manager { sdkAuth.NewGitLabAuthenticator(), sdkAuth.NewCodeBuddyAuthenticator(), sdkAuth.NewCursorAuthenticator(), + sdkAuth.NewQoderAuthenticator(), ) return manager } diff --git a/internal/cmd/qoder_login.go b/internal/cmd/qoder_login.go new file mode 100644 index 00000000..a0f72edc --- /dev/null +++ b/internal/cmd/qoder_login.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth" + log "github.com/sirupsen/logrus" +) + +// DoQoderLogin handles the Qoder device flow using the shared authentication manager. +// It initiates the device-based authentication process for Qoder services and saves +// the authentication tokens to the configured auth directory. +// +// Parameters: +// - cfg: The application configuration +// - options: Login options including browser behavior and prompts +func DoQoderLogin(cfg *config.Config, options *LoginOptions) { + if options == nil { + options = &LoginOptions{} + } + + manager := newAuthManager() + + promptFn := options.Prompt + if promptFn == nil { + promptFn = func(prompt string) (string, error) { + fmt.Println() + fmt.Println(prompt) + var value string + _, err := fmt.Scanln(&value) + return value, err + } + } + + authOpts := &sdkAuth.LoginOptions{ + NoBrowser: options.NoBrowser, + CallbackPort: options.CallbackPort, + Metadata: map[string]string{}, + Prompt: promptFn, + } + + _, savedPath, err := manager.Login(context.Background(), "qoder", cfg, authOpts) + if err != nil { + if emailErr, ok := errors.AsType[*sdkAuth.EmailRequiredError](err); ok { + log.Error(emailErr.Error()) + return + } + fmt.Printf("Qoder authentication failed: %v\n", err) + return + } + + if savedPath != "" { + fmt.Printf("Authentication saved to %s\n", savedPath) + } + + fmt.Println("Qoder authentication successful!") +} diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 04b962b8..1d85d911 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -25,6 +25,7 @@ type staticModelsJSON struct { CodexPlus []*ModelInfo `json:"codex-plus"` CodexPro []*ModelInfo `json:"codex-pro"` Kimi []*ModelInfo `json:"kimi"` + Qoder []*ModelInfo `json:"qoder"` Antigravity []*ModelInfo `json:"antigravity"` XAI []*ModelInfo `json:"xai"` } @@ -253,6 +254,8 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo { return GetAntigravityModels() case "xai", "x-ai", "grok": return GetXAIModels() + case "qoder": + return GetQoderModels() default: return nil } @@ -288,6 +291,7 @@ func LookupStaticModelInfo(modelID string) *ModelInfo { data.Kimi, data.Antigravity, data.XAI, + data.Qoder, } for _, models := range allModels { for _, m := range models { @@ -755,3 +759,8 @@ func GetAmazonQModels() []*ModelInfo { }, } } + +// GetQoderModels returns the Qoder model definitions. +func GetQoderModels() []*ModelInfo { + return cloneModelInfos(getModels().Qoder) +} diff --git a/internal/registry/model_updater.go b/internal/registry/model_updater.go index 40033801..79b3be21 100644 --- a/internal/registry/model_updater.go +++ b/internal/registry/model_updater.go @@ -214,6 +214,7 @@ func detectChangedProviders(oldData, newData *staticModelsJSON) []string { {"codex", oldData.CodexPlus, newData.CodexPlus}, {"codex", oldData.CodexPro, newData.CodexPro}, {"kimi", oldData.Kimi, newData.Kimi}, + {"qoder", oldData.Qoder, newData.Qoder}, {"antigravity", oldData.Antigravity, newData.Antigravity}, {"xai", oldData.XAI, newData.XAI}, } @@ -335,6 +336,7 @@ func validateModelsCatalog(data *staticModelsJSON) error { {name: "codex-plus", models: data.CodexPlus}, {name: "codex-pro", models: data.CodexPro}, {name: "kimi", models: data.Kimi}, + {name: "qoder", models: data.Qoder}, {name: "antigravity", models: data.Antigravity}, {name: "xai", models: data.XAI}, } diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json index 7c235d41..1a02a23a 100644 --- a/internal/registry/models/models.json +++ b/internal/registry/models/models.json @@ -2277,5 +2277,158 @@ ] } } + ], + "qoder": [ + { + "id": "qoder/auto", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "Qoder Auto", + "description": "Qoder Auto — automatically selects the best model for your prompt", + "context_length": 131072, + "max_completion_tokens": 65536 + }, + { + "id": "qoder/ultimate", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "Qoder Ultimate", + "description": "Qoder Ultimate — highest quality tier", + "context_length": 131072, + "max_completion_tokens": 65536, + "thinking": { + "min": 1024, + "max": 32000, + "zero_allowed": true, + "dynamic_allowed": true + } + }, + { + "id": "qoder/performance", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "Qoder Performance", + "description": "Qoder Performance — balanced quality and speed", + "context_length": 131072, + "max_completion_tokens": 32768, + "thinking": { + "min": 1024, + "max": 32000, + "zero_allowed": true, + "dynamic_allowed": true + } + }, + { + "id": "qoder/efficient", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "Qoder Efficient", + "description": "Qoder Efficient — cost-efficient tier", + "context_length": 131072, + "max_completion_tokens": 16384 + }, + { + "id": "qoder/lite", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "Qoder Lite", + "description": "Qoder Lite — fastest and most affordable tier", + "context_length": 131072, + "max_completion_tokens": 16384 + }, + { + "id": "qoder/qmodel", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "Qwen 3.6 Plus (Qoder)", + "description": "Qoder frontier — Qwen 3.6 Plus", + "context_length": 131072, + "max_completion_tokens": 32768 + }, + { + "id": "qoder/dmodel", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "DeepSeek V4 Pro (Qoder)", + "description": "Qoder frontier — DeepSeek V4 Pro", + "context_length": 131072, + "max_completion_tokens": 65536, + "thinking": { + "min": 1024, + "max": 32000, + "zero_allowed": true, + "dynamic_allowed": true + } + }, + { + "id": "qoder/dfmodel", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "DeepSeek V4 Flash (Qoder)", + "description": "Qoder frontier — DeepSeek V4 Flash", + "context_length": 131072, + "max_completion_tokens": 16384 + }, + { + "id": "qoder/gm51model", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "GLM 5.1 (Qoder)", + "description": "Qoder frontier — GLM 5.1", + "context_length": 131072, + "max_completion_tokens": 32768, + "thinking": { + "min": 1024, + "max": 32000, + "zero_allowed": true, + "dynamic_allowed": true + } + }, + { + "id": "qoder/kmodel", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "Kimi K2.6 (Qoder)", + "description": "Qoder frontier — Kimi K2.6", + "context_length": 131072, + "max_completion_tokens": 32768, + "thinking": { + "min": 1024, + "max": 32000, + "zero_allowed": true, + "dynamic_allowed": true + } + }, + { + "id": "qoder/mmodel", + "object": "model", + "created": 1767196800, + "owned_by": "qoder", + "type": "qoder", + "display_name": "MiniMax M2.7 (Qoder)", + "description": "Qoder frontier — MiniMax M2.7", + "context_length": 131072, + "max_completion_tokens": 65536 + } ] -} +} \ No newline at end of file diff --git a/internal/runtime/executor/helps/qoder_encoding.go b/internal/runtime/executor/helps/qoder_encoding.go new file mode 100644 index 00000000..3a30a558 --- /dev/null +++ b/internal/runtime/executor/helps/qoder_encoding.go @@ -0,0 +1,51 @@ +package helps + +import ( + "encoding/base64" +) + +// QoderEncodeBody encodes a request body using the Qoder body encoding scheme. +// This is a port of qoder2api's QoderEncoding.java. The algorithm: +// +// 1. Standard base64-encode the plaintext bytes. +// 2. Rearrange: split into thirds, reorder as [tail][mid][head]. +// 3. Substitute each character using a custom alphabet mapping. +// +// The encoded body must be sent with &Encode=1 appended to the URL. +// The server decodes in reverse. This obfuscation prevents Alibaba Cloud WAF +// from pattern-matching the plaintext request body. +func QoderEncodeBody(plaintext []byte) string { + std := base64.StdEncoding.EncodeToString(plaintext) + n := len(std) + a := n / 3 + // Rearrange: [tail][mid][head] + rearranged := std[n-a:] + std[a:n-a] + std[:a] + out := make([]byte, n) + for i := 0; i < n; i++ { + c := rearranged[i] + if int(c) < 128 && qoderS2C[c] >= 0 { + out[i] = byte(qoderS2C[c]) + } else { + out[i] = c + } + } + return string(out) +} + +const ( + qoderCustomAlphabet = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!" + qoderStdAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +) + +// qoderS2C maps standard base64 chars → custom alphabet chars. +var qoderS2C [128]int + +func init() { + for i := range qoderS2C { + qoderS2C[i] = -1 + } + for i := 0; i < 64; i++ { + qoderS2C[qoderStdAlphabet[i]] = int(qoderCustomAlphabet[i]) + } + qoderS2C['='] = int('$') // custom pad +} diff --git a/internal/runtime/executor/helps/qoder_encoding_test.go b/internal/runtime/executor/helps/qoder_encoding_test.go new file mode 100644 index 00000000..21630153 --- /dev/null +++ b/internal/runtime/executor/helps/qoder_encoding_test.go @@ -0,0 +1,55 @@ +package helps + +import ( + "encoding/json" + "testing" +) + +func TestQoderEncodeBody_roundtrip(t *testing.T) { + inputs := []string{ + "hello world", + `{"messages":[{"role":"user","content":"UNION SELECT 1,2,3"}]}`, + `{"content":"execSync( etc/passwd "}`, + } + for _, input := range inputs { + encoded := QoderEncodeBody([]byte(input)) + if len(encoded) == 0 { + t.Errorf("encode(%q) returned empty string", input) + } + // Verify it doesn't look like JSON (WAF can't pattern-match it) + var v interface{} + if json.Unmarshal([]byte(encoded), &v) == nil { + t.Errorf("encode(%q) produced valid JSON — encoding may not be working", input) + } + } +} + +func TestQoderEncodeBody_wafTriggers(t *testing.T) { + body := []byte(`{"stream":true,"messages":[{"role":"user","content":"UNION SELECT 1,2,3 FROM users; execSync("}]}`) + encoded := QoderEncodeBody(body) + if len(encoded) == 0 { + t.Fatal("QoderEncodeBody returned empty string") + } + for _, trigger := range []string{"UNION SELECT", "execSync(", "FROM users"} { + if containsStr(encoded, trigger) { + t.Errorf("encoded body still contains trigger %q", trigger) + } + } + t.Logf("encoded length: %d, first 80 chars: %s", len(encoded), encoded[:min(80, len(encoded))]) +} + +func containsStr(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/runtime/executor/qoder_executor.go b/internal/runtime/executor/qoder_executor.go new file mode 100644 index 00000000..53d3fb4a --- /dev/null +++ b/internal/runtime/executor/qoder_executor.go @@ -0,0 +1,1050 @@ +package executor + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/google/uuid" + qoderauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/qoder" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" +) + +// QoderExecutor executes requests against the Qoder API with COSY authentication +type QoderExecutor struct { + cfg *config.Config +} + +// NewQoderExecutor creates a new Qoder executor +func NewQoderExecutor(cfg *config.Config) *QoderExecutor { + return &QoderExecutor{ + cfg: cfg, + } +} + +// Identifier returns the provider identifier +func (e *QoderExecutor) Identifier() string { + return "qoder" +} + +// ExecuteStream executes a streaming request against Qoder API +func (e *QoderExecutor) ExecuteStream(ctx context.Context, authRecord *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + // Get token storage from auth record + storage, ok := authRecord.Storage.(*qoderauth.QoderTokenStorage) + if !ok { + return nil, fmt.Errorf("invalid auth storage type for qoder: %T", authRecord.Storage) + } + + // Note: Qoder device tokens are long-lived (~30 days) and the upstream + // /algo/api/v3/user/refresh_token endpoint returns 403 for them — see + // QoderExecutor.Refresh's no-op rationale. We deliberately do not call + // RefreshTokenIfNeeded per request: it would just produce a 403 in the + // log on every chat call. Token expiry is handled by the user re-running + // --qoder-login. + + // Translate non-openai formats to chat completions before extracting messages + payload := req.Payload + if opts.SourceFormat != "" && opts.SourceFormat != sdktranslator.FormatOpenAI { + payload = sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FormatOpenAI, req.Model, payload, false) + } + + // Parse request to get model and messages + var chatReq map[string]interface{} + if err := json.Unmarshal(payload, &chatReq); err != nil { + return nil, fmt.Errorf("failed to parse request: %w", err) + } + + // Map model name — strip provider prefix so qoder/auto → auto + model, _ := chatReq["model"].(string) + qoderModel := strings.TrimPrefix(model, "qoder/") + if mapped, ok := qoderauth.ModelMap[qoderModel]; ok { + qoderModel = mapped + } else { + return nil, fmt.Errorf("unsupported qoder model: %q (received %q)", qoderModel, model) + } + + // Normalize messages: flatten Anthropic/OpenAI multipart content arrays + // to plain strings (Qoder's chat endpoint expects content to be a string). + // tool_calls / role:"tool" turns pass through verbatim — Qoder accepts + // the canonical OpenAI structure and emits real tool_use events. + messagesRaw, _ := chatReq["messages"].([]interface{}) + toolsRaw := chatReq["tools"] + normalized, systemText := normalizeQoderMessages(messagesRaw) + + // Resolve the per-model server-side metadata (is_vl, is_reasoning, + // max_input_tokens, ...). Failing here is a hard error — sending the + // wrong block silently downgrades to a different model. + modelConfig, err := buildQoderModelConfig(storage, qoderModel) + if err != nil { + return nil, err + } + + isReasoning, _ := modelConfig["is_reasoning"].(bool) + maxOutputTokens, _ := modelConfig["max_output_tokens"].(float64) + + // Last user message text — used by Qoder for the chat_context "current + // turn" preview slot. The full conversation still goes through `messages`. + lastUser := lastUserText(normalized) + + // Stable IDs derived from content so retries hit upstream caches. + // session_id is stable per user+model (routing affinity). + // chat_record_id is deterministic per payload (dedup/cache key). + sessionID := stableHash("qoder-session", storage.UserID, qoderModel) + recordID := stableChatRecordID(qoderModel, normalized, toolsRaw, int(maxOutputTokens)) + + // Start with the model's maximum output tokens, then clamp to + // any user-requested limit so callers can cap cost/latency/UI. + maxTokens := 32768 + if maxOutputTokens > 0 { + maxTokens = int(maxOutputTokens) + } + if userMax, ok := chatReq["max_tokens"].(float64); ok && userMax > 0 { + if int(userMax) < maxTokens { + maxTokens = int(userMax) + } + } + if userMax, ok := chatReq["max_completion_tokens"].(float64); ok && userMax > 0 { + if int(userMax) < maxTokens { + maxTokens = int(userMax) + } + } + + reqBody := map[string]interface{}{ + "request_id": uuid.New().String(), + "request_set_id": recordID, + "chat_record_id": recordID, + "session_id": sessionID, + "stream": true, + "chat_task": "FREE_INPUT", + "is_reply": true, + "is_retry": false, + "source": 1, + "version": "3", + "session_type": "qodercli", + "agent_id": "agent_common", + "task_id": "common", + "code_language": "", + "chat_prompt": "", + "image_urls": nil, + "aliyun_user_type": "", + "system": systemText, + "messages": normalized, + "tools": []interface{}{}, + "parameters": map[string]interface{}{"max_tokens": maxTokens}, + "chat_context": map[string]interface{}{ + "chatPrompt": "", + "imageUrls": nil, + "extra": map[string]interface{}{ + "context": []interface{}{}, + "modelConfig": map[string]interface{}{ + "key": qoderModel, + "is_reasoning": isReasoning, + }, + "originalContent": lastUser, + }, + "features": []interface{}{}, + "text": lastUser, + }, + "model_config": modelConfig, + "business": map[string]interface{}{ + "product": "cli", + "version": "1.0.0", + "type": "agent", + "stage": "start", + "id": uuid.New().String(), + "name": truncate(lastUser, 30), + "begin_at": time.Now().UnixMilli(), + }, + } + if toolsRaw != nil { + reqBody["tools"] = toolsRaw + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + // Encode the body to bypass Alibaba Cloud WAF pattern matching. + // The server decodes when &Encode=1 is present in the URL. + encodedBytes := []byte(helps.QoderEncodeBody(bodyBytes)) + + headers, err := qoderauth.BuildAuthHeaders( + encodedBytes, + qoderauth.QoderChatURLEncoded, + qoderauth.CosyCredentials{ + UserID: storage.UserID, + AuthToken: storage.Token, + Name: storage.Name, + Email: storage.Email, + MachineID: storage.MachineID, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to build COSY auth: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", qoderauth.QoderChatURLEncoded, bytes.NewReader(encodedBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + httpReq.Header.Set("Cache-Control", "no-cache") + headers.Apply(httpReq) + modelSource, _ := modelConfig["source"].(string) + if modelSource == "" { + modelSource = "system" + } + httpReq.Header.Set("X-Model-Key", qoderModel) + httpReq.Header.Set("X-Model-Source", modelSource) + // Disable automatic gzip — Accept-Encoding: gzip triggers signature + // validation on the Qoder upstream and causes 403 Signature invalid. + httpReq.Header.Set("Accept-Encoding", "identity") + + httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, authRecord, 0) + httpResp, err := httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + if httpResp.StatusCode != http.StatusOK { + defer func() { _ = httpResp.Body.Close() }() + body, _ := io.ReadAll(httpResp.Body) + allow := httpResp.Header.Get("Allow") + server := httpResp.Header.Get("Server") + bodyPreview := truncate(string(body), 500) + log.WithFields(log.Fields{ + "url": qoderauth.QoderChatURL, + "server": server, + "content_type": httpResp.Header.Get("Content-Type"), + "x_request_id": httpResp.Header.Get("X-Request-Id"), + "x_eagleeye_id": httpResp.Header.Get("Eagleeye-Traceid"), + "x_oss_request": httpResp.Header.Get("X-Oss-Request-Id"), + "allow": allow, + "body_truncated": bodyPreview, + }).Warnf("qoder: upstream %d allow=%q server=%q body=%q", httpResp.StatusCode, allow, server, bodyPreview) + return nil, newQoderStatusError(httpResp.StatusCode, string(body)) + } + + // Create streaming channel + out := make(chan cliproxyexecutor.StreamChunk) + go func() { + defer close(out) + defer func() { _ = httpResp.Body.Close() }() + + // Shared across all TranslateStream calls in this stream — the + // translator carries open-block / sequence state through it; a + // per-chunk var would re-emit message_start on every delta. + var streamParam any + + scanner := bufio.NewScanner(httpResp.Body) + scanner.Buffer(nil, 52_428_800) // 50MB max line + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + // Skip non-data lines + if !bytes.HasPrefix(line, []byte("data:")) { + continue + } + + data := bytes.TrimPrefix(line, []byte("data:")) + data = bytes.TrimPrefix(data, []byte(" ")) + if bytes.Equal(data, []byte("[DONE]")) { + emitDone(ctx, out, opts.SourceFormat, req.Model, opts.OriginalRequest, payload, &streamParam) + return + } + + // Parse Qoder response envelope + var event map[string]interface{} + if err := json.Unmarshal(data, &event); err != nil { + continue + } + statusVal := 200 + if rawStatus, ok := event["statusCodeValue"]; ok { + switch v := rawStatus.(type) { + case float64: + statusVal = int(v) + case int: + statusVal = v + } + } + innerStr, _ := event["body"].(string) + if statusVal != http.StatusOK { + msg := innerStr + if msg == "" { + msg = fmt.Sprintf("upstream status %d", statusVal) + } + out <- cliproxyexecutor.StreamChunk{Err: newQoderStatusError(statusVal, msg)} + return + } + if innerStr == "" { + continue + } + if innerStr == "[DONE]" { + emitDone(ctx, out, opts.SourceFormat, req.Model, opts.OriginalRequest, payload, &streamParam) + return + } + var inner map[string]interface{} + if err := json.Unmarshal([]byte(innerStr), &inner); err != nil { + continue + } + chunkBytes, err := buildOpenAIChunk(inner, model) + if err != nil { + continue + } + // Reconstruct an OpenAI-compatible SSE line ("data: {chunk}"). + // Qoder's upstream nests OpenAI chunks inside a + // {statusCodeValue, body} envelope so unlike kimi/openai-compat/ + // codebuddy we can't forward the raw upstream line — we have to + // rebuild the SSE frame here. The format matches what those + // other executors feed into TranslateStream so the translators' + // "expects data: prefix" assumption holds. + ssePayload := append([]byte("data: "), chunkBytes...) + + // Always run through TranslateStream. When source==target + // (OpenAI client) it strips the "data:" prefix and returns + // raw JSON; the OpenAI handler then re-adds the SSE framing. + // For cross-format clients (Anthropic/Gemini) it emits the + // format-specific stream events (message_start / + // content_block_delta / ...) directly as fully framed bytes + // because those handlers write chunks verbatim. + to := sdktranslator.FormatOpenAI + from := opts.SourceFormat + if from == "" { + from = to + } + frames := sdktranslator.TranslateStream(ctx, to, from, + req.Model, opts.OriginalRequest, payload, ssePayload, &streamParam) + for _, frame := range frames { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: frame}: + case <-ctx.Done(): + return + } + } + } + // Scanner loop exited naturally (EOF). Emit a terminating + // "data: [DONE]" / Anthropic message_stop frame so the client + // closes the stream cleanly. + emitDone(ctx, out, opts.SourceFormat, req.Model, opts.OriginalRequest, payload, &streamParam) + // Check for scanner errors + if err := scanner.Err(); err != nil { + out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("scanner error: %w", err)} + } + }() + + return &cliproxyexecutor.StreamResult{ + Headers: httpResp.Header.Clone(), + Chunks: out, + }, nil +} + +// lastUserText returns the text of the last user message in the (already +// normalized) message list, or empty when there isn't one. Qoder uses this +// for the chat_context "current turn" preview slot; the full conversation +// still travels through the messages array. +func lastUserText(messages []interface{}) string { + for i := len(messages) - 1; i >= 0; i-- { + msgMap, ok := messages[i].(map[string]interface{}) + if !ok { + continue + } + if role, _ := msgMap["role"].(string); role != "user" { + continue + } + if s, ok := msgMap["content"].(string); ok { + return s + } + return extractContentGeneric(msgMap["content"]) + } + return "" +} + +// extractContentGeneric extracts text content from message content field +func extractContentGeneric(content interface{}) string { + switch v := content.(type) { + case string: + return v + case []interface{}: + var parts []string + for _, item := range v { + if itemMap, ok := item.(map[string]interface{}); ok { + if itemMap["type"] == "text" { + if text, ok := itemMap["text"].(string); ok { + parts = append(parts, text) + } + continue + } + if text, ok := itemMap["text"].(string); ok { + parts = append(parts, text) + } + } + } + return strings.Join(parts, "\n") + default: + if content == nil { + return "" + } + return fmt.Sprintf("%v", content) + } +} + +// normalizeQoderMessages clones each message and applies sanitizations +// required by Qoder's upstream: +// +// 1. Flatten content: Anthropic/OpenAI multipart content arrays +// ([{type:"text",text:"..."}]) are collapsed to plain strings. +// +// 2. Remap system messages: Qoder rejects role="system" in the messages +// array; system prompt content is collected and returned separately +// so the caller can place it in the top-level "system" request field. +func normalizeQoderMessages(messages []interface{}) (normalized []interface{}, systemText string) { + if len(messages) == 0 { + return nil, "" + } + out := make([]interface{}, 0, len(messages)) + var systemParts []string + for _, msg := range messages { + msgMap, ok := msg.(map[string]interface{}) + if !ok { + continue + } + // Collect system messages — Qoder does not accept role="system" + // in the messages array, so we remap them to the top-level + // "system" request field. + if role, _ := msgMap["role"].(string); role == "system" { + if text := extractContentGeneric(msgMap["content"]); text != "" { + systemParts = append(systemParts, text) + } + continue + } + cloned := make(map[string]interface{}, len(msgMap)) + for k, v := range msgMap { + cloned[k] = v + } + cloned["content"] = extractContentGeneric(msgMap["content"]) + out = append(out, cloned) + } + return out, strings.Join(systemParts, "\n\n") +} + +func buildOpenAIChunk(inner map[string]interface{}, model string) ([]byte, error) { + if inner == nil { + return nil, fmt.Errorf("empty inner payload") + } + if _, ok := inner["model"]; !ok || inner["model"] == "" { + inner["model"] = model + } + if choices, ok := inner["choices"].([]interface{}); ok { + if len(choices) == 0 { + if inner["finish_reason"] != nil || inner["stop"] != nil { + inner["choices"] = []map[string]interface{}{{ + "index": 0, + "delta": map[string]interface{}{}, + "finish_reason": "stop", + }} + } + } + } + return json.Marshal(inner) +} + +// emitDone publishes the terminating SSE frame(s) for the stream. The +// upstream "[DONE]" sentinel is fed through TranslateStream so the +// client's SourceFormat dictates the actual wire bytes — "data: [DONE]\n\n" +// for OpenAI, "event: message_stop\ndata: {...}\n\n" for Anthropic, and +// the equivalent format-specific terminators for Gemini etc. This mirrors +// the pattern used by kimi_executor. +// +// param must be the same pointer the per-chunk TranslateStream calls used +// — the Anthropic translator (and others) need the carried state to know +// which content_block indices to close, the running token count, etc. +func emitDone(ctx context.Context, out chan<- cliproxyexecutor.StreamChunk, + sourceFormat sdktranslator.Format, reqModel string, originalReq, body []byte, param *any) { + to := sdktranslator.FormatOpenAI + from := sourceFormat + if from == "" { + from = to + } + frames := sdktranslator.TranslateStream(ctx, to, from, + reqModel, originalReq, body, []byte("[DONE]"), param) + for _, frame := range frames { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: frame}: + case <-ctx.Done(): + return + } + } +} + +// qoderStatusError implements StatusError for Qoder API errors +type qoderStatusError struct { + status int + message string +} + +func newQoderStatusError(status int, message string) *qoderStatusError { + return &qoderStatusError{status: status, message: message} +} + +func (e *qoderStatusError) Error() string { + return fmt.Sprintf("Qoder API error %d: %s", e.status, e.message) +} + +func (e *qoderStatusError) StatusCode() int { + return e.status +} + +// CountTokens estimates token count for the request (placeholder implementation) +func (e *QoderExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + // Translate non-openai formats before extracting messages + payload := req.Payload + if opts.SourceFormat != "" && opts.SourceFormat != sdktranslator.FormatOpenAI { + payload = sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FormatOpenAI, req.Model, payload, false) + } + + // Simple estimation: 1 token ≈ 4 characters + var chatReq map[string]interface{} + if err := json.Unmarshal(payload, &chatReq); err != nil { + return cliproxyexecutor.Response{}, err + } + + messagesRaw, _ := chatReq["messages"].([]interface{}) + totalChars := 0 + for _, msg := range messagesRaw { + if msgMap, ok := msg.(map[string]interface{}); ok { + content := extractContentGeneric(msgMap["content"]) + totalChars += len(content) + } + } + + estimatedTokens := totalChars / 4 + if estimatedTokens < 1 { + estimatedTokens = 1 + } + + response := map[string]interface{}{ + "usage": map[string]int{ + "prompt_tokens": estimatedTokens, + "completion_tokens": 0, + "total_tokens": estimatedTokens, + }, + } + + responseBytes, _ := json.Marshal(response) + return cliproxyexecutor.Response{ + Payload: responseBytes, + }, nil +} + +// Execute executes a non-streaming request against Qoder API +func (e *QoderExecutor) Execute(ctx context.Context, authRecord *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + // We need ExecuteStream to: + // 1. Translate the request payload from the client's SourceFormat + // (Anthropic/Gemini/etc) into OpenAI before sending to Qoder. + // 2. Emit raw OpenAI chunks so we can accumulate choices[0].delta. + // + // (1) requires opts.SourceFormat to stay as the original; (2) requires + // it to be OpenAI. Resolve by translating the payload up-front, then + // passing FormatOpenAI for both directions to ExecuteStream. + internalReq := req + internalOpts := opts + if opts.SourceFormat != "" && opts.SourceFormat != sdktranslator.FormatOpenAI { + internalReq.Payload = sdktranslator.TranslateRequest( + opts.SourceFormat, sdktranslator.FormatOpenAI, + req.Model, req.Payload, false) + } + internalOpts.SourceFormat = sdktranslator.FormatOpenAI + + streamResult, err := e.ExecuteStream(ctx, authRecord, internalReq, internalOpts) + if err != nil { + return cliproxyexecutor.Response{}, err + } + + // Accumulate all chunks + var content strings.Builder + var finishReason string + type pendingToolCall struct { + ID string + Name string + Arguments string + } + pendingToolCalls := make(map[int]*pendingToolCall) + + for chunk := range streamResult.Chunks { + if chunk.Err != nil { + return cliproxyexecutor.Response{}, chunk.Err + } + + // ExecuteStream was called with SourceFormat=FormatOpenAI so + // TranslateStream strips the "data:" prefix and returns raw JSON. + // Skip empty or [DONE] payloads. + raw := chunk.Payload + if len(raw) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("[DONE]")) { + continue + } + + var oiChunk map[string]interface{} + if err := json.Unmarshal(raw, &oiChunk); err == nil { + if choices, ok := oiChunk["choices"].([]interface{}); ok && len(choices) > 0 { + if choice, ok := choices[0].(map[string]interface{}); ok { + if delta, ok := choice["delta"].(map[string]interface{}); ok { + if toolCalls, ok := delta["tool_calls"].([]interface{}); ok { + for _, call := range toolCalls { + callMap, ok := call.(map[string]interface{}) + if !ok { + continue + } + idx := 0 + if rawIdx, ok := callMap["index"].(float64); ok { + idx = int(rawIdx) + } + entry := pendingToolCalls[idx] + if entry == nil { + entry = &pendingToolCall{} + pendingToolCalls[idx] = entry + } + if id, ok := callMap["id"].(string); ok && id != "" { + entry.ID = id + } + if fn, ok := callMap["function"].(map[string]interface{}); ok { + if name, ok := fn["name"].(string); ok && name != "" { + entry.Name = name + } + if args, ok := fn["arguments"].(string); ok && args != "" { + entry.Arguments += args + } + } + } + } + if contentStr, ok := delta["content"].(string); ok { + content.WriteString(contentStr) + } + } + if fr, ok := choice["finish_reason"].(string); ok && fr != "" { + finishReason = fr + } + } + } + } + } + + var toolCalls []map[string]interface{} + if finishReason == "tool_calls" && len(pendingToolCalls) > 0 { + for i := 0; i < len(pendingToolCalls); i++ { + entry, ok := pendingToolCalls[i] + if !ok || entry == nil { + continue + } + id := entry.ID + if id == "" { + id = fmt.Sprintf("call_%d", time.Now().UnixNano()) + } + args := entry.Arguments + if strings.TrimSpace(args) == "" { + args = "{}" + } + toolCalls = append(toolCalls, map[string]interface{}{ + "id": id, + "type": "function", + "function": map[string]interface{}{ + "name": entry.Name, + "arguments": args, + }, + }) + } + } + + // Build final response + message := map[string]interface{}{ + "role": "assistant", + "content": content.String(), + } + if len(toolCalls) > 0 { + message["tool_calls"] = toolCalls + } + response := map[string]interface{}{ + "id": fmt.Sprintf("qoder-%d", time.Now().UnixNano()), + "object": "chat.completion", + "created": time.Now().Unix(), + "model": req.Model, + "choices": []map[string]interface{}{ + { + "index": 0, + "message": message, + "finish_reason": finishReason, + }, + }, + } + + responseBytes, _ := json.Marshal(response) + + // Translate the Qoder OpenAI-format response back to the client's + // expected SourceFormat. Reuse internalReq.Payload — that's already + // the OpenAI-translated payload we computed above before calling + // ExecuteStream, so we don't need to re-translate. + var param any + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatOpenAI, opts.SourceFormat, req.Model, opts.OriginalRequest, internalReq.Payload, responseBytes, ¶m) + responseBytes = out + + return cliproxyexecutor.Response{ + Payload: responseBytes, + Headers: streamResult.Headers, + }, nil +} + +// Refresh is a no-op for Qoder. +// +// Qoder's device-flow token (the "dt-..." string) is already long-lived +// (~30 days for the access token, ~360 days for the refresh token per +// the deviceToken/poll response). The upstream does not expose the +// classic OAuth refresh dance — every endpoint we've observed (cubk1's +// qoder2api, Veria, the official @qoder-ai/qodercli) either skips +// refresh entirely or routes through a different /jobToken exchange +// flow that requires personalToken (we don't have one). +// +// Hitting /algo/api/v3/user/refresh_token with our device token returns +// 403 "Forbidden" / errorCode=Forbidden — the endpoint is not for our +// flow. Mark the auth refreshed-now and keep going; if a real expiry +// happens the user re-runs --qoder-login. +func (e *QoderExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if auth == nil { + return nil, fmt.Errorf("qoder executor: auth is nil") + } + return auth, nil +} + +// HttpRequest injects Qoder COSY authentication into the HTTP request and executes it +func (e *QoderExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + storage, ok := auth.Storage.(*qoderauth.QoderTokenStorage) + if !ok { + return nil, fmt.Errorf("invalid auth storage type for qoder") + } + + // Read request body for COSY signing + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + headers, err := qoderauth.BuildAuthHeaders( + bodyBytes, + req.URL.String(), + qoderauth.CosyCredentials{ + UserID: storage.UserID, + AuthToken: storage.Token, + Name: storage.Name, + Email: storage.Email, + MachineID: storage.MachineID, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to build COSY auth: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + headers.Apply(req) + + req = req.WithContext(ctx) + httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(req) +} + +// buildQoderModelConfig returns the model_config block for a chat request, +// pulled from the cache populated by FetchQoderModels (which mirrors what +// /algo/api/v2/model/list publishes — per-model is_vl / is_reasoning / +// max_input_tokens / price_factor / strategies / ...). Returns an error +// when the cache has no entry for modelKey: that means we either never +// successfully fetched the model list for this auth, or the user asked +// for a model the server doesn't expose. Either way we should fail loudly +// rather than guess and silently get downgraded to a different model. +func buildQoderModelConfig(storage *qoderauth.QoderTokenStorage, modelKey string) (map[string]interface{}, error) { + raw, ok := storage.GetModelConfig(modelKey) + if !ok || len(raw) == 0 { + keys := storage.ModelConfigKeys() + if len(keys) == 0 { + return nil, fmt.Errorf("qoder: model config cache is empty (model list not fetched yet); restart the service or check /algo/api/v2/model/list connectivity") + } + sort.Strings(keys) + return nil, fmt.Errorf("qoder: no model_config cached for %q; known models: %s", modelKey, strings.Join(keys, ", ")) + } + var cfg map[string]interface{} + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("qoder: cached model_config for %q is invalid JSON: %w", modelKey, err) + } + if cfg == nil { + return nil, fmt.Errorf("qoder: cached model_config for %q decoded to nil", modelKey) + } + // The cache stores the model description; ensure the key matches what + // we're sending (handles model alias rewrites in caller). + cfg["key"] = modelKey + return cfg, nil +} + +// FetchQoderModels retrieves the live model list from Qoder's +// /algo/api/v2/model/list endpoint and converts it into ModelInfo entries. +// Falls back to the static registry if the auth lacks credentials, the request +// fails, or the response is malformed. Mirrors the FetchKiloModels / +// FetchCursorModels pattern used by other dynamic providers. +func FetchQoderModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo { + storage, ok := auth.Storage.(*qoderauth.QoderTokenStorage) + if !ok || storage == nil || storage.Token == "" { + log.Debug("qoder: no token, returning static models") + return registry.GetQoderModels() + } + + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + headers, err := qoderauth.BuildAuthHeaders(nil, qoderauth.QoderModelListURL, qoderauth.CosyCredentials{ + UserID: storage.UserID, + AuthToken: storage.Token, + Name: storage.Name, + Email: storage.Email, + MachineID: storage.MachineID, + }) + if err != nil { + log.Warnf("qoder: build cosy headers for model list: %v", err) + return registry.GetQoderModels() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, qoderauth.QoderModelListURL, nil) + if err != nil { + log.Warnf("qoder: build model list request: %v", err) + return registry.GetQoderModels() + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Encoding", "identity") + headers.Apply(req) + + httpClient := helps.NewProxyAwareHTTPClient(ctx, cfg, auth, 0) + resp, err := httpClient.Do(req) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + log.Warnf("qoder: model list fetch canceled: %v", err) + } else { + log.Warnf("qoder: model list fetch failed: %v", err) + } + return registry.GetQoderModels() + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Warnf("qoder: read model list response: %v", err) + return registry.GetQoderModels() + } + if resp.StatusCode != http.StatusOK { + log.Warnf("qoder: model list returned %d: %s", resp.StatusCode, truncate(string(body), 300)) + return registry.GetQoderModels() + } + + chat := gjson.GetBytes(body, "chat") + if !chat.Exists() || !chat.IsArray() { + log.Warnf("qoder: model list response missing 'chat' array") + return registry.GetQoderModels() + } + + now := time.Now().Unix() + models := make([]*registry.ModelInfo, 0, 16) + configs := make(map[string]json.RawMessage, 16) + chat.ForEach(func(_, entry gjson.Result) bool { + key := entry.Get("key").String() + if key == "" { + return true + } + if !entry.Get("enable").Bool() { + return true + } + display := entry.Get("display_name").String() + if display == "" { + display = key + } + ctxLen := int(entry.Get("max_input_tokens").Int()) + isVL := entry.Get("is_vl").Bool() + + // Cache the raw upstream JSON for this model so ExecuteStream can + // forward the exact model_config the server published (per-model + // is_vl / is_reasoning / max_input_tokens / price_factor / ...). + configs[key] = json.RawMessage(entry.Raw) + + mi := ®istry.ModelInfo{ + ID: "qoder/" + key, + Object: "model", + Created: now, + OwnedBy: "qoder", + Type: "qoder", + DisplayName: display, + Description: fmt.Sprintf("%s via Qoder", display), + ContextLength: ctxLen, + } + if isVL { + mi.SupportedInputModalities = []string{"TEXT", "IMAGE"} + } + // Parse thinking_config from upstream. Qoder returns per-model + // effort levels (e.g. dmodel has only high/max, ultimate has + // low/medium/high/max/xhigh) and a disabled key to indicate + // whether reasoning can be turned off. Models without + // thinking_config but with is_reasoning=true still get a + // basic Thinking marker (no predefined levels). + if tc := entry.Get("thinking_config"); tc.Exists() { + ts := ®istry.ThinkingSupport{} + if tc.Get("disabled").Exists() { + ts.ZeroAllowed = true + } + efforts := tc.Get("enabled.efforts") + if efforts.Exists() && efforts.IsObject() { + levels := make([]string, 0, 5) + efforts.ForEach(func(key, _ gjson.Result) bool { + levels = append(levels, key.String()) + return true + }) + ts.Levels = levels + } + mi.Thinking = ts + } else if entry.Get("is_reasoning").Bool() { + mi.Thinking = ®istry.ThinkingSupport{} + } + models = append(models, mi) + return true + }) + + if len(models) == 0 { + log.Warn("qoder: model list returned no enabled models, falling back to static") + return registry.GetQoderModels() + } + + storage.SetModelConfigs(configs) + + log.Infof("qoder: fetched %d models from /algo/api/v2/model/list", len(models)) + + // Fetch usage alongside models so the management UI has fresh credit data. + // Use context.Background() so the goroutine outlives the caller's context. + go FetchQoderUsage(context.Background(), auth, cfg) + + return models +} + +// stableHash returns a deterministic hex identifier from the given inputs. +func stableHash(prefix string, inputs ...string) string { + h := sha256.New() + h.Write([]byte(prefix)) + for _, in := range inputs { + h.Write([]byte{0}) + h.Write([]byte(in)) + } + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// stableChatRecordID produces a deterministic chat_record_id from the +// request payload so retries with identical content hit upstream caches. +func stableChatRecordID(model string, messages []interface{}, toolsRaw interface{}, maxTokens int) string { + h := sha256.New() + h.Write([]byte("qoder-record")) + h.Write([]byte{0}) + h.Write([]byte(model)) + for _, msg := range messages { + m, _ := msg.(map[string]interface{}) + if m == nil { + continue + } + if role, _ := m["role"].(string); role != "" { + h.Write([]byte{0}) + h.Write([]byte(role)) + } + if content, _ := m["content"].(string); content != "" { + h.Write([]byte{0}) + h.Write([]byte(content)) + } + } + if toolsRaw != nil { + toolsJSON, _ := json.Marshal(toolsRaw) + h.Write([]byte{0}) + h.Write(toolsJSON) + } + h.Write([]byte{0}) + h.Write([]byte(fmt.Sprintf("mt=%d", maxTokens))) + return hex.EncodeToString(h.Sum(nil))[:16] +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// FetchQoderUsage fetches the current quota usage from /api/v2/quota/usage +// and caches the result in storage.UsageInfo. It is called opportunistically +// alongside FetchQoderModels so the management UI can display credit balance +// without a separate round-trip. +func FetchQoderUsage(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) *qoderauth.QoderUsageInfo { + storage, ok := auth.Storage.(*qoderauth.QoderTokenStorage) + if !ok || storage == nil || storage.Token == "" { + return nil + } + + const usageURL = "https://openapi.qoder.sh/api/v2/quota/usage" + log.Debugf("qoder: fetching usage for user %s (token len=%d)", storage.UserID, len(storage.Token)) + req, err := http.NewRequest(http.MethodGet, usageURL, nil) + if err != nil { + log.Debugf("qoder: build usage request: %v", err) + return nil + } + req.Header.Set("Authorization", "Bearer "+storage.Token) + req.Header.Set("Accept", "application/json") + + httpClient := helps.NewProxyAwareHTTPClient(ctx, cfg, auth, 15*time.Second) + resp, err := httpClient.Do(req) + if err != nil { + log.Debugf("qoder: usage fetch failed: %v", err) + return nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + log.Debugf("qoder: usage fetch returned %d", resp.StatusCode) + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Debugf("qoder: read usage response: %v", err) + return nil + } + + var info qoderauth.QoderUsageInfo + if err := json.Unmarshal(body, &info); err != nil { + log.Debugf("qoder: parse usage response: %v", err) + return nil + } + + storage.SetUsageInfo(&info) + log.Debugf("qoder: usage fetched — %.0f/%.0f %s used (%.1f%%)", + info.UserQuota.Used, info.UserQuota.Total, info.UserQuota.Unit, + info.TotalUsagePercentage*100) + return &info +} diff --git a/internal/runtime/executor/qoder_executor_test.go b/internal/runtime/executor/qoder_executor_test.go new file mode 100644 index 00000000..8939d21c --- /dev/null +++ b/internal/runtime/executor/qoder_executor_test.go @@ -0,0 +1,659 @@ +package executor + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/qoder" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" +) + +// TestNewQoderExecutor tests the constructor +func TestNewQoderExecutor(t *testing.T) { + cfg := &config.Config{} + executor := NewQoderExecutor(cfg) + if executor == nil { + t.Fatal("NewQoderExecutor returned nil") + } + if got := executor.Identifier(); got != "qoder" { + t.Errorf("Identifier() = %q, want %q", got, "qoder") + } +} + +// TestIdentifier tests the identifier method +func TestIdentifier(t *testing.T) { + executor := NewQoderExecutor(&config.Config{}) + if got := executor.Identifier(); got != "qoder" { + t.Errorf("Identifier() = %q, want %q", got, "qoder") + } +} + +// TestExecuteStream_InvalidAuthStorage tests error for wrong storage type +func TestExecuteStream_InvalidAuthStorage(t *testing.T) { + executor := NewQoderExecutor(&config.Config{}) + + // Create a mock that doesn't implement TokenStorage + authRecord := &cliproxyauth.Auth{ + Storage: nil, // nil storage + } + + req := cliproxyexecutor.Request{ + Payload: []byte(`{"model":"auto","messages":[]}`), + } + + opts := cliproxyexecutor.Options{} + + result, err := executor.ExecuteStream(context.Background(), authRecord, req, opts) + if result != nil { + t.Errorf("expected nil result, got %+v", result) + } + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "invalid auth storage type") { + t.Errorf("error %q does not contain %q", err.Error(), "invalid auth storage type") + } +} + +// TestExecuteStream_TokenRefreshFailure tests handling of token refresh failure +func TestExecuteStream_TokenRefreshFailure(t *testing.T) { + executor := NewQoderExecutor(&config.Config{}) + + storage := &qoder.QoderTokenStorage{ + Token: "token", + RefreshToken: "refresh", + ExpireTime: 1000, // Expired + UserID: "user123", + Name: "Test User", + Email: "test@example.com", + } + + authRecord := &cliproxyauth.Auth{ + Storage: storage, + } + + req := cliproxyexecutor.Request{ + Payload: []byte(`{"model":"auto","messages":[]}`), + } + + opts := cliproxyexecutor.Options{} + + // The request should still proceed despite refresh failure (warning logged) + result, err := executor.ExecuteStream(context.Background(), authRecord, req, opts) + // Should fail because we can't actually make the HTTP request + if err == nil { + t.Error("expected error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %+v", result) + } +} + +// TestExecuteStream_InvalidRequestPayload tests handling of malformed JSON +func TestExecuteStream_InvalidRequestPayload(t *testing.T) { + executor := NewQoderExecutor(&config.Config{}) + + storage := &qoder.QoderTokenStorage{ + Token: "token", + RefreshToken: "refresh", + ExpireTime: time.Now().Add(1 * time.Hour).UnixMilli(), + UserID: "user123", + Name: "Test User", + Email: "test@example.com", + } + + authRecord := &cliproxyauth.Auth{ + Storage: storage, + } + + req := cliproxyexecutor.Request{ + Payload: []byte(`invalid json`), + } + + opts := cliproxyexecutor.Options{} + + result, err := executor.ExecuteStream(context.Background(), authRecord, req, opts) + if result != nil { + t.Errorf("expected nil result, got %+v", result) + } + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to parse request") { + t.Errorf("error %q does not contain %q", err.Error(), "failed to parse request") + } +} + +// TestExecuteStream_BuildAuthHeadersFailure tests auth header generation failure +func TestExecuteStream_BuildAuthHeadersFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `data: {"body":"{\\"error\\":\\"test\\"}"} +`) + })) + defer server.Close() + + executor := NewQoderExecutor(&config.Config{}) + + storage := &qoder.QoderTokenStorage{ + Token: "token", + RefreshToken: "refresh", + ExpireTime: time.Now().Add(1 * time.Hour).UnixMilli(), + UserID: "user123", + Name: "Test User", + Email: "test@example.com", + } + + authRecord := &cliproxyauth.Auth{ + Storage: storage, + } + + req := cliproxyexecutor.Request{ + Payload: []byte(`{"model":"auto","messages":[]}`), + } + + opts := cliproxyexecutor.Options{} + + result, err := executor.ExecuteStream(context.Background(), authRecord, req, opts) + // Should fail because we can't build proper auth headers with test data + if err == nil { + t.Error("expected error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %+v", result) + } +} + +// TestExecuteStream_HTTPRequestFailure tests network error handling +func TestExecuteStream_HTTPRequestFailure(t *testing.T) { + executor := NewQoderExecutor(&config.Config{}) + + storage := &qoder.QoderTokenStorage{ + Token: "token", + RefreshToken: "refresh", + ExpireTime: time.Now().Add(1 * time.Hour).UnixMilli(), + UserID: "user123", + Name: "Test User", + Email: "test@example.com", + } + + authRecord := &cliproxyauth.Auth{ + Storage: storage, + } + + req := cliproxyexecutor.Request{ + Payload: []byte(`{"model":"auto","messages":[]}`), + } + + opts := cliproxyexecutor.Options{} + + // Use an invalid URL that will cause connection failure + result, err := executor.ExecuteStream(context.Background(), authRecord, req, opts) + if err == nil { + t.Error("expected error, got nil") + } + if result != nil { + t.Errorf("expected nil result, got %+v", result) + } +} + +// TestExecuteStream_NonOKResponse verifies ExecuteStream surfaces a clear +// error when no model_config has been cached for the requested model +// (i.e. /algo/api/v2/model/list was never fetched, or the model is unknown). +func TestExecuteStream_NonOKResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "Internal Server Error") + })) + defer server.Close() + + executor := NewQoderExecutor(&config.Config{}) + + storage := &qoder.QoderTokenStorage{ + Token: "token", + RefreshToken: "refresh", + ExpireTime: time.Now().Add(1 * time.Hour).UnixMilli(), + UserID: "user123", + Name: "Test User", + Email: "test@example.com", + } + + authRecord := &cliproxyauth.Auth{ + Storage: storage, + } + + req := cliproxyexecutor.Request{ + Payload: []byte(`{"model":"auto","messages":[]}`), + } + + opts := cliproxyexecutor.Options{} + + result, err := executor.ExecuteStream(context.Background(), authRecord, req, opts) + if result != nil { + t.Errorf("expected nil result, got %+v", result) + } + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "model config cache is empty") { + t.Errorf("error %q does not contain %q", err.Error(), "model config cache is empty") + } +} + +// TestExecuteStream_StreamParsing tests successful stream parsing +func TestExecuteStream_StreamParsing(t *testing.T) { + // This test requires overriding QoderChatURL which is a constant + // Skipping as it can't be properly tested without code changes + t.Skip("requires ability to override QoderChatURL") +} + +// TestExecuteStream_StreamErrorInResponse tests handling of error messages in stream +func TestExecuteStream_StreamErrorInResponse(t *testing.T) { + // This test requires overriding QoderChatURL which is a constant + // Skipping as it can't be properly tested without code changes + t.Skip("requires ability to override QoderChatURL") +} + +// TestExecuteStream_StreamContextCancel tests context cancellation +func TestExecuteStream_StreamContextCancel(t *testing.T) { + // This test requires overriding QoderChatURL which is a constant + // Skipping as it can't be properly tested without code changes + t.Skip("requires ability to override QoderChatURL") +} + +// TestBuildOpenAIChunk tests message transformation +func TestBuildOpenAIChunk(t *testing.T) { + inner := map[string]interface{}{ + "choices": []interface{}{ + map[string]interface{}{ + "delta": map[string]interface{}{ + "content": "test", + }, + }, + }, + } + + chunkBytes, err := buildOpenAIChunk(inner, "gpt-4") + if err != nil { + t.Fatalf("buildOpenAIChunk returned error: %v", err) + } + if chunkBytes == nil { + t.Fatal("buildOpenAIChunk returned nil bytes") + } + + var result map[string]interface{} + if err = json.Unmarshal(chunkBytes, &result); err != nil { + t.Fatalf("failed to unmarshal chunk: %v", err) + } + if got := result["model"]; got != "gpt-4" { + t.Errorf("model = %v, want %q", got, "gpt-4") + } +} + +// TestNewQoderStatusError tests error creation +func TestNewQoderStatusError(t *testing.T) { + err := newQoderStatusError(500, "test error") + if err == nil { + t.Fatal("newQoderStatusError returned nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error %q does not contain %q", err.Error(), "500") + } + if !strings.Contains(err.Error(), "test error") { + t.Errorf("error %q does not contain %q", err.Error(), "test error") + } +} + +// TestExecuteStream_ModelMapping tests model name mapping +func TestExecuteStream_ModelMapping(t *testing.T) { + executor := NewQoderExecutor(&config.Config{}) + + storage := &qoder.QoderTokenStorage{ + Token: "token", + RefreshToken: "refresh", + ExpireTime: time.Now().Add(1 * time.Hour).UnixMilli(), + UserID: "user123", + Name: "Test User", + Email: "test@example.com", + } + + authRecord := &cliproxyauth.Auth{ + Storage: storage, + } + + // Test with a mapped model name + req := cliproxyexecutor.Request{ + Payload: []byte(`{"model":"auto","messages":[]}`), + } + + opts := cliproxyexecutor.Options{} + + // We can't easily override the URL, so this test will fail + // Just verify it doesn't panic + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if _, err := executor.ExecuteStream(ctx, authRecord, req, opts); err == nil { + t.Error("expected error, got nil") + } +} + +// TestExecute_InvalidAuth tests that Execute returns an error when the auth +// storage type is invalid. This fails before the HTTP call, so it can be +// tested without a mock server. +func TestExecute_InvalidAuth(t *testing.T) { + executor := NewQoderExecutor(&config.Config{}) + authRecord := &cliproxyauth.Auth{ + Storage: nil, + } + req := cliproxyexecutor.Request{ + Payload: []byte(`{"model":"auto","messages":[]}`), + } + opts := cliproxyexecutor.Options{} + + resp, err := executor.Execute(context.Background(), authRecord, req, opts) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "invalid auth storage type") { + t.Errorf("error %q does not contain %q", err.Error(), "invalid auth storage type") + } + if len(resp.Payload) != 0 { + t.Errorf("expected empty payload, got %d bytes", len(resp.Payload)) + } +} + +// TestExecute_TranslateNonStream_SameFormatIsPassthrough validates that when +// SourceFormat equals FormatOpenAI (Qoder's native response format), the +// TranslateNonStream call returns the response unchanged. This is the +// common case and must not break clients. +func TestExecute_TranslateNonStream_SameFormatIsPassthrough(t *testing.T) { + openAIResp := map[string]interface{}{ + "id": "chatcmpl-test-123", + "object": "chat.completion", + "created": 1712345678, + "model": "auto", + "choices": []map[string]interface{}{ + { + "index": 0, + "message": map[string]interface{}{ + "role": "assistant", + "content": "Hello from Qoder", + }, + "finish_reason": "stop", + }, + }, + } + responseBytes, err := json.Marshal(openAIResp) + if err != nil { + t.Fatalf("marshal openAIResp: %v", err) + } + + // When both from and to are FormatOpenAI, TranslateNonStream + // falls back to returning rawJSON unchanged (no translator registered). + var param any + out := sdktranslator.TranslateNonStream( + context.Background(), + sdktranslator.FormatOpenAI, + sdktranslator.FormatOpenAI, + "auto", + nil, nil, + responseBytes, + ¶m, + ) + + var result map[string]interface{} + if err = json.Unmarshal(out, &result); err != nil { + t.Fatalf("unmarshal translated response: %v", err) + } + if got := result["object"]; got != "chat.completion" { + t.Errorf("object = %v, want %q", got, "chat.completion") + } + choices, ok := result["choices"].([]interface{}) + if !ok { + t.Fatalf("choices is not []interface{}: %T", result["choices"]) + } + if len(choices) != 1 { + t.Fatalf("len(choices) = %d, want 1", len(choices)) + } + msg := choices[0].(map[string]interface{})["message"].(map[string]interface{}) + if got := msg["content"]; got != "Hello from Qoder" { + t.Errorf("message.content = %v, want %q", got, "Hello from Qoder") + } +} + +// TestExecute_TranslateNonStream_EmptySourceFormatIsPassthrough validates +// that when SourceFormat is empty (not set by handler), the response is +// returned unchanged. +func TestExecute_TranslateNonStream_EmptySourceFormatIsPassthrough(t *testing.T) { + openAIResp := map[string]interface{}{ + "id": "chatcmpl-test-456", + "object": "chat.completion", + "created": 1712345678, + "model": "auto", + "choices": []map[string]interface{}{ + { + "index": 0, + "message": map[string]interface{}{ + "role": "assistant", + "content": "Hello", + }, + "finish_reason": "stop", + }, + }, + } + responseBytes, _ := json.Marshal(openAIResp) + + // Empty SourceFormat: no translator registered, raw JSON returned as-is. + var param any + out := sdktranslator.TranslateNonStream( + context.Background(), + sdktranslator.FormatOpenAI, + "", // empty SourceFormat + "auto", + nil, nil, + responseBytes, + ¶m, + ) + + var result map[string]interface{} + if err := json.Unmarshal(out, &result); err != nil { + t.Fatalf("unmarshal translated response: %v", err) + } + if got := result["object"]; got != "chat.completion" { + t.Errorf("object = %v, want %q", got, "chat.completion") + } +} + +// TestExecute_TranslateNonStream_NonOpenAISourceFormat validates that when +// SourceFormat differs from FormatOpenAI (e.g. "openai-response" from +// /v1/responses route), TranslateNonStream is called and returns a +// translated payload (or the raw JSON as fallback if no translator is +// registered for that format pair). This is the bugfix scenario. +func TestExecute_TranslateNonStream_NonOpenAISourceFormat(t *testing.T) { + openAIResp := map[string]interface{}{ + "id": "chatcmpl-test-789", + "object": "chat.completion", + "created": 1712345678, + "model": "auto", + "choices": []map[string]interface{}{ + { + "index": 0, + "message": map[string]interface{}{ + "role": "assistant", + "content": "Will be translated", + }, + "finish_reason": "stop", + }, + }, + } + responseBytes, _ := json.Marshal(openAIResp) + + // Simulate a request from /v1/responses route (sets SourceFormat to "openai-response"). + // If a translator is registered, it will transform the payload; otherwise + // the raw JSON is returned as fallback. Either way, this must not panic + // or return an empty response. + sourceFmt := sdktranslator.FromString("openai-response") + var param any + out := sdktranslator.TranslateNonStream( + context.Background(), + sdktranslator.FormatOpenAI, + sourceFmt, + "auto", + nil, nil, + responseBytes, + ¶m, + ) + + if len(out) == 0 { + t.Error("expected non-empty translated output") + } + if !json.Valid(out) { + t.Error("TranslateNonStream must return valid JSON") + } +} + +// TestExecute_ResponseStructureMatchesOpenAISchema validates that the +// accumulated non-stream response built by Execute follows the OpenAI +// chat-completions schema before translation. +func TestExecute_ResponseStructureMatchesOpenAISchema(t *testing.T) { + // Replicate the response structure built in Execute (lines 672-684). + content := "test content" + finishReason := "stop" + model := "auto" + + response := map[string]interface{}{ + "id": fmt.Sprintf("qoder-%d", time.Now().UnixNano()), + "object": "chat.completion", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]interface{}{ + { + "index": 0, + "message": map[string]interface{}{ + "role": "assistant", + "content": content, + }, + "finish_reason": finishReason, + }, + }, + } + + responseBytes, err := json.Marshal(response) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + + var result map[string]interface{} + if err = json.Unmarshal(responseBytes, &result); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + // Verify top-level fields match OpenAI schema. + if got := result["object"]; got != "chat.completion" { + t.Errorf("object = %v, want %q", got, "chat.completion") + } + if got := result["model"]; got != model { + t.Errorf("model = %v, want %q", got, model) + } + if id, _ := result["id"].(string); id == "" { + t.Error("id is empty") + } + if created, _ := result["created"].(float64); created == 0 { + t.Error("created is zero") + } + + // Verify choices array. + choices, ok := result["choices"].([]interface{}) + if !ok { + t.Fatalf("choices is not []interface{}: %T", result["choices"]) + } + if len(choices) != 1 { + t.Fatalf("len(choices) = %d, want 1", len(choices)) + } + + choice, ok := choices[0].(map[string]interface{}) + if !ok { + t.Fatalf("choice is not map[string]interface{}: %T", choices[0]) + } + if got := choice["index"]; got != float64(0) { + t.Errorf("choice.index = %v, want 0", got) + } + if got := choice["finish_reason"]; got != finishReason { + t.Errorf("choice.finish_reason = %v, want %q", got, finishReason) + } + + msg, ok := choice["message"].(map[string]interface{}) + if !ok { + t.Fatalf("message is not map[string]interface{}: %T", choice["message"]) + } + if got := msg["role"]; got != "assistant" { + t.Errorf("message.role = %v, want %q", got, "assistant") + } + if got := msg["content"]; got != content { + t.Errorf("message.content = %v, want %q", got, content) + } +} + +// TestExecute_TranslateNonStream_UsesRequestPayload verifies that when +// SourceFormat differs from FormatOpenAI, the request payload is translated +// before being passed to TranslateNonStream (matching the pattern in +// the fix). +func TestExecute_TranslateNonStream_UsesTranslatedRequestPayload(t *testing.T) { + // Simulate the request translation that happens in the Execute fix. + sourceFmt := sdktranslator.FromString("gemini") + originalRequest := []byte(`{"model":"auto","messages":[{"role":"user","content":"hi"}],"generationConfig":{}}`) + reqPayload := []byte(`{"model":"auto","messages":[{"role":"user","content":"hi"}]}`) + openAIResp, _ := json.Marshal(map[string]interface{}{ + "id": "test", + "object": "chat.completion", + "created": 1, + "model": "auto", + "choices": []map[string]interface{}{ + {"index": 0, "message": map[string]interface{}{ + "role": "assistant", "content": "hi", + }, "finish_reason": "stop"}, + }, + }) + + // Translate request: sourceFmt -> FormatOpenAI (as done in the fix) + translatedPayload := reqPayload + if sourceFmt != "" && sourceFmt != sdktranslator.FormatOpenAI { + translatedPayload = sdktranslator.TranslateRequest( + sourceFmt, sdktranslator.FormatOpenAI, + "auto", reqPayload, false, + ) + } + if translatedPayload == nil { + t.Fatal("translated payload is nil") + } + + // Now call TranslateNonStream with the translated request payload. + var param any + out := sdktranslator.TranslateNonStream( + context.Background(), + sdktranslator.FormatOpenAI, + sourceFmt, + "auto", + originalRequest, + translatedPayload, + openAIResp, + ¶m, + ) + + if len(out) == 0 { + t.Error("expected non-empty translated output") + } + if !json.Valid(out) { + t.Error("TranslateNonStream must return valid JSON") + } +} diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 47990bc1..726d7d41 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -11,6 +11,7 @@ import ( "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/codex" + qoderauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/qoder" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/geminicli" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) @@ -169,6 +170,22 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] } } } + if provider == "qoder" { + // Deserialize the on-disk JSON directly into the storage struct so + // every persisted field — including the cached model_configs map + // written by SaveTokenToFile — survives restarts and hot-reloads. + // Field-by-field copying from the metadata map drops nested types + // like ModelConfigs (map[string]json.RawMessage) and would force + // buildQoderModelConfig to fail with "model config cache is empty" + // whenever /algo/api/v2/model/list is unavailable. + var storage qoderauth.QoderTokenStorage + if errStorage := json.Unmarshal(data, &storage); errStorage == nil { + if storage.Type == "" { + storage.Type = "qoder" + } + a.Storage = &storage + } + } if provider == "gemini-cli" { if virtuals := SynthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 { for _, v := range virtuals { diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index 63b394aa..f2a7f39f 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + qoderauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/qoder" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) @@ -955,3 +956,110 @@ func TestFileSynthesizer_Synthesize_MultiProjectGeminiWithNote(t *testing.T) { } } } + +// TestSynthesizeFileAuths_QoderPreservesModelConfigs verifies that hot-reloading +// a qoder auth file does not drop the cached model_configs map written by +// QoderTokenStorage.SaveTokenToFile. Without it, buildQoderModelConfig fails +// with "model config cache is empty" until /algo/api/v2/model/list returns, +// even when the disk copy already has the answer. +func TestSynthesizeFileAuths_QoderPreservesModelConfigs(t *testing.T) { + tmpDir := t.TempDir() + authPath := filepath.Join(tmpDir, "qoder-test@example.com.json") + + storage := &qoderauth.QoderTokenStorage{ + Token: "tok", + RefreshToken: "rtok", + UserID: "u-123", + Name: "Test", + Email: "test@example.com", + ExpireTime: 1234567890, + Type: "qoder", + MachineID: "m-1", + ModelConfigs: map[string]json.RawMessage{ + "dfmodel": json.RawMessage(`{"key":"dfmodel","format":"openai","is_vl":true,"max_input_tokens":131072}`), + }, + } + if err := storage.SaveTokenToFile(authPath); err != nil { + t.Fatalf("SaveTokenToFile: %v", err) + } + + data, err := os.ReadFile(authPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tmpDir, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + auths := SynthesizeAuthFile(ctx, authPath, data) + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + + a := auths[0] + if a.Provider != "qoder" { + t.Fatalf("expected provider qoder, got %q", a.Provider) + } + storedAny := a.Storage + stored, ok := storedAny.(*qoderauth.QoderTokenStorage) + if !ok { + t.Fatalf("expected *QoderTokenStorage, got %T", storedAny) + } + if stored.Email != "test@example.com" { + t.Errorf("expected email preserved, got %q", stored.Email) + } + raw, ok := stored.GetModelConfig("dfmodel") + if !ok { + t.Fatalf("expected cached model_configs entry to survive reload, keys=%v", stored.ModelConfigKeys()) + } + if !strings.Contains(string(raw), `"is_vl":true`) { + t.Errorf("expected raw model_config to contain is_vl, got %s", string(raw)) + } +} + +// TestSynthesizeFileAuths_QoderHandlesMissingModelConfigs ensures that auth +// files written by older builds (no model_configs key) still synthesize +// without error — the cache simply starts empty and FetchQoderModels will +// repopulate it. +func TestSynthesizeFileAuths_QoderHandlesMissingModelConfigs(t *testing.T) { + tmpDir := t.TempDir() + authPath := filepath.Join(tmpDir, "qoder-old@example.com.json") + payload := map[string]any{ + "type": "qoder", + "email": "old@example.com", + "token": "tok", + "refresh_token": "rtok", + "user_id": "u-1", + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if err := os.WriteFile(authPath, data, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tmpDir, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + auths := SynthesizeAuthFile(ctx, authPath, data) + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + stored, ok := auths[0].Storage.(*qoderauth.QoderTokenStorage) + if !ok { + t.Fatalf("expected *QoderTokenStorage, got %T", auths[0].Storage) + } + if stored.Email != "old@example.com" { + t.Errorf("expected email preserved, got %q", stored.Email) + } + if len(stored.ModelConfigKeys()) != 0 { + t.Errorf("expected empty cache, got %v", stored.ModelConfigKeys()) + } +} diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index 74b13c8e..0356d702 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -15,6 +15,7 @@ import ( "sync" "time" + qoderauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/qoder" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) @@ -267,6 +268,17 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, if email, ok := metadata["email"].(string); ok && email != "" { auth.Attributes["email"] = email } + if provider == "qoder" { + var storage qoderauth.QoderTokenStorage + if raw, errMarshal := json.Marshal(metadata); errMarshal == nil { + if errUnmarshal := json.Unmarshal(raw, &storage); errUnmarshal == nil { + if strings.TrimSpace(storage.Type) == "" { + storage.Type = "qoder" + } + auth.Storage = &storage + } + } + } cliproxyauth.ApplyCustomHeadersFromMetadata(auth) return auth, nil } diff --git a/sdk/auth/qoder.go b/sdk/auth/qoder.go new file mode 100644 index 00000000..52e0e9f0 --- /dev/null +++ b/sdk/auth/qoder.go @@ -0,0 +1,133 @@ +package auth + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/qoder" + "github.com/router-for-me/CLIProxyAPI/v7/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" +) + +// QoderAuthenticator implements the device flow login for Qoder accounts. +type QoderAuthenticator struct{} + +// NewQoderAuthenticator constructs a Qoder authenticator. +func NewQoderAuthenticator() *QoderAuthenticator { + return &QoderAuthenticator{} +} + +func (a *QoderAuthenticator) Provider() string { + return "qoder" +} + +func (a *QoderAuthenticator) RefreshLead() *time.Duration { + // Qoder device tokens are long-lived (~30 days), and we don't have + // a working refresh path (see QoderExecutor.Refresh comment). Use a + // short non-zero lead so the auto-refresh loop still revisits the + // auth periodically — but never within the same minute it just ran. + // Returning nil disables scheduled refresh entirely; we keep a + // nominal 24h lead so admins can observe through the management API. + d := 24 * time.Hour + return &d +} + +func (a *QoderAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { + if cfg == nil { + return nil, fmt.Errorf("cliproxy auth: configuration is required") + } + if ctx == nil { + ctx = context.Background() + } + if opts == nil { + opts = &LoginOptions{} + } + + authSvc := qoder.NewQoderAuth(cfg) + + // Initiate device flow + deviceFlow, err := authSvc.InitiateDeviceFlow(ctx) + if err != nil { + return nil, fmt.Errorf("qoder device flow initiation failed: %w", err) + } + + authURL := deviceFlow.VerificationURIComplete + + // Open browser or display URL + if !opts.NoBrowser { + fmt.Println("Opening browser for Qoder authentication") + if !browser.IsAvailable() { + log.Warn("No browser available; please open the URL manually") + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } else if err = browser.OpenURL(authURL); err != nil { + log.Warnf("Failed to open browser automatically: %v", err) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } + } else { + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } + + fmt.Println("Waiting for Qoder authentication...") + + // Poll for token + tokenData, err := authSvc.PollForToken(ctx, deviceFlow) + if err != nil { + return nil, fmt.Errorf("qoder authentication failed: %w", err) + } + + // Resolve user info (best effort). FetchUserInfo only needs the access + // token, so we always attempt it — UserID is informational here. + tokenStorage := authSvc.CreateTokenStorage(tokenData, deviceFlow.MachineID) + name, email := authSvc.SaveUserInfo(ctx, tokenData.AccessToken, tokenData.UserID, "", "") + + // Resolve a label for the auth file name. Preference order: + // 1. email returned by /userinfo + // 2. opts.Metadata[email|alias] supplied by the caller + // 3. tokenData.UserID — stable per account, deterministic file name + // 4. timestamp — last-resort unique fallback so non-interactive + // flows (Docker, management API, scripts) never block on a prompt + // + // We never prompt: prompting would deadlock callers that have no TTY, + // and we already have enough information to write a unique file. + label := strings.TrimSpace(email) + if label == "" && opts.Metadata != nil { + label = strings.TrimSpace(opts.Metadata["email"]) + if label == "" { + label = strings.TrimSpace(opts.Metadata["alias"]) + } + } + if label == "" { + label = strings.TrimSpace(tokenData.UserID) + } + if label == "" { + label = fmt.Sprintf("user-%d", time.Now().UnixMilli()) + } + + tokenStorage.Email = label + tokenStorage.Name = name + + // Generate file name + fileName := fmt.Sprintf("qoder-%s.json", label) + metadata := map[string]any{ + "email": label, + "name": name, + "user_id": tokenData.UserID, + } + + fmt.Println("Qoder authentication successful") + if name != "" { + fmt.Printf("Logged in as %s <%s>\n", name, label) + } + + return &coreauth.Auth{ + ID: fileName, + Provider: a.Provider(), + FileName: fileName, + Storage: tokenStorage, + Metadata: metadata, + }, nil +} diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go index 59ff1c6d..0addc677 100644 --- a/sdk/auth/refresh_registry.go +++ b/sdk/auth/refresh_registry.go @@ -19,6 +19,7 @@ func init() { registerRefreshLead("gitlab", func() Authenticator { return NewGitLabAuthenticator() }) registerRefreshLead("codebuddy", func() Authenticator { return NewCodeBuddyAuthenticator() }) registerRefreshLead("cursor", func() Authenticator { return NewCursorAuthenticator() }) + registerRefreshLead("qoder", func() Authenticator { return NewQoderAuthenticator() }) } func registerRefreshLead(provider string, factory func() Authenticator) { diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 355a31e4..7a2ca301 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -481,6 +481,8 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace s.coreManager.RegisterExecutor(executor.NewCodeBuddyExecutor(s.cfg)) case "gitlab": s.coreManager.RegisterExecutor(executor.NewGitLabExecutor(s.cfg)) + case "qoder": + s.coreManager.RegisterExecutor(executor.NewQoderExecutor(s.cfg)) default: providerKey := strings.ToLower(strings.TrimSpace(a.Provider)) if providerKey == "" { @@ -1245,6 +1247,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { case "codebuddy": models = registry.GetCodeBuddyModels() models = applyExcludedModels(models, excluded) + case "qoder": + models = executor.FetchQoderModels(context.Background(), a, s.cfg) + models = applyExcludedModels(models, excluded) default: // Handle OpenAI-compatibility providers by name using config if s.cfg != nil {