From 95d09f60f81b715a979f4613e54c741dd11140a8 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 2 Jan 2026 10:21:05 +0800 Subject: [PATCH 01/19] =?UTF-8?q?feat(antigravity):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20models=20=E7=AB=AF=E7=82=B9=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /antigravity/models: 返回全部模型(Claude + Gemini) - /antigravity/v1/models: 返回全部模型(Claude API 格式) - /antigravity/v1beta/models: 仅返回 Gemini 模型(v1beta 格式) 统一管理 antigravity 模型定义,避免重复代码 --- backend/internal/handler/gateway_handler.go | 23 ++++- .../internal/handler/gemini_v1beta_handler.go | 9 +- .../internal/pkg/antigravity/claude_types.go | 88 +++++++++++++++++++ backend/internal/server/routes/gateway.go | 3 + 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index a2f833ff..057cb2ca 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -308,10 +309,21 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // Models handles listing available models // GET /v1/models -// Returns different model lists based on the API key's group platform +// Returns different model lists based on the API key's group platform or forced platform func (h *GatewayHandler) Models(c *gin.Context) { apiKey, _ := middleware2.GetApiKeyFromContext(c) + // 优先检查强制平台(/antigravity 路由) + if forcePlatform, ok := middleware2.GetForcePlatformFromContext(c); ok { + if forcePlatform == service.PlatformAntigravity { + c.JSON(http.StatusOK, gin.H{ + "object": "list", + "data": antigravity.DefaultModels(), + }) + return + } + } + // Return OpenAI models for OpenAI platform groups if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == "openai" { c.JSON(http.StatusOK, gin.H{ @@ -328,6 +340,15 @@ func (h *GatewayHandler) Models(c *gin.Context) { }) } +// AntigravityModels 返回 Antigravity 支持的全部模型 +// GET /antigravity/models +func (h *GatewayHandler) AntigravityModels(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "object": "list", + "data": antigravity.DefaultModels(), + }) +} + // Usage handles getting account balance for CC Switch integration // GET /v1/usage func (h *GatewayHandler) Usage(c *gin.Context) { diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 4e99e00d..e50f86aa 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/gemini" "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -32,9 +33,9 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { return } - // 强制 antigravity 模式:直接返回静态模型列表 + // 强制 antigravity 模式:返回 antigravity 支持的模型列表 if forcePlatform == service.PlatformAntigravity { - c.JSON(http.StatusOK, gemini.FallbackModelsList()) + c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList()) return } @@ -84,9 +85,9 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { return } - // 强制 antigravity 模式:直接返回静态模型信息 + // 强制 antigravity 模式:返回 antigravity 模型信息 if forcePlatform == service.PlatformAntigravity { - c.JSON(http.StatusOK, gemini.FallbackModel(modelName)) + c.JSON(http.StatusOK, antigravity.FallbackGeminiModel(modelName)) return } diff --git a/backend/internal/pkg/antigravity/claude_types.go b/backend/internal/pkg/antigravity/claude_types.go index 01b805cd..6baa7907 100644 --- a/backend/internal/pkg/antigravity/claude_types.go +++ b/backend/internal/pkg/antigravity/claude_types.go @@ -135,3 +135,91 @@ type ErrorDetail struct { Type string `json:"type"` Message string `json:"message"` } + +// modelDef Antigravity 模型定义(内部使用) +type modelDef struct { + ID string + DisplayName string + CreatedAt string // 仅 Claude API 格式使用 +} + +// Antigravity 支持的 Claude 模型 +var claudeModels = []modelDef{ + {ID: "claude-opus-4-5-thinking", DisplayName: "Claude Opus 4.5 Thinking", CreatedAt: "2025-11-01T00:00:00Z"}, + {ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", CreatedAt: "2025-09-29T00:00:00Z"}, + {ID: "claude-sonnet-4-5-thinking", DisplayName: "Claude Sonnet 4.5 Thinking", CreatedAt: "2025-09-29T00:00:00Z"}, +} + +// Antigravity 支持的 Gemini 模型 +var geminiModels = []modelDef{ + {ID: "gemini-2.5-flash", DisplayName: "Gemini 2.5 Flash", CreatedAt: "2025-01-01T00:00:00Z"}, + {ID: "gemini-2.5-flash-lite", DisplayName: "Gemini 2.5 Flash Lite", CreatedAt: "2025-01-01T00:00:00Z"}, + {ID: "gemini-2.5-flash-thinking", DisplayName: "Gemini 2.5 Flash Thinking", CreatedAt: "2025-01-01T00:00:00Z"}, + {ID: "gemini-3-flash", DisplayName: "Gemini 3 Flash", CreatedAt: "2025-06-01T00:00:00Z"}, + {ID: "gemini-3-pro-low", DisplayName: "Gemini 3 Pro Low", CreatedAt: "2025-06-01T00:00:00Z"}, + {ID: "gemini-3-pro-high", DisplayName: "Gemini 3 Pro High", CreatedAt: "2025-06-01T00:00:00Z"}, + {ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z"}, + {ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"}, +} + +// ========== Claude API 格式 (/v1/models) ========== + +// ClaudeModel Claude API 模型格式 +type ClaudeModel struct { + ID string `json:"id"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + CreatedAt string `json:"created_at"` +} + +// DefaultModels 返回 Claude API 格式的模型列表(Claude + Gemini) +func DefaultModels() []ClaudeModel { + all := append(claudeModels, geminiModels...) + result := make([]ClaudeModel, len(all)) + for i, m := range all { + result[i] = ClaudeModel{ID: m.ID, Type: "model", DisplayName: m.DisplayName, CreatedAt: m.CreatedAt} + } + return result +} + +// ========== Gemini v1beta 格式 (/v1beta/models) ========== + +// GeminiModel Gemini v1beta 模型格式 +type GeminiModel struct { + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods,omitempty"` +} + +// GeminiModelsListResponse Gemini v1beta 模型列表响应 +type GeminiModelsListResponse struct { + Models []GeminiModel `json:"models"` +} + +var defaultGeminiMethods = []string{"generateContent", "streamGenerateContent"} + +// DefaultGeminiModels 返回 Gemini v1beta 格式的模型列表(仅 Gemini 模型) +func DefaultGeminiModels() []GeminiModel { + result := make([]GeminiModel, len(geminiModels)) + for i, m := range geminiModels { + result[i] = GeminiModel{Name: "models/" + m.ID, DisplayName: m.DisplayName, SupportedGenerationMethods: defaultGeminiMethods} + } + return result +} + +// FallbackGeminiModelsList 返回 Gemini v1beta 格式的模型列表响应 +func FallbackGeminiModelsList() GeminiModelsListResponse { + return GeminiModelsListResponse{Models: DefaultGeminiModels()} +} + +// FallbackGeminiModel 返回单个模型信息(v1beta 格式) +func FallbackGeminiModel(model string) GeminiModel { + if model == "" { + return GeminiModel{Name: "models/unknown", SupportedGenerationMethods: defaultGeminiMethods} + } + name := model + if len(model) < 7 || model[:7] != "models/" { + name = "models/" + model + } + return GeminiModel{Name: name, SupportedGenerationMethods: defaultGeminiMethods} +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 38df9225..4f1083b0 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -47,6 +47,9 @@ func RegisterGatewayRoutes( // OpenAI Responses API(不带v1前缀的别名) r.POST("/responses", bodyLimit, gin.HandlerFunc(apiKeyAuth), h.OpenAIGateway.Responses) + // Antigravity 模型列表 + r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), h.Gateway.AntigravityModels) + // Antigravity 专用路由(仅使用 antigravity 账户,不混合调度) antigravityV1 := r.Group("/antigravity/v1") antigravityV1.Use(bodyLimit) From f1fdb5d38f80f108fc781f12cc28373f75a9a2c3 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 2 Jan 2026 10:32:20 +0800 Subject: [PATCH 02/19] =?UTF-8?q?refactor:=20/antigravity/v1/models=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E4=B8=93=E7=94=A8=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不再复用 Models(),避免内部 ForcePlatform 判断 --- backend/internal/handler/gateway_handler.go | 11 ----------- backend/internal/server/routes/gateway.go | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 475bc38f..7eb7007e 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -402,17 +402,6 @@ func (h *GatewayHandler) Messages(c *gin.Context) { func (h *GatewayHandler) Models(c *gin.Context) { apiKey, _ := middleware2.GetApiKeyFromContext(c) - // 优先检查强制平台(/antigravity 路由) - if forcePlatform, ok := middleware2.GetForcePlatformFromContext(c); ok { - if forcePlatform == service.PlatformAntigravity { - c.JSON(http.StatusOK, gin.H{ - "object": "list", - "data": antigravity.DefaultModels(), - }) - return - } - } - var groupID *int64 var platform string diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 4f1083b0..941f1ce9 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -58,7 +58,7 @@ func RegisterGatewayRoutes( { antigravityV1.POST("/messages", h.Gateway.Messages) antigravityV1.POST("/messages/count_tokens", h.Gateway.CountTokens) - antigravityV1.GET("/models", h.Gateway.Models) + antigravityV1.GET("/models", h.Gateway.AntigravityModels) antigravityV1.GET("/usage", h.Gateway.Usage) } From 9d3ec9e627e164bf3020970026956246d6ce9442 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 2 Jan 2026 15:53:05 +0800 Subject: [PATCH 03/19] =?UTF-8?q?feat(keys):=20=E9=80=82=E9=85=8D=20Antigr?= =?UTF-8?q?avity=20=E5=92=8C=20Gemini=20=E5=B9=B3=E5=8F=B0=E7=9A=84?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E6=95=99=E7=A8=8B=E4=B8=8E=20CCS=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UseKeyModal: 添加 Antigravity 两级 Tab (Claude Code / Gemini CLI) - UseKeyModal: 添加 Gemini 平台的 Gemini CLI 教程 - UseKeyModal: Antigravity 平台统一使用 /antigravity 后缀 - KeysView: CCS 导入支持 Antigravity (询问客户端) / Gemini / OpenAI - i18n: 添加相关中英文翻译 --- frontend/src/components/keys/UseKeyModal.vue | 185 ++++++++++++++++--- frontend/src/i18n/locales/en.ts | 22 ++- frontend/src/i18n/locales/zh.ts | 22 ++- frontend/src/views/user/KeysView.vue | 108 ++++++++++- 4 files changed, 307 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 9414523d..7db67b96 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -28,7 +28,29 @@ {{ platformDescription }}

- + +
+ +
+ +
+ + + +
(null) const selectedKey = ref(null) const copiedKeyId = ref(null) const groupSelectorKeyId = ref(null) @@ -871,8 +916,48 @@ const closeModals = () => { } } -const importToCcswitch = (apiKey: string) => { +const importToCcswitch = (row: ApiKey) => { + const platform = row.group?.platform || 'anthropic' + + // For antigravity platform, show client selection dialog + if (platform === 'antigravity') { + pendingCcsRow.value = row + showCcsClientSelect.value = true + return + } + + // For other platforms, execute directly + executeCcsImport(row, platform === 'gemini' ? 'gemini' : 'claude') +} + +const executeCcsImport = (row: ApiKey, clientType: 'claude' | 'gemini') => { const baseUrl = publicSettings.value?.api_base_url || window.location.origin + const platform = row.group?.platform || 'anthropic' + + // Determine app name and endpoint based on platform and client type + let app: string + let endpoint: string + + if (platform === 'antigravity') { + // Antigravity always uses /antigravity suffix + app = clientType === 'gemini' ? 'gemini' : 'claude' + endpoint = `${baseUrl}/antigravity` + } else { + switch (platform) { + case 'openai': + app = 'codex' + endpoint = baseUrl + break + case 'gemini': + app = 'gemini' + endpoint = baseUrl + break + default: // anthropic + app = 'claude' + endpoint = baseUrl + } + } + const usageScript = `({ request: { url: "{{baseUrl}}/v1/usage", @@ -889,11 +974,11 @@ const importToCcswitch = (apiKey: string) => { })` const params = new URLSearchParams({ resource: 'provider', - app: 'claude', + app: app, name: 'sub2api', homepage: baseUrl, - endpoint: baseUrl, - apiKey: apiKey, + endpoint: endpoint, + apiKey: row.key, configFormat: 'json', usageEnabled: 'true', usageScript: btoa(usageScript), @@ -916,6 +1001,19 @@ const importToCcswitch = (apiKey: string) => { } } +const handleCcsClientSelect = (clientType: 'claude' | 'gemini') => { + if (pendingCcsRow.value) { + executeCcsImport(pendingCcsRow.value, clientType) + } + showCcsClientSelect.value = false + pendingCcsRow.value = null +} + +const closeCcsClientSelect = () => { + showCcsClientSelect.value = false + pendingCcsRow.value = null +} + onMounted(() => { loadApiKeys() loadGroups() From b2b842bf7a579aa84f3a39a81fda3047987801e5 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 2 Jan 2026 12:38:03 +0800 Subject: [PATCH 04/19] =?UTF-8?q?refactor(antigravity):=20countTokens=20?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E7=9B=B4=E6=8E=A5=E8=BF=94=E5=9B=9E=E7=A9=BA?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gemini 端点 countTokens 直接返回 {"totalTokens": 0} - Claude 端点 countTokens 返回 {"input_tokens": 0} - 移除透传上游和本地估算逻辑 --- .../service/antigravity_gateway_service.go | 50 +++++-------------- backend/internal/service/gateway_service.go | 5 +- 2 files changed, 14 insertions(+), 41 deletions(-) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 267d7548..0798f46d 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -467,8 +467,19 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co } switch action { - case "generateContent", "streamGenerateContent", "countTokens": + case "generateContent", "streamGenerateContent": // ok + case "countTokens": + // 直接返回空值,不透传上游 + c.JSON(http.StatusOK, map[string]any{"totalTokens": 0}) + return &ForwardResult{ + RequestID: "", + Usage: ClaudeUsage{}, + Model: originalModel, + Stream: false, + Duration: time.Since(time.Now()), + FirstTokenMs: nil, + }, nil default: return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action) } @@ -523,18 +534,6 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co sleepAntigravityBackoff(attempt) continue } - if action == "countTokens" { - estimated := estimateGeminiCountTokens(body) - c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) - return &ForwardResult{ - RequestID: "", - Usage: ClaudeUsage{}, - Model: originalModel, - Stream: false, - Duration: time.Since(startTime), - FirstTokenMs: nil, - }, nil - } return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries") } @@ -551,18 +550,6 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co if resp.StatusCode == 429 { s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) } - if action == "countTokens" { - estimated := estimateGeminiCountTokens(body) - c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) - return &ForwardResult{ - RequestID: "", - Usage: ClaudeUsage{}, - Model: originalModel, - Stream: false, - Duration: time.Since(startTime), - FirstTokenMs: nil, - }, nil - } resp = &http.Response{ StatusCode: resp.StatusCode, Header: resp.Header.Clone(), @@ -585,19 +572,6 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) s.handleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody) - if action == "countTokens" { - estimated := estimateGeminiCountTokens(body) - c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) - return &ForwardResult{ - RequestID: requestID, - Usage: ClaudeUsage{}, - Model: originalModel, - Stream: false, - Duration: time.Since(startTime), - FirstTokenMs: nil, - }, nil - } - if s.shouldFailoverUpstreamError(resp.StatusCode) { return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode} } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index ba1d5bb3..3932c35c 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1750,10 +1750,9 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, body := parsed.Body reqModel := parsed.Model - // Antigravity 账户不支持 count_tokens 转发,返回估算值 - // 参考 Antigravity-Manager 和 proxycast 实现 + // Antigravity 账户不支持 count_tokens 转发,直接返回空值 if account.Platform == PlatformAntigravity { - c.JSON(http.StatusOK, gin.H{"input_tokens": 100}) + c.JSON(http.StatusOK, gin.H{"input_tokens": 0}) return nil } From 991c3ea68b720b78dfb6add4716c10e5b6f745cd Mon Sep 17 00:00:00 2001 From: song Date: Fri, 2 Jan 2026 17:30:18 +0800 Subject: [PATCH 05/19] =?UTF-8?q?refactor(antigravity):=20haiku=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=98=A0=E5=B0=84=E5=88=B0=20sonnet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/antigravity_gateway_service.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 0798f46d..3093dd9a 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -49,11 +49,11 @@ var antigravityPrefixMapping = []struct { {"gemini-3-pro-image", "gemini-3-pro-image"}, // gemini-3-pro-image-preview 等 {"claude-3-5-sonnet", "claude-sonnet-4-5"}, // 旧版 claude-3-5-sonnet-xxx {"claude-sonnet-4-5", "claude-sonnet-4-5"}, // claude-sonnet-4-5-xxx - {"claude-haiku-4-5", "gemini-3-flash"}, // claude-haiku-4-5-xxx + {"claude-haiku-4-5", "claude-sonnet-4-5"}, // claude-haiku-4-5-xxx → sonnet {"claude-opus-4-5", "claude-opus-4-5-thinking"}, - {"claude-3-haiku", "gemini-3-flash"}, // 旧版 claude-3-haiku-xxx + {"claude-3-haiku", "claude-sonnet-4-5"}, // 旧版 claude-3-haiku-xxx → sonnet {"claude-sonnet-4", "claude-sonnet-4-5"}, - {"claude-haiku-4", "gemini-3-flash"}, + {"claude-haiku-4", "claude-sonnet-4-5"}, // → sonnet {"claude-opus-4", "claude-opus-4-5-thinking"}, {"gemini-3-pro", "gemini-3-pro-high"}, // gemini-3-pro, gemini-3-pro-preview 等 } From 7f5ec284881bc9dbf723d38485faa2d4bd84b71d Mon Sep 17 00:00:00 2001 From: song Date: Fri, 2 Jan 2026 17:46:06 +0800 Subject: [PATCH 06/19] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20Simple=20Mod?= =?UTF-8?q?e=20=E8=AF=B4=E6=98=8E=E5=92=8C=20Antigravity=20=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++++++++++++++++ README_CN.md | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/README.md b/README.md index a6d051b0..c3a37e68 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,16 @@ go generate ./cmd/server --- +## Simple Mode + +Simple Mode is designed for individual developers or internal teams who want quick access without full SaaS features. + +- Enable: Set environment variable `RUN_MODE=simple` +- Difference: Hides SaaS-related features and skips billing process +- Security note: In production, you must also set `SIMPLE_MODE_CONFIRM=true` to allow startup + +--- + ## Antigravity Support Sub2API supports [Antigravity](https://antigravity.so/) accounts. After authorization, dedicated endpoints are available for Claude and Gemini models. @@ -321,6 +331,12 @@ Antigravity accounts support optional **hybrid scheduling**. When enabled, the g > **⚠️ Warning**: Anthropic Claude and Antigravity Claude **cannot be mixed within the same conversation context**. Use groups to isolate them properly. +### Known Issues + +In Claude Code, Plan Mode cannot exit automatically. (Normally when using the native Claude API, after planning is complete, Claude Code will pop up options for users to approve or reject the plan.) + +**Workaround**: Press `Shift + Tab` to manually exit Plan Mode, then type your response to approve or reject the plan. + --- ## Project Structure diff --git a/README_CN.md b/README_CN.md index 0e15be1f..8fe46c51 100644 --- a/README_CN.md +++ b/README_CN.md @@ -331,6 +331,10 @@ Antigravity 账户支持可选的**混合调度**功能。开启后,通用端 > **⚠️ 注意**:Anthropic Claude 和 Antigravity Claude **不能在同一上下文中混合使用**,请通过分组功能做好隔离。 + +### 已知问题 +在 Claude Code 中,无法自动退出Plan Mode。(正常使用原生Claude Api时,Plan 完成后,Claude Code会弹出弹出选项让用户同意或拒绝Plan。) +解决办法:shift + Tab,手动退出Plan mode,然后输入内容 告诉 Claude Code 同意或拒绝 Plan --- ## 项目结构 From 8a50ca592aa055120a8e6a80a9e1c1956ee11428 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 2 Jan 2026 17:50:39 +0800 Subject: [PATCH 07/19] =?UTF-8?q?test:=20=E6=9B=B4=E6=96=B0=20haiku=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=98=A0=E5=B0=84=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/antigravity_model_mapping_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/internal/service/antigravity_model_mapping_test.go b/backend/internal/service/antigravity_model_mapping_test.go index 1e37cdc2..39000e4f 100644 --- a/backend/internal/service/antigravity_model_mapping_test.go +++ b/backend/internal/service/antigravity_model_mapping_test.go @@ -104,28 +104,28 @@ func TestAntigravityGatewayService_GetMappedModel(t *testing.T) { expected: "claude-opus-4-5-thinking", }, { - name: "系统映射 - claude-haiku-4 → gemini-3-flash", + name: "系统映射 - claude-haiku-4 → claude-sonnet-4-5", requestedModel: "claude-haiku-4", accountMapping: nil, - expected: "gemini-3-flash", + expected: "claude-sonnet-4-5", }, { - name: "系统映射 - claude-haiku-4-5 → gemini-3-flash", + name: "系统映射 - claude-haiku-4-5 → claude-sonnet-4-5", requestedModel: "claude-haiku-4-5", accountMapping: nil, - expected: "gemini-3-flash", + expected: "claude-sonnet-4-5", }, { - name: "系统映射 - claude-3-haiku-20240307 → gemini-3-flash", + name: "系统映射 - claude-3-haiku-20240307 → claude-sonnet-4-5", requestedModel: "claude-3-haiku-20240307", accountMapping: nil, - expected: "gemini-3-flash", + expected: "claude-sonnet-4-5", }, { - name: "系统映射 - claude-haiku-4-5-20251001 → gemini-3-flash", + name: "系统映射 - claude-haiku-4-5-20251001 → claude-sonnet-4-5", requestedModel: "claude-haiku-4-5-20251001", accountMapping: nil, - expected: "gemini-3-flash", + expected: "claude-sonnet-4-5", }, { name: "系统映射 - claude-sonnet-4-5-20250929", From 4543a6f0432aaa6da21d9474e631becf109a66b1 Mon Sep 17 00:00:00 2001 From: song Date: Fri, 2 Jan 2026 22:41:55 +0800 Subject: [PATCH 08/19] =?UTF-8?q?refactor(antigravity):=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E9=A2=9D=E5=BA=A6=E5=88=B7=E6=96=B0=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=E4=B8=8E=20Claude=20=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Antigravity 的额度刷新从后台定时刷新改为按需获取模式,与 Claude 统一: - 删除 AntigravityQuotaRefresher 后台服务 - 新增 QuotaFetcher 接口和 AntigravityQuotaFetcher 实现 - 前端改为调用 usage API 获取额度,支持 loading/error 状态 - 统一使用内存缓存(10 分钟 TTL) --- backend/cmd/server/wire.go | 5 - backend/cmd/server/wire_gen.go | 11 +- .../internal/service/account_usage_service.go | 85 ++++++- .../service/antigravity_quota_fetcher.go | 111 +++++++++ .../service/antigravity_quota_refresher.go | 222 ------------------ backend/internal/service/quota_fetcher.go | 19 ++ backend/internal/service/wire.go | 14 +- .../components/account/AccountUsageCell.vue | 107 +++++---- frontend/src/types/index.ts | 7 + 9 files changed, 274 insertions(+), 307 deletions(-) create mode 100644 backend/internal/service/antigravity_quota_fetcher.go delete mode 100644 backend/internal/service/antigravity_quota_refresher.go create mode 100644 backend/internal/service/quota_fetcher.go diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 8596b8ba..ff6ab4e6 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -70,7 +70,6 @@ func provideCleanup( openaiOAuth *service.OpenAIOAuthService, geminiOAuth *service.GeminiOAuthService, antigravityOAuth *service.AntigravityOAuthService, - antigravityQuota *service.AntigravityQuotaRefresher, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -113,10 +112,6 @@ func provideCleanup( antigravityOAuth.Stop() return nil }}, - {"AntigravityQuotaRefresher", func() error { - antigravityQuota.Stop() - return nil - }}, {"Redis", func() error { return rdb.Close() }}, diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 5cbc774d..ac4e23ce 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -90,7 +90,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { geminiQuotaService := service.NewGeminiQuotaService(configConfig, settingRepository) rateLimitService := service.NewRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService) claudeUsageFetcher := repository.NewClaudeUsageFetcher() - accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService) + antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository) + accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher) geminiTokenCache := repository.NewGeminiTokenCache(redisClient) geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService) gatewayCache := repository.NewGatewayCache(redisClient) @@ -145,8 +146,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService) httpServer := server.ProvideHTTPServer(configConfig, engine) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, configConfig) - antigravityQuotaRefresher := service.ProvideAntigravityQuotaRefresher(accountRepository, proxyRepository, antigravityOAuthService, configConfig) - v := provideCleanup(client, redisClient, tokenRefreshService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, antigravityQuotaRefresher) + v := provideCleanup(client, redisClient, tokenRefreshService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService) application := &Application{ Server: httpServer, Cleanup: v, @@ -179,7 +179,6 @@ func provideCleanup( openaiOAuth *service.OpenAIOAuthService, geminiOAuth *service.GeminiOAuthService, antigravityOAuth *service.AntigravityOAuthService, - antigravityQuota *service.AntigravityQuotaRefresher, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -221,10 +220,6 @@ func provideCleanup( antigravityOAuth.Stop() return nil }}, - {"AntigravityQuotaRefresher", func() error { - antigravityQuota.Stop() - return nil - }}, {"Redis", func() error { return rdb.Close() }}, diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index dfceac07..d851af6e 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -91,6 +91,12 @@ type UsageProgress struct { WindowStats *WindowStats `json:"window_stats,omitempty"` // 窗口期统计(从窗口开始到当前的使用量) } +// AntigravityModelQuota Antigravity 单个模型的配额信息 +type AntigravityModelQuota struct { + Utilization int `json:"utilization"` // 使用率 0-100 + ResetTime string `json:"reset_time"` // 重置时间 ISO8601 +} + // UsageInfo 账号使用量信息 type UsageInfo struct { UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间 @@ -99,6 +105,9 @@ type UsageInfo struct { SevenDaySonnet *UsageProgress `json:"seven_day_sonnet,omitempty"` // 7天Sonnet窗口 GeminiProDaily *UsageProgress `json:"gemini_pro_daily,omitempty"` // Gemini Pro 日配额 GeminiFlashDaily *UsageProgress `json:"gemini_flash_daily,omitempty"` // Gemini Flash 日配额 + + // Antigravity 多模型配额 + AntigravityQuota map[string]*AntigravityModelQuota `json:"antigravity_quota,omitempty"` } // ClaudeUsageResponse Anthropic API返回的usage结构 @@ -124,19 +133,27 @@ type ClaudeUsageFetcher interface { // AccountUsageService 账号使用量查询服务 type AccountUsageService struct { - accountRepo AccountRepository - usageLogRepo UsageLogRepository - usageFetcher ClaudeUsageFetcher - geminiQuotaService *GeminiQuotaService + accountRepo AccountRepository + usageLogRepo UsageLogRepository + usageFetcher ClaudeUsageFetcher + geminiQuotaService *GeminiQuotaService + antigravityQuotaFetcher *AntigravityQuotaFetcher } // NewAccountUsageService 创建AccountUsageService实例 -func NewAccountUsageService(accountRepo AccountRepository, usageLogRepo UsageLogRepository, usageFetcher ClaudeUsageFetcher, geminiQuotaService *GeminiQuotaService) *AccountUsageService { +func NewAccountUsageService( + accountRepo AccountRepository, + usageLogRepo UsageLogRepository, + usageFetcher ClaudeUsageFetcher, + geminiQuotaService *GeminiQuotaService, + antigravityQuotaFetcher *AntigravityQuotaFetcher, +) *AccountUsageService { return &AccountUsageService{ - accountRepo: accountRepo, - usageLogRepo: usageLogRepo, - usageFetcher: usageFetcher, - geminiQuotaService: geminiQuotaService, + accountRepo: accountRepo, + usageLogRepo: usageLogRepo, + usageFetcher: usageFetcher, + geminiQuotaService: geminiQuotaService, + antigravityQuotaFetcher: antigravityQuotaFetcher, } } @@ -154,6 +171,11 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U return s.getGeminiUsage(ctx, account) } + // Antigravity 平台:使用 AntigravityQuotaFetcher 获取额度 + if account.Platform == PlatformAntigravity { + return s.getAntigravityUsage(ctx, account) + } + // 只有oauth类型账号可以通过API获取usage(有profile scope) if account.CanGetUsage() { var apiResp *ClaudeUsageResponse @@ -230,6 +252,51 @@ func (s *AccountUsageService) getGeminiUsage(ctx context.Context, account *Accou return usage, nil } +// antigravityUsageCache 缓存 Antigravity 额度数据 +type antigravityUsageCache struct { + usageInfo *UsageInfo + timestamp time.Time +} + +var antigravityCacheMap = sync.Map{} + +// getAntigravityUsage 获取 Antigravity 账户额度 +func (s *AccountUsageService) getAntigravityUsage(ctx context.Context, account *Account) (*UsageInfo, error) { + if s.antigravityQuotaFetcher == nil || !s.antigravityQuotaFetcher.CanFetch(account) { + now := time.Now() + return &UsageInfo{UpdatedAt: &now}, nil + } + + // 1. 检查缓存(10 分钟) + if cached, ok := antigravityCacheMap.Load(account.ID); ok { + if cache, ok := cached.(*antigravityUsageCache); ok && time.Since(cache.timestamp) < apiCacheTTL { + // 重新计算 RemainingSeconds + usage := cache.usageInfo + if usage.FiveHour != nil && usage.FiveHour.ResetsAt != nil { + usage.FiveHour.RemainingSeconds = int(time.Until(*usage.FiveHour.ResetsAt).Seconds()) + } + return usage, nil + } + } + + // 2. 获取代理 URL + proxyURL := s.antigravityQuotaFetcher.GetProxyURL(ctx, account) + + // 3. 调用 API 获取额度 + result, err := s.antigravityQuotaFetcher.FetchQuota(ctx, account, proxyURL) + if err != nil { + return nil, fmt.Errorf("fetch antigravity quota failed: %w", err) + } + + // 4. 缓存结果 + antigravityCacheMap.Store(account.ID, &antigravityUsageCache{ + usageInfo: result.UsageInfo, + timestamp: time.Now(), + }) + + return result.UsageInfo, nil +} + // addWindowStats 为 usage 数据添加窗口期统计 // 使用独立缓存(1 分钟),与 API 缓存分离 func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) { diff --git a/backend/internal/service/antigravity_quota_fetcher.go b/backend/internal/service/antigravity_quota_fetcher.go new file mode 100644 index 00000000..c9024e33 --- /dev/null +++ b/backend/internal/service/antigravity_quota_fetcher.go @@ -0,0 +1,111 @@ +package service + +import ( + "context" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" +) + +// AntigravityQuotaFetcher 从 Antigravity API 获取额度 +type AntigravityQuotaFetcher struct { + proxyRepo ProxyRepository +} + +// NewAntigravityQuotaFetcher 创建 AntigravityQuotaFetcher +func NewAntigravityQuotaFetcher(proxyRepo ProxyRepository) *AntigravityQuotaFetcher { + return &AntigravityQuotaFetcher{proxyRepo: proxyRepo} +} + +// CanFetch 检查是否可以获取此账户的额度 +func (f *AntigravityQuotaFetcher) CanFetch(account *Account) bool { + if account.Platform != PlatformAntigravity { + return false + } + accessToken := account.GetCredential("access_token") + return accessToken != "" +} + +// FetchQuota 获取 Antigravity 账户额度信息 +func (f *AntigravityQuotaFetcher) FetchQuota(ctx context.Context, account *Account, proxyURL string) (*QuotaResult, error) { + accessToken := account.GetCredential("access_token") + projectID := account.GetCredential("project_id") + + // 如果没有 project_id,生成一个随机的 + if projectID == "" { + projectID = antigravity.GenerateMockProjectID() + } + + client := antigravity.NewClient(proxyURL) + + // 调用 API 获取配额 + modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID) + if err != nil { + return nil, err + } + + // 转换为 UsageInfo + usageInfo := f.buildUsageInfo(modelsResp) + + return &QuotaResult{ + UsageInfo: usageInfo, + Raw: modelsRaw, + }, nil +} + +// buildUsageInfo 将 API 响应转换为 UsageInfo +func (f *AntigravityQuotaFetcher) buildUsageInfo(modelsResp *antigravity.FetchAvailableModelsResponse) *UsageInfo { + now := time.Now() + info := &UsageInfo{ + UpdatedAt: &now, + AntigravityQuota: make(map[string]*AntigravityModelQuota), + } + + // 遍历所有模型,填充 AntigravityQuota + for modelName, modelInfo := range modelsResp.Models { + if modelInfo.QuotaInfo == nil { + continue + } + + // remainingFraction 是剩余比例 (0.0-1.0),转换为使用率百分比 + utilization := int((1.0 - modelInfo.QuotaInfo.RemainingFraction) * 100) + + info.AntigravityQuota[modelName] = &AntigravityModelQuota{ + Utilization: utilization, + ResetTime: modelInfo.QuotaInfo.ResetTime, + } + } + + // 同时设置 FiveHour 用于兼容展示(取主要模型) + priorityModels := []string{"claude-sonnet-4-20250514", "claude-sonnet-4", "gemini-2.5-pro"} + for _, modelName := range priorityModels { + if modelInfo, ok := modelsResp.Models[modelName]; ok && modelInfo.QuotaInfo != nil { + utilization := (1.0 - modelInfo.QuotaInfo.RemainingFraction) * 100 + progress := &UsageProgress{ + Utilization: utilization, + } + if modelInfo.QuotaInfo.ResetTime != "" { + if resetTime, err := time.Parse(time.RFC3339, modelInfo.QuotaInfo.ResetTime); err == nil { + progress.ResetsAt = &resetTime + progress.RemainingSeconds = int(time.Until(resetTime).Seconds()) + } + } + info.FiveHour = progress + break + } + } + + return info +} + +// GetProxyURL 获取账户的代理 URL +func (f *AntigravityQuotaFetcher) GetProxyURL(ctx context.Context, account *Account) string { + if account.ProxyID == nil || f.proxyRepo == nil { + return "" + } + proxy, err := f.proxyRepo.GetByID(ctx, *account.ProxyID) + if err != nil || proxy == nil { + return "" + } + return proxy.URL() +} diff --git a/backend/internal/service/antigravity_quota_refresher.go b/backend/internal/service/antigravity_quota_refresher.go deleted file mode 100644 index c4b11d73..00000000 --- a/backend/internal/service/antigravity_quota_refresher.go +++ /dev/null @@ -1,222 +0,0 @@ -package service - -import ( - "context" - "log" - "sync" - "time" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" -) - -// AntigravityQuotaRefresher 定时刷新 Antigravity 账户的配额信息 -type AntigravityQuotaRefresher struct { - accountRepo AccountRepository - proxyRepo ProxyRepository - cfg *config.TokenRefreshConfig - - stopCh chan struct{} - wg sync.WaitGroup -} - -// NewAntigravityQuotaRefresher 创建配额刷新器 -func NewAntigravityQuotaRefresher( - accountRepo AccountRepository, - proxyRepo ProxyRepository, - _ *AntigravityOAuthService, - cfg *config.Config, -) *AntigravityQuotaRefresher { - return &AntigravityQuotaRefresher{ - accountRepo: accountRepo, - proxyRepo: proxyRepo, - cfg: &cfg.TokenRefresh, - stopCh: make(chan struct{}), - } -} - -// Start 启动后台配额刷新服务 -func (r *AntigravityQuotaRefresher) Start() { - if !r.cfg.Enabled { - log.Println("[AntigravityQuota] Service disabled by configuration") - return - } - - r.wg.Add(1) - go r.refreshLoop() - - log.Printf("[AntigravityQuota] Service started (check every %d minutes)", r.cfg.CheckIntervalMinutes) -} - -// Stop 停止服务 -func (r *AntigravityQuotaRefresher) Stop() { - close(r.stopCh) - r.wg.Wait() - log.Println("[AntigravityQuota] Service stopped") -} - -// refreshLoop 刷新循环 -func (r *AntigravityQuotaRefresher) refreshLoop() { - defer r.wg.Done() - - checkInterval := time.Duration(r.cfg.CheckIntervalMinutes) * time.Minute - if checkInterval < time.Minute { - checkInterval = 5 * time.Minute - } - - ticker := time.NewTicker(checkInterval) - defer ticker.Stop() - - // 启动时立即执行一次 - r.processRefresh() - - for { - select { - case <-ticker.C: - r.processRefresh() - case <-r.stopCh: - return - } - } -} - -// processRefresh 执行一次刷新 -func (r *AntigravityQuotaRefresher) processRefresh() { - ctx := context.Background() - - // 查询所有 active 的账户,然后过滤 antigravity 平台 - allAccounts, err := r.accountRepo.ListActive(ctx) - if err != nil { - log.Printf("[AntigravityQuota] Failed to list accounts: %v", err) - return - } - - // 过滤 antigravity 平台账户 - var accounts []Account - for _, acc := range allAccounts { - if acc.Platform == PlatformAntigravity { - accounts = append(accounts, acc) - } - } - - if len(accounts) == 0 { - return - } - - refreshed, failed := 0, 0 - - for i := range accounts { - account := &accounts[i] - - if err := r.refreshAccountQuota(ctx, account); err != nil { - log.Printf("[AntigravityQuota] Account %d (%s) failed: %v", account.ID, account.Name, err) - failed++ - } else { - refreshed++ - } - } - - log.Printf("[AntigravityQuota] Cycle complete: total=%d, refreshed=%d, failed=%d", - len(accounts), refreshed, failed) -} - -// refreshAccountQuota 刷新单个账户的配额 -func (r *AntigravityQuotaRefresher) refreshAccountQuota(ctx context.Context, account *Account) error { - accessToken := account.GetCredential("access_token") - projectID := account.GetCredential("project_id") - - if accessToken == "" { - return nil // 没有 access_token,跳过 - } - - // token 过期则跳过,由 TokenRefreshService 负责刷新 - if r.isTokenExpired(account) { - return nil - } - - // 获取代理 URL - var proxyURL string - if account.ProxyID != nil { - proxy, err := r.proxyRepo.GetByID(ctx, *account.ProxyID) - if err == nil && proxy != nil { - proxyURL = proxy.URL() - } - } - - client := antigravity.NewClient(proxyURL) - - if account.Extra == nil { - account.Extra = make(map[string]any) - } - - // 获取账户信息(tier、project_id 等) - loadResp, loadRaw, _ := client.LoadCodeAssist(ctx, accessToken) - if loadRaw != nil { - account.Extra["load_code_assist"] = loadRaw - } - if loadResp != nil { - // 尝试从 API 获取 project_id - if projectID == "" && loadResp.CloudAICompanionProject != "" { - projectID = loadResp.CloudAICompanionProject - account.Credentials["project_id"] = projectID - } - } - - // 如果仍然没有 project_id,随机生成一个并保存 - if projectID == "" { - projectID = antigravity.GenerateMockProjectID() - account.Credentials["project_id"] = projectID - log.Printf("[AntigravityQuotaRefresher] 为账户 %d 生成随机 project_id: %s", account.ID, projectID) - } - - // 调用 API 获取配额 - modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID) - if err != nil { - return r.accountRepo.Update(ctx, account) // 保存已有的 load_code_assist 信息 - } - - // 保存完整的配额响应 - if modelsRaw != nil { - account.Extra["available_models"] = modelsRaw - } - - // 解析配额数据为前端使用的格式 - r.updateAccountQuota(account, modelsResp) - - account.Extra["last_refresh"] = time.Now().Format(time.RFC3339) - - // 保存到数据库 - return r.accountRepo.Update(ctx, account) -} - -// isTokenExpired 检查 token 是否过期 -func (r *AntigravityQuotaRefresher) isTokenExpired(account *Account) bool { - expiresAt := account.GetCredentialAsTime("expires_at") - if expiresAt == nil { - return false - } - - // 提前 5 分钟认为过期 - return time.Now().Add(5 * time.Minute).After(*expiresAt) -} - -// updateAccountQuota 更新账户的配额信息(前端使用的格式) -func (r *AntigravityQuotaRefresher) updateAccountQuota(account *Account, modelsResp *antigravity.FetchAvailableModelsResponse) { - quota := make(map[string]any) - - for modelName, modelInfo := range modelsResp.Models { - if modelInfo.QuotaInfo == nil { - continue - } - - // 转换 remainingFraction (0.0-1.0) 为百分比 (0-100) - remaining := int(modelInfo.QuotaInfo.RemainingFraction * 100) - - quota[modelName] = map[string]any{ - "remaining": remaining, - "reset_time": modelInfo.QuotaInfo.ResetTime, - } - } - - account.Extra["quota"] = quota -} diff --git a/backend/internal/service/quota_fetcher.go b/backend/internal/service/quota_fetcher.go new file mode 100644 index 00000000..40d8572c --- /dev/null +++ b/backend/internal/service/quota_fetcher.go @@ -0,0 +1,19 @@ +package service + +import ( + "context" +) + +// QuotaFetcher 额度获取接口,各平台实现此接口 +type QuotaFetcher interface { + // CanFetch 检查是否可以获取此账户的额度 + CanFetch(account *Account) bool + // FetchQuota 获取账户额度信息 + FetchQuota(ctx context.Context, account *Account, proxyURL string) (*QuotaResult, error) +} + +// QuotaResult 额度获取结果 +type QuotaResult struct { + UsageInfo *UsageInfo // 转换后的使用信息 + Raw map[string]any // 原始响应,可存入 account.Extra +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 7971f041..74ed2f93 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -54,18 +54,6 @@ func ProvideTimingWheelService() *TimingWheelService { return svc } -// ProvideAntigravityQuotaRefresher creates and starts AntigravityQuotaRefresher -func ProvideAntigravityQuotaRefresher( - accountRepo AccountRepository, - proxyRepo ProxyRepository, - oauthSvc *AntigravityOAuthService, - cfg *config.Config, -) *AntigravityQuotaRefresher { - svc := NewAntigravityQuotaRefresher(accountRepo, proxyRepo, oauthSvc, cfg) - svc.Start() - return svc -} - // ProvideDeferredService creates and starts DeferredService func ProvideDeferredService(accountRepo AccountRepository, timingWheel *TimingWheelService) *DeferredService { svc := NewDeferredService(accountRepo, timingWheel, 10*time.Second) @@ -124,6 +112,6 @@ var ProviderSet = wire.NewSet( ProvideTokenRefreshService, ProvideTimingWheelService, ProvideDeferredService, - ProvideAntigravityQuotaRefresher, + NewAntigravityQuotaFetcher, NewUserAttributeService, ) diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 8dfb9f38..b0bc6c32 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -93,7 +93,7 @@
-
- +