Merge branch 'Wei-Shaw:main' into main
This commit is contained in:
@@ -65,3 +65,27 @@ func (h *AntigravityOAuthHandler) ExchangeCode(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, tokenInfo)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
|
|||||||
writer := csv.NewWriter(&buf)
|
writer := csv.NewWriter(&buf)
|
||||||
|
|
||||||
// Write header
|
// 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())
|
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -213,6 +213,10 @@ func (h *RedeemHandler) Export(c *gin.Context) {
|
|||||||
if code.UsedBy != nil {
|
if code.UsedBy != nil {
|
||||||
usedBy = fmt.Sprintf("%d", *code.UsedBy)
|
usedBy = fmt.Sprintf("%d", *code.UsedBy)
|
||||||
}
|
}
|
||||||
|
usedByEmail := ""
|
||||||
|
if code.User != nil {
|
||||||
|
usedByEmail = code.User.Email
|
||||||
|
}
|
||||||
usedAt := ""
|
usedAt := ""
|
||||||
if code.UsedAt != nil {
|
if code.UsedAt != nil {
|
||||||
usedAt = code.UsedAt.Format("2006-01-02 15:04:05")
|
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),
|
fmt.Sprintf("%.2f", code.Value),
|
||||||
code.Status,
|
code.Status,
|
||||||
usedBy,
|
usedBy,
|
||||||
|
usedByEmail,
|
||||||
usedAt,
|
usedAt,
|
||||||
code.CreatedAt.Format("2006-01-02 15:04:05"),
|
code.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type ClaudeMessage struct {
|
|||||||
|
|
||||||
// ThinkingConfig Thinking 配置
|
// ThinkingConfig Thinking 配置
|
||||||
type ThinkingConfig struct {
|
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
|
BudgetTokens int `json:"budget_tokens,omitempty"` // thinking budget
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ type GeminiUsageMetadata struct {
|
|||||||
CandidatesTokenCount int `json:"candidatesTokenCount,omitempty"`
|
CandidatesTokenCount int `json:"candidatesTokenCount,omitempty"`
|
||||||
CachedContentTokenCount int `json:"cachedContentTokenCount,omitempty"`
|
CachedContentTokenCount int `json:"cachedContentTokenCount,omitempty"`
|
||||||
TotalTokenCount int `json:"totalTokenCount,omitempty"`
|
TotalTokenCount int `json:"totalTokenCount,omitempty"`
|
||||||
|
ThoughtsTokenCount int `json:"thoughtsTokenCount,omitempty"` // thinking tokens(按输出价格计费)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeminiGroundingMetadata Gemini grounding 元数据(Web Search)
|
// GeminiGroundingMetadata Gemini grounding 元数据(Web Search)
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ const MaxTokensBudgetPadding = 1000
|
|||||||
// Gemini 2.5 Flash thinking budget 上限
|
// Gemini 2.5 Flash thinking budget 上限
|
||||||
const Gemini25FlashThinkingBudgetLimit = 24576
|
const Gemini25FlashThinkingBudgetLimit = 24576
|
||||||
|
|
||||||
|
// 对于 Antigravity 的 Claude(budget-only)模型,该语义最终等价为 thinkingBudget=24576。
|
||||||
|
// 这里复用相同数值以保持行为一致。
|
||||||
|
const ClaudeAdaptiveHighThinkingBudgetTokens = Gemini25FlashThinkingBudgetLimit
|
||||||
|
|
||||||
// ensureMaxTokensGreaterThanBudget 确保 max_tokens > budget_tokens
|
// ensureMaxTokensGreaterThanBudget 确保 max_tokens > budget_tokens
|
||||||
// Claude API 要求启用 thinking 时,max_tokens 必须大于 thinking.budget_tokens
|
// Claude API 要求启用 thinking 时,max_tokens 必须大于 thinking.budget_tokens
|
||||||
// 返回调整后的 maxTokens 和是否进行了调整
|
// 返回调整后的 maxTokens 和是否进行了调整
|
||||||
@@ -96,7 +100,7 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检测是否启用 thinking
|
// 检测是否启用 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
|
// 只有 Gemini 模型支持 dummy thought workaround
|
||||||
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
|
// Claude 模型通过 Vertex/Google API 需要有效的 thought signatures
|
||||||
@@ -198,8 +202,7 @@ type modelInfo struct {
|
|||||||
|
|
||||||
// modelInfoMap 模型前缀 → 模型信息映射
|
// modelInfoMap 模型前缀 → 模型信息映射
|
||||||
// 只有在此映射表中的模型才会注入身份提示词
|
// 只有在此映射表中的模型才会注入身份提示词
|
||||||
// 注意:当前 claude-opus-4-6 会被映射到 claude-opus-4-5-thinking,
|
// 注意:模型映射逻辑在网关层完成;这里仅用于按模型前缀判断是否注入身份提示词。
|
||||||
// 但保留此条目以便后续 Antigravity 上游支持 4.6 时快速切换
|
|
||||||
var modelInfoMap = map[string]modelInfo{
|
var modelInfoMap = map[string]modelInfo{
|
||||||
"claude-opus-4-5": {DisplayName: "Claude Opus 4.5", CanonicalID: "claude-opus-4-5-20250929"},
|
"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"},
|
"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
|
return maxOutputTokensUpperBound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAntigravityOpus46Model(model string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(model), "claude-opus-4-6")
|
||||||
|
}
|
||||||
|
|
||||||
func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
|
func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
|
||||||
maxLimit := maxOutputTokensLimit(req.Model)
|
maxLimit := maxOutputTokensLimit(req.Model)
|
||||||
config := &GeminiGenerationConfig{
|
config := &GeminiGenerationConfig{
|
||||||
@@ -606,25 +613,36 @@ func buildGenerationConfig(req *ClaudeRequest) *GeminiGenerationConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Thinking 配置
|
// Thinking 配置
|
||||||
if req.Thinking != nil && req.Thinking.Type == "enabled" {
|
if req.Thinking != nil && (req.Thinking.Type == "enabled" || req.Thinking.Type == "adaptive") {
|
||||||
config.ThinkingConfig = &GeminiThinkingConfig{
|
config.ThinkingConfig = &GeminiThinkingConfig{
|
||||||
IncludeThoughts: true,
|
IncludeThoughts: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// - thinking.type=enabled:budget_tokens>0 用显式预算
|
||||||
|
// - thinking.type=adaptive:仅在 Antigravity 的 Opus 4.6 上覆写为 (24576)
|
||||||
|
budget := -1
|
||||||
if req.Thinking.BudgetTokens > 0 {
|
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 上限
|
// gemini-2.5-flash 上限
|
||||||
if strings.Contains(req.Model, "gemini-2.5-flash") && budget > Gemini25FlashThinkingBudgetLimit {
|
if strings.Contains(req.Model, "gemini-2.5-flash") && budget > Gemini25FlashThinkingBudgetLimit {
|
||||||
budget = Gemini25FlashThinkingBudgetLimit
|
budget = Gemini25FlashThinkingBudgetLimit
|
||||||
}
|
}
|
||||||
config.ThinkingConfig.ThinkingBudget = budget
|
|
||||||
|
|
||||||
// 自动修正:max_tokens 必须大于 budget_tokens
|
// 自动修正:max_tokens 必须大于 budget_tokens(Claude 上游要求)
|
||||||
if adjusted, ok := ensureMaxTokensGreaterThanBudget(config.MaxOutputTokens, budget); ok {
|
if adjusted, ok := ensureMaxTokensGreaterThanBudget(config.MaxOutputTokens, budget); ok {
|
||||||
log.Printf("[Antigravity] Auto-adjusted max_tokens from %d to %d (must be > budget_tokens=%d)",
|
log.Printf("[Antigravity] Auto-adjusted max_tokens from %d to %d (must be > budget_tokens=%d)",
|
||||||
config.MaxOutputTokens, adjusted, budget)
|
config.MaxOutputTokens, adjusted, budget)
|
||||||
config.MaxOutputTokens = adjusted
|
config.MaxOutputTokens = adjusted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
config.ThinkingConfig.ThinkingBudget = budget
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.MaxOutputTokens > maxLimit {
|
if config.MaxOutputTokens > maxLimit {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
|
|||||||
if geminiResp.UsageMetadata != nil {
|
if geminiResp.UsageMetadata != nil {
|
||||||
cached := geminiResp.UsageMetadata.CachedContentTokenCount
|
cached := geminiResp.UsageMetadata.CachedContentTokenCount
|
||||||
usage.InputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
|
usage.InputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
|
||||||
usage.OutputTokens = geminiResp.UsageMetadata.CandidatesTokenCount
|
usage.OutputTokens = geminiResp.UsageMetadata.CandidatesTokenCount + geminiResp.UsageMetadata.ThoughtsTokenCount
|
||||||
usage.CacheReadInputTokens = cached
|
usage.CacheReadInputTokens = cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte {
|
|||||||
if geminiResp.UsageMetadata != nil {
|
if geminiResp.UsageMetadata != nil {
|
||||||
cached := geminiResp.UsageMetadata.CachedContentTokenCount
|
cached := geminiResp.UsageMetadata.CachedContentTokenCount
|
||||||
p.inputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
|
p.inputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
|
||||||
p.outputTokens = geminiResp.UsageMetadata.CandidatesTokenCount
|
p.outputTokens = geminiResp.UsageMetadata.CandidatesTokenCount + geminiResp.UsageMetadata.ThoughtsTokenCount
|
||||||
p.cacheReadTokens = cached
|
p.cacheReadTokens = cached
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
|
|||||||
if v1Resp.Response.UsageMetadata != nil {
|
if v1Resp.Response.UsageMetadata != nil {
|
||||||
cached := v1Resp.Response.UsageMetadata.CachedContentTokenCount
|
cached := v1Resp.Response.UsageMetadata.CachedContentTokenCount
|
||||||
usage.InputTokens = v1Resp.Response.UsageMetadata.PromptTokenCount - cached
|
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
|
usage.CacheReadInputTokens = cached
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -448,7 +448,12 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
|
|||||||
q = q.Where(dbaccount.TypeEQ(accountType))
|
q = q.Where(dbaccount.TypeEQ(accountType))
|
||||||
}
|
}
|
||||||
if status != "" {
|
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 != "" {
|
if search != "" {
|
||||||
q = q.Where(dbaccount.NameContainsFold(search))
|
q = q.Where(dbaccount.NameContainsFold(search))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
"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/pkg/pagination"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"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))
|
q = q.Where(redeemcode.StatusEQ(status))
|
||||||
}
|
}
|
||||||
if search != "" {
|
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)
|
total, err := q.Count(ctx)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
|
"github.com/Wei-Shaw/sub2api/ent/apikey"
|
||||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
|
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
"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.EmailContainsFold(filters.Search),
|
||||||
dbuser.UsernameContainsFold(filters.Search),
|
dbuser.UsernameContainsFold(filters.Search),
|
||||||
dbuser.NotesContainsFold(filters.Search),
|
dbuser.NotesContainsFold(filters.Search),
|
||||||
|
dbuser.HasAPIKeysWith(apikey.KeyContainsFold(filters.Search)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers)
|
|||||||
{
|
{
|
||||||
antigravity.POST("/oauth/auth-url", h.Admin.AntigravityOAuth.GenerateAuthURL)
|
antigravity.POST("/oauth/auth-url", h.Admin.AntigravityOAuth.GenerateAuthURL)
|
||||||
antigravity.POST("/oauth/exchange-code", h.Admin.AntigravityOAuth.ExchangeCode)
|
antigravity.POST("/oauth/exchange-code", h.Admin.AntigravityOAuth.ExchangeCode)
|
||||||
|
antigravity.POST("/oauth/refresh-token", h.Admin.AntigravityOAuth.RefreshToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
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 版本
|
// 应用 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)
|
mappedModel = applyThinkingModelSuffix(mappedModel, thinkingEnabled)
|
||||||
|
|
||||||
// 获取 access_token
|
// 获取 access_token
|
||||||
|
|||||||
@@ -591,6 +591,75 @@ func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) {
|
|||||||
require.NotContains(t, body, "event: error")
|
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
|
// TestStreamUpstreamResponse_ClientDisconnectDrainsUsage
|
||||||
|
|||||||
@@ -192,6 +192,43 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken
|
|||||||
return nil, fmt.Errorf("token 刷新失败 (重试后): %w", lastErr)
|
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 {
|
func isNonRetryableAntigravityOAuthError(err error) bool {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
nonRetryable := []string{
|
nonRetryable := []string{
|
||||||
|
|||||||
@@ -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 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
|
parsed.ThinkingEnabled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,9 +161,9 @@ func parseIntegralNumber(raw any) (int, bool) {
|
|||||||
// Returns filtered body or original body if filtering fails (fail-safe)
|
// Returns filtered body or original body if filtering fails (fail-safe)
|
||||||
// This prevents 400 errors from invalid thinking block signatures
|
// This prevents 400 errors from invalid thinking block signatures
|
||||||
//
|
//
|
||||||
// Strategy:
|
// 策略:
|
||||||
// - When thinking.type != "enabled": Remove all thinking blocks
|
// - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块
|
||||||
// - When thinking.type == "enabled": Only remove thinking blocks without valid signatures
|
// - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块(避免 400)
|
||||||
// (blocks with missing/empty/dummy signatures that would cause 400 errors)
|
// (blocks with missing/empty/dummy signatures that would cause 400 errors)
|
||||||
func FilterThinkingBlocks(body []byte) []byte {
|
func FilterThinkingBlocks(body []byte) []byte {
|
||||||
return filterThinkingBlocksInternal(body, false)
|
return filterThinkingBlocksInternal(body, false)
|
||||||
@@ -489,9 +489,9 @@ func FilterSignatureSensitiveBlocksForRetry(body []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// filterThinkingBlocksInternal removes invalid thinking blocks from request
|
// filterThinkingBlocksInternal removes invalid thinking blocks from request
|
||||||
// Strategy:
|
// 策略:
|
||||||
// - When thinking.type != "enabled": Remove all thinking blocks
|
// - 当 thinking.type 不是 "enabled"/"adaptive":移除所有 thinking 相关块
|
||||||
// - When thinking.type == "enabled": Only remove thinking blocks without valid signatures
|
// - 当 thinking.type 是 "enabled"/"adaptive":仅移除缺失/无效 signature 的 thinking 块
|
||||||
func filterThinkingBlocksInternal(body []byte, _ bool) []byte {
|
func filterThinkingBlocksInternal(body []byte, _ bool) []byte {
|
||||||
// Fast path: if body doesn't contain "thinking", skip parsing
|
// Fast path: if body doesn't contain "thinking", skip parsing
|
||||||
if !bytes.Contains(body, []byte(`"type":"thinking"`)) &&
|
if !bytes.Contains(body, []byte(`"type":"thinking"`)) &&
|
||||||
@@ -511,7 +511,7 @@ func filterThinkingBlocksInternal(body []byte, _ bool) []byte {
|
|||||||
// Check if thinking is enabled
|
// Check if thinking is enabled
|
||||||
thinkingEnabled := false
|
thinkingEnabled := false
|
||||||
if thinking, ok := req["thinking"].(map[string]any); ok {
|
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
|
thinkingEnabled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ func TestParseGatewayRequest_ThinkingEnabled(t *testing.T) {
|
|||||||
require.True(t, parsed.ThinkingEnabled)
|
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) {
|
func TestParseGatewayRequest_MaxTokens(t *testing.T) {
|
||||||
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`)
|
body := []byte(`{"model":"claude-haiku-4-5","max_tokens":1}`)
|
||||||
parsed, err := ParseGatewayRequest(body, "")
|
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"}]}]}`,
|
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,
|
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",
|
name: "handles no thinking blocks",
|
||||||
input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"}]}]}`,
|
input: `{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":[{"type":"text","text":"Hello"}]}]}`,
|
||||||
|
|||||||
@@ -3631,7 +3631,8 @@ func requestNeedsBetaFeatures(body []byte) bool {
|
|||||||
if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
|
if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
|
||||||
return true
|
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 true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -2663,11 +2663,12 @@ func extractGeminiUsage(geminiResp map[string]any) *ClaudeUsage {
|
|||||||
prompt, _ := asInt(usageMeta["promptTokenCount"])
|
prompt, _ := asInt(usageMeta["promptTokenCount"])
|
||||||
cand, _ := asInt(usageMeta["candidatesTokenCount"])
|
cand, _ := asInt(usageMeta["candidatesTokenCount"])
|
||||||
cached, _ := asInt(usageMeta["cachedContentTokenCount"])
|
cached, _ := asInt(usageMeta["cachedContentTokenCount"])
|
||||||
|
thoughts, _ := asInt(usageMeta["thoughtsTokenCount"])
|
||||||
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
|
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
|
||||||
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
|
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
|
||||||
return &ClaudeUsage{
|
return &ClaudeUsage{
|
||||||
InputTokens: prompt - cached,
|
InputTokens: prompt - cached,
|
||||||
OutputTokens: cand,
|
OutputTokens: cand + thoughts,
|
||||||
CacheReadInputTokens: cached,
|
CacheReadInputTokens: cached,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestConvertClaudeToolsToGeminiTools_CustomType 测试custom类型工具转换
|
// TestConvertClaudeToolsToGeminiTools_CustomType 测试custom类型工具转换
|
||||||
@@ -203,3 +205,70 @@ func TestEnsureGeminiFunctionCallThoughtSignatures_InsertsWhenMissing(t *testing
|
|||||||
t.Fatalf("expected injected thoughtSignature %q, got: %s", geminiDummyThoughtSignature, s)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,4 +53,18 @@ export async function exchangeCode(
|
|||||||
return data
|
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 }
|
||||||
|
|||||||
@@ -1647,12 +1647,12 @@
|
|||||||
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
|
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
|
||||||
:allow-multiple="form.platform === 'anthropic'"
|
:allow-multiple="form.platform === 'anthropic'"
|
||||||
:show-cookie-option="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"
|
:platform="form.platform"
|
||||||
:show-project-id="geminiOAuthType === 'code_assist'"
|
:show-project-id="geminiOAuthType === 'code_assist'"
|
||||||
@generate-url="handleGenerateUrl"
|
@generate-url="handleGenerateUrl"
|
||||||
@cookie-auth="handleCookieAuth"
|
@cookie-auth="handleCookieAuth"
|
||||||
@validate-refresh-token="handleOpenAIValidateRT"
|
@validate-refresh-token="handleValidateRefreshToken"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</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 formatDateTimeLocal = formatDateTimeLocalInput
|
||||||
const parseDateTimeLocal = parseDateTimeLocalInput
|
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 授权码兑换
|
// Gemini OAuth 授权码兑换
|
||||||
const handleGeminiExchange = async (authCode: string) => {
|
const handleGeminiExchange = async (authCode: string) => {
|
||||||
if (!authCode.trim() || !geminiOAuth.sessionId.value) return
|
if (!authCode.trim() || !geminiOAuth.sessionId.value) return
|
||||||
|
|||||||
@@ -45,19 +45,19 @@
|
|||||||
class="text-blue-600 focus:ring-blue-500"
|
class="text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||||
t('admin.accounts.oauth.openai.refreshTokenAuth')
|
t(getOAuthKey('refreshTokenAuth'))
|
||||||
}}</span>
|
}}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Refresh Token Input (OpenAI only) -->
|
<!-- Refresh Token Input (OpenAI / Antigravity) -->
|
||||||
<div v-if="inputMethod === 'refresh_token'" class="space-y-4">
|
<div v-if="inputMethod === 'refresh_token'" class="space-y-4">
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
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">
|
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||||
{{ t('admin.accounts.oauth.openai.refreshTokenDesc') }}
|
{{ t(getOAuthKey('refreshTokenDesc')) }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Refresh Token Input -->
|
<!-- Refresh Token Input -->
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
v-model="refreshTokenInput"
|
v-model="refreshTokenInput"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="input w-full resize-y font-mono text-sm"
|
class="input w-full resize-y font-mono text-sm"
|
||||||
:placeholder="t('admin.accounts.oauth.openai.refreshTokenPlaceholder')"
|
:placeholder="t(getOAuthKey('refreshTokenPlaceholder'))"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p
|
<p
|
||||||
v-if="parsedRefreshTokenCount > 1"
|
v-if="parsedRefreshTokenCount > 1"
|
||||||
@@ -128,8 +128,8 @@
|
|||||||
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
||||||
{{
|
{{
|
||||||
loading
|
loading
|
||||||
? t('admin.accounts.oauth.openai.validating')
|
? t(getOAuthKey('validating'))
|
||||||
: t('admin.accounts.oauth.openai.validateAndCreate')
|
: t(getOAuthKey('validateAndCreate'))
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 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 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 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>
|
</script>
|
||||||
|
|||||||
@@ -29,17 +29,19 @@
|
|||||||
<!-- Logo/Brand -->
|
<!-- Logo/Brand -->
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
<!-- Custom Logo or Default Logo -->
|
<!-- Custom Logo or Default Logo -->
|
||||||
<div
|
<template v-if="settingsLoaded">
|
||||||
class="mb-4 inline-flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl shadow-lg shadow-primary-500/30"
|
<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>
|
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||||
<h1 class="text-gradient mb-2 text-3xl font-bold">
|
</div>
|
||||||
{{ siteName }}
|
<h1 class="text-gradient mb-2 text-3xl font-bold">
|
||||||
</h1>
|
{{ siteName }}
|
||||||
<p class="text-sm text-gray-500 dark:text-dark-400">
|
</h1>
|
||||||
{{ siteSubtitle }}
|
<p class="text-sm text-gray-500 dark:text-dark-400">
|
||||||
</p>
|
{{ siteSubtitle }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Container -->
|
<!-- Card Container -->
|
||||||
@@ -61,25 +63,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { getPublicSettings } from '@/api/auth'
|
import { useAppStore } from '@/stores'
|
||||||
import { sanitizeUrl } from '@/utils/url'
|
import { sanitizeUrl } from '@/utils/url'
|
||||||
|
|
||||||
const siteName = ref('Sub2API')
|
const appStore = useAppStore()
|
||||||
const siteLogo = ref('')
|
|
||||||
const siteSubtitle = ref('Subscription to API Conversion Platform')
|
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())
|
const currentYear = computed(() => new Date().getFullYear())
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
try {
|
appStore.fetchPublicSettings()
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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> => {
|
const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record<string, unknown> => {
|
||||||
let expiresAt: string | undefined
|
let expiresAt: string | undefined
|
||||||
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
|
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
|
||||||
@@ -110,6 +139,7 @@ export function useAntigravityOAuth() {
|
|||||||
resetState,
|
resetState,
|
||||||
generateAuthUrl,
|
generateAuthUrl,
|
||||||
exchangeAuthCode,
|
exchangeAuthCode,
|
||||||
|
validateRefreshToken,
|
||||||
buildCredentials
|
buildCredentials
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,7 +841,7 @@ export default {
|
|||||||
createUser: 'Create User',
|
createUser: 'Create User',
|
||||||
editUser: 'Edit User',
|
editUser: 'Edit User',
|
||||||
deleteUser: 'Delete User',
|
deleteUser: 'Delete User',
|
||||||
searchUsers: 'Search users...',
|
searchUsers: 'Search by email, username, notes, or API key...',
|
||||||
allRoles: 'All Roles',
|
allRoles: 'All Roles',
|
||||||
allStatus: 'All Status',
|
allStatus: 'All Status',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
@@ -1774,13 +1774,20 @@ export default {
|
|||||||
authCode: 'Authorization URL or Code',
|
authCode: 'Authorization URL or Code',
|
||||||
authCodePlaceholder:
|
authCodePlaceholder:
|
||||||
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
|
'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',
|
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',
|
failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
|
||||||
missingExchangeParams: 'Missing code, session ID, or state',
|
missingExchangeParams: 'Missing code, session ID, or state',
|
||||||
failedToExchangeCode: 'Failed to exchange Antigravity auth code'
|
failedToExchangeCode: 'Failed to exchange Antigravity auth code',
|
||||||
}
|
// Refresh Token auth
|
||||||
},
|
refreshTokenAuth: 'Manual RT',
|
||||||
// Gemini specific (platform-wide)
|
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: {
|
gemini: {
|
||||||
helpButton: 'Help',
|
helpButton: 'Help',
|
||||||
helpDialog: {
|
helpDialog: {
|
||||||
@@ -2129,7 +2136,7 @@ export default {
|
|||||||
title: 'Redeem Code Management',
|
title: 'Redeem Code Management',
|
||||||
description: 'Generate and manage redeem codes',
|
description: 'Generate and manage redeem codes',
|
||||||
generateCodes: 'Generate Codes',
|
generateCodes: 'Generate Codes',
|
||||||
searchCodes: 'Search codes...',
|
searchCodes: 'Search codes or email...',
|
||||||
allTypes: 'All Types',
|
allTypes: 'All Types',
|
||||||
allStatus: 'All Status',
|
allStatus: 'All Status',
|
||||||
balance: 'Balance',
|
balance: 'Balance',
|
||||||
|
|||||||
@@ -865,8 +865,8 @@ export default {
|
|||||||
editUser: '编辑用户',
|
editUser: '编辑用户',
|
||||||
deleteUser: '删除用户',
|
deleteUser: '删除用户',
|
||||||
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
|
deleteConfirmMessage: "确定要删除用户 '{email}' 吗?此操作无法撤销。",
|
||||||
searchPlaceholder: '搜索用户邮箱或用户名、备注、支持模糊查询...',
|
searchPlaceholder: '邮箱/用户名/备注/API Key 模糊搜索...',
|
||||||
searchUsers: '搜索用户邮箱或用户名、备注、支持模糊查询',
|
searchUsers: '邮箱/用户名/备注/API Key 模糊搜索',
|
||||||
roleFilter: '角色筛选',
|
roleFilter: '角色筛选',
|
||||||
allRoles: '全部角色',
|
allRoles: '全部角色',
|
||||||
allStatus: '全部状态',
|
allStatus: '全部状态',
|
||||||
@@ -1913,7 +1913,15 @@ export default {
|
|||||||
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
|
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
|
||||||
failedToGenerateUrl: '生成 Antigravity 授权链接失败',
|
failedToGenerateUrl: '生成 Antigravity 授权链接失败',
|
||||||
missingExchangeParams: '缺少 code / session_id / state',
|
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)
|
// Gemini specific (platform-wide)
|
||||||
@@ -2292,7 +2300,7 @@ export default {
|
|||||||
allStatus: '全部状态',
|
allStatus: '全部状态',
|
||||||
unused: '未使用',
|
unused: '未使用',
|
||||||
used: '已使用',
|
used: '已使用',
|
||||||
searchCodes: '搜索兑换码...',
|
searchCodes: '搜索兑换码或邮箱...',
|
||||||
exportCsv: '导出 CSV',
|
exportCsv: '导出 CSV',
|
||||||
deleteAllUnused: '删除全部未使用',
|
deleteAllUnused: '删除全部未使用',
|
||||||
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
|
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
type SanitizeOptions = {
|
type SanitizeOptions = {
|
||||||
allowRelative?: boolean
|
allowRelative?: boolean
|
||||||
|
allowDataUrl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeUrl(value: string, options: SanitizeOptions = {}): string {
|
export function sanitizeUrl(value: string, options: SanitizeOptions = {}): string {
|
||||||
@@ -18,6 +19,11 @@ export function sanitizeUrl(value: string, options: SanitizeOptions = {}): strin
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 允许 data:image/ 开头的 data URL(仅限图片类型)
|
||||||
|
if (options.allowDataUrl && trimmed.startsWith('data:image/')) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
// 只接受绝对 URL,不使用 base URL 来避免相对路径被解析为当前域名
|
// 只接受绝对 URL,不使用 base URL 来避免相对路径被解析为当前域名
|
||||||
// 检查是否以 http:// 或 https:// 开头
|
// 检查是否以 http:// 或 https:// 开头
|
||||||
if (!trimmed.match(/^https?:\/\//i)) {
|
if (!trimmed.match(/^https?:\/\//i)) {
|
||||||
|
|||||||
@@ -117,9 +117,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #cell-used_by="{ value }">
|
<template #cell-used_by="{ value, row }">
|
||||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
<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>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user