Merge branch 'main' into test
冲突解决: - wire_gen.go: 合并 antigravityGatewayService 和 ProvideConcurrencyCache - user_repo_integration_test.go: 保留 NotFound 测试 - antigravity_gateway_service.go: 适配 httpUpstream.Do 新签名 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
25
AGENTS.md
25
AGENTS.md
@@ -1,25 +0,0 @@
|
|||||||
<!-- OPENSPEC:START -->
|
|
||||||
# OpenSpec Instructions
|
|
||||||
|
|
||||||
These instructions are for AI assistants working in this project.
|
|
||||||
|
|
||||||
Always open `@/openspec/AGENTS.md` when the request:
|
|
||||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
|
||||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
|
||||||
- Sounds ambiguous and you need the authoritative spec before coding
|
|
||||||
|
|
||||||
Use `@/openspec/AGENTS.md` to learn:
|
|
||||||
- How to create and apply change proposals
|
|
||||||
- Spec format and conventions
|
|
||||||
- Project structure and guidelines
|
|
||||||
|
|
||||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
|
||||||
|
|
||||||
<!-- OPENSPEC:END -->
|
|
||||||
|
|
||||||
## 强制语言规范
|
|
||||||
|
|
||||||
以下为强制规定:
|
|
||||||
- 与用户交流一律使用中文。
|
|
||||||
- 代码文档与代码注释一律使用中文。
|
|
||||||
- OpenSpec 提案与相关说明一律使用中文。
|
|
||||||
@@ -93,8 +93,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
|
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
|
||||||
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
|
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
|
||||||
geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService)
|
geminiTokenProvider := service.NewGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService)
|
||||||
|
gatewayCache := repository.NewGatewayCache(redisClient)
|
||||||
|
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
|
||||||
|
antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService)
|
||||||
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
httpUpstream := repository.NewHTTPUpstream(configConfig)
|
||||||
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, geminiTokenProvider, httpUpstream)
|
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream)
|
||||||
|
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, geminiTokenProvider, antigravityGatewayService, httpUpstream)
|
||||||
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
|
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
|
||||||
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
concurrencyService := service.NewConcurrencyService(concurrencyCache)
|
||||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService)
|
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService)
|
||||||
@@ -102,7 +106,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
|
||||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||||
antigravityOAuthService := service.NewAntigravityOAuthService(proxyRepository)
|
|
||||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||||
proxyHandler := admin.NewProxyHandler(adminService)
|
proxyHandler := admin.NewProxyHandler(adminService)
|
||||||
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
||||||
@@ -115,7 +118,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
|
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
|
||||||
adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService)
|
adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService)
|
||||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler)
|
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler)
|
||||||
gatewayCache := repository.NewGatewayCache(redisClient)
|
|
||||||
pricingRemoteClient := repository.NewPricingRemoteClient()
|
pricingRemoteClient := repository.NewPricingRemoteClient()
|
||||||
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
|
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -127,8 +129,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
timingWheelService := service.ProvideTimingWheelService()
|
timingWheelService := service.ProvideTimingWheelService()
|
||||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService)
|
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService)
|
||||||
antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService)
|
|
||||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream)
|
|
||||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService)
|
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService)
|
||||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService)
|
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService)
|
||||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService)
|
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService)
|
||||||
|
|||||||
@@ -918,6 +918,37 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Antigravity accounts: return Claude + Gemini models
|
||||||
|
if account.Platform == service.PlatformAntigravity {
|
||||||
|
// Antigravity 支持 Claude 和部分 Gemini 模型
|
||||||
|
type UnifiedModel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var models []UnifiedModel
|
||||||
|
|
||||||
|
// 添加 Claude 模型
|
||||||
|
for _, m := range claude.DefaultModels {
|
||||||
|
models = append(models, UnifiedModel{
|
||||||
|
ID: m.ID,
|
||||||
|
Type: m.Type,
|
||||||
|
DisplayName: m.DisplayName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 Gemini 3 系列模型用于测试
|
||||||
|
geminiTestModels := []UnifiedModel{
|
||||||
|
{ID: "gemini-3-flash", Type: "model", DisplayName: "Gemini 3 Flash"},
|
||||||
|
{ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview"},
|
||||||
|
}
|
||||||
|
models = append(models, geminiTestModels...)
|
||||||
|
|
||||||
|
response.Success(c, models)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Claude/Anthropic accounts
|
// Handle Claude/Anthropic accounts
|
||||||
// For OAuth and Setup-Token accounts: return default models
|
// For OAuth and Setup-Token accounts: return default models
|
||||||
if account.IsOAuth() {
|
if account.IsOAuth() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package antigravity
|
package antigravity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -11,6 +12,19 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewAPIRequest 创建 Antigravity API 请求(v1internal 端点)
|
||||||
|
func NewAPIRequest(ctx context.Context, action, accessToken string, body []byte) (*http.Request, error) {
|
||||||
|
apiURL := fmt.Sprintf("%s/v1internal:%s", BaseURL, action)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("User-Agent", UserAgent)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TokenResponse Google OAuth token 响应
|
// TokenResponse Google OAuth token 响应
|
||||||
type TokenResponse struct {
|
type TokenResponse struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
@@ -201,20 +215,20 @@ func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo
|
|||||||
return &userInfo, nil
|
return &userInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadCodeAssist 获取 project_id
|
// LoadCodeAssist 获取账户信息,返回解析后的结构体和原始 JSON
|
||||||
func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, error) {
|
func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, map[string]any, error) {
|
||||||
reqBody := LoadCodeAssistRequest{}
|
reqBody := LoadCodeAssistRequest{}
|
||||||
reqBody.Metadata.IDEType = "ANTIGRAVITY"
|
reqBody.Metadata.IDEType = "ANTIGRAVITY"
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(reqBody)
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
return nil, nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := BaseURL + "/v1internal:loadCodeAssist"
|
url := BaseURL + "/v1internal:loadCodeAssist"
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(bodyBytes)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
return nil, nil, fmt.Errorf("创建请求失败: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -222,25 +236,29 @@ func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadC
|
|||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("loadCodeAssist 请求失败: %w", err)
|
return nil, nil, fmt.Errorf("loadCodeAssist 请求失败: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
respBodyBytes, err := io.ReadAll(resp.Body)
|
respBodyBytes, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
return nil, nil, fmt.Errorf("读取响应失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("loadCodeAssist 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes))
|
return nil, nil, fmt.Errorf("loadCodeAssist 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
var loadResp LoadCodeAssistResponse
|
var loadResp LoadCodeAssistResponse
|
||||||
if err := json.Unmarshal(respBodyBytes, &loadResp); err != nil {
|
if err := json.Unmarshal(respBodyBytes, &loadResp); err != nil {
|
||||||
return nil, fmt.Errorf("响应解析失败: %w", err)
|
return nil, nil, fmt.Errorf("响应解析失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &loadResp, nil
|
// 解析原始 JSON 为 map
|
||||||
|
var rawResp map[string]any
|
||||||
|
_ = json.Unmarshal(respBodyBytes, &rawResp)
|
||||||
|
|
||||||
|
return &loadResp, rawResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelQuotaInfo 模型配额信息
|
// ModelQuotaInfo 模型配额信息
|
||||||
@@ -264,18 +282,18 @@ type FetchAvailableModelsResponse struct {
|
|||||||
Models map[string]ModelInfo `json:"models"`
|
Models map[string]ModelInfo `json:"models"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchAvailableModels 获取可用模型和配额信息
|
// FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON
|
||||||
func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string) (*FetchAvailableModelsResponse, error) {
|
func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string) (*FetchAvailableModelsResponse, map[string]any, error) {
|
||||||
reqBody := FetchAvailableModelsRequest{Project: projectID}
|
reqBody := FetchAvailableModelsRequest{Project: projectID}
|
||||||
bodyBytes, err := json.Marshal(reqBody)
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
return nil, nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
apiURL := BaseURL + "/v1internal:fetchAvailableModels"
|
apiURL := BaseURL + "/v1internal:fetchAvailableModels"
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(bodyBytes)))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(string(bodyBytes)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
return nil, nil, fmt.Errorf("创建请求失败: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -283,23 +301,27 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
|
|||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetchAvailableModels 请求失败: %w", err)
|
return nil, nil, fmt.Errorf("fetchAvailableModels 请求失败: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
respBodyBytes, err := io.ReadAll(resp.Body)
|
respBodyBytes, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
return nil, nil, fmt.Errorf("读取响应失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("fetchAvailableModels 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes))
|
return nil, nil, fmt.Errorf("fetchAvailableModels 失败 (HTTP %d): %s", resp.StatusCode, string(respBodyBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelsResp FetchAvailableModelsResponse
|
var modelsResp FetchAvailableModelsResponse
|
||||||
if err := json.Unmarshal(respBodyBytes, &modelsResp); err != nil {
|
if err := json.Unmarshal(respBodyBytes, &modelsResp); err != nil {
|
||||||
return nil, fmt.Errorf("响应解析失败: %w", err)
|
return nil, nil, fmt.Errorf("响应解析失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &modelsResp, nil
|
// 解析原始 JSON 为 map
|
||||||
|
var rawResp map[string]any
|
||||||
|
_ = json.Unmarshal(respBodyBytes, &rawResp)
|
||||||
|
|
||||||
|
return &modelsResp, rawResp, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,3 +177,24 @@ func BuildAuthorizationURL(state, codeChallenge string) string {
|
|||||||
|
|
||||||
return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode())
|
return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateMockProjectID 生成随机 project_id(当 API 不返回时使用)
|
||||||
|
// 格式:{形容词}-{名词}-{5位随机字符}
|
||||||
|
func GenerateMockProjectID() string {
|
||||||
|
adjectives := []string{"useful", "bright", "swift", "calm", "bold"}
|
||||||
|
nouns := []string{"fuze", "wave", "spark", "flow", "core"}
|
||||||
|
|
||||||
|
randBytes, _ := GenerateRandomBytes(7)
|
||||||
|
|
||||||
|
adj := adjectives[int(randBytes[0])%len(adjectives)]
|
||||||
|
noun := nouns[int(randBytes[1])%len(nouns)]
|
||||||
|
|
||||||
|
// 生成 5 位随机字符(a-z0-9)
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
suffix := make([]byte, 5)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
suffix[i] = charset[int(randBytes[i+2])%len(charset)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%s-%s", adj, noun, string(suffix))
|
||||||
|
}
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ func (r *accountRepository) BatchUpdateLastUsed(ctx context.Context, updates map
|
|||||||
|
|
||||||
idx := 1
|
idx := 1
|
||||||
for id, ts := range updates {
|
for id, ts := range updates {
|
||||||
caseSQL += " WHEN $" + itoa(idx) + " THEN $" + itoa(idx+1)
|
caseSQL += " WHEN $" + itoa(idx) + " THEN $" + itoa(idx+1) + "::timestamptz"
|
||||||
args = append(args, id, ts)
|
args = append(args, id, ts)
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
idx += 2
|
idx += 2
|
||||||
|
|||||||
@@ -390,4 +390,3 @@ func mustBindAccountToGroup(t *testing.T, client *dbent.Client, accountID, group
|
|||||||
Save(ctx)
|
Save(ctx)
|
||||||
require.NoError(t, err, "create account_group")
|
require.NoError(t, err, "create account_group")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ func (s *UserRepoSuite) mustCreateSubscription(userID, groupID int64, mutate fun
|
|||||||
create := s.client.UserSubscription.Create().
|
create := s.client.UserSubscription.Create().
|
||||||
SetUserID(userID).
|
SetUserID(userID).
|
||||||
SetGroupID(groupID).
|
SetGroupID(groupID).
|
||||||
SetStartsAt(now.Add(-1*time.Hour)).
|
SetStartsAt(now.Add(-1 * time.Hour)).
|
||||||
SetExpiresAt(now.Add(24*time.Hour)).
|
SetExpiresAt(now.Add(24 * time.Hour)).
|
||||||
SetStatus(service.SubscriptionStatusActive).
|
SetStatus(service.SubscriptionStatusActive).
|
||||||
SetAssignedAt(now).
|
SetAssignedAt(now).
|
||||||
SetNotes("")
|
SetNotes("")
|
||||||
@@ -528,4 +528,3 @@ func (s *UserRepoSuite) TestDeductBalance_NotFound() {
|
|||||||
// DeductBalance 在用户不存在时返回 ErrInsufficientBalance 因为 WHERE 条件不匹配
|
// DeductBalance 在用户不存在时返回 ErrInsufficientBalance 因为 WHERE 条件不匹配
|
||||||
s.Require().ErrorIs(err, service.ErrInsufficientBalance)
|
s.Require().ErrorIs(err, service.ErrInsufficientBalance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ type TestEvent struct {
|
|||||||
|
|
||||||
// AccountTestService handles account testing operations
|
// AccountTestService handles account testing operations
|
||||||
type AccountTestService struct {
|
type AccountTestService struct {
|
||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
oauthService *OAuthService
|
oauthService *OAuthService
|
||||||
openaiOAuthService *OpenAIOAuthService
|
openaiOAuthService *OpenAIOAuthService
|
||||||
geminiTokenProvider *GeminiTokenProvider
|
geminiTokenProvider *GeminiTokenProvider
|
||||||
httpUpstream HTTPUpstream
|
antigravityGatewayService *AntigravityGatewayService
|
||||||
|
httpUpstream HTTPUpstream
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAccountTestService creates a new AccountTestService
|
// NewAccountTestService creates a new AccountTestService
|
||||||
@@ -57,14 +58,16 @@ func NewAccountTestService(
|
|||||||
oauthService *OAuthService,
|
oauthService *OAuthService,
|
||||||
openaiOAuthService *OpenAIOAuthService,
|
openaiOAuthService *OpenAIOAuthService,
|
||||||
geminiTokenProvider *GeminiTokenProvider,
|
geminiTokenProvider *GeminiTokenProvider,
|
||||||
|
antigravityGatewayService *AntigravityGatewayService,
|
||||||
httpUpstream HTTPUpstream,
|
httpUpstream HTTPUpstream,
|
||||||
) *AccountTestService {
|
) *AccountTestService {
|
||||||
return &AccountTestService{
|
return &AccountTestService{
|
||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
oauthService: oauthService,
|
oauthService: oauthService,
|
||||||
openaiOAuthService: openaiOAuthService,
|
openaiOAuthService: openaiOAuthService,
|
||||||
geminiTokenProvider: geminiTokenProvider,
|
geminiTokenProvider: geminiTokenProvider,
|
||||||
httpUpstream: httpUpstream,
|
antigravityGatewayService: antigravityGatewayService,
|
||||||
|
httpUpstream: httpUpstream,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +144,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
|||||||
return s.testGeminiAccountConnection(c, account, modelID)
|
return s.testGeminiAccountConnection(c, account, modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if account.Platform == PlatformAntigravity {
|
||||||
|
return s.testAntigravityAccountConnection(c, account, modelID)
|
||||||
|
}
|
||||||
|
|
||||||
return s.testClaudeAccountConnection(c, account, modelID)
|
return s.testClaudeAccountConnection(c, account, modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +335,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
|||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "https://api.openai.com"
|
baseURL = "https://api.openai.com"
|
||||||
}
|
}
|
||||||
apiURL = strings.TrimSuffix(baseURL, "/") + "/v1/responses"
|
apiURL = strings.TrimSuffix(baseURL, "/") + "/responses"
|
||||||
} else {
|
} else {
|
||||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
|
||||||
}
|
}
|
||||||
@@ -457,6 +464,46 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
|
|||||||
return s.processGeminiStream(c, resp.Body)
|
return s.processGeminiStream(c, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testAntigravityAccountConnection tests an Antigravity account's connection
|
||||||
|
// 支持 Claude 和 Gemini 两种协议,使用非流式请求
|
||||||
|
func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// 默认模型:Claude 使用 claude-sonnet-4-5,Gemini 使用 gemini-3-pro-preview
|
||||||
|
testModelID := modelID
|
||||||
|
if testModelID == "" {
|
||||||
|
testModelID = "claude-sonnet-4-5"
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.antigravityGatewayService == nil {
|
||||||
|
return s.sendErrorAndEnd(c, "Antigravity gateway service not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
|
c.Writer.Header().Set("Connection", "keep-alive")
|
||||||
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
c.Writer.Flush()
|
||||||
|
|
||||||
|
// Send test_start event
|
||||||
|
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
||||||
|
|
||||||
|
// 调用 AntigravityGatewayService.TestConnection(复用协议转换逻辑)
|
||||||
|
result, err := s.antigravityGatewayService.TestConnection(ctx, account, testModelID)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送响应内容
|
||||||
|
if result.Text != "" {
|
||||||
|
s.sendEvent(c, TestEvent{Type: "content", Text: result.Text})
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildGeminiAPIKeyRequest builds request for Gemini API Key accounts
|
// buildGeminiAPIKeyRequest builds request for Gemini API Key accounts
|
||||||
func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
|
func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
|
||||||
apiKey := account.GetCredential("api_key")
|
apiKey := account.GetCredential("api_key")
|
||||||
@@ -514,7 +561,12 @@ func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, accoun
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap payload in Code Assist format
|
// Code Assist mode (with project_id)
|
||||||
|
return s.buildCodeAssistRequest(ctx, accessToken, projectID, modelID, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCodeAssistRequest builds request for Google Code Assist API (used by Gemini CLI and Antigravity)
|
||||||
|
func (s *AccountTestService) buildCodeAssistRequest(ctx context.Context, accessToken, projectID, modelID string, payload []byte) (*http.Request, error) {
|
||||||
var inner map[string]any
|
var inner map[string]any
|
||||||
if err := json.Unmarshal(payload, &inner); err != nil {
|
if err := json.Unmarshal(payload, &inner); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -130,6 +130,158 @@ func (s *AntigravityGatewayService) IsModelSupported(requestedModel string) bool
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConnectionResult 测试连接结果
|
||||||
|
type TestConnectionResult struct {
|
||||||
|
Text string // 响应文本
|
||||||
|
MappedModel string // 实际使用的模型
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection 测试 Antigravity 账号连接(非流式,无重试、无计费)
|
||||||
|
// 支持 Claude 和 Gemini 两种协议,根据 modelID 前缀自动选择
|
||||||
|
func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account *Account, modelID string) (*TestConnectionResult, error) {
|
||||||
|
// 获取 token
|
||||||
|
if s.tokenProvider == nil {
|
||||||
|
return nil, errors.New("antigravity token provider not configured")
|
||||||
|
}
|
||||||
|
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取 access_token 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 project_id(部分账户类型可能没有)
|
||||||
|
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||||
|
|
||||||
|
// 模型映射
|
||||||
|
mappedModel := s.getMappedModel(account, modelID)
|
||||||
|
|
||||||
|
// 构建请求体
|
||||||
|
var requestBody []byte
|
||||||
|
if strings.HasPrefix(modelID, "gemini-") {
|
||||||
|
// Gemini 模型:直接使用 Gemini 格式
|
||||||
|
requestBody, err = s.buildGeminiTestRequest(projectID, mappedModel)
|
||||||
|
} else {
|
||||||
|
// Claude 模型:使用协议转换
|
||||||
|
requestBody, err = s.buildClaudeTestRequest(projectID, mappedModel)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("构建请求失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 HTTP 请求(非流式)
|
||||||
|
req, err := antigravity.NewAPIRequest(ctx, "generateContent", accessToken, requestBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理 URL
|
||||||
|
proxyURL := ""
|
||||||
|
if account.ProxyID != nil && account.Proxy != nil {
|
||||||
|
proxyURL = account.Proxy.URL()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求失败: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
// 读取响应
|
||||||
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("API 返回 %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解包 v1internal 响应
|
||||||
|
unwrapped, err := s.unwrapV1InternalResponse(respBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解包响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取响应文本
|
||||||
|
text := extractGeminiResponseText(unwrapped)
|
||||||
|
|
||||||
|
return &TestConnectionResult{
|
||||||
|
Text: text,
|
||||||
|
MappedModel: mappedModel,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildGeminiTestRequest 构建 Gemini 格式测试请求
|
||||||
|
func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model string) ([]byte, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"contents": []map[string]any{
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"parts": []map[string]any{
|
||||||
|
{"text": "hi"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
|
return s.wrapV1InternalRequest(projectID, model, payloadBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildClaudeTestRequest 构建 Claude 格式测试请求并转换为 Gemini 格式
|
||||||
|
func (s *AntigravityGatewayService) buildClaudeTestRequest(projectID, mappedModel string) ([]byte, error) {
|
||||||
|
claudeReq := &antigravity.ClaudeRequest{
|
||||||
|
Model: mappedModel,
|
||||||
|
Messages: []antigravity.ClaudeMessage{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: json.RawMessage(`"hi"`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MaxTokens: 1024,
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
return antigravity.TransformClaudeToGemini(claudeReq, projectID, mappedModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractGeminiResponseText 从 Gemini 响应中提取文本
|
||||||
|
func extractGeminiResponseText(respBody []byte) string {
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, ok := resp["candidates"].([]any)
|
||||||
|
if !ok || len(candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate, ok := candidates[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
content, ok := candidate["content"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parts, ok := content["parts"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var texts []string
|
||||||
|
for _, part := range parts {
|
||||||
|
if partMap, ok := part.(map[string]any); ok {
|
||||||
|
if text, ok := partMap["text"].(string); ok && text != "" {
|
||||||
|
texts = append(texts, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(texts, "")
|
||||||
|
}
|
||||||
|
|
||||||
// wrapV1InternalRequest 包装请求为 v1internal 格式
|
// wrapV1InternalRequest 包装请求为 v1internal 格式
|
||||||
func (s *AntigravityGatewayService) wrapV1InternalRequest(projectID, model string, originalBody []byte) ([]byte, error) {
|
func (s *AntigravityGatewayService) wrapV1InternalRequest(projectID, model string, originalBody []byte) ([]byte, error) {
|
||||||
var request any
|
var request any
|
||||||
@@ -191,11 +343,8 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
return nil, fmt.Errorf("获取 access_token 失败: %w", err)
|
return nil, fmt.Errorf("获取 access_token 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 project_id
|
// 获取 project_id(部分账户类型可能没有)
|
||||||
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||||
if projectID == "" {
|
|
||||||
return nil, errors.New("project_id not found in credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 代理 URL
|
// 代理 URL
|
||||||
proxyURL := ""
|
proxyURL := ""
|
||||||
@@ -209,26 +358,19 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
return nil, fmt.Errorf("transform request: %w", err)
|
return nil, fmt.Errorf("transform request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建上游 URL
|
// 构建上游 action
|
||||||
action := "generateContent"
|
action := "generateContent"
|
||||||
if claudeReq.Stream {
|
if claudeReq.Stream {
|
||||||
action = "streamGenerateContent"
|
action = "streamGenerateContent?alt=sse"
|
||||||
}
|
|
||||||
fullURL := fmt.Sprintf("%s/v1internal:%s", antigravity.BaseURL, action)
|
|
||||||
if claudeReq.Stream {
|
|
||||||
fullURL += "?alt=sse"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重试循环
|
// 重试循环
|
||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
for attempt := 1; attempt <= antigravityMaxRetries; attempt++ {
|
for attempt := 1; attempt <= antigravityMaxRetries; attempt++ {
|
||||||
upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(geminiBody))
|
upstreamReq, err := antigravity.NewAPIRequest(ctx, action, accessToken, geminiBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
|
||||||
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
|
||||||
upstreamReq.Header.Set("User-Agent", antigravity.UserAgent)
|
|
||||||
|
|
||||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -341,11 +483,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
|||||||
return nil, fmt.Errorf("获取 access_token 失败: %w", err)
|
return nil, fmt.Errorf("获取 access_token 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 project_id
|
// 获取 project_id(部分账户类型可能没有)
|
||||||
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
projectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||||
if projectID == "" {
|
|
||||||
return nil, errors.New("project_id not found in credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 代理 URL
|
// 代理 URL
|
||||||
proxyURL := ""
|
proxyURL := ""
|
||||||
@@ -359,26 +498,22 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建上游 URL
|
// 构建上游 action
|
||||||
upstreamAction := action
|
upstreamAction := action
|
||||||
if action == "generateContent" && stream {
|
if action == "generateContent" && stream {
|
||||||
upstreamAction = "streamGenerateContent"
|
upstreamAction = "streamGenerateContent"
|
||||||
}
|
}
|
||||||
fullURL := fmt.Sprintf("%s/v1internal:%s", antigravity.BaseURL, upstreamAction)
|
|
||||||
if stream || upstreamAction == "streamGenerateContent" {
|
if stream || upstreamAction == "streamGenerateContent" {
|
||||||
fullURL += "?alt=sse"
|
upstreamAction += "?alt=sse"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重试循环
|
// 重试循环
|
||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
for attempt := 1; attempt <= antigravityMaxRetries; attempt++ {
|
for attempt := 1; attempt <= antigravityMaxRetries; attempt++ {
|
||||||
upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(wrappedBody))
|
upstreamReq, err := antigravity.NewAPIRequest(ctx, upstreamAction, accessToken, wrappedBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
|
||||||
upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
|
|
||||||
upstreamReq.Header.Set("User-Agent", antigravity.UserAgent)
|
|
||||||
|
|
||||||
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -141,14 +141,20 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
|
|||||||
result.Email = userInfo.Email
|
result.Email = userInfo.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 project_id
|
// 获取 project_id(部分账户类型可能没有)
|
||||||
loadResp, err := client.LoadCodeAssist(ctx, tokenResp.AccessToken)
|
loadResp, _, err := client.LoadCodeAssist(ctx, tokenResp.AccessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败: %v\n", err)
|
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败: %v\n", err)
|
||||||
} else if loadResp != nil && loadResp.CloudAICompanionProject != "" {
|
} else if loadResp != nil && loadResp.CloudAICompanionProject != "" {
|
||||||
result.ProjectID = loadResp.CloudAICompanionProject
|
result.ProjectID = loadResp.CloudAICompanionProject
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兜底:随机生成 project_id
|
||||||
|
if result.ProjectID == "" {
|
||||||
|
result.ProjectID = antigravity.GenerateMockProjectID()
|
||||||
|
fmt.Printf("[AntigravityOAuth] 使用随机生成的 project_id: %s\n", result.ProjectID)
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +174,10 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken
|
|||||||
client := antigravity.NewClient(proxyURL)
|
client := antigravity.NewClient(proxyURL)
|
||||||
tokenResp, err := client.RefreshToken(ctx, refreshToken)
|
tokenResp, err := client.RefreshToken(ctx, refreshToken)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - 300
|
now := time.Now()
|
||||||
|
expiresAt := now.Unix() + tokenResp.ExpiresIn - 300
|
||||||
|
fmt.Printf("[AntigravityOAuth] Token refreshed: expires_in=%d, expires_at=%d (%s)\n",
|
||||||
|
tokenResp.ExpiresIn, expiresAt, time.Unix(expiresAt, 0).Format("2006-01-02 15:04:05"))
|
||||||
return &AntigravityTokenInfo{
|
return &AntigravityTokenInfo{
|
||||||
AccessToken: tokenResp.AccessToken,
|
AccessToken: tokenResp.AccessToken,
|
||||||
RefreshToken: tokenResp.RefreshToken,
|
RefreshToken: tokenResp.RefreshToken,
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ func (r *AntigravityQuotaRefresher) refreshAccountQuota(ctx context.Context, acc
|
|||||||
accessToken := account.GetCredential("access_token")
|
accessToken := account.GetCredential("access_token")
|
||||||
projectID := account.GetCredential("project_id")
|
projectID := account.GetCredential("project_id")
|
||||||
|
|
||||||
if accessToken == "" || projectID == "" {
|
if accessToken == "" {
|
||||||
return nil // 没有有效凭证,跳过
|
return nil // 没有 access_token,跳过
|
||||||
}
|
}
|
||||||
|
|
||||||
// token 过期则跳过,由 TokenRefreshService 负责刷新
|
// token 过期则跳过,由 TokenRefreshService 负责刷新
|
||||||
@@ -145,21 +145,46 @@ func (r *AntigravityQuotaRefresher) refreshAccountQuota(ctx context.Context, acc
|
|||||||
|
|
||||||
client := antigravity.NewClient(proxyURL)
|
client := antigravity.NewClient(proxyURL)
|
||||||
|
|
||||||
// 获取账户类型(tier)
|
if account.Extra == nil {
|
||||||
loadResp, _ := client.LoadCodeAssist(ctx, accessToken)
|
account.Extra = make(map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取账户信息(tier、project_id 等)
|
||||||
|
loadResp, loadRaw, _ := client.LoadCodeAssist(ctx, accessToken)
|
||||||
|
if loadRaw != nil {
|
||||||
|
account.Extra["load_code_assist"] = loadRaw
|
||||||
|
}
|
||||||
if loadResp != nil {
|
if loadResp != nil {
|
||||||
r.updateAccountTier(account, loadResp)
|
// 尝试从 API 获取 project_id
|
||||||
|
if projectID == "" && loadResp.CloudAICompanionProject != "" {
|
||||||
|
projectID = loadResp.CloudAICompanionProject
|
||||||
|
account.Credentials["project_id"] = projectID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍然没有 project_id,随机生成一个并保存
|
||||||
|
if projectID == "" {
|
||||||
|
projectID = antigravity.GenerateMockProjectID()
|
||||||
|
account.Credentials["project_id"] = projectID
|
||||||
|
log.Printf("[AntigravityQuotaRefresher] 为账户 %d 生成随机 project_id: %s", account.ID, projectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用 API 获取配额
|
// 调用 API 获取配额
|
||||||
modelsResp, err := client.FetchAvailableModels(ctx, accessToken, projectID)
|
modelsResp, modelsRaw, err := client.FetchAvailableModels(ctx, accessToken, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return r.accountRepo.Update(ctx, account) // 保存已有的 load_code_assist 信息
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析配额数据并更新 extra 字段
|
// 保存完整的配额响应
|
||||||
|
if modelsRaw != nil {
|
||||||
|
account.Extra["available_models"] = modelsRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析配额数据为前端使用的格式
|
||||||
r.updateAccountQuota(account, modelsResp)
|
r.updateAccountQuota(account, modelsResp)
|
||||||
|
|
||||||
|
account.Extra["last_refresh"] = time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
// 保存到数据库
|
// 保存到数据库
|
||||||
return r.accountRepo.Update(ctx, account)
|
return r.accountRepo.Update(ctx, account)
|
||||||
}
|
}
|
||||||
@@ -175,35 +200,8 @@ func (r *AntigravityQuotaRefresher) isTokenExpired(account *Account) bool {
|
|||||||
return time.Now().Add(5 * time.Minute).After(*expiresAt)
|
return time.Now().Add(5 * time.Minute).After(*expiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateAccountTier 更新账户类型信息
|
// updateAccountQuota 更新账户的配额信息(前端使用的格式)
|
||||||
func (r *AntigravityQuotaRefresher) updateAccountTier(account *Account, loadResp *antigravity.LoadCodeAssistResponse) {
|
|
||||||
if account.Extra == nil {
|
|
||||||
account.Extra = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
tier := loadResp.GetTier()
|
|
||||||
if tier != "" {
|
|
||||||
account.Extra["tier"] = tier
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存不符合条件的原因(如 INELIGIBLE_ACCOUNT)
|
|
||||||
if len(loadResp.IneligibleTiers) > 0 && loadResp.IneligibleTiers[0] != nil {
|
|
||||||
ineligible := loadResp.IneligibleTiers[0]
|
|
||||||
if ineligible.ReasonCode != "" {
|
|
||||||
account.Extra["ineligible_reason_code"] = ineligible.ReasonCode
|
|
||||||
}
|
|
||||||
if ineligible.ReasonMessage != "" {
|
|
||||||
account.Extra["ineligible_reason_message"] = ineligible.ReasonMessage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateAccountQuota 更新账户的配额信息
|
|
||||||
func (r *AntigravityQuotaRefresher) updateAccountQuota(account *Account, modelsResp *antigravity.FetchAvailableModelsResponse) {
|
func (r *AntigravityQuotaRefresher) updateAccountQuota(account *Account, modelsResp *antigravity.FetchAvailableModelsResponse) {
|
||||||
if account.Extra == nil {
|
|
||||||
account.Extra = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
quota := make(map[string]any)
|
quota := make(map[string]any)
|
||||||
|
|
||||||
for modelName, modelInfo := range modelsResp.Models {
|
for modelName, modelInfo := range modelsResp.Models {
|
||||||
@@ -221,5 +219,4 @@ func (r *AntigravityQuotaRefresher) updateAccountQuota(account *Account, modelsR
|
|||||||
}
|
}
|
||||||
|
|
||||||
account.Extra["quota"] = quota
|
account.Extra["quota"] = quota
|
||||||
account.Extra["last_quota_check"] = time.Now().Format(time.RFC3339)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// antigravityRefreshWindow Antigravity token 提前刷新窗口:15分钟
|
||||||
|
// Google OAuth token 有效期55分钟,提前15分钟刷新
|
||||||
|
antigravityRefreshWindow = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
// AntigravityTokenRefresher 实现 TokenRefresher 接口
|
// AntigravityTokenRefresher 实现 TokenRefresher 接口
|
||||||
type AntigravityTokenRefresher struct {
|
type AntigravityTokenRefresher struct {
|
||||||
antigravityOAuthService *AntigravityOAuthService
|
antigravityOAuthService *AntigravityOAuthService
|
||||||
@@ -23,7 +29,8 @@ func (r *AntigravityTokenRefresher) CanRefresh(account *Account) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NeedsRefresh 检查账户是否需要刷新
|
// NeedsRefresh 检查账户是否需要刷新
|
||||||
func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
|
// Antigravity 使用固定的10分钟刷新窗口,忽略全局配置
|
||||||
|
func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, _ time.Duration) bool {
|
||||||
if !r.CanRefresh(account) {
|
if !r.CanRefresh(account) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -36,7 +43,7 @@ func (r *AntigravityTokenRefresher) NeedsRefresh(account *Account, refreshWindow
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
expiryTime := time.Unix(expiresAt, 0)
|
expiryTime := time.Unix(expiresAt, 0)
|
||||||
return time.Until(expiryTime) < refreshWindow
|
return time.Until(expiryTime) < antigravityRefreshWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh 执行 token 刷新
|
// Refresh 执行 token 刷新
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ func TestApiKeyService_Delete_OwnerMismatch(t *testing.T) {
|
|||||||
|
|
||||||
err := svc.Delete(context.Background(), 10, 2) // API Key ID=10, 调用者 userID=2
|
err := svc.Delete(context.Background(), 10, 2) // API Key ID=10, 调用者 userID=2
|
||||||
require.ErrorIs(t, err, ErrInsufficientPerms)
|
require.ErrorIs(t, err, ErrInsufficientPerms)
|
||||||
require.Empty(t, repo.deletedIDs) // 验证删除操作未被调用
|
require.Empty(t, repo.deletedIDs) // 验证删除操作未被调用
|
||||||
require.Empty(t, cache.invalidated) // 验证缓存未被清除
|
require.Empty(t, cache.invalidated) // 验证缓存未被清除
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestApiKeyService_Delete_Success 测试所有者成功删除 API Key 的场景。
|
// TestApiKeyService_Delete_Success 测试所有者成功删除 API Key 的场景。
|
||||||
@@ -164,7 +164,7 @@ func TestApiKeyService_Delete_Success(t *testing.T) {
|
|||||||
|
|
||||||
err := svc.Delete(context.Background(), 42, 7) // API Key ID=42, 调用者 userID=7
|
err := svc.Delete(context.Background(), 42, 7) // API Key ID=42, 调用者 userID=7
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []int64{42}, repo.deletedIDs) // 验证正确的 API Key 被删除
|
require.Equal(t, []int64{42}, repo.deletedIDs) // 验证正确的 API Key 被删除
|
||||||
require.Equal(t, []int64{7}, cache.invalidated) // 验证所有者的缓存被清除
|
require.Equal(t, []int64{7}, cache.invalidated) // 验证所有者的缓存被清除
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
|||||||
// API Key accounts use Platform API or custom base URL
|
// API Key accounts use Platform API or custom base URL
|
||||||
baseURL := account.GetOpenAIBaseURL()
|
baseURL := account.GetOpenAIBaseURL()
|
||||||
if baseURL != "" {
|
if baseURL != "" {
|
||||||
targetURL = baseURL + "/v1/responses"
|
targetURL = baseURL + "/responses"
|
||||||
} else {
|
} else {
|
||||||
targetURL = openaiPlatformAPIURL
|
targetURL = openaiPlatformAPIURL
|
||||||
}
|
}
|
||||||
|
|||||||
0
build_image.sh
Executable file → Normal file
0
build_image.sh
Executable file → Normal file
@@ -96,7 +96,7 @@
|
|||||||
<!-- Antigravity OAuth accounts: show quota from extra field -->
|
<!-- Antigravity OAuth accounts: show quota from extra field -->
|
||||||
<template v-else-if="account.platform === 'antigravity' && account.type === 'oauth'">
|
<template v-else-if="account.platform === 'antigravity' && account.type === 'oauth'">
|
||||||
<!-- 账户类型徽章 -->
|
<!-- 账户类型徽章 -->
|
||||||
<div v-if="antigravityTierLabel" class="mb-1">
|
<div v-if="antigravityTierLabel" class="mb-1 flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
@@ -105,6 +105,28 @@
|
|||||||
>
|
>
|
||||||
{{ antigravityTierLabel }}
|
{{ antigravityTierLabel }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- 不合格账户警告图标 -->
|
||||||
|
<span
|
||||||
|
v-if="hasIneligibleTiers"
|
||||||
|
class="group relative cursor-help"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 text-red-500"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute left-0 top-full z-50 mt-1 w-80 whitespace-normal break-words rounded bg-gray-900 px-3 py-2 text-xs leading-relaxed text-white opacity-0 shadow-lg transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.ineligibleWarning') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="hasAntigravityQuota" class="space-y-1">
|
<div v-if="hasAntigravityQuota" class="space-y-1">
|
||||||
@@ -403,11 +425,26 @@ const antigravityClaude45Usage = computed(() =>
|
|||||||
getAntigravityUsage(['claude-sonnet-4-5', 'claude-opus-4-5-thinking'])
|
getAntigravityUsage(['claude-sonnet-4-5', 'claude-opus-4-5-thinking'])
|
||||||
)
|
)
|
||||||
|
|
||||||
// Antigravity 账户类型
|
// Antigravity 账户类型(从 load_code_assist 响应中提取)
|
||||||
const antigravityTier = computed(() => {
|
const antigravityTier = computed(() => {
|
||||||
const extra = props.account.extra as Record<string, unknown> | undefined
|
const extra = props.account.extra as Record<string, unknown> | undefined
|
||||||
if (!extra || typeof extra.tier !== 'string') return null
|
if (!extra) return null
|
||||||
return extra.tier as string
|
|
||||||
|
const loadCodeAssist = extra.load_code_assist as Record<string, unknown> | undefined
|
||||||
|
if (!loadCodeAssist) return null
|
||||||
|
|
||||||
|
// 优先取 paidTier,否则取 currentTier
|
||||||
|
const paidTier = loadCodeAssist.paidTier as Record<string, unknown> | undefined
|
||||||
|
if (paidTier && typeof paidTier.id === 'string') {
|
||||||
|
return paidTier.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTier = loadCodeAssist.currentTier as Record<string, unknown> | undefined
|
||||||
|
if (currentTier && typeof currentTier.id === 'string') {
|
||||||
|
return currentTier.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
// 账户类型显示标签
|
// 账户类型显示标签
|
||||||
@@ -438,6 +475,18 @@ const antigravityTierClass = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 检测账户是否有不合格状态(ineligibleTiers)
|
||||||
|
const hasIneligibleTiers = computed(() => {
|
||||||
|
const extra = props.account.extra as Record<string, unknown> | undefined
|
||||||
|
if (!extra) return false
|
||||||
|
|
||||||
|
const loadCodeAssist = extra.load_code_assist as Record<string, unknown> | undefined
|
||||||
|
if (!loadCodeAssist) return false
|
||||||
|
|
||||||
|
const ineligibleTiers = loadCodeAssist.ineligibleTiers as unknown[] | undefined
|
||||||
|
return Array.isArray(ineligibleTiers) && ineligibleTiers.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
const loadUsage = async () => {
|
const loadUsage = async () => {
|
||||||
// Fetch usage for Anthropic OAuth and Setup Token accounts
|
// Fetch usage for Anthropic OAuth and Setup Token accounts
|
||||||
// OpenAI usage comes from account.extra field (updated during forwarding)
|
// OpenAI usage comes from account.extra field (updated during forwarding)
|
||||||
|
|||||||
@@ -1194,7 +1194,9 @@ export default {
|
|||||||
free: 'Free',
|
free: 'Free',
|
||||||
pro: 'Pro',
|
pro: 'Pro',
|
||||||
ultra: 'Ultra'
|
ultra: 'Ultra'
|
||||||
}
|
},
|
||||||
|
ineligibleWarning:
|
||||||
|
'This account is not eligible for Antigravity, but API forwarding still works. Use at your own risk.'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Proxies
|
// Proxies
|
||||||
|
|||||||
@@ -994,6 +994,8 @@ export default {
|
|||||||
pro: 'Pro',
|
pro: 'Pro',
|
||||||
ultra: 'Ultra'
|
ultra: 'Ultra'
|
||||||
},
|
},
|
||||||
|
ineligibleWarning:
|
||||||
|
'该账号无 Antigravity 使用权限,但仍能进行 API 转发。继续使用请自行承担风险。',
|
||||||
form: {
|
form: {
|
||||||
nameLabel: '账号名称',
|
nameLabel: '账号名称',
|
||||||
namePlaceholder: '请输入账号名称',
|
namePlaceholder: '请输入账号名称',
|
||||||
|
|||||||
Reference in New Issue
Block a user