From e681131b8897ff2e554c83bac9163a8551fd7113 Mon Sep 17 00:00:00 2001 From: hpy_ubuntu <2757652611@qq.com> Date: Sat, 9 May 2026 14:02:45 +0800 Subject: [PATCH 1/4] feat: add auth health status tracking and Kiro quota query Resolves #23 ## Health Status Tracking (recent_requests) Add per-auth request success/failure tracking via a 20-bucket ring buffer (10-minute intervals, 200-minute sliding window). The /v0/management/auth-files endpoint now returns 'success', 'failed', and 'recent_requests' fields for each auth entry, enabling the management panel to render health status indicators (red/green/yellow bars). Changes: - sdk/cliproxy/auth/types.go: Add Success/Failed counters, recentRequestRing type, RecentRequestBucket export type, recordRecentRequest() and RecentRequestsSnapshot() methods to Auth struct. - sdk/cliproxy/auth/conductor.go: Call recordRecentRequest() and increment counters in MarkResult(); preserve counters across auth reloads. - internal/api/handlers/management/auth_files.go: Expose success, failed, and recent_requests in buildAuthFileEntry() response. ## Kiro Quota Query Add a management API endpoint and background cache for querying Kiro (AWS CodeWhisperer) usage quota per auth entry. - GET /v0/management/kiro-quota?auth_index=: Returns usage breakdown, remaining quota, usage percentage, subscription info, and next reset time. - kiro_quota field in /v0/management/auth-files: Each Kiro auth entry now includes cached quota info (plan, used, limit, remaining, percentage). - Background refresher: Quota data is refreshed every 5 minutes for all active Kiro credentials. Changes: - internal/api/handlers/management/kiro_quota.go: GetKiroQuota handler, findKiroAuth, extractKiroTokenData, buildKiroQuotaStatus helpers. - internal/api/handlers/management/kiro_quota_cache.go: Background quota cache with StartKiroQuotaRefresher goroutine. - internal/api/server.go: Register /kiro-quota route and start refresher. --- .../api/handlers/management/auth_files.go | 6 + .../api/handlers/management/kiro_quota.go | 174 ++++++++++++++++++ .../handlers/management/kiro_quota_cache.go | 141 ++++++++++++++ internal/api/server.go | 2 + sdk/cliproxy/auth/conductor.go | 10 + sdk/cliproxy/auth/types.go | 96 ++++++++++ 6 files changed, 429 insertions(+) create mode 100644 internal/api/handlers/management/kiro_quota.go create mode 100644 internal/api/handlers/management/kiro_quota_cache.go diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 28902fe55e..f92f452df2 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -400,6 +400,9 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { "source": "memory", "size": int64(0), } + entry["success"] = auth.Success + entry["failed"] = auth.Failed + entry["recent_requests"] = auth.RecentRequestsSnapshot(time.Now()) if email := authEmail(auth); email != "" { entry["email"] = email } @@ -443,6 +446,9 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { if claims := extractCodexIDTokenClaims(auth); claims != nil { entry["id_token"] = claims } + if kiroQuota := h.getKiroQuotaCached(auth); kiroQuota != nil { + entry["kiro_quota"] = kiroQuota + } // Expose priority from Attributes (set by synthesizer from JSON "priority" field). // Fall back to Metadata for auths registered via UploadAuthFile (no synthesizer). if p := strings.TrimSpace(authAttribute(auth, "priority")); p != "" { diff --git a/internal/api/handlers/management/kiro_quota.go b/internal/api/handlers/management/kiro_quota.go new file mode 100644 index 0000000000..421dfa77ec --- /dev/null +++ b/internal/api/handlers/management/kiro_quota.go @@ -0,0 +1,174 @@ +package management + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +// GetKiroQuota fetches Kiro (AWS CodeWhisperer) usage quota information. +// +// Endpoint: +// +// GET /v0/management/kiro-quota +// +// Query Parameters (optional): +// - auth_index: The credential "auth_index" from GET /v0/management/auth-files. +// If omitted, uses the first available Kiro credential. +// +// Response: +// +// Returns the UsageQuotaResponse with usage breakdown and subscription info. +// +// Example: +// +// curl -sS -X GET "http://127.0.0.1:8317/v0/management/kiro-quota?auth_index=" \ +// -H "Authorization: Bearer " +func (h *Handler) GetKiroQuota(c *gin.Context) { + authIndex := strings.TrimSpace(c.Query("auth_index")) + if authIndex == "" { + authIndex = strings.TrimSpace(c.Query("authIndex")) + } + if authIndex == "" { + authIndex = strings.TrimSpace(c.Query("AuthIndex")) + } + + auth := h.findKiroAuth(authIndex) + if auth == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "no kiro credential found"}) + return + } + + // Extract token data from auth metadata + tokenData := extractKiroTokenData(auth) + if tokenData == nil || tokenData.AccessToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "kiro access token not available (token may need refresh)"}) + return + } + + // Create usage checker with proxy-aware HTTP client + checker := kiro.NewUsageCheckerWithClient( + util.SetProxy(&h.cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second}), + ) + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + usage, err := checker.CheckUsage(ctx, tokenData) + if err != nil { + log.WithError(err).Debug("kiro quota request failed") + c.JSON(http.StatusBadGateway, gin.H{"error": "kiro quota request failed: " + err.Error()}) + return + } + + // Build enriched response + response := gin.H{ + "usage": usage, + "quota_status": buildKiroQuotaStatus(usage), + "auth_index": auth.Index, + "auth_name": auth.FileName, + } + + c.JSON(http.StatusOK, response) +} + +// findKiroAuth locates a Kiro credential by auth_index or returns the first available one. +func (h *Handler) findKiroAuth(authIndex string) *coreauth.Auth { + if h == nil || h.authManager == nil { + return nil + } + + auths := h.authManager.List() + var firstKiro *coreauth.Auth + + for _, auth := range auths { + if auth == nil { + continue + } + provider := strings.ToLower(strings.TrimSpace(auth.Provider)) + if provider != "kiro" { + continue + } + if auth.Disabled { + continue + } + if firstKiro == nil { + firstKiro = auth + } + if authIndex != "" { + auth.EnsureIndex() + if auth.Index == authIndex { + return auth + } + } + } + + if authIndex == "" { + return firstKiro + } + return nil +} + +// extractKiroTokenData extracts KiroTokenData from a coreauth.Auth's Metadata. +func extractKiroTokenData(auth *coreauth.Auth) *kiro.KiroTokenData { + if auth == nil || auth.Metadata == nil { + return nil + } + + accessToken, _ := auth.Metadata["access_token"].(string) + refreshToken, _ := auth.Metadata["refresh_token"].(string) + profileArn, _ := auth.Metadata["profile_arn"].(string) + clientID, _ := auth.Metadata["client_id"].(string) + clientSecret, _ := auth.Metadata["client_secret"].(string) + region, _ := auth.Metadata["region"].(string) + startURL, _ := auth.Metadata["start_url"].(string) + + if accessToken == "" { + return nil + } + + return &kiro.KiroTokenData{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ProfileArn: profileArn, + ClientID: clientID, + ClientSecret: clientSecret, + Region: region, + StartURL: startURL, + } +} + +// buildKiroQuotaStatus builds a summary status from the usage response. +func buildKiroQuotaStatus(usage *kiro.UsageQuotaResponse) gin.H { + if usage == nil { + return gin.H{"exhausted": true, "remaining": 0} + } + + remaining := kiro.GetRemainingQuota(usage) + exhausted := kiro.IsQuotaExhausted(usage) + percentage := kiro.GetUsagePercentage(usage) + + status := gin.H{ + "exhausted": exhausted, + "remaining": remaining, + "usage_percentage": percentage, + } + + if usage.NextDateReset > 0 { + status["next_reset"] = time.Unix(int64(usage.NextDateReset/1000), 0) + } + + if usage.SubscriptionInfo != nil { + status["subscription"] = usage.SubscriptionInfo + } + + return status +} diff --git a/internal/api/handlers/management/kiro_quota_cache.go b/internal/api/handlers/management/kiro_quota_cache.go new file mode 100644 index 0000000000..c430ed3165 --- /dev/null +++ b/internal/api/handlers/management/kiro_quota_cache.go @@ -0,0 +1,141 @@ +package management + +import ( + "context" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" +) + +// kiroQuotaCache stores cached quota info for Kiro auth entries. +var ( + kiroQuotaMu sync.RWMutex + kiroQuotaStore = make(map[string]gin.H) // keyed by auth ID +) + +// getKiroQuotaCached returns cached quota info for a Kiro auth entry. +func (h *Handler) getKiroQuotaCached(auth *coreauth.Auth) gin.H { + if auth == nil { + return nil + } + provider := strings.ToLower(strings.TrimSpace(auth.Provider)) + if provider != "kiro" { + return nil + } + + kiroQuotaMu.RLock() + result, ok := kiroQuotaStore[auth.ID] + kiroQuotaMu.RUnlock() + + if ok { + return result + } + + // If not cached, try to fetch synchronously (first time only) + return h.fetchAndCacheKiroQuota(auth) +} + +// fetchAndCacheKiroQuota fetches quota for a single Kiro auth and caches it. +func (h *Handler) fetchAndCacheKiroQuota(auth *coreauth.Auth) gin.H { + if auth == nil || auth.Metadata == nil { + return nil + } + + tokenData := extractKiroTokenData(auth) + if tokenData == nil || tokenData.AccessToken == "" { + return nil + } + + checker := kiro.NewUsageCheckerWithClient( + util.SetProxy(&h.cfg.SDKConfig, &http.Client{Timeout: 15 * time.Second}), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + usage, err := checker.CheckUsage(ctx, tokenData) + if err != nil { + log.WithError(err).Debugf("kiro quota fetch failed for %s", auth.ID) + return nil + } + + result := buildKiroQuotaEntry(usage) + + kiroQuotaMu.Lock() + kiroQuotaStore[auth.ID] = result + kiroQuotaMu.Unlock() + + return result +} + +// buildKiroQuotaEntry builds the quota info map from a usage response. +func buildKiroQuotaEntry(usage *kiro.UsageQuotaResponse) gin.H { + if usage == nil || len(usage.UsageBreakdownList) == 0 { + return nil + } + + bd := usage.UsageBreakdownList[0] + result := gin.H{ + "resource_type": bd.ResourceType, + "used": bd.CurrentUsageWithPrecision, + "limit": bd.UsageLimitWithPrecision, + "remaining": bd.UsageLimitWithPrecision - bd.CurrentUsageWithPrecision, + "usage_percentage": kiro.GetUsagePercentage(usage), + "exhausted": kiro.IsQuotaExhausted(usage), + } + + if usage.SubscriptionInfo != nil { + result["plan"] = usage.SubscriptionInfo.SubscriptionTitle + } + + if usage.NextDateReset > 0 { + result["next_reset"] = time.Unix(int64(usage.NextDateReset/1000), 0) + } + + return result +} + +// StartKiroQuotaRefresher starts a background goroutine that periodically +// refreshes Kiro quota info for all active Kiro auth entries. +func (h *Handler) StartKiroQuotaRefresher() { + go func() { + // Initial delay to let the service start up + time.Sleep(10 * time.Second) + h.refreshAllKiroQuotas() + + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + h.refreshAllKiroQuotas() + } + }() +} + +// refreshAllKiroQuotas fetches quota for all active Kiro auth entries. +func (h *Handler) refreshAllKiroQuotas() { + if h == nil || h.authManager == nil { + return + } + + auths := h.authManager.List() + for _, auth := range auths { + if auth == nil || auth.Disabled { + continue + } + provider := strings.ToLower(strings.TrimSpace(auth.Provider)) + if provider != "kiro" { + continue + } + h.fetchAndCacheKiroQuota(auth) + // Small delay between requests to avoid rate limiting + time.Sleep(2 * time.Second) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 8fd7f4680f..07832d9619 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -665,6 +665,8 @@ func (s *Server) registerManagementRoutes() { mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel) mgmt.GET("/copilot-quota", s.mgmt.GetCopilotQuota) + mgmt.GET("/kiro-quota", s.mgmt.GetKiroQuota) + s.mgmt.StartKiroQuotaRefresher() mgmt.GET("/api-keys", s.mgmt.GetAPIKeys) mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 6628c20b8a..0e3bb71581 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1121,6 +1121,9 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) { auth.Index = existing.Index auth.indexAssigned = existing.indexAssigned } + auth.Success = existing.Success + auth.Failed = existing.Failed + auth.recentRequests = existing.recentRequests if !existing.Disabled && existing.Status != StatusDisabled && !auth.Disabled && auth.Status != StatusDisabled { if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 { auth.ModelStates = existing.ModelStates @@ -1977,6 +1980,13 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) { if auth, ok := m.auths[result.AuthID]; ok && auth != nil { now := time.Now() + auth.recordRecentRequest(now, result.Success) + if result.Success { + auth.Success++ + } else { + auth.Failed++ + } + if result.Success { if result.Model != "" { state := ensureModelState(auth, result.Model) diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 16814eb899..f3ffcbf501 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -90,8 +90,15 @@ type Auth struct { ModelStates map[string]*ModelState `json:"model_states,omitempty"` // Runtime carries non-serialisable data used during execution (in-memory only). + // Success counts the total number of successful requests routed through this auth. + Success int64 `json:"-"` + // Failed counts the total number of failed requests routed through this auth. + Failed int64 `json:"-"` + Runtime any `json:"-"` + recentRequests recentRequestRing `json:"-"` + indexAssigned bool `json:"-"` } @@ -583,3 +590,92 @@ func normaliseUnix(raw int64) time.Time { } return time.Unix(raw, 0) } + +// --- Recent Requests Health Tracking --- + +const ( + recentRequestBucketSeconds int64 = 10 * 60 + recentRequestBucketCount = 20 +) + +type recentRequestBucket struct { + bucketID int64 + success int64 + failed int64 +} + +type recentRequestRing struct { + buckets [recentRequestBucketCount]recentRequestBucket +} + +// RecentRequestBucket is the exported per-bucket snapshot returned by the management API. +type RecentRequestBucket struct { + Time string `json:"time"` + Success int64 `json:"success"` + Failed int64 `json:"failed"` +} + +func recentRequestBucketID(now time.Time) int64 { + if now.IsZero() { + return 0 + } + return now.Unix() / recentRequestBucketSeconds +} + +func recentRequestBucketIndex(bucketID int64) int { + mod := bucketID % int64(recentRequestBucketCount) + if mod < 0 { + mod += int64(recentRequestBucketCount) + } + return int(mod) +} + +func formatRecentRequestBucketLabel(bucketID int64) string { + start := time.Unix(bucketID*recentRequestBucketSeconds, 0).In(time.Local) + end := start.Add(time.Duration(recentRequestBucketSeconds) * time.Second) + return start.Format("15:04") + "-" + end.Format("15:04") +} + +func (a *Auth) recordRecentRequest(now time.Time, success bool) { + if a == nil { + return + } + bucketID := recentRequestBucketID(now) + idx := recentRequestBucketIndex(bucketID) + bucket := &a.recentRequests.buckets[idx] + if bucket.bucketID != bucketID { + bucket.bucketID = bucketID + bucket.success = 0 + bucket.failed = 0 + } + if success { + bucket.success++ + } else { + bucket.failed++ + } +} + +// RecentRequestsSnapshot returns the last 20 ten-minute buckets of request counts. +func (a *Auth) RecentRequestsSnapshot(now time.Time) []RecentRequestBucket { + out := make([]RecentRequestBucket, 0, recentRequestBucketCount) + if a == nil { + return out + } + + currentBucketID := recentRequestBucketID(now) + for i := recentRequestBucketCount - 1; i >= 0; i-- { + bucketID := currentBucketID - int64(i) + idx := recentRequestBucketIndex(bucketID) + bucket := a.recentRequests.buckets[idx] + entry := RecentRequestBucket{ + Time: formatRecentRequestBucketLabel(bucketID), + } + if bucket.bucketID == bucketID { + entry.Success = bucket.success + entry.Failed = bucket.failed + } + out = append(out, entry) + } + + return out +} From a49cb2789b0c2255d6575d877283712f661938f1 Mon Sep 17 00:00:00 2001 From: rensumo Date: Sat, 9 May 2026 18:03:10 +0800 Subject: [PATCH 2/4] feat: add codebuddy thinking provider, fix joycode streaming, cleanup kimi/joycode tool schemas - Add codebuddy thinking provider (reasoning_effort format) for codebuddy and codebuddy-ai - Register codebuddy/codebuddy-ai in thinking pipeline (apply, strip, validate) - Update deepseek-v4-flash thinking levels to [high, max], remove unsupported IMAGE modality - Fix joycode streaming: strip SSE data: prefix before TranslateStream to avoid double prefix - Add CleanupOrphanedRequiredInTools for kimi/joycode to remove orphaned required entries in tool schemas - Update docker-compose.yml image to ghcr.io/ve-ria/cli-proxy-api-plus:latest --- docker-compose.yml | 2 +- internal/registry/model_definitions.go | 3 +- .../executor/helps/thinking_providers.go | 1 + internal/runtime/executor/joycode_executor.go | 17 ++-- internal/runtime/executor/kimi_executor.go | 2 + internal/thinking/apply.go | 19 ++-- internal/thinking/provider/codebuddy/apply.go | 97 +++++++++++++++++++ internal/thinking/strip.go | 2 +- internal/thinking/validate.go | 2 +- internal/util/gemini_schema.go | 39 ++++++++ 10 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 internal/thinking/provider/codebuddy/apply.go diff --git a/docker-compose.yml b/docker-compose.yml index 1490c3446d..5c1858a507 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: cli-proxy-api: - image: rensumo/cli-proxy-api-plus:latest + image: ghcr.io/ve-ria/cli-proxy-api-plus:latest pull_policy: always build: context: . diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 665e3ebb4e..b54579907c 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -243,8 +243,7 @@ func GetCodeBuddyModels() []*ModelInfo { ID: "deepseek-v4-flash", Object: "model", Created: now, OwnedBy: "tencent", Type: "codebuddy", DisplayName: "DeepSeek V4 Flash", Description: "DeepSeek V4 Flash via CodeBuddy", ContextLength: 1000000, MaxCompletionTokens: 50000, SupportedEndpoints: []string{"/chat/completions"}, - Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}, - SupportedInputModalities: []string{"TEXT", "IMAGE"}, + Thinking: &ThinkingSupport{Levels: []string{"high", "max"}}, }, { ID: "deepseek-v3-2-volc", Object: "model", Created: now, OwnedBy: "tencent", diff --git a/internal/runtime/executor/helps/thinking_providers.go b/internal/runtime/executor/helps/thinking_providers.go index 4c79044e87..4be9827a75 100644 --- a/internal/runtime/executor/helps/thinking_providers.go +++ b/internal/runtime/executor/helps/thinking_providers.go @@ -4,6 +4,7 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/antigravity" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codearts" + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codebuddy" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli" diff --git a/internal/runtime/executor/joycode_executor.go b/internal/runtime/executor/joycode_executor.go index 1b9ca00357..a12deca46c 100644 --- a/internal/runtime/executor/joycode_executor.go +++ b/internal/runtime/executor/joycode_executor.go @@ -17,6 +17,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" @@ -174,28 +175,27 @@ func (e *JoyCodeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth. continue } - var data []byte + var data string if strings.HasPrefix(line, "data: ") { - data = []byte(strings.TrimPrefix(line, "data: ")) + data = strings.TrimPrefix(line, "data: ") } else if strings.HasPrefix(line, "data:") { - data = []byte(strings.TrimPrefix(line, "data:")) + data = strings.TrimPrefix(line, "data:") } else { continue } - if string(data) == "[DONE]" { + if data == "[DONE]" { break } - if pt := gjson.GetBytes(data, "usage.prompt_tokens").Int(); pt > 0 { + if pt := gjson.Get(data, "usage.prompt_tokens").Int(); pt > 0 { totalPromptTokens = pt } - if ct := gjson.GetBytes(data, "usage.completion_tokens").Int(); ct > 0 { + if ct := gjson.Get(data, "usage.completion_tokens").Int(); ct > 0 { totalCompletionTokens = ct } - chunk := []byte("data: " + string(data) + "\n\n") - translatedChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, req.Payload, chunk, &streamParam) + translatedChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, req.Payload, []byte(data), &streamParam) for _, tc := range translatedChunks { if len(tc) > 0 { chunks <- cliproxyexecutor.StreamChunk{Payload: tc} @@ -272,6 +272,7 @@ func buildJoyCodePayload(openaiPayload []byte, modelName string, auth *cliproxya log.Errorf("joycode: failed to marshal payload: %v", err) return openaiPayload } + result = util.CleanupOrphanedRequiredInTools(result) return result } diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 931e3a569f..50d6613df7 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -113,6 +113,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req if err != nil { return resp, err } + body = util.CleanupOrphanedRequiredInTools(body) url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) @@ -222,6 +223,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut if err != nil { return nil, err } + body = util.CleanupOrphanedRequiredInTools(body) url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 1edeac874c..0774d6f5ea 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -11,13 +11,15 @@ import ( // providerAppliers maps provider names to their ProviderApplier implementations. var providerAppliers = map[string]ProviderApplier{ - "gemini": nil, - "gemini-cli": nil, - "claude": nil, - "openai": nil, - "codex": nil, - "antigravity": nil, - "kimi": nil, + "gemini": nil, + "gemini-cli": nil, + "claude": nil, + "openai": nil, + "codex": nil, + "antigravity": nil, + "kimi": nil, + "codebuddy": nil, + "codebuddy-ai": nil, } // GetProviderApplier returns the ProviderApplier for the given provider name. @@ -322,12 +324,11 @@ func extractThinkingConfig(body []byte, provider string) ThinkingConfig { return extractClaudeConfig(body) case "gemini", "gemini-cli", "antigravity": return extractGeminiConfig(body, provider) - case "openai": + case "openai", "codebuddy", "codebuddy-ai": return extractOpenAIConfig(body) case "codex": return extractCodexConfig(body) case "kimi": - // Kimi uses OpenAI-compatible reasoning_effort format return extractOpenAIConfig(body) default: return ThinkingConfig{} diff --git a/internal/thinking/provider/codebuddy/apply.go b/internal/thinking/provider/codebuddy/apply.go new file mode 100644 index 0000000000..35d41a32ca --- /dev/null +++ b/internal/thinking/provider/codebuddy/apply.go @@ -0,0 +1,97 @@ +package codebuddy + +import ( + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type Applier struct{} + +var _ thinking.ProviderApplier = (*Applier)(nil) + +func NewApplier() *Applier { + return &Applier{} +} + +func init() { + applier := NewApplier() + thinking.RegisterProvider("codebuddy", applier) + thinking.RegisterProvider("codebuddy-ai", applier) +} + +func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) { + if thinking.IsUserDefinedModel(modelInfo) { + return applyCompatibleCodeBuddy(body, config) + } + if modelInfo.Thinking == nil { + return body, nil + } + + if config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone { + return body, nil + } + + if len(body) == 0 || !gjson.ValidBytes(body) { + body = []byte(`{}`) + } + + if config.Mode == thinking.ModeLevel { + result, _ := sjson.SetBytes(body, "reasoning_effort", string(config.Level)) + return result, nil + } + + effort := "" + support := modelInfo.Thinking + if config.Budget == 0 { + if support.ZeroAllowed || thinking.HasLevel(support.Levels, string(thinking.LevelNone)) { + effort = string(thinking.LevelNone) + } + } + if effort == "" && config.Level != "" { + effort = string(config.Level) + } + if effort == "" && len(support.Levels) > 0 { + effort = support.Levels[0] + } + if effort == "" { + return body, nil + } + + result, _ := sjson.SetBytes(body, "reasoning_effort", effort) + return result, nil +} + +func applyCompatibleCodeBuddy(body []byte, config thinking.ThinkingConfig) ([]byte, error) { + if len(body) == 0 || !gjson.ValidBytes(body) { + body = []byte(`{}`) + } + + var effort string + switch config.Mode { + case thinking.ModeLevel: + if config.Level == "" { + return body, nil + } + effort = string(config.Level) + case thinking.ModeNone: + effort = string(thinking.LevelNone) + if config.Level != "" { + effort = string(config.Level) + } + case thinking.ModeAuto: + effort = string(thinking.LevelAuto) + case thinking.ModeBudget: + level, ok := thinking.ConvertBudgetToLevel(config.Budget) + if !ok { + return body, nil + } + effort = level + default: + return body, nil + } + + result, _ := sjson.SetBytes(body, "reasoning_effort", effort) + return result, nil +} diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go index 1e1712d195..da71f57dfe 100644 --- a/internal/thinking/strip.go +++ b/internal/thinking/strip.go @@ -35,7 +35,7 @@ func StripThinkingConfig(body []byte, provider string) []byte { paths = []string{"generationConfig.thinkingConfig"} case "gemini-cli", "antigravity": paths = []string{"request.generationConfig.thinkingConfig"} - case "openai": + case "openai", "codebuddy", "codebuddy-ai": paths = []string{"reasoning_effort"} case "kimi": paths = []string{ diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go index 4a3ca97ce8..9169c60333 100644 --- a/internal/thinking/validate.go +++ b/internal/thinking/validate.go @@ -357,7 +357,7 @@ func isGeminiFamily(provider string) bool { func isOpenAIFamily(provider string) bool { switch provider { - case "openai", "openai-response", "codex": + case "openai", "openai-response", "codex", "codebuddy", "codebuddy-ai": return true default: return false diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index 4cc946d5f3..88810654a3 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -606,6 +606,45 @@ func addEmptySchemaPlaceholder(jsonStr string) string { // --- Helpers --- +// CleanupOrphanedRequiredInTools removes "required" entries from +// tools[].function.parameters that reference properties not defined in the +// corresponding "properties" object. Moonshot/Kimi strictly validates that +// every item in "required" must have a matching entry in "properties". +func CleanupOrphanedRequiredInTools(body []byte) []byte { + if len(body) == 0 || !gjson.ValidBytes(body) { + return body + } + tools := gjson.GetBytes(body, "tools") + if !tools.Exists() || !tools.IsArray() || len(tools.Array()) == 0 { + return body + } + + out := string(body) + changed := false + + tools.ForEach(func(idx, tool gjson.Result) bool { + params := tool.Get("function.parameters") + if !params.Exists() { + return true + } + cleaned := cleanupRequiredFields(params.Raw) + if cleaned != params.Raw { + path := fmt.Sprintf("tools.%d.function.parameters", idx.Int()) + updated, err := sjson.SetRaw(out, path, cleaned) + if err == nil { + out = updated + changed = true + } + } + return true + }) + + if !changed { + return body + } + return []byte(out) +} + func findPaths(jsonStr, field string) []string { var paths []string Walk(gjson.Parse(jsonStr), "", field, &paths) From f0d096ca1a8e29a70d57fbd18c7202329f3317c3 Mon Sep 17 00:00:00 2001 From: rensumo Date: Sat, 9 May 2026 18:04:23 +0800 Subject: [PATCH 3/4] Delete .trae/specs directory --- .../fix-codearts-integration/checklist.md | 19 --- .trae/specs/fix-codearts-integration/spec.md | 113 ------------------ .trae/specs/fix-codearts-integration/tasks.md | 59 --------- .../specs/web-management-center/checklist.md | 16 --- .trae/specs/web-management-center/spec.md | 104 ---------------- .trae/specs/web-management-center/tasks.md | 61 ---------- 6 files changed, 372 deletions(-) delete mode 100644 .trae/specs/fix-codearts-integration/checklist.md delete mode 100644 .trae/specs/fix-codearts-integration/spec.md delete mode 100644 .trae/specs/fix-codearts-integration/tasks.md delete mode 100644 .trae/specs/web-management-center/checklist.md delete mode 100644 .trae/specs/web-management-center/spec.md delete mode 100644 .trae/specs/web-management-center/tasks.md diff --git a/.trae/specs/fix-codearts-integration/checklist.md b/.trae/specs/fix-codearts-integration/checklist.md deleted file mode 100644 index 27663be056..0000000000 --- a/.trae/specs/fix-codearts-integration/checklist.md +++ /dev/null @@ -1,19 +0,0 @@ -- [x] `signer.go` 的 `SignRequest` 对 POST 请求计算实际 body SHA256 hash,不再始终使用空 hash -- [x] `buildCodeArtsPayload` 输出的 JSON 包含 chat_id、client、task、task_parameters、is_delta_response、user_id、attempt、parent_message_id 字段 -- [x] `buildCodeArtsPayload` 的 messages 格式为 `[{type: "text", content: "..."}]` -- [x] `PrepareRequest` 设置 Accept: text/event-stream、Heartbeat-Enable、Ide-Name、X-Snap-Traceid 等请求头 -- [x] `sdk/auth/codearts.go` 存在且实现 Authenticator 接口(Provider、Login、RefreshLead) -- [x] `sdk/auth/refresh_registry.go` 中注册了 codearts -- [x] `internal/cmd/auth_manager.go` 中注册了 NewCodeArtsAuthenticator() -- [x] `cmd/server/main.go` 中有 `--codearts-login` flag -- [x] `internal/cmd/codearts_login.go` 存在且实现 DoCodeArtsLogin -- [x] `--codearts-login` flag 处理分支正确调用 DoCodeArtsLogin -- [x] `internal/thinking/provider/codearts/apply.go` 存在且注册了 "codearts" provider -- [x] `internal/runtime/executor/helps/thinking_providers.go` 导入了 codearts thinking provider -- [x] `config.example.yaml` 中 oauth-model-alias Supported channels 包含 codearts -- [x] `config.example.yaml` 中 oauth-excluded-models Supported channels 包含 codearts 且不包含 qwen -- [x] `internal/config/config.go` 中 OAuthExcludedModels 注释包含 codearts -- [x] `internal/api/server.go` 中无中文注释 -- [x] `internal/watcher/watcher.go` 中无中文注释 -- [x] `go build -o test-output ./cmd/server && rm test-output` 编译通过 -- [x] `gofmt -w .` 格式化通过 diff --git a/.trae/specs/fix-codearts-integration/spec.md b/.trae/specs/fix-codearts-integration/spec.md deleted file mode 100644 index 587b68c345..0000000000 --- a/.trae/specs/fix-codearts-integration/spec.md +++ /dev/null @@ -1,113 +0,0 @@ -# CodeArts 集成修复与优化 Spec - -## Why -CodeArts 是项目中唯一一个有完整 executor/auth/translator/OAuth web handler 但**缺少 CLI 登录命令** (`--codearts-login`) 的 provider。用户只能通过浏览器 Web OAuth 流程登录,且 token 无法通过 SDK 自动刷新机制续期。此外,executor 的 payload 构建与 Python 参考实现存在显著差距,签名算法有 bug,以及多处代码规范问题。 - -## What Changes -- 新增 `--codearts-login` CLI flag 及完整登录链路 -- 新增 `sdk/auth/codearts.go` SDK Authenticator -- 在 `sdk/auth/refresh_registry.go` 注册 CodeArts refresh lead -- 在 `internal/cmd/auth_manager.go` 注册 CodeArts authenticator -- 修复 `signer.go` 中 body hash 始终为空的 bug(POST 请求签名不包含 body) -- 修复 `buildCodeArtsPayload` 与 Python 参考实现的差距(缺少 chat_id、task_parameters、is_delta_response 等关键字段) -- 修复 `buildCodeArtsPayload` 中 messages 格式与 Python 参考实现不一致(缺少 `type` 字段) -- 新增 CodeArts thinking provider -- 修复 `config.example.yaml` 和 `config.go` 注释中遗漏 `codearts` channel -- 修复 `oauth-excluded-models` 注释中不存在的 `qwen` channel -- 翻译 `server.go` 和 `watcher.go` 中的中文注释为英文 - -## Impact -- Affected specs: CodeArts 认证、token 自动刷新、thinking pipeline -- Affected code: - - `cmd/server/main.go` — 新增 flag - - `internal/cmd/codearts_login.go` — 新文件 - - `sdk/auth/codearts.go` — 新文件 - - `sdk/auth/refresh_registry.go` — 注册 codearts - - `internal/cmd/auth_manager.go` — 注册 codearts authenticator - - `internal/auth/codearts/signer.go` — 修复 body hash bug - - `internal/runtime/executor/codearts_executor.go` — 修复 payload 构建 - - `internal/thinking/provider/codearts/apply.go` — 新文件 - - `internal/runtime/executor/helps/thinking_providers.go` — 注册 codearts - - `config.example.yaml` — 修复注释 - - `internal/config/config.go` — 修复注释 - - `internal/api/server.go` — 翻译中文注释 - - `internal/watcher/watcher.go` — 翻译中文注释 - -## ADDED Requirements - -### Requirement: CodeArts CLI Login Command -系统 SHALL 提供 `--codearts-login` 命令行 flag,允许用户通过 CLI 完成 CodeArts OAuth 登录流程。 - -#### Scenario: 用户通过 CLI 登录 CodeArts -- **WHEN** 用户执行 `./server --codearts-login` -- **THEN** 系统启动本地回调服务器,生成 ticket_id,打开浏览器到 HuaweiCloud 登录页面 -- **AND** 用户完成登录后,系统轮询获取认证结果 -- **AND** 系统将 AK/SK/SecurityToken 凭证保存到 auth 目录 -- **AND** 输出 "CodeArts authentication successful!" - -### Requirement: CodeArts SDK Authenticator -系统 SHALL 在 `sdk/auth/` 包中提供 `CodeArtsAuthenticator`,实现 `Authenticator` 接口。 - -#### Scenario: Authenticator 注册与使用 -- **WHEN** 系统初始化 auth manager -- **THEN** `CodeArtsAuthenticator` 被注册到 manager 中,provider 为 "codearts" -- **AND** `RefreshLead()` 返回 4 小时(token 有效期 24h,提前 4h 刷新) - -### Requirement: CodeArts Refresh Lead 注册 -系统 SHALL 在 `sdk/auth/refresh_registry.go` 中注册 codearts 的 refresh lead。 - -#### Scenario: 自动刷新调度 -- **WHEN** auto refresh loop 检查 codearts auth 的过期时间 -- **THEN** `ProviderRefreshLead("codearts", nil)` 返回 4 小时 duration -- **AND** token 在过期前 4 小时自动触发刷新 - -### Requirement: Signer Body Hash 修复 -`SignRequest` SHALL 对 POST 请求计算实际 body 的 SHA256 hash,而非始终使用空 body hash。 - -#### Scenario: POST 请求签名 -- **WHEN** 对 POST 请求调用 `SignRequest` -- **THEN** body hash 应为请求 body 的 SHA256 哈希值 -- **AND** 签名结果与 Python 参考实现一致 - -### Requirement: CodeArts Payload 构建对齐 Python 参考 -`buildCodeArtsPayload` SHALL 生成与 Python 参考实现 (`CodeArts-2api.py`) 一致的请求格式。 - -#### Scenario: 完整 payload 构建 -- **WHEN** 构建 CodeArts chat 请求 -- **THEN** payload 包含 `chat_id`(UUID)、`client: "IDE"`、`task: "chat"`、`task_parameters`(含 is_intent_recognition、W3_Search、codebase_search、related_question、preferred_language、enable_code_interpreter、ide、routerVersion、isNewClient、features.support_end_tag 等) -- **AND** payload 包含 `is_delta_response: true`、`user_id`、`attempt: 1`、`parent_message_id: ""` -- **AND** messages 格式为 `[{type: "text", content: "..."}]`(而非当前的 `[{role: "...", content: "..."}]`) -- **AND** system 消息格式为 `[System]\n{content}`,assistant 消息格式为 `[Assistant]\n{content}` - -### Requirement: CodeArts Thinking Provider -系统 SHALL 提供 CodeArts thinking provider,将 thinking level 映射到 CodeArts 的 `task_parameters.temperature` 或其他合适字段。 - -#### Scenario: Thinking suffix 应用 -- **WHEN** 用户请求模型 `Glm-5-internal(high)` -- **THEN** thinking provider 将 level "high" 应用到 CodeArts 请求中 - -### Requirement: 文档注释更新 -配置文件和代码注释 SHALL 正确列出所有支持的 channel。 - -#### Scenario: oauth-model-alias 和 oauth-excluded-models 注释 -- **WHEN** 用户查看 `config.example.yaml` 或 `config.go` -- **THEN** `oauth-model-alias` 的 Supported channels 注释包含 `codearts` -- **AND** `oauth-excluded-models` 的 Supported channels 注释包含 `codearts` 且不包含不存在的 `qwen` - -### Requirement: 中文注释翻译 -代码中的中文注释 SHALL 被翻译为英文。 - -#### Scenario: server.go 和 watcher.go 注释 -- **WHEN** 检查 `server.go` 和 `watcher.go` 中的注释 -- **THEN** 所有注释均为英文 - -## MODIFIED Requirements - -### Requirement: CodeArts Executor PrepareRequest -`PrepareRequest` SHALL 设置与 Python 参考实现一致的请求头,包括 `Accept: text/event-stream`、`Heartbeat-Enable: true`、`Ide-Name`、`Ide-Version`、`Is-Confidential`、`X-Language`、`X-Snap-Traceid` 等。 - -### Requirement: CodeArts Executor Non-Stream Mode -`Execute`(非流式)方法 SHALL 在 payload 中设置 `stream: true`(因为 CodeArts API 即使非流式请求也返回 SSE),并添加注释说明此行为。 - -## REMOVED Requirements -无 diff --git a/.trae/specs/fix-codearts-integration/tasks.md b/.trae/specs/fix-codearts-integration/tasks.md deleted file mode 100644 index 58096e54bb..0000000000 --- a/.trae/specs/fix-codearts-integration/tasks.md +++ /dev/null @@ -1,59 +0,0 @@ -# Tasks - -- [x] Task 1: 修复 `signer.go` body hash bug — POST 请求签名必须包含实际 body 的 SHA256 hash - - [x] SubTask 1.1: 修改 `SignRequest` 函数签名,接受 body 参数 - - [x] SubTask 1.2: 计算 body SHA256 hash 替代空 hash - - [x] SubTask 1.3: 更新所有 `SignRequest` 调用点(executor、auth refresh) - - [x] SubTask 1.4: 验证签名结果与 Python 参考实现一致 - -- [x] Task 2: 修复 `buildCodeArtsPayload` 对齐 Python 参考实现 - - [x] SubTask 2.1: 将 messages 格式从 `[{role, content}]` 改为 `[{type: "text", content}]` - - [x] SubTask 2.2: 添加 chat_id (UUID)、client、task、task_parameters 完整结构 - - [x] SubTask 2.3: 添加 is_delta_response、user_id、attempt、parent_message_id 字段 - - [x] SubTask 2.4: 修复 tool_calls/tool result 消息格式对齐 Python 实现 - - [x] SubTask 2.5: 添加 tools 和 temperature 到 task_parameters 中 - -- [x] Task 3: 修复 `PrepareRequest` 请求头对齐 Python 参考 - - [x] SubTask 3.1: 添加 Accept: text/event-stream、Heartbeat-Enable: true 等缺失头 - - [x] SubTask 3.2: 添加 Ide-Name、Ide-Version、Is-Confidential、X-Language、X-Snap-Traceid - - [x] SubTask 3.3: 更新 SignRequest 调用传入 body - -- [x] Task 4: 新增 `sdk/auth/codearts.go` SDK Authenticator - - [x] SubTask 4.1: 实现 `CodeArtsAuthenticator` struct,Provider() 返回 "codearts" - - [x] SubTask 4.2: 实现 `RefreshLead()` 返回 4 小时 - - [x] SubTask 4.3: 实现 `Login()` 方法:启动回调服务器 → 生成 ticket → 打开浏览器 → 轮询 → 保存凭证 - -- [x] Task 5: 注册 CodeArts 到 SDK auth 系统 - - [x] SubTask 5.1: 在 `sdk/auth/refresh_registry.go` 注册 codearts refresh lead - - [x] SubTask 5.2: 在 `internal/cmd/auth_manager.go` 注册 `NewCodeArtsAuthenticator()` - -- [x] Task 6: 新增 `--codearts-login` CLI flag 和登录命令 - - [x] SubTask 6.1: 在 `cmd/server/main.go` 添加 `codeartsLogin` flag - - [x] SubTask 6.2: 在 flag 处理分支添加 `else if codeartsLogin` 调用 - - [x] SubTask 6.3: 创建 `internal/cmd/codearts_login.go` 实现 `DoCodeArtsLogin` - -- [x] Task 7: 新增 CodeArts thinking provider - - [x] SubTask 7.1: 创建 `internal/thinking/provider/codearts/apply.go` - - [x] SubTask 7.2: 实现 Apply 方法,将 thinking level 映射到请求参数 - - [x] SubTask 7.3: 在 `internal/runtime/executor/helps/thinking_providers.go` 注册 - -- [x] Task 8: 修复文档注释 - - [x] SubTask 8.1: 更新 `config.example.yaml` 中 oauth-model-alias Supported channels 添加 codearts - - [x] SubTask 8.2: 更新 `config.example.yaml` 中 oauth-excluded-models Supported channels 添加 codearts 移除 qwen - - [x] SubTask 8.3: 更新 `internal/config/config.go` 中 OAuthExcludedModels 注释添加 codearts - -- [x] Task 9: 翻译中文注释为英文 - - [x] SubTask 9.1: 翻译 `internal/api/server.go` 中的中文注释 - - [x] SubTask 9.2: 翻译 `internal/watcher/watcher.go` 中的中文注释 - -- [x] Task 10: 编译验证 - - [x] SubTask 10.1: `go build -o test-output ./cmd/server && rm test-output` - - [x] SubTask 10.2: `gofmt -w .` - -# Task Dependencies -- Task 1 (signer fix) → Task 3 (PrepareRequest uses signer) -- Task 4 (SDK authenticator) → Task 5 (registration) → Task 6 (CLI flag) -- Task 2 (payload fix) and Task 3 (headers) are independent of Task 4-6 -- Task 7 (thinking provider) is independent -- Task 8 (docs) and Task 9 (comments) are independent -- Task 10 depends on all others diff --git a/.trae/specs/web-management-center/checklist.md b/.trae/specs/web-management-center/checklist.md deleted file mode 100644 index 193021ec3d..0000000000 --- a/.trae/specs/web-management-center/checklist.md +++ /dev/null @@ -1,16 +0,0 @@ -- [x] Next.js + shadcn/ui 项目在 `./web` 下成功初始化,`npm run dev` 可正常启动 -- [x] API client 层封装所有 `/v0/management/*` 端点,包含认证 header 管理 -- [x] Management Key 登录页面可用,输入 key 后可访问管理面板 -- [x] Dashboard 页面显示服务器版本、状态、Provider 概览 -- [x] Config 页面可查看和编辑 config.yaml,各项设置控件可用 -- [x] Auth Files 页面可列出、上传、删除、启用/禁用、编辑认证文件 -- [x] OAuth 页面可列出所有 OAuth Provider,发起 OAuth 流程并轮询状态 -- [x] API Keys 页面可管理所有类型的 Key(api-keys、gemini、claude、codex、vertex、openai-compatibility) -- [x] Usage 页面可查看统计、导出和导入数据 -- [x] Logs 页面可实时查看日志、查看错误日志、清理日志 -- [x] `GET /v0/management/oauth-providers` 端点返回正确的 Provider 列表 -- [x] `//go:embed` 成功嵌入 Next.js 静态导出产物 -- [x] 磁盘文件优先、嵌入资源 fallback 的逻辑正确 -- [x] `go build -o test-output ./cmd/server && rm test-output` 编译通过 -- [x] 前端 `npm run build` 成功生成静态导出产物 -- [x] 所有代码注释使用英文 diff --git a/.trae/specs/web-management-center/spec.md b/.trae/specs/web-management-center/spec.md deleted file mode 100644 index bd8a2a7d2a..0000000000 --- a/.trae/specs/web-management-center/spec.md +++ /dev/null @@ -1,104 +0,0 @@ -# CLIProxyAPIPlus Web Management Center Spec - -## Why -当前 Management Center 是一个从 GitHub Releases 动态下载的单文件 SPA,无法内嵌到二进制中,离线环境不可用,且开发体验受限。需要基于 Next.js + shadcn/ui 构建一个现代化的 Web 管理面板,放在 `./web` 目录下,最终通过 `//go:embed` 内嵌到 Go 二进制中。 - -## What Changes -- 在 `./web` 下创建 Next.js + shadcn/ui 项目 -- 实现完整的管理面板前端,对接现有 `/v0/management/*` REST API -- 在 Go 后端新增 `GET /v0/management/oauth-providers` 端点,返回支持 OAuth 认证的 Provider 列表 -- 修改 Go 后端 `serveManagementControlPanel` 逻辑,支持从嵌入的静态资源 fallback 提供前端 -- 使用 `//go:embed` 将 Next.js 静态导出产物嵌入 Go 二进制 - -## Impact -- Affected specs: Management API, OAuth authentication, static asset serving -- Affected code: - - `internal/api/server.go` — 新增路由、修改静态文件服务逻辑 - - `internal/api/handlers/management/` — 新增 OAuth providers handler - - `internal/managementasset/` — 新增 embed fallback 逻辑 - - `sdk/auth/manager.go` — 暴露已注册 provider 列表 - - 新增 `./web/` 目录 — Next.js 前端项目 - -## ADDED Requirements - -### Requirement: Next.js Web Management Center -系统 SHALL 在 `./web` 目录下提供一个基于 Next.js + shadcn/ui 的 Web 管理面板,覆盖现有 Management API 的所有功能。 - -#### Scenario: 用户访问管理面板 -- **WHEN** 用户通过浏览器访问 `/management.html` 或 `/`(根路径) -- **THEN** 系统返回嵌入的 Next.js 静态导出产物 - -#### Scenario: 用户查看 Dashboard -- **WHEN** 用户打开 Dashboard 页面 -- **THEN** 显示服务器状态概览(版本、运行时间、配置摘要、Provider 状态) - -#### Scenario: 用户管理 Auth Files -- **WHEN** 用户在 Auth Files 页面操作 -- **THEN** 可以查看、上传、删除、启用/禁用认证文件,编辑 prefix/proxy_url/priority/note 字段 - -#### Scenario: 用户发起 OAuth 认证 -- **WHEN** 用户点击某个 OAuth Provider 的登录按钮 -- **THEN** 系统发起 OAuth 流程,打开认证 URL,轮询 `/get-auth-status` 直到完成 - -#### Scenario: 用户管理 API Keys -- **WHEN** 用户在 API Keys 页面操作 -- **THEN** 可以 CRUD 管理 api-keys、gemini-api-key、claude-api-key、codex-api-key、vertex-api-key、openai-compatibility - -#### Scenario: 用户查看 Usage 统计 -- **WHEN** 用户打开 Usage 页面 -- **THEN** 显示使用量统计,支持导出和导入 - -#### Scenario: 用户查看 Logs -- **WHEN** 用户打开 Logs 页面 -- **THEN** 实时显示日志流,支持查看错误日志和请求日志 - -#### Scenario: 用户编辑配置 -- **WHEN** 用户在 Config 页面操作 -- **THEN** 可以查看和编辑 config.yaml,修改各项设置(debug、proxy-url、routing strategy 等) - -### Requirement: OAuth Providers API Endpoint -系统 SHALL 提供 `GET /v0/management/oauth-providers` 端点,返回当前支持 OAuth 认证的 Provider 列表。 - -#### Scenario: 获取 OAuth Provider 列表 -- **WHEN** 客户端发送 `GET /v0/management/oauth-providers` -- **THEN** 返回 JSON 格式的 Provider 列表,包含 key、display_name、flow_type、auth_url_endpoint 等信息 - -#### Scenario: 响应格式 -```json -{ - "providers": [ - { - "key": "claude", - "display_name": "Claude (Anthropic)", - "flow_type": "authorization_code_pkce", - "auth_url_endpoint": "/anthropic-auth-url", - "aliases": ["anthropic"] - } - ] -} -``` - -### Requirement: Web UI Embedding -系统 SHALL 通过 `//go:embed` 将 Next.js 静态导出产物嵌入 Go 二进制,并在磁盘文件不可用时作为 fallback。 - -#### Scenario: 首次启动无磁盘文件 -- **WHEN** 服务器首次启动且磁盘上没有 management.html -- **THEN** 使用嵌入的静态资源响应请求 - -#### Scenario: 磁盘文件存在 -- **WHEN** 磁盘上存在通过自动更新下载的 management.html -- **THEN** 优先使用磁盘版本(支持后续自动更新) - -#### Scenario: 离线环境 -- **WHEN** 服务器在无网络环境下启动 -- **THEN** 嵌入的静态资源确保管理面板可用 - -## MODIFIED Requirements - -### Requirement: Static Asset Serving -原有逻辑:仅从磁盘文件或 GitHub Releases 下载提供 management.html。 -修改为:优先使用磁盘文件 → 下载到磁盘 → fallback 到嵌入资源。支持 Next.js 静态导出的多文件结构(index.html + _next/ 资源目录)。 - -## REMOVED Requirements - -无移除的需求。现有 Management Center 的自动更新机制保持不变,嵌入资源仅作为 fallback。 diff --git a/.trae/specs/web-management-center/tasks.md b/.trae/specs/web-management-center/tasks.md deleted file mode 100644 index cd0072a004..0000000000 --- a/.trae/specs/web-management-center/tasks.md +++ /dev/null @@ -1,61 +0,0 @@ -# Tasks - -- [x] Task 1: 初始化 Next.js + shadcn/ui 项目 - - [x] SubTask 1.1: 在 `./web` 下用 `npx shadcn@latest init` 初始化 Next.js 项目(App Router、TypeScript、Tailwind CSS v4) - - [x] SubTask 1.2: 安装核心 shadcn 组件:button, card, tabs, table, dialog, sheet, badge, input, select, switch, textarea, separator, skeleton, alert, dropdown-menu, tooltip, avatar, form, sonner, sidebar, scroll-area, empty, spinner, field, field-group - - [x] SubTask 1.3: 配置项目结构:`src/app/`(页面路由)、`src/components/`(组件)、`src/lib/`(工具函数和 API client)、`src/hooks/`(自定义 hooks) - - [x] SubTask 1.4: 创建 API client 层(`src/lib/api.ts`),封装所有 `/v0/management/*` 端点调用,包含认证 header 管理 - - [x] SubTask 1.5: 创建 Management Key 认证上下文和登录页面 - -- [x] Task 2: 实现 Dashboard 页面 - - [x] SubTask 2.1: 创建 Sidebar 导航布局(Dashboard、Config、Auth Files、OAuth、API Keys、Usage、Logs) - - [x] SubTask 2.2: 实现 Dashboard 页面:服务器版本、运行状态、Provider 概览、配置摘要卡片 - -- [x] Task 3: 实现 Config 页面 - - [x] SubTask 3.1: 创建 Config 编辑器页面,支持查看和编辑 config.yaml(代码编辑器 + 表单混合模式) - - [x] SubTask 3.2: 实现各项设置的开关/输入控件:debug、logging-to-file、usage-statistics、request-log、ws-auth、force-model-prefix、proxy-url、request-retry、max-retry-interval、routing-strategy - -- [x] Task 4: 实现 Auth Files 页面 - - [x] SubTask 4.1: 创建 Auth Files 列表页,展示所有认证文件(Table 组件),显示 provider、label、status、email、priority 等字段 - - [x] SubTask 4.2: 实现上传认证文件功能(Dialog + 文件选择) - - [x] SubTask 4.3: 实现删除认证文件功能(AlertDialog 确认) - - [x] SubTask 4.4: 实现编辑认证文件字段(prefix、proxy_url、headers、priority、note) - - [x] SubTask 4.5: 实现启用/禁用认证文件(Switch 组件) - -- [x] Task 5: 实现 OAuth 页面 - - [x] SubTask 5.1: 创建 OAuth Providers 列表页,展示所有支持 OAuth 的 Provider(调用新的 `/oauth-providers` 端点) - - [x] SubTask 5.2: 实现各 Provider 的 OAuth 登录流程:发起认证 → 打开 URL → 轮询状态 → 显示结果 - - [x] SubTask 5.3: 实现 OAuth Model Alias 管理和 OAuth Excluded Models 管理 - -- [x] Task 6: 实现 API Keys 页面 - - [x] SubTask 6.1: 创建 API Keys 管理页,使用 Tabs 切换不同类型(api-keys、gemini、claude、codex、vertex、openai-compatibility) - - [x] SubTask 6.2: 实现每种 Key 类型的 CRUD 操作(Table + Dialog 表单) - - [x] SubTask 6.3: 实现 AmpCode 配置管理(upstream-url、upstream-api-key、model-mappings) - -- [x] Task 7: 实现 Usage 页面 - - [x] SubTask 7.1: 创建 Usage 统计页面,展示使用量数据(Table + Card 汇总) - - [x] SubTask 7.2: 实现导出和导入功能(Button + Dialog) - -- [x] Task 8: 实现 Logs 页面 - - [x] SubTask 8.1: 创建 Logs 查看页面,实时流式显示日志(ScrollArea + 自动滚动) - - [x] SubTask 8.2: 实现错误日志列表和下载 - - [x] SubTask 8.3: 实现请求日志查看和清理 - -- [x] Task 9: Go 后端 — 新增 OAuth Providers API - - [x] SubTask 9.1: 在 `sdk/auth/manager.go` 中添加 `ListProviders()` 方法,返回已注册的 Provider 信息 - - [x] SubTask 9.2: 在 `internal/api/handlers/management/` 中新增 `oauth_providers.go`,实现 `GetOAuthProviders` handler - - [x] SubTask 9.3: 在 `internal/api/server.go` 的 management 路由组中注册 `GET /oauth-providers` - - [x] SubTask 9.4: 编写单元测试 - -- [x] Task 10: Go 后端 — Web UI 嵌入 - - [x] SubTask 10.1: 在 `internal/managementasset/` 中新增 `embed.go`,使用 `//go:embed` 嵌入 Next.js 静态导出产物 - - [x] SubTask 10.2: 修改 `serveManagementControlPanel`,支持多文件静态资源服务(index.html + _next/ 目录) - - [x] SubTask 10.3: 实现 fallback 逻辑:磁盘文件优先 → 嵌入资源 fallback - - [x] SubTask 10.4: 添加构建脚本/Makefile target,将 `cd web && npm run build` 的产物复制到嵌入目录 - - [x] SubTask 10.5: 验证编译通过(`go build -o test-output ./cmd/server && rm test-output`) - -# Task Dependencies -- [Task 2-8] 依赖 [Task 1](项目初始化和 API client) -- [Task 5] 依赖 [Task 9](OAuth Providers API) -- [Task 10] 依赖 [Task 1-8](前端构建产物需要存在才能嵌入) -- [Task 2-8] 之间可并行开发 From 83082f471ba2bdcb3590516188df27e1f8de977c Mon Sep 17 00:00:00 2001 From: rensumo Date: Sat, 9 May 2026 18:09:57 +0800 Subject: [PATCH 4/4] style: gofmt all files --- internal/registry/model_definitions.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index b54579907c..8db16f739a 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -243,7 +243,7 @@ func GetCodeBuddyModels() []*ModelInfo { ID: "deepseek-v4-flash", Object: "model", Created: now, OwnedBy: "tencent", Type: "codebuddy", DisplayName: "DeepSeek V4 Flash", Description: "DeepSeek V4 Flash via CodeBuddy", ContextLength: 1000000, MaxCompletionTokens: 50000, SupportedEndpoints: []string{"/chat/completions"}, - Thinking: &ThinkingSupport{Levels: []string{"high", "max"}}, + Thinking: &ThinkingSupport{Levels: []string{"high", "max"}}, }, { ID: "deepseek-v3-2-volc", Object: "model", Created: now, OwnedBy: "tencent",