Compare commits

...

14 Commits

Author SHA1 Message Date
Wesley Liddick
c7b42148a5 Merge pull request #559 from wucm667/fix/auth-page-logo
fix: 修复登录/注册页面自定义 Logo 不显示及闪烁问题
2026-02-11 20:01:03 +08:00
Wesley Liddick
bc1abb6a23 Merge pull request #557 from james-6-23/main
feat(admin): 为账户和兑换码新增邮箱搜索及限流过滤功能
2026-02-11 20:00:43 +08:00
Wesley Liddick
d307d48def Merge pull request #551 from SilentFlower/opus4.6-think
[UPDATE] 增强 Claude Thinking 模式支持与 Opus 4.6 动态预算适配
2026-02-11 20:00:22 +08:00
Wesley Liddick
1bb40084fc Merge pull request #550 from Tian-orz/feat/antigravity-refresh-token-import
feat(antigravity): 支持 Refresh Token 批量导入创建 OAuth 账号
2026-02-11 19:59:52 +08:00
Wesley Liddick
8f0efa16ca Merge pull request #555 from sususu98/fix/gemini-thoughts-token-billing
fix: include Gemini thoughtsTokenCount in output token billing
2026-02-11 19:53:43 +08:00
wucm667
ef2c35dbb1 🐛 fix: 修复登录/注册页面自定义 Logo 不显示及闪烁问题
- sanitizeUrl 新增 allowDataUrl 选项,支持 data:image/ 格式的 base64 图片 URL
- AuthLayout 改用 appStore 缓存数据,避免重复 API 请求和默认 Logo 闪烁

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:04:57 +08:00
kyx236
04a1a7c2b5 feat(admin): Add email search and rate limit filtering for accounts and redeem codes
- Add used_by_email column to redeem code export CSV for better user identification
- Implement rate_limited status filter in account listing with RateLimitResetAt check
- Extend redeem code search to include user email in addition to code matching
- Add API key search capability to user listing filters
- Display user email in redeem code table used_by column for improved visibility
- Update search placeholders in UI to reflect expanded search capabilities (email, username, notes, API key)
- Improve Chinese and English localization strings for search hints
2026-02-11 16:39:42 +08:00
sususu98
d21d70a5cf fix: include Gemini thoughtsTokenCount in output token billing
Gemini 2.5 Pro/Flash thinking models return thoughtsTokenCount separately
from candidatesTokenCount in usageMetadata, but this field was not parsed
or included in billing calculations, causing thinking tokens to be
unbilled.

- Add ThoughtsTokenCount field to GeminiUsageMetadata struct
- Include thoughtsTokenCount in OutputTokens across all 3 Gemini usage
  parsing paths (non-streaming, streaming, compat layer)
- Add tests covering thinking token scenarios

Closes #554
2026-02-11 15:41:54 +08:00
SilentFlower
e73b778d2b Merge branch 'main' into opus4.6-think 2026-02-11 13:56:30 +08:00
Wesley Liddick
723102766b Merge pull request #553 from Edric-Li/feat/antigravity-onboard-projectid
feat(antigravity): 添加 onboardUser 支持,修复 project_id 缺失问题
2026-02-11 13:52:44 +08:00
Edric Li
a4a46a8618 feat(antigravity): 添加 onboardUser 支持并修复 project_id 补齐逻辑
- 新增 OnboardUser API 客户端方法,支持账号 onboarding 获取 project_id
- loadProjectIDWithRetry 增加 onboard 回退:LoadCodeAssist 未返回 project_id 时自动触发 onboarding
- GetAccessToken 中 project_id 补齐改用轻量 FillProjectID 替代全量 RefreshAccountToken
- 补齐逻辑增加 5 分钟冷却机制,防止频繁重试
- OnboardUser 轮询等待改为 context 感知,支持提前取消
- 提取 mergeCredentials 辅助方法消除重复代码
- 新增 extractProjectIDFromOnboardResponse 和 resolveDefaultTierID 单元测试
2026-02-11 13:41:55 +08:00
SilentFlower
6ae82e04d5 [UPDATE] 优化思考预算逻辑与代码结构
🧠 refactor(antigravity): 完善 thinking 预算分配策略并重构工具构建逻辑
2026-02-11 10:39:54 +08:00
SilentFlower
19cca11e00 [UPDATE] 增强 Claude Thinking 模式支持与 Opus 4.6 动态预算适配
 feat(antigravity): 支持 thinking adaptive 类型并适配 Opus 4.6 动态预算
🧪 test(gateway): 增加 thinking 模式解析与签名块过滤的边界用例测试
2026-02-11 10:31:16 +08:00
Tian
c8f87a9c92 feat(antigravity): 支持 Refresh Token 批量导入创建 OAuth 账号
后端新增 ValidateRefreshToken service 方法和 POST /oauth/refresh-token 端点,
前端新增 API/Composable/UI 集成,OAuthAuthorizationFlow i18n 动态化,
支持在 Antigravity 创建账号时批量粘贴 Refresh Token 自动验证并创建账号。
2026-02-11 01:23:21 +08:00
34 changed files with 982 additions and 84 deletions

View File

@@ -65,3 +65,27 @@ func (h *AntigravityOAuthHandler) ExchangeCode(c *gin.Context) {
response.Success(c, tokenInfo)
}
// AntigravityRefreshTokenRequest represents the request for validating Antigravity refresh token
type AntigravityRefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
ProxyID *int64 `json:"proxy_id"`
}
// RefreshToken validates an Antigravity refresh token and returns full token info
// POST /api/v1/admin/antigravity/oauth/refresh-token
func (h *AntigravityOAuthHandler) RefreshToken(c *gin.Context) {
var req AntigravityRefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求无效: "+err.Error())
return
}
tokenInfo, err := h.antigravityOAuthService.ValidateRefreshToken(c.Request.Context(), req.RefreshToken, req.ProxyID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, tokenInfo)
}

View File

@@ -202,7 +202,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
writer := csv.NewWriter(&buf)
// Write header
if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_at", "created_at"}); err != nil {
if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_by_email", "used_at", "created_at"}); err != nil {
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
return
}
@@ -213,6 +213,10 @@ func (h *RedeemHandler) Export(c *gin.Context) {
if code.UsedBy != nil {
usedBy = fmt.Sprintf("%d", *code.UsedBy)
}
usedByEmail := ""
if code.User != nil {
usedByEmail = code.User.Email
}
usedAt := ""
if code.UsedAt != nil {
usedAt = code.UsedAt.Format("2006-01-02 15:04:05")
@@ -224,6 +228,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
fmt.Sprintf("%.2f", code.Value),
code.Status,
usedBy,
usedByEmail,
usedAt,
code.CreatedAt.Format("2006-01-02 15:04:05"),
}); err != nil {

View File

@@ -27,7 +27,7 @@ type ClaudeMessage struct {
// ThinkingConfig Thinking 配置
type ThinkingConfig struct {
Type string `json:"type"` // "enabled" or "disabled"
Type string `json:"type"` // "enabled" / "adaptive" / "disabled"
BudgetTokens int `json:"budget_tokens,omitempty"` // thinking budget
}

View File

@@ -115,6 +115,23 @@ type LoadCodeAssistResponse struct {
IneligibleTiers []*IneligibleTier `json:"ineligibleTiers,omitempty"`
}
// OnboardUserRequest onboardUser 请求
type OnboardUserRequest struct {
TierID string `json:"tierId"`
Metadata struct {
IDEType string `json:"ideType"`
Platform string `json:"platform,omitempty"`
PluginType string `json:"pluginType,omitempty"`
} `json:"metadata"`
}
// OnboardUserResponse onboardUser 响应
type OnboardUserResponse struct {
Name string `json:"name,omitempty"`
Done bool `json:"done"`
Response map[string]any `json:"response,omitempty"`
}
// GetTier 获取账户类型
// 优先返回 paidTier付费订阅级别否则返回 currentTier
func (r *LoadCodeAssistResponse) GetTier() string {
@@ -361,6 +378,117 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
return nil, nil, lastErr
}
// OnboardUser 触发账号 onboarding并返回 project_id
// 说明:
// 1) 部分账号 loadCodeAssist 不会立即返回 cloudaicompanionProject
// 2) 这时需要调用 onboardUser 完成初始化,之后才能拿到 project_id。
func (c *Client) OnboardUser(ctx context.Context, accessToken, tierID string) (string, error) {
tierID = strings.TrimSpace(tierID)
if tierID == "" {
return "", fmt.Errorf("tier_id 为空")
}
reqBody := OnboardUserRequest{TierID: tierID}
reqBody.Metadata.IDEType = "ANTIGRAVITY"
reqBody.Metadata.Platform = "PLATFORM_UNSPECIFIED"
reqBody.Metadata.PluginType = "GEMINI"
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("序列化请求失败: %w", err)
}
availableURLs := BaseURLs
var lastErr error
for urlIdx, baseURL := range availableURLs {
apiURL := baseURL + "/v1internal:onboardUser"
for attempt := 1; attempt <= 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
if err != nil {
lastErr = fmt.Errorf("创建请求失败: %w", err)
break
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", UserAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("onboardUser 请求失败: %w", err)
if shouldFallbackToNextURL(err, 0) && urlIdx < len(availableURLs)-1 {
log.Printf("[antigravity] onboardUser URL fallback: %s -> %s", baseURL, availableURLs[urlIdx+1])
break
}
return "", lastErr
}
respBodyBytes, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
return "", fmt.Errorf("读取响应失败: %w", err)
}
if shouldFallbackToNextURL(nil, resp.StatusCode) && urlIdx < len(availableURLs)-1 {
log.Printf("[antigravity] onboardUser URL fallback (HTTP %d): %s -> %s", resp.StatusCode, baseURL, availableURLs[urlIdx+1])
break
}
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("onboardUser 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes))
return "", lastErr
}
var onboardResp OnboardUserResponse
if err := json.Unmarshal(respBodyBytes, &onboardResp); err != nil {
lastErr = fmt.Errorf("onboardUser 响应解析失败: %w", err)
return "", lastErr
}
if onboardResp.Done {
if projectID := extractProjectIDFromOnboardResponse(onboardResp.Response); projectID != "" {
DefaultURLAvailability.MarkSuccess(baseURL)
return projectID, nil
}
lastErr = fmt.Errorf("onboardUser 完成但未返回 project_id")
return "", lastErr
}
// done=false 时等待后重试(与 CLIProxyAPI 行为一致)
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
return "", ctx.Err()
}
}
}
if lastErr != nil {
return "", lastErr
}
return "", fmt.Errorf("onboardUser 未返回 project_id")
}
func extractProjectIDFromOnboardResponse(resp map[string]any) string {
if len(resp) == 0 {
return ""
}
if v, ok := resp["cloudaicompanionProject"]; ok {
switch project := v.(type) {
case string:
return strings.TrimSpace(project)
case map[string]any:
if id, ok := project["id"].(string); ok {
return strings.TrimSpace(id)
}
}
}
return ""
}
// ModelQuotaInfo 模型配额信息
type ModelQuotaInfo struct {
RemainingFraction float64 `json:"remainingFraction"`

View File

@@ -0,0 +1,76 @@
package antigravity
import (
"testing"
)
func TestExtractProjectIDFromOnboardResponse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
resp map[string]any
want string
}{
{
name: "nil response",
resp: nil,
want: "",
},
{
name: "empty response",
resp: map[string]any{},
want: "",
},
{
name: "project as string",
resp: map[string]any{
"cloudaicompanionProject": "my-project-123",
},
want: "my-project-123",
},
{
name: "project as string with spaces",
resp: map[string]any{
"cloudaicompanionProject": " my-project-123 ",
},
want: "my-project-123",
},
{
name: "project as map with id",
resp: map[string]any{
"cloudaicompanionProject": map[string]any{
"id": "proj-from-map",
},
},
want: "proj-from-map",
},
{
name: "project as map without id",
resp: map[string]any{
"cloudaicompanionProject": map[string]any{
"name": "some-name",
},
},
want: "",
},
{
name: "missing cloudaicompanionProject key",
resp: map[string]any{
"otherField": "value",
},
want: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := extractProjectIDFromOnboardResponse(tc.resp)
if got != tc.want {
t.Fatalf("extractProjectIDFromOnboardResponse() = %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -155,6 +155,7 @@ type GeminiUsageMetadata struct {
CandidatesTokenCount int `json:"candidatesTokenCount,omitempty"`
CachedContentTokenCount int `json:"cachedContentTokenCount,omitempty"`
TotalTokenCount int `json:"totalTokenCount,omitempty"`
ThoughtsTokenCount int `json:"thoughtsTokenCount,omitempty"` // thinking tokens按输出价格计费
}
// GeminiGroundingMetadata Gemini grounding 元数据Web Search

View File

@@ -64,6 +64,10 @@ const MaxTokensBudgetPadding = 1000
// Gemini 2.5 Flash thinking budget 上限
const Gemini25FlashThinkingBudgetLimit = 24576
// 对于 Antigravity 的 Claudebudget-only模型该语义最终等价为 thinkingBudget=24576。
// 这里复用相同数值以保持行为一致。
const ClaudeAdaptiveHighThinkingBudgetTokens = Gemini25FlashThinkingBudgetLimit
// ensureMaxTokensGreaterThanBudget 确保 max_tokens > budget_tokens
// Claude API 要求启用 thinking 时max_tokens 必须大于 thinking.budget_tokens
// 返回调整后的 maxTokens 和是否进行了调整
@@ -96,7 +100,7 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
}
// 检测是否启用 thinking
isThinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
isThinkingEnabled := claudeReq.Thinking != nil && (claudeReq.Thinking.Type == "enabled" || claudeReq.Thinking.Type == "adaptive")
// 只有 Gemini 模型支持 dummy thought workaround
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
@@ -198,8 +202,7 @@ type modelInfo struct {
// modelInfoMap 模型前缀 → 模型信息映射
// 只有在此映射表中的模型才会注入身份提示词
// 注意:当前 claude-opus-4-6 会被映射到 claude-opus-4-5-thinking
// 但保留此条目以便后续 Antigravity 上游支持 4.6 时快速切换
// 注意:模型映射逻辑在网关层完成;这里仅用于按模型前缀判断是否注入身份提示词。
var modelInfoMap = map[string]modelInfo{
"claude-opus-4-5": {DisplayName: "Claude Opus 4.5", CanonicalID: "claude-opus-4-5-20250929"},
"claude-opus-4-6": {DisplayName: "Claude Opus 4.6", CanonicalID: "claude-opus-4-6"},
@@ -593,6 +596,10 @@ func maxOutputTokensLimit(model string) int {
return maxOutputTokensUpperBound
}
func isAntigravityOpus46Model(model string) bool {
return strings.HasPrefix(strings.ToLower(model), "claude-opus-4-6")
}
func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
maxLimit := maxOutputTokensLimit(req.Model)
config := &GeminiGenerationConfig{
@@ -606,25 +613,36 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
}
// Thinking 配置
if req.Thinking != nil && req.Thinking.Type == "enabled" {
if req.Thinking != nil && (req.Thinking.Type == "enabled" || req.Thinking.Type == "adaptive") {
config.ThinkingConfig = &GeminiThinkingConfig{
IncludeThoughts: true,
}
// - thinking.type=enabledbudget_tokens>0 用显式预算
// - thinking.type=adaptive仅在 Antigravity 的 Opus 4.6 上覆写为 24576
budget := -1
if req.Thinking.BudgetTokens > 0 {
budget := req.Thinking.BudgetTokens
budget = req.Thinking.BudgetTokens
}
if req.Thinking.Type == "adaptive" && isAntigravityOpus46Model(req.Model) {
budget = ClaudeAdaptiveHighThinkingBudgetTokens
}
// 正预算需要做上限与 max_tokens 约束;动态预算(-1直接透传给上游。
if budget > 0 {
// gemini-2.5-flash 上限
if strings.Contains(req.Model, "gemini-2.5-flash") && budget > Gemini25FlashThinkingBudgetLimit {
budget = Gemini25FlashThinkingBudgetLimit
}
config.ThinkingConfig.ThinkingBudget = budget
// 自动修正max_tokens 必须大于 budget_tokens
// 自动修正max_tokens 必须大于 budget_tokensClaude 上游要求)
if adjusted, ok := ensureMaxTokensGreaterThanBudget(config.MaxOutputTokens, budget); ok {
log.Printf("[Antigravity] Auto-adjusted max_tokens from %d to %d (must be > budget_tokens=%d)",
config.MaxOutputTokens, adjusted, budget)
config.MaxOutputTokens = adjusted
}
}
config.ThinkingConfig.ThinkingBudget = budget
}
if config.MaxOutputTokens > maxLimit {

View File

@@ -259,3 +259,93 @@ func TestBuildTools_CustomTypeTools(t *testing.T) {
})
}
}
func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) {
tests := []struct {
name string
model string
thinking *ThinkingConfig
wantBudget int
wantPresent bool
}{
{
name: "enabled without budget defaults to dynamic (-1)",
model: "claude-opus-4-6-thinking",
thinking: &ThinkingConfig{Type: "enabled"},
wantBudget: -1,
wantPresent: true,
},
{
name: "enabled with budget uses the provided value",
model: "claude-opus-4-6-thinking",
thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: 1024},
wantBudget: 1024,
wantPresent: true,
},
{
name: "enabled with -1 budget uses dynamic (-1)",
model: "claude-opus-4-6-thinking",
thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: -1},
wantBudget: -1,
wantPresent: true,
},
{
name: "adaptive on opus4.6 maps to high budget (24576)",
model: "claude-opus-4-6-thinking",
thinking: &ThinkingConfig{Type: "adaptive", BudgetTokens: 20000},
wantBudget: ClaudeAdaptiveHighThinkingBudgetTokens,
wantPresent: true,
},
{
name: "adaptive on non-opus model keeps default dynamic (-1)",
model: "claude-sonnet-4-5-thinking",
thinking: &ThinkingConfig{Type: "adaptive"},
wantBudget: -1,
wantPresent: true,
},
{
name: "disabled does not emit thinkingConfig",
model: "claude-opus-4-6-thinking",
thinking: &ThinkingConfig{Type: "disabled", BudgetTokens: 1024},
wantBudget: 0,
wantPresent: false,
},
{
name: "nil thinking does not emit thinkingConfig",
model: "claude-opus-4-6-thinking",
thinking: nil,
wantBudget: 0,
wantPresent: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := &ClaudeRequest{
Model: tt.model,
Thinking: tt.thinking,
}
cfg := buildGenerationConfig(req)
if cfg == nil {
t.Fatalf("expected non-nil generationConfig")
}
if tt.wantPresent {
if cfg.ThinkingConfig == nil {
t.Fatalf("expected thinkingConfig to be present")
}
if !cfg.ThinkingConfig.IncludeThoughts {
t.Fatalf("expected includeThoughts=true")
}
if cfg.ThinkingConfig.ThinkingBudget != tt.wantBudget {
t.Fatalf("expected thinkingBudget=%d, got %d", tt.wantBudget, cfg.ThinkingConfig.ThinkingBudget)
}
return
}
if cfg.ThinkingConfig != nil {
t.Fatalf("expected thinkingConfig to be nil, got %+v", cfg.ThinkingConfig)
}
})
}
}

View File

@@ -279,7 +279,7 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
if geminiResp.UsageMetadata != nil {
cached := geminiResp.UsageMetadata.CachedContentTokenCount
usage.InputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
usage.OutputTokens = geminiResp.UsageMetadata.CandidatesTokenCount
usage.OutputTokens = geminiResp.UsageMetadata.CandidatesTokenCount + geminiResp.UsageMetadata.ThoughtsTokenCount
usage.CacheReadInputTokens = cached
}

View File

@@ -85,7 +85,7 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte {
if geminiResp.UsageMetadata != nil {
cached := geminiResp.UsageMetadata.CachedContentTokenCount
p.inputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
p.outputTokens = geminiResp.UsageMetadata.CandidatesTokenCount
p.outputTokens = geminiResp.UsageMetadata.CandidatesTokenCount + geminiResp.UsageMetadata.ThoughtsTokenCount
p.cacheReadTokens = cached
}
@@ -146,7 +146,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
if v1Resp.Response.UsageMetadata != nil {
cached := v1Resp.Response.UsageMetadata.CachedContentTokenCount
usage.InputTokens = v1Resp.Response.UsageMetadata.PromptTokenCount - cached
usage.OutputTokens = v1Resp.Response.UsageMetadata.CandidatesTokenCount
usage.OutputTokens = v1Resp.Response.UsageMetadata.CandidatesTokenCount + v1Resp.Response.UsageMetadata.ThoughtsTokenCount
usage.CacheReadInputTokens = cached
}

View File

@@ -448,7 +448,12 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
q = q.Where(dbaccount.TypeEQ(accountType))
}
if status != "" {
q = q.Where(dbaccount.StatusEQ(status))
switch status {
case "rate_limited":
q = q.Where(dbaccount.RateLimitResetAtGT(time.Now()))
default:
q = q.Where(dbaccount.StatusEQ(status))
}
}
if search != "" {
q = q.Where(dbaccount.NameContainsFold(search))

View File

@@ -6,6 +6,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
)
@@ -106,7 +107,12 @@ func (r *redeemCodeRepository) ListWithFilters(ctx context.Context, params pagin
q = q.Where(redeemcode.StatusEQ(status))
}
if search != "" {
q = q.Where(redeemcode.CodeContainsFold(search))
q = q.Where(
redeemcode.Or(
redeemcode.CodeContainsFold(search),
redeemcode.HasUserWith(user.EmailContainsFold(search)),
),
)
}
total, err := q.Count(ctx)

View File

@@ -10,6 +10,7 @@ import (
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/apikey"
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -191,6 +192,7 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
dbuser.EmailContainsFold(filters.Search),
dbuser.UsernameContainsFold(filters.Search),
dbuser.NotesContainsFold(filters.Search),
dbuser.HasAPIKeysWith(apikey.KeyContainsFold(filters.Search)),
),
)
}

View File

@@ -281,6 +281,7 @@ func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers)
{
antigravity.POST("/oauth/auth-url", h.Admin.AntigravityOAuth.GenerateAuthURL)
antigravity.POST("/oauth/exchange-code", h.Admin.AntigravityOAuth.ExchangeCode)
antigravity.POST("/oauth/refresh-token", h.Admin.AntigravityOAuth.RefreshToken)
}
}

View File

@@ -1309,7 +1309,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
return nil, s.writeClaudeError(c, http.StatusForbidden, "permission_error", fmt.Sprintf("model %s not in whitelist", claudeReq.Model))
}
// 应用 thinking 模式自动后缀:如果 thinking 开启且目标是 claude-sonnet-4-5自动改为 thinking 版本
thinkingEnabled := claudeReq.Thinking != nil && claudeReq.Thinking.Type == "enabled"
thinkingEnabled := claudeReq.Thinking != nil && (claudeReq.Thinking.Type == "enabled" || claudeReq.Thinking.Type == "adaptive")
mappedModel = applyThinkingModelSuffix(mappedModel, thinkingEnabled)
// 获取 access_token

View File

@@ -553,6 +553,75 @@ func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) {
require.NotContains(t, body, "event: error")
}
// TestHandleGeminiStreamingResponse_ThoughtsTokenCount
// 验证Gemini 流式转发时 thoughtsTokenCount 被计入 OutputTokens
func TestHandleGeminiStreamingResponse_ThoughtsTokenCount(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
pr, pw := io.Pipe()
resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
go func() {
defer func() { _ = pw.Close() }()
fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"thoughtsTokenCount":50}}`)
fmt.Fprintln(pw, "")
fmt.Fprintln(pw, `data: {"candidates":[{"content":{"parts":[{"text":" world"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":30,"thoughtsTokenCount":80,"cachedContentTokenCount":10}}`)
fmt.Fprintln(pw, "")
}()
result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
_ = pr.Close()
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.usage)
// promptTokenCount=100, cachedContentTokenCount=10 → InputTokens=90
require.Equal(t, 90, result.usage.InputTokens)
// candidatesTokenCount=30 + thoughtsTokenCount=80 → OutputTokens=110
require.Equal(t, 110, result.usage.OutputTokens)
require.Equal(t, 10, result.usage.CacheReadInputTokens)
}
// TestHandleClaudeStreamingResponse_ThoughtsTokenCount
// 验证Gemini→Claude 流式转换时 thoughtsTokenCount 被计入 OutputTokens
func TestHandleClaudeStreamingResponse_ThoughtsTokenCount(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
pr, pw := io.Pipe()
resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
go func() {
defer func() { _ = pw.Close() }()
fmt.Fprintln(pw, `data: {"response":{"candidates":[{"content":{"parts":[{"text":"Hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":50,"candidatesTokenCount":10,"thoughtsTokenCount":25}}}`)
fmt.Fprintln(pw, "")
}()
result, err := svc.handleClaudeStreamingResponse(c, resp, time.Now(), "gemini-2.5-pro")
_ = pr.Close()
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.usage)
// promptTokenCount=50 → InputTokens=50
require.Equal(t, 50, result.usage.InputTokens)
// candidatesTokenCount=10 + thoughtsTokenCount=25 → OutputTokens=35
require.Equal(t, 35, result.usage.OutputTokens)
}
// --- 流式客户端断开检测测试 ---
// TestStreamUpstreamResponse_ClientDisconnectDrainsUsage

View File

@@ -192,6 +192,43 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken
return nil, fmt.Errorf("token 刷新失败 (重试后): %w", lastErr)
}
// ValidateRefreshToken 用 refresh token 验证并获取完整的 token 信息(含 email 和 project_id
func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refreshToken string, proxyID *int64) (*AntigravityTokenInfo, error) {
var proxyURL string
if proxyID != nil {
proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
if err == nil && proxy != nil {
proxyURL = proxy.URL()
}
}
// 刷新 token
tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL)
if err != nil {
return nil, err
}
// 获取用户信息email
client := antigravity.NewClient(proxyURL)
userInfo, err := client.GetUserInfo(ctx, tokenInfo.AccessToken)
if err != nil {
fmt.Printf("[AntigravityOAuth] 警告: 获取用户信息失败: %v\n", err)
} else {
tokenInfo.Email = userInfo.Email
}
// 获取 project_id容错失败不阻塞
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
if loadErr != nil {
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
tokenInfo.ProjectIDMissing = true
} else {
tokenInfo.ProjectID = projectID
}
return tokenInfo, nil
}
func isNonRetryableAntigravityOAuthError(err error) bool {
msg := err.Error()
nonRetryable := []string{
@@ -273,12 +310,21 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
}
client := antigravity.NewClient(proxyURL)
loadResp, _, err := client.LoadCodeAssist(ctx, accessToken)
loadResp, loadRaw, err := client.LoadCodeAssist(ctx, accessToken)
if err == nil && loadResp != nil && loadResp.CloudAICompanionProject != "" {
return loadResp.CloudAICompanionProject, nil
}
if err == nil {
if projectID, onboardErr := tryOnboardProjectID(ctx, client, accessToken, loadRaw); onboardErr == nil && projectID != "" {
return projectID, nil
} else if onboardErr != nil {
lastErr = onboardErr
continue
}
}
// 记录错误
if err != nil {
lastErr = err
@@ -292,6 +338,65 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
return "", fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
}
func tryOnboardProjectID(ctx context.Context, client *antigravity.Client, accessToken string, loadRaw map[string]any) (string, error) {
tierID := resolveDefaultTierID(loadRaw)
if tierID == "" {
return "", fmt.Errorf("loadCodeAssist 未返回可用的默认 tier")
}
projectID, err := client.OnboardUser(ctx, accessToken, tierID)
if err != nil {
return "", fmt.Errorf("onboardUser 失败 (tier=%s): %w", tierID, err)
}
return projectID, nil
}
func resolveDefaultTierID(loadRaw map[string]any) string {
if len(loadRaw) == 0 {
return ""
}
rawTiers, ok := loadRaw["allowedTiers"]
if !ok {
return ""
}
tiers, ok := rawTiers.([]any)
if !ok {
return ""
}
for _, rawTier := range tiers {
tier, ok := rawTier.(map[string]any)
if !ok {
continue
}
if isDefault, _ := tier["isDefault"].(bool); !isDefault {
continue
}
if id, ok := tier["id"].(string); ok {
id = strings.TrimSpace(id)
if id != "" {
return id
}
}
}
return ""
}
// FillProjectID 仅获取 project_id不刷新 OAuth token
func (s *AntigravityOAuthService) FillProjectID(ctx context.Context, account *Account, accessToken string) (string, error) {
var proxyURL string
if account.ProxyID != nil {
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
if err == nil && proxy != nil {
proxyURL = proxy.URL()
}
}
return s.loadProjectIDWithRetry(ctx, accessToken, proxyURL, 3)
}
// BuildAccountCredentials 构建账户凭证
func (s *AntigravityOAuthService) BuildAccountCredentials(tokenInfo *AntigravityTokenInfo) map[string]any {
creds := map[string]any{

View File

@@ -0,0 +1,82 @@
package service
import (
"testing"
)
func TestResolveDefaultTierID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
loadRaw map[string]any
want string
}{
{
name: "nil loadRaw",
loadRaw: nil,
want: "",
},
{
name: "missing allowedTiers",
loadRaw: map[string]any{
"paidTier": map[string]any{"id": "g1-pro-tier"},
},
want: "",
},
{
name: "empty allowedTiers",
loadRaw: map[string]any{"allowedTiers": []any{}},
want: "",
},
{
name: "tier missing id field",
loadRaw: map[string]any{
"allowedTiers": []any{
map[string]any{"isDefault": true},
},
},
want: "",
},
{
name: "allowedTiers but no default",
loadRaw: map[string]any{
"allowedTiers": []any{
map[string]any{"id": "free-tier", "isDefault": false},
map[string]any{"id": "standard-tier", "isDefault": false},
},
},
want: "",
},
{
name: "default tier found",
loadRaw: map[string]any{
"allowedTiers": []any{
map[string]any{"id": "free-tier", "isDefault": true},
map[string]any{"id": "standard-tier", "isDefault": false},
},
},
want: "free-tier",
},
{
name: "default tier id with spaces",
loadRaw: map[string]any{
"allowedTiers": []any{
map[string]any{"id": " standard-tier ", "isDefault": true},
},
},
want: "standard-tier",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := resolveDefaultTierID(tc.loadRaw)
if got != tc.want {
t.Fatalf("resolveDefaultTierID() = %q, want %q", got, tc.want)
}
})
}
}

View File

@@ -7,12 +7,14 @@ import (
"log/slog"
"strconv"
"strings"
"sync"
"time"
)
const (
antigravityTokenRefreshSkew = 3 * time.Minute
antigravityTokenCacheSkew = 5 * time.Minute
antigravityBackfillCooldown = 5 * time.Minute
)
// AntigravityTokenCache Token 缓存接口(复用 GeminiTokenCache 接口定义)
@@ -23,6 +25,7 @@ type AntigravityTokenProvider struct {
accountRepo AccountRepository
tokenCache AntigravityTokenCache
antigravityOAuthService *AntigravityOAuthService
backfillCooldown sync.Map // key: int64 (account.ID) → value: time.Time
}
func NewAntigravityTokenProvider(
@@ -93,13 +96,7 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
if err != nil {
return "", err
}
newCredentials := p.antigravityOAuthService.BuildAccountCredentials(tokenInfo)
for k, v := range account.Credentials {
if _, exists := newCredentials[k]; !exists {
newCredentials[k] = v
}
}
account.Credentials = newCredentials
p.mergeCredentials(account, tokenInfo)
if updateErr := p.accountRepo.Update(ctx, account); updateErr != nil {
log.Printf("[AntigravityTokenProvider] Failed to update account credentials: %v", updateErr)
}
@@ -113,6 +110,21 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
return "", errors.New("access_token not found in credentials")
}
// 如果账号还没有 project_id尝试在线补齐避免请求 daily/sandbox 时出现
// "Invalid project resource name projects/"。
// 仅调用 loadProjectIDWithRetry不刷新 OAuth token带冷却机制防止频繁重试。
if strings.TrimSpace(account.GetCredential("project_id")) == "" && p.antigravityOAuthService != nil {
if p.shouldAttemptBackfill(account.ID) {
p.markBackfillAttempted(account.ID)
if projectID, err := p.antigravityOAuthService.FillProjectID(ctx, account, accessToken); err == nil && projectID != "" {
account.Credentials["project_id"] = projectID
if updateErr := p.accountRepo.Update(ctx, account); updateErr != nil {
log.Printf("[AntigravityTokenProvider] project_id 补齐持久化失败: %v", updateErr)
}
}
}
}
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
if p.tokenCache != nil {
latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
@@ -144,6 +156,31 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
return accessToken, nil
}
// mergeCredentials 将 tokenInfo 构建的凭证合并到 account 中,保留原有未覆盖的字段
func (p *AntigravityTokenProvider) mergeCredentials(account *Account, tokenInfo *AntigravityTokenInfo) {
newCredentials := p.antigravityOAuthService.BuildAccountCredentials(tokenInfo)
for k, v := range account.Credentials {
if _, exists := newCredentials[k]; !exists {
newCredentials[k] = v
}
}
account.Credentials = newCredentials
}
// shouldAttemptBackfill 检查是否应该尝试补齐 project_id冷却期内不重复尝试
func (p *AntigravityTokenProvider) shouldAttemptBackfill(accountID int64) bool {
if v, ok := p.backfillCooldown.Load(accountID); ok {
if lastAttempt, ok := v.(time.Time); ok {
return time.Since(lastAttempt) > antigravityBackfillCooldown
}
}
return true
}
func (p *AntigravityTokenProvider) markBackfillAttempted(accountID int64) {
p.backfillCooldown.Store(accountID, time.Now())
}
func AntigravityTokenCacheKey(account *Account) string {
projectID := strings.TrimSpace(account.GetCredential("project_id"))
if projectID != "" {

View File

@@ -101,9 +101,9 @@ func ParseGatewayRequest(body []byte, protocol string) (*ParsedRequest, error) {
}
}
// thinking: {type: "enabled"}
// thinking: {type: "enabled" | "adaptive"}
if rawThinking, ok := req["thinking"].(map[string]any); ok {
if t, ok := rawThinking["type"].(string); ok && t == "enabled" {
if t, ok := rawThinking["type"].(string); ok && (t == "enabled" || t == "adaptive") {
parsed.ThinkingEnabled = true
}
}
@@ -161,9 +161,9 @@ func parseIntegralNumber(raw any) (int, bool) {
// Returns filtered body or original body if filtering fails (fail-safe)
// This prevents 400 errors from invalid thinking block signatures
//
// Strategy:
// - When thinking.type != "enabled": Remove all thinking blocks
// - When thinking.type == "enabled": Only remove thinking blocks without valid signatures
// 策略:
// - thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块
// - thinking.type "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块(避免 400
// (blocks with missing/empty/dummy signatures that would cause 400 errors)
func FilterThinkingBlocks(body []byte) []byte {
return filterThinkingBlocksInternal(body, false)
@@ -489,9 +489,9 @@ func FilterSignatureSensitiveBlocksForRetry(body []byte) []byte {
}
// filterThinkingBlocksInternal removes invalid thinking blocks from request
// Strategy:
// - When thinking.type != "enabled": Remove all thinking blocks
// - When thinking.type == "enabled": Only remove thinking blocks without valid signatures
// 策略:
// - thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块
// - thinking.type "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块
func filterThinkingBlocksInternal(body []byte, _ bool) []byte {
// Fast path: if body doesn't contain "thinking", skip parsing
if !bytes.Contains(body, []byte(`"type":"thinking"`)) &&
@@ -511,7 +511,7 @@ func filterThinkingBlocksInternal(body []byte, _ bool) []byte {
// Check if thinking is enabled
thinkingEnabled := false
if thinking, ok := req["thinking"].(map[string]any); ok {
if thinkType, ok := thinking["type"].(string); ok && thinkType == "enabled" {
if thinkType, ok := thinking["type"].(string); ok && (thinkType == "enabled" || thinkType == "adaptive") {
thinkingEnabled = true
}
}

View File

@@ -29,6 +29,14 @@ func TestParseGatewayRequest_ThinkingEnabled(t *testing.T) {
require.True(t, parsed.ThinkingEnabled)
}
func TestParseGatewayRequest_ThinkingAdaptiveEnabled(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-4-5","thinking":{"type":"adaptive"},"messages":[{"content":"hi"}]}`)
parsed, err := ParseGatewayRequest(body, "")
require.NoError(t, err)
require.Equal(t, "claude-sonnet-4-5", parsed.Model)
require.True(t, parsed.ThinkingEnabled)
}
func TestParseGatewayRequest_MaxTokens(t *testing.T) {
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`)
parsed, err := ParseGatewayRequest(body, "")
@@ -209,6 +217,16 @@ func TestFilterThinkingBlocks(t *testing.T) {
input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"},{"type":"thinking","thinking":"internal","signature":"invalid"},{"type":"text","text":"World"}]}]}`,
shouldFilter: true,
},
{
name: "does not filter signed thinking blocks when thinking adaptive",
input: `{"thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"ok","signature":"sig_real_123"},{"type":"text","text":"B"}]}]}`,
shouldFilter: false,
},
{
name: "filters unsigned thinking blocks when thinking adaptive",
input: `{"thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"internal","signature":""},{"type":"text","text":"B"}]}]}`,
shouldFilter: true,
},
{
name: "handles no thinking blocks",
input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"}]}]}`,

View File

@@ -3631,7 +3631,8 @@ func requestNeedsBetaFeatures(body []byte) bool {
if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
return true
}
if strings.EqualFold(gjson.GetBytes(body, "thinking.type").String(), "enabled") {
thinkingType := gjson.GetBytes(body, "thinking.type").String()
if strings.EqualFold(thinkingType, "enabled") || strings.EqualFold(thinkingType, "adaptive") {
return true
}
return false

View File

@@ -2663,11 +2663,12 @@ func extractGeminiUsage(geminiResp map[string]any) *ClaudeUsage {
prompt, _ := asInt(usageMeta["promptTokenCount"])
cand, _ := asInt(usageMeta["candidatesTokenCount"])
cached, _ := asInt(usageMeta["cachedContentTokenCount"])
thoughts, _ := asInt(usageMeta["thoughtsTokenCount"])
// 注意Gemini 的 promptTokenCount 包含 cachedContentTokenCount
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens需要减去
return &ClaudeUsage{
InputTokens: prompt - cached,
OutputTokens: cand,
OutputTokens: cand + thoughts,
CacheReadInputTokens: cached,
}
}

View File

@@ -4,6 +4,8 @@ import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestConvertClaudeToolsToGeminiTools_CustomType 测试custom类型工具转换
@@ -203,3 +205,70 @@ func TestEnsureGeminiFunctionCallThoughtSignatures_InsertsWhenMissing(t *testing
t.Fatalf("expected injected thoughtSignature %q, got: %s", geminiDummyThoughtSignature, s)
}
}
func TestExtractGeminiUsage_ThoughtsTokenCount(t *testing.T) {
tests := []struct {
name string
resp map[string]any
wantInput int
wantOutput int
wantCacheRead int
wantNil bool
}{
{
name: "with thoughtsTokenCount",
resp: map[string]any{
"usageMetadata": map[string]any{
"promptTokenCount": float64(100),
"candidatesTokenCount": float64(20),
"thoughtsTokenCount": float64(50),
},
},
wantInput: 100,
wantOutput: 70,
},
{
name: "with thoughtsTokenCount and cache",
resp: map[string]any{
"usageMetadata": map[string]any{
"promptTokenCount": float64(100),
"candidatesTokenCount": float64(20),
"cachedContentTokenCount": float64(30),
"thoughtsTokenCount": float64(50),
},
},
wantInput: 70,
wantOutput: 70,
wantCacheRead: 30,
},
{
name: "without thoughtsTokenCount (old model)",
resp: map[string]any{
"usageMetadata": map[string]any{
"promptTokenCount": float64(100),
"candidatesTokenCount": float64(20),
},
},
wantInput: 100,
wantOutput: 20,
},
{
name: "no usageMetadata",
resp: map[string]any{},
wantNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
usage := extractGeminiUsage(tt.resp)
if tt.wantNil {
require.Nil(t, usage)
return
}
require.NotNil(t, usage)
require.Equal(t, tt.wantInput, usage.InputTokens)
require.Equal(t, tt.wantOutput, usage.OutputTokens)
require.Equal(t, tt.wantCacheRead, usage.CacheReadInputTokens)
})
}
}

View File

@@ -53,4 +53,18 @@ export async function exchangeCode(
return data
}
export default { generateAuthUrl, exchangeCode }
export async function refreshAntigravityToken(
refreshToken: string,
proxyId?: number | null
): Promise<AntigravityTokenInfo> {
const payload: Record<string, any> = { refresh_token: refreshToken }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<AntigravityTokenInfo>(
'/admin/antigravity/oauth/refresh-token',
payload
)
return data
}
export default { generateAuthUrl, exchangeCode, refreshAntigravityToken }

View File

@@ -1647,12 +1647,12 @@
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:show-refresh-token-option="form.platform === 'openai'"
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
@validate-refresh-token="handleOpenAIValidateRT"
@validate-refresh-token="handleValidateRefreshToken"
/>
</div>
@@ -2802,6 +2802,14 @@ const handleGenerateUrl = async () => {
}
}
const handleValidateRefreshToken = (rt: string) => {
if (form.platform === 'openai') {
handleOpenAIValidateRT(rt)
} else if (form.platform === 'antigravity') {
handleAntigravityValidateRT(rt)
}
}
const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput
@@ -2950,6 +2958,95 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
}
}
// Antigravity 手动 RT 批量验证和创建
const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
if (!refreshTokenInput.trim()) return
// Parse multiple refresh tokens (one per line)
const refreshTokens = refreshTokenInput
.split('\n')
.map((rt) => rt.trim())
.filter((rt) => rt)
if (refreshTokens.length === 0) {
antigravityOAuth.error.value = t('admin.accounts.oauth.antigravity.pleaseEnterRefreshToken')
return
}
antigravityOAuth.loading.value = true
antigravityOAuth.error.value = ''
let successCount = 0
let failedCount = 0
const errors: string[] = []
try {
for (let i = 0; i < refreshTokens.length; i++) {
try {
const tokenInfo = await antigravityOAuth.validateRefreshToken(
refreshTokens[i],
form.proxy_id
)
if (!tokenInfo) {
failedCount++
errors.push(`#${i + 1}: ${antigravityOAuth.error.value || 'Validation failed'}`)
antigravityOAuth.error.value = ''
continue
}
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
// Generate account name with index for batch
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
// Note: Antigravity doesn't have buildExtraInfo, so we pass empty extra or rely on credentials
await adminAPI.accounts.create({
name: accountName,
notes: form.notes,
platform: 'antigravity',
type: 'oauth',
credentials,
extra: {},
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
successCount++
} catch (error: any) {
failedCount++
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
errors.push(`#${i + 1}: ${errMsg}`)
}
}
// Show results
if (successCount > 0 && failedCount === 0) {
appStore.showSuccess(
refreshTokens.length > 1
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
: t('admin.accounts.accountCreated')
)
emit('created')
handleClose()
} else if (successCount > 0 && failedCount > 0) {
appStore.showWarning(
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
)
antigravityOAuth.error.value = errors.join('\n')
emit('created')
} else {
antigravityOAuth.error.value = errors.join('\n')
appStore.showError(t('admin.accounts.oauth.batchFailed'))
}
} finally {
antigravityOAuth.loading.value = false
}
}
// Gemini OAuth 授权码兑换
const handleGeminiExchange = async (authCode: string) => {
if (!authCode.trim() || !geminiOAuth.sessionId.value) return

View File

@@ -45,19 +45,19 @@
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.refreshTokenAuth')
t(getOAuthKey('refreshTokenAuth'))
}}</span>
</label>
</div>
</div>
<!-- Refresh Token Input (OpenAI only) -->
<!-- Refresh Token Input (OpenAI / Antigravity) -->
<div v-if="inputMethod === 'refresh_token'" class="space-y-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.accounts.oauth.openai.refreshTokenDesc') }}
{{ t(getOAuthKey('refreshTokenDesc')) }}
</p>
<!-- Refresh Token Input -->
@@ -78,7 +78,7 @@
v-model="refreshTokenInput"
rows="3"
class="input w-full resize-y font-mono text-sm"
:placeholder="t('admin.accounts.oauth.openai.refreshTokenPlaceholder')"
:placeholder="t(getOAuthKey('refreshTokenPlaceholder'))"
></textarea>
<p
v-if="parsedRefreshTokenCount > 1"
@@ -128,8 +128,8 @@
<Icon v-else name="sparkles" size="sm" class="mr-2" />
{{
loading
? t('admin.accounts.oauth.openai.validating')
: t('admin.accounts.oauth.openai.validateAndCreate')
? t(getOAuthKey('validating'))
: t(getOAuthKey('validateAndCreate'))
}}
</button>
</div>

View File

@@ -21,5 +21,5 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }])
</script>

View File

@@ -29,17 +29,19 @@
<!-- Logo/Brand -->
<div class="mb-8 text-center">
<!-- Custom Logo or Default Logo -->
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
>
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<h1 class="text-gradient mb-2 text-3xl font-bold">
{{ siteName }}
</h1>
<p class="text-sm text-gray-500 dark:text-dark-400">
{{ siteSubtitle }}
</p>
<template v-if="settingsLoaded">
<div
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
>
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
</div>
<h1 class="text-gradient mb-2 text-3xl font-bold">
{{ siteName }}
</h1>
<p class="text-sm text-gray-500 dark:text-dark-400">
{{ siteSubtitle }}
</p>
</template>
</div>
<!-- Card Container -->
@@ -61,25 +63,21 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getPublicSettings } from '@/api/auth'
import { computed, onMounted } from 'vue'
import { useAppStore } from '@/stores'
import { sanitizeUrl } from '@/utils/url'
const siteName = ref('Sub2API')
const siteLogo = ref('')
const siteSubtitle = ref('Subscription to API Conversion Platform')
const appStore = useAppStore()
const siteName = computed(() => appStore.siteName || 'Sub2API')
const siteLogo = computed(() => sanitizeUrl(appStore.siteLogo || '', { allowRelative: true, allowDataUrl: true }))
const siteSubtitle = computed(() => appStore.cachedPublicSettings?.site_subtitle || 'Subscription to API Conversion Platform')
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
const currentYear = computed(() => new Date().getFullYear())
onMounted(async () => {
try {
const settings = await getPublicSettings()
siteName.value = settings.site_name || 'Sub2API'
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
} catch (error) {
console.error('Failed to load public settings:', error)
}
onMounted(() => {
appStore.fetchPublicSettings()
})
</script>

View File

@@ -83,6 +83,35 @@ export function useAntigravityOAuth() {
}
}
const validateRefreshToken = async (
refreshToken: string,
proxyId?: number | null
): Promise<AntigravityTokenInfo | null> => {
if (!refreshToken.trim()) {
error.value = t('admin.accounts.oauth.antigravity.pleaseEnterRefreshToken')
return null
}
loading.value = true
error.value = ''
try {
const tokenInfo = await adminAPI.antigravity.refreshAntigravityToken(
refreshToken.trim(),
proxyId
)
return tokenInfo as AntigravityTokenInfo
} catch (err: any) {
error.value =
err.response?.data?.detail || t('admin.accounts.oauth.antigravity.failedToValidateRT')
// Don't show global error toast for batch validation to avoid spamming
// appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record<string, unknown> => {
let expiresAt: string | undefined
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
@@ -110,6 +139,7 @@ export function useAntigravityOAuth() {
resetState,
generateAuthUrl,
exchangeAuthCode,
validateRefreshToken,
buildCredentials
}
}

View File

@@ -841,7 +841,7 @@ export default {
createUser: 'Create User',
editUser: 'Edit User',
deleteUser: 'Delete User',
searchUsers: 'Search users...',
searchUsers: 'Search by email, username, notes, or API key...',
allRoles: 'All Roles',
allStatus: 'All Status',
admin: 'Admin',
@@ -1774,13 +1774,20 @@ export default {
authCode: 'Authorization URL or Code',
authCodePlaceholder:
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
missingExchangeParams: 'Missing code, session ID, or state',
failedToExchangeCode: 'Failed to exchange Antigravity auth code'
}
},
// Gemini specific (platform-wide)
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
missingExchangeParams: 'Missing code, session ID, or state',
failedToExchangeCode: 'Failed to exchange Antigravity auth code',
// Refresh Token auth
refreshTokenAuth: 'Manual RT',
refreshTokenDesc: 'Enter your existing Antigravity Refresh Token. Supports batch input (one per line). The system will automatically validate and create accounts.',
refreshTokenPlaceholder: 'Paste your Antigravity Refresh Token...\nSupports multiple tokens, one per line',
validating: 'Validating...',
validateAndCreate: 'Validate & Create',
pleaseEnterRefreshToken: 'Please enter Refresh Token',
failedToValidateRT: 'Failed to validate Refresh Token'
}
}, // Gemini specific (platform-wide)
gemini: {
helpButton: 'Help',
helpDialog: {
@@ -2129,7 +2136,7 @@ export default {
title: 'Redeem Code Management',
description: 'Generate and manage redeem codes',
generateCodes: 'Generate Codes',
searchCodes: 'Search codes...',
searchCodes: 'Search codes or email...',
allTypes: 'All Types',
allStatus: 'All Status',
balance: 'Balance',

View File

@@ -865,8 +865,8 @@ export default {
editUser: '编辑用户',
deleteUser: '删除用户',
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
searchPlaceholder: '搜索用户邮箱用户名备注、支持模糊查询...',
searchUsers: '搜索用户邮箱用户名备注、支持模糊查询',
searchPlaceholder: '邮箱/用户名/备注/API Key 模糊搜索...',
searchUsers: '邮箱/用户名/备注/API Key 模糊搜索',
roleFilter: '角色筛选',
allRoles: '全部角色',
allStatus: '全部状态',
@@ -1913,7 +1913,15 @@ export default {
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
failedToGenerateUrl: '生成 Antigravity 授权链接失败',
missingExchangeParams: '缺少 code / session_id / state',
failedToExchangeCode: 'Antigravity 授权码兑换失败'
failedToExchangeCode: 'Antigravity 授权码兑换失败',
// Refresh Token auth
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入您已有的 Antigravity Refresh Token支持批量输入每行一个系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 Antigravity Refresh Token...\n支持多个每行一个',
validating: '验证中...',
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token',
failedToValidateRT: '验证 Refresh Token 失败'
}
},
// Gemini specific (platform-wide)
@@ -2292,7 +2300,7 @@ export default {
allStatus: '全部状态',
unused: '未使用',
used: '已使用',
searchCodes: '搜索兑换码...',
searchCodes: '搜索兑换码或邮箱...',
exportCsv: '导出 CSV',
deleteAllUnused: '删除全部未使用',
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',

View File

@@ -6,6 +6,7 @@
*/
type SanitizeOptions = {
allowRelative?: boolean
allowDataUrl?: boolean
}
export function sanitizeUrl(value: string, options: SanitizeOptions = {}): string {
@@ -18,6 +19,11 @@ export function sanitizeUrl(value: string, options: SanitizeOptions = {}): strin
return trimmed
}
// 允许 data:image/ 开头的 data URL仅限图片类型
if (options.allowDataUrl && trimmed.startsWith('data:image/')) {
return trimmed
}
// 只接受绝对 URL不使用 base URL 来避免相对路径被解析为当前域名
// 检查是否以 http:// 或 https:// 开头
if (!trimmed.match(/^https?:\/\//i)) {

View File

@@ -117,9 +117,9 @@
</span>
</template>
<template #cell-used_by="{ value }">
<template #cell-used_by="{ value, row }">
<span class="text-sm text-gray-500 dark:text-dark-400">
{{ value ? t('admin.redeem.userPrefix', { id: value }) : '-' }}
{{ row.user?.email || (value ? t('admin.redeem.userPrefix', { id: value }) : '-') }}
</span>
</template>