fix(gemini): 模型列表与模型详情接口支持 Agent Platform (Vertex) 账号

Gemini 分组对外提供 AI Studio 兼容协议,分组内账号不一定是 AI Studio。
分组只剩 Vertex 服务账号时,GET /v1beta/models 与 /v1beta/models/{model}
选中服务账号后报 502 unsupported account type: service_account。

- SelectAccountForAIStudioEndpoints 改名 SelectAccountForModelsEndpoints,
  Vertex 账号排在所有 AI Studio 兼容账号之后参与选号
- ForwardAIStudioGET 拆为 ForwardListModels / ForwardGetModel,按账号类型分流:
  AI Studio 兼容账号原样转发;Vertex 服务账号读取 Google 发布方模型列表并
  转换为 AI Studio 格式,模型详情在该模型所用区域的列表中查找;Vertex API Key
  账号返回内置列表
- Vertex 发布方模型分页读取抽成 listVertexGeminiPublisherModels,与后台
  同步上游模型共用
- 模型接口响应体上限改用 gateway.models_list_read_max_bytes,超出时报错
This commit is contained in:
feeeei
2026-10-11 22:03:36 +08:00
parent 4426593e9d
commit daebb7a80c
7 changed files with 620 additions and 176 deletions
@@ -74,7 +74,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return
}
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
account, err := h.geminiCompatService.SelectAccountForModelsEndpoints(c.Request.Context(), apiKey.GroupID)
if err != nil {
if len(agModels) > 0 {
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
@@ -85,7 +85,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return
}
res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models")
res, err := h.geminiCompatService.ForwardListModels(c.Request.Context(), account)
if err != nil {
googleError(c, http.StatusBadGateway, err.Error())
return
@@ -251,7 +251,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
return
}
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
account, err := h.geminiCompatService.SelectAccountForModelsEndpoints(c.Request.Context(), apiKey.GroupID)
if err != nil {
// 没有 gemini 账户,检查是否有 antigravity 账户可用
hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
@@ -265,7 +265,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
return
}
res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models/"+modelName)
res, err := h.geminiCompatService.ForwardGetModel(c.Request.Context(), account, modelName)
if err != nil {
googleError(c, http.StatusBadGateway, err.Error())
return
@@ -109,7 +109,7 @@ func TestGeminiV1BetaHandler_ListModelsAntigravityFallback(t *testing.T) {
expectedBehavior string
}{
{
name: "有Gemini账户-调用ForwardAIStudioGET",
name: "有Gemini账户-调用ForwardListModels",
hasGeminiAccount: true,
hasAntigravity: false,
expectedBehavior: "forward_to_upstream",
@@ -155,7 +155,7 @@ func TestGeminiV1BetaHandler_GetModelAntigravityFallback(t *testing.T) {
expectedBehavior string
}{
{
name: "有Gemini账户-调用ForwardAIStudioGET",
name: "有Gemini账户-调用ForwardGetModel",
hasGeminiAccount: true,
hasAntigravity: false,
expectedBehavior: "forward_to_upstream",
@@ -0,0 +1,83 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type geminiVertexModelsTokenCache struct{}
func (geminiVertexModelsTokenCache) GetAccessToken(context.Context, string) (string, error) {
return "ya29.vertex-token", nil
}
func (geminiVertexModelsTokenCache) SetAccessToken(context.Context, string, string, time.Duration) error {
return nil
}
func (geminiVertexModelsTokenCache) DeleteAccessToken(context.Context, string) error {
return nil
}
func (geminiVertexModelsTokenCache) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) {
return false, nil
}
func (geminiVertexModelsTokenCache) ReleaseRefreshLock(context.Context, string) error {
return nil
}
// 分组里只有 Agent Platform (Vertex) 服务账号时,模型列表与模型详情接口返回 Vertex 上的 Gemini 模型。
func TestGeminiV1BetaModelsEndpoints_VertexServiceAccountOnlyGroup(t *testing.T) {
groupID := int64(8)
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{groupID: {{
ID: 18,
Platform: service.PlatformGemini,
Type: service.AccountTypeServiceAccount,
Credentials: map[string]any{
"location": "global",
"service_account_json": map[string]any{
"type": "service_account",
"project_id": "proj",
"private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
"client_email": "svc@proj.iam.gserviceaccount.com",
},
},
}}}}}
upstream := &geminiMixedModelsUpstream{status: http.StatusOK, body: `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}]}`}
tokenProvider := service.NewGeminiTokenProvider(nil, geminiVertexModelsTokenCache{}, nil)
h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, tokenProvider, nil, upstream, nil, &config.Config{})}
apiKey := &service.APIKey{GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformGemini}}
t.Run("list", func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
h.GeminiV1BetaListModels(c)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.JSONEq(t, `{"models":[{"name":"models/gemini-3.8-flash","supportedGenerationMethods":["generateContent","streamGenerateContent"]}]}`, rec.Body.String())
})
t.Run("get", func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-3.8-flash", nil)
c.Params = gin.Params{{Key: "model", Value: "gemini-3.8-flash"}}
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
h.GeminiV1BetaGetModel(c)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.JSONEq(t, `{"name":"models/gemini-3.8-flash","supportedGenerationMethods":["generateContent","streamGenerateContent"]}`, rec.Body.String())
})
}
@@ -529,94 +529,6 @@ func (s *GeminiMessagesCompatService) HasAntigravityAccounts(ctx context.Context
return len(accounts) > 0, nil
}
// SelectAccountForAIStudioEndpoints selects an account that is likely to succeed against
// generativelanguage.googleapis.com (e.g. GET /v1beta/models).
//
// Preference order:
// 1) API key accounts (AI Studio)
// 2) OAuth accounts without project_id (AI Studio OAuth)
// 3) OAuth accounts explicitly marked as ai_studio
// 4) Any remaining Gemini accounts (fallback)
func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx context.Context, groupID *int64) (*Account, error) {
accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformGemini, true)
if err != nil {
return nil, fmt.Errorf("query accounts failed: %w", err)
}
if len(accounts) == 0 {
return nil, errors.New("no available Gemini accounts")
}
rank := func(a *Account) int {
if a == nil {
return 999
}
switch a.Type {
case AccountTypeAPIKey:
if strings.TrimSpace(a.GetCredential("api_key")) != "" {
return 0
}
return 9
case AccountTypeOAuth:
if strings.TrimSpace(a.GetCredential("project_id")) == "" {
return 1
}
if strings.TrimSpace(a.GetCredential("oauth_type")) == "ai_studio" {
return 2
}
// Code Assist OAuth tokens often lack AI Studio scopes for models listing.
return 3
case AccountTypeServiceAccount:
// Vertex service accounts use aiplatform.googleapis.com, not the AI Studio
// endpoint (generativelanguage.googleapis.com), so they cannot serve these requests.
return 999
default:
return 10
}
}
var selected *Account
for i := range accounts {
acc := &accounts[i]
if selected == nil {
selected = acc
continue
}
r1, r2 := rank(acc), rank(selected)
if r1 < r2 {
selected = acc
continue
}
if r1 > r2 {
continue
}
if acc.Priority < selected.Priority {
selected = acc
} else if acc.Priority == selected.Priority {
switch {
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
selected = acc
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
// keep selected
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
if acc.Type == AccountTypeOAuth && selected.Type != AccountTypeOAuth {
selected = acc
}
default:
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
selected = acc
}
}
}
}
if selected == nil {
return nil, errors.New("no available Gemini accounts")
}
return s.hydrateSelectedAccount(ctx, selected)
}
func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
beginUpstreamResponseModelObservation(c)
beginGeminiImageOutputObservation(c)
@@ -2916,78 +2828,6 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte
return &geminiNativeStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil
}
// ForwardAIStudioGET forwards a GET request to AI Studio (generativelanguage.googleapis.com) for
// endpoints like /v1beta/models and /v1beta/models/{model}.
//
// This is used to support Gemini SDKs that call models listing endpoints before generation.
func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, account *Account, path string) (*UpstreamHTTPResult, error) {
if account == nil {
return nil, errors.New("account is nil")
}
// path 会被直接拼到上游 base URL 后面,因此按路径护栏逐片段校验,
// 见 upstream_path_guard.go。
sanitizedPath, ok := sanitizedUpstreamPathSuffix(path)
if !ok || sanitizedPath == "" {
return nil, errors.New("invalid path")
}
path = sanitizedPath
baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
return nil, err
}
fullURL := strings.TrimRight(normalizedBaseURL, "/") + path
var proxyURL string
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return nil, err
}
switch account.Type {
case AccountTypeAPIKey:
apiKey := strings.TrimSpace(account.GetCredential("api_key"))
if apiKey == "" {
return nil, errors.New("gemini api_key not configured")
}
req.Header.Set("x-goog-api-key", apiKey)
case AccountTypeOAuth:
if s.tokenProvider == nil {
return nil, errors.New("gemini token provider not configured")
}
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
default:
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
}
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
wwwAuthenticate := resp.Header.Get("Www-Authenticate")
filteredHeaders := responseheaders.FilterHeaders(resp.Header, s.responseHeaderFilter)
if wwwAuthenticate != "" {
filteredHeaders.Set("Www-Authenticate", wwwAuthenticate)
}
return &UpstreamHTTPResult{
StatusCode: resp.StatusCode,
Headers: filteredHeaders,
Body: body,
}, nil
}
// unwrapGeminiResponse 解包 Gemini OAuth 响应中的 response 字段
// 使用 gjson 零拷贝提取,避免完整 Unmarshal+Marshal
func unwrapGeminiResponse(raw []byte) ([]byte, error) {
@@ -0,0 +1,302 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
)
// Gemini 分组对外提供 AI Studio 兼容协议(GET /v1beta/models、/v1beta/models/{model}),
// 分组内的账号可以是 AI Studio 兼容上游(API Key / OAuth),也可以是 Agent Platform (Vertex)。
// 本文件把不同账号类型的模型接口统一成 AI Studio 格式的响应。
// errGeminiModelsUpstreamStatus 在 Vertex 分页读取遇到非 2xx 时中止分页,原始响应由调用方另行保存。
var errGeminiModelsUpstreamStatus = errors.New("upstream model list returned non-2xx status")
// SelectAccountForModelsEndpoints 为模型列表与模型详情接口选择账号。
//
// 优先级:
// 1. 配置了 api_key 的 API Key 账号
// 2. 不带 project_id 的 OAuth 账号(AI Studio OAuth)
// 3. 显式标记为 ai_studio 的 OAuth 账号
// 4. Code Assist OAuth 账号(令牌常缺 AI Studio 权限范围,多半退回内置列表)
// 5. 其余类型的账号
// 6. Vertex 服务账号(服务账号 JSON 认证,读取 Google 发布方模型列表)
// 7. Vertex API Key 账号(列表接口不接受 API Key,只能返回内置列表)
//
// Vertex 账号排在所有 AI Studio 兼容账号之后,混合分组始终由 AI Studio 兼容账号应答。
// 同级账号按 priority、最近使用时间挑选。
func (s *GeminiMessagesCompatService) SelectAccountForModelsEndpoints(ctx context.Context, groupID *int64) (*Account, error) {
accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformGemini, true)
if err != nil {
return nil, fmt.Errorf("query accounts failed: %w", err)
}
if len(accounts) == 0 {
return nil, errors.New("no available Gemini accounts")
}
var selected *Account
for i := range accounts {
acc := &accounts[i]
if selected == nil {
selected = acc
continue
}
r1, r2 := modelsEndpointAccountRank(acc), modelsEndpointAccountRank(selected)
if r1 < r2 {
selected = acc
continue
}
if r1 > r2 {
continue
}
if acc.Priority < selected.Priority {
selected = acc
} else if acc.Priority == selected.Priority {
switch {
case acc.LastUsedAt == nil && selected.LastUsedAt != nil:
selected = acc
case acc.LastUsedAt != nil && selected.LastUsedAt == nil:
// keep selected
case acc.LastUsedAt == nil && selected.LastUsedAt == nil:
if acc.Type == AccountTypeOAuth && selected.Type != AccountTypeOAuth {
selected = acc
}
default:
if acc.LastUsedAt.Before(*selected.LastUsedAt) {
selected = acc
}
}
}
}
if selected == nil {
return nil, errors.New("no available Gemini accounts")
}
return s.hydrateSelectedAccount(ctx, selected)
}
// modelsEndpointAccountRank 返回账号在模型接口选号中的优先级,数值越小越优先,顺序见 SelectAccountForModelsEndpoints。
func modelsEndpointAccountRank(a *Account) int {
if a == nil {
return 999
}
switch a.Type {
case AccountTypeAPIKey:
if strings.TrimSpace(a.GetCredential("api_key")) != "" {
return 0
}
return 9
case AccountTypeOAuth:
if strings.TrimSpace(a.GetCredential("project_id")) == "" {
return 1
}
if strings.TrimSpace(a.GetCredential("oauth_type")) == "ai_studio" {
return 2
}
return 3
case AccountTypeServiceAccount:
if a.IsVertexAPIKey() {
return 12
}
return 11
default:
return 10
}
}
// ForwardListModels 读取账号上游的模型列表,返回 AI Studio 格式的响应(GET /v1beta/models)。
// AI Studio 兼容账号原样返回上游响应;Vertex 服务账号读取 Google 发布方模型列表后转换格式,
// 上游非 2xx 时原样返回该响应;Vertex API Key 账号返回内置列表。
func (s *GeminiMessagesCompatService) ForwardListModels(ctx context.Context, account *Account) (*UpstreamHTTPResult, error) {
if account == nil {
return nil, errors.New("account is nil")
}
if !account.IsVertexServiceAccount() {
return s.forwardGeminiModelsGET(ctx, account, "/v1beta/models")
}
if account.IsVertexAPIKey() {
return geminiModelsJSONResult(http.StatusOK, gemini.FallbackModelsList())
}
modelIDs, failure, err := s.listVertexGeminiModels(ctx, account, account.VertexLocation(""))
if err != nil {
return nil, err
}
if failure != nil {
return failure, nil
}
models := make([]gemini.Model, 0, len(modelIDs))
for _, modelID := range modelIDs {
models = append(models, gemini.FallbackModel(modelID))
}
return geminiModelsJSONResult(http.StatusOK, gemini.ModelsListResponse{Models: models})
}
// ForwardGetModel 读取账号上游的单个模型信息,返回 AI Studio 格式的响应(GET /v1beta/models/{model})。
// Vertex 服务账号在该模型生成请求所用区域的模型列表中查找,找不到时返回 404;Vertex API Key 账号返回内置信息。
func (s *GeminiMessagesCompatService) ForwardGetModel(ctx context.Context, account *Account, model string) (*UpstreamHTTPResult, error) {
if account == nil {
return nil, errors.New("account is nil")
}
if !account.IsVertexServiceAccount() {
return s.forwardGeminiModelsGET(ctx, account, "/v1beta/models/"+model)
}
if account.IsVertexAPIKey() {
return geminiModelsJSONResult(http.StatusOK, gemini.FallbackModel(model))
}
modelIDs, failure, err := s.listVertexGeminiModels(ctx, account, account.VertexLocation(model))
if err != nil {
return nil, err
}
if failure != nil {
return failure, nil
}
if slices.Contains(modelIDs, model) {
return geminiModelsJSONResult(http.StatusOK, gemini.FallbackModel(model))
}
return geminiModelsJSONResult(http.StatusNotFound, map[string]any{
"error": map[string]any{
"code": http.StatusNotFound,
"message": fmt.Sprintf("models/%s is not found", model),
"status": googleapi.HTTPStatusToGoogleStatus(http.StatusNotFound),
},
})
}
// forwardGeminiModelsGET 向 AI Studio 兼容上游转发模型接口的 GET 请求。
// path 会被直接拼到上游 base URL 后面,因此按路径护栏逐片段校验,见 upstream_path_guard.go。
func (s *GeminiMessagesCompatService) forwardGeminiModelsGET(ctx context.Context, account *Account, path string) (*UpstreamHTTPResult, error) {
sanitizedPath, ok := sanitizedUpstreamPathSuffix(path)
if !ok || sanitizedPath == "" {
return nil, errors.New("invalid path")
}
baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
return nil, err
}
fullURL := strings.TrimRight(normalizedBaseURL, "/") + sanitizedPath
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return nil, err
}
switch account.Type {
case AccountTypeAPIKey:
apiKey := strings.TrimSpace(account.GetCredential("api_key"))
if apiKey == "" {
return nil, errors.New("gemini api_key not configured")
}
req.Header.Set("x-goog-api-key", apiKey)
case AccountTypeOAuth:
if s.tokenProvider == nil {
return nil, errors.New("gemini token provider not configured")
}
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
default:
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
}
return s.doGeminiModelsGET(req, account)
}
// listVertexGeminiModels 读取 Vertex 服务账号在指定区域可用的 Gemini 模型。
// 上游返回非 2xx 时 failure 为该页的原始响应,modelIDs 与 err 均为空。
func (s *GeminiMessagesCompatService) listVertexGeminiModels(ctx context.Context, account *Account, location string) (modelIDs []string, failure *UpstreamHTTPResult, err error) {
if s.tokenProvider == nil {
return nil, nil, errors.New("gemini token provider not configured")
}
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
if err != nil {
return nil, nil, err
}
modelIDs, err = listVertexGeminiPublisherModels(location, func(_ int, listURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
res, err := s.doGeminiModelsGET(req, account)
if err != nil {
return nil, err
}
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
failure = res
return nil, errGeminiModelsUpstreamStatus
}
return res.Body, nil
})
if failure != nil {
return nil, failure, nil
}
if err != nil {
return nil, nil, err
}
return modelIDs, nil, nil
}
// doGeminiModelsGET 发送模型接口请求并读取响应。响应体上限取 gateway.models_list_read_max_bytes,超出时报错;响应头按配置过滤。
func (s *GeminiMessagesCompatService) doGeminiModelsGET(req *http.Request, account *Account) (*UpstreamHTTPResult, error) {
var proxyURL string
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, err
}
defer func() { _ = resp.Body.Close() }()
bodyLimit := resolveModelsListReadLimit(s.cfg)
body, _ := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
if int64(len(body)) > bodyLimit {
return nil, fmt.Errorf("upstream model list response exceeds %d bytes", bodyLimit)
}
wwwAuthenticate := resp.Header.Get("Www-Authenticate")
filteredHeaders := responseheaders.FilterHeaders(resp.Header, s.responseHeaderFilter)
if wwwAuthenticate != "" {
filteredHeaders.Set("Www-Authenticate", wwwAuthenticate)
}
return &UpstreamHTTPResult{
StatusCode: resp.StatusCode,
Headers: filteredHeaders,
Body: body,
}, nil
}
// geminiModelsJSONResult 把网关生成的模型接口响应包装成 UpstreamHTTPResult,交给 handler 统一写出。
func geminiModelsJSONResult(status int, payload any) (*UpstreamHTTPResult, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return &UpstreamHTTPResult{
StatusCode: status,
Headers: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
Body: body,
}, nil
}
@@ -0,0 +1,208 @@
//go:build unit
package service
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
"github.com/stretchr/testify/require"
)
func newGeminiModelsEndpointsTestService(accounts []Account, upstream HTTPUpstream) *GeminiMessagesCompatService {
repo := &mockAccountRepoForGemini{listByGroupFunc: func(context.Context, int64, []string) ([]Account, error) {
return accounts, nil
}}
return &GeminiMessagesCompatService{
accountRepo: repo,
httpUpstream: upstream,
cfg: &config.Config{},
tokenProvider: NewGeminiTokenProvider(nil, &vertexModelsTokenCacheStub{token: "ya29.vertex-token"}, nil),
}
}
func decodeGeminiModelNames(t *testing.T, body []byte) []string {
t.Helper()
var got gemini.ModelsListResponse
require.NoError(t, json.Unmarshal(body, &got))
names := make([]string, 0, len(got.Models))
for _, model := range got.Models {
require.Contains(t, model.SupportedGenerationMethods, "generateContent")
names = append(names, model.Name)
}
return names
}
func TestSelectAccountForModelsEndpoints_VertexServiceAccountOnlyGroup(t *testing.T) {
groupID := int64(8)
vertex := *vertexModelSyncTestAccount(nil)
vertex.ID = 18
svc := newGeminiModelsEndpointsTestService([]Account{vertex}, nil)
account, err := svc.SelectAccountForModelsEndpoints(context.Background(), &groupID)
require.NoError(t, err)
require.Equal(t, int64(18), account.ID)
}
func TestModelsEndpointAccountRank_Order(t *testing.T) {
ordered := []*Account{
{Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k"}},
{Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{}},
{Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{"project_id": "p", "oauth_type": "ai_studio"}},
{Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{"project_id": "p", "oauth_type": "code_assist"}},
{Platform: PlatformGemini, Type: AccountTypeUpstream, Credentials: map[string]any{"api_key": "k"}},
vertexModelSyncTestAccount(nil),
{Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{"auth_mode": "apikey", "api_key": "AQ.k"}},
}
for i := 1; i < len(ordered); i++ {
require.Less(t, modelsEndpointAccountRank(ordered[i-1]), modelsEndpointAccountRank(ordered[i]), "position %d", i)
}
}
func TestSelectAccountForModelsEndpoints_PrefersAPIKeyOverVertex(t *testing.T) {
groupID := int64(8)
vertex := *vertexModelSyncTestAccount(nil)
vertex.ID = 18
apiKey := Account{ID: 19, Platform: PlatformGemini, Type: AccountTypeAPIKey, Priority: 9, Credentials: map[string]any{"api_key": "k"}}
svc := newGeminiModelsEndpointsTestService([]Account{vertex, apiKey}, nil)
account, err := svc.SelectAccountForModelsEndpoints(context.Background(), &groupID)
require.NoError(t, err)
require.Equal(t, int64(19), account.ID)
}
func TestForwardListModels_VertexServiceAccountReturnsAIStudioFormat(t *testing.T) {
upstream := &httpUpstreamRecorder{responses: []*http.Response{
vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[
{"name":"publishers/google/models/gemini-3.8-flash"},
{"name":"publishers/google/models/gemma4"}
],"nextPageToken":"page-2"}`),
vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[{"name":"publishers/google/models/gemini-2.5-flash"}]}`),
}}
svc := newGeminiModelsEndpointsTestService(nil, upstream)
res, err := svc.ForwardListModels(context.Background(), vertexModelSyncTestAccount(map[string]any{"location": "europe-west4"}))
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
require.Equal(t, []string{"models/gemini-2.5-flash", "models/gemini-3.8-flash"}, decodeGeminiModelNames(t, res.Body))
require.Len(t, upstream.requests, 2)
require.Equal(t, "https://europe-west4-aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300", upstream.requests[0].URL.String())
require.Equal(t, "https://europe-west4-aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300&pageToken=page-2", upstream.requests[1].URL.String())
for _, req := range upstream.requests {
require.Equal(t, "Bearer ya29.vertex-token", req.Header.Get("Authorization"))
}
}
func TestForwardListModels_VertexUpstreamErrorPassesThrough(t *testing.T) {
body := `{"error":{"code":403,"status":"PERMISSION_DENIED"}}`
upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusForbidden, body)}
svc := newGeminiModelsEndpointsTestService(nil, upstream)
res, err := svc.ForwardListModels(context.Background(), vertexModelSyncTestAccount(map[string]any{"location": "global"}))
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, res.StatusCode)
require.JSONEq(t, body, string(res.Body))
}
func TestForwardListModels_VertexAPIKeyReturnsBuiltinList(t *testing.T) {
upstream := &httpUpstreamRecorder{}
svc := newGeminiModelsEndpointsTestService(nil, upstream)
account := &Account{ID: 174, Platform: PlatformGemini, Type: AccountTypeServiceAccount, Credentials: map[string]any{
"auth_mode": "apikey",
"api_key": "AQ.test-key",
}}
res, err := svc.ForwardListModels(context.Background(), account)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
expected, err := json.Marshal(gemini.FallbackModelsList())
require.NoError(t, err)
require.JSONEq(t, string(expected), string(res.Body))
require.Empty(t, upstream.requests)
}
func TestForwardListModels_APIKeyForwardsToBaseURL(t *testing.T) {
upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, `{"models":[{"name":"models/gemini-native"}]}`)}
svc := newGeminiModelsEndpointsTestService(nil, upstream)
account := &Account{ID: 261, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{
"api_key": "sk-pool",
"base_url": "https://relay.example.com",
}}
res, err := svc.ForwardListModels(context.Background(), account)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
require.JSONEq(t, `{"models":[{"name":"models/gemini-native"}]}`, string(res.Body))
require.Equal(t, "https://relay.example.com/v1beta/models", upstream.lastReq.URL.String())
require.Equal(t, "sk-pool", upstream.lastReq.Header.Get("x-goog-api-key"))
}
func TestForwardGetModel_VertexServiceAccountLooksUpModelList(t *testing.T) {
listBody := `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}]}`
t.Run("listed model", func(t *testing.T) {
upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, listBody)}
svc := newGeminiModelsEndpointsTestService(nil, upstream)
res, err := svc.ForwardGetModel(context.Background(), vertexModelSyncTestAccount(nil), "gemini-3.8-flash")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
var model gemini.Model
require.NoError(t, json.Unmarshal(res.Body, &model))
require.Equal(t, "models/gemini-3.8-flash", model.Name)
})
t.Run("unlisted model", func(t *testing.T) {
upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, listBody)}
svc := newGeminiModelsEndpointsTestService(nil, upstream)
res, err := svc.ForwardGetModel(context.Background(), vertexModelSyncTestAccount(nil), "gemini-unknown")
require.NoError(t, err)
require.Equal(t, http.StatusNotFound, res.StatusCode)
require.JSONEq(t, `{"error":{"code":404,"message":"models/gemini-unknown is not found","status":"NOT_FOUND"}}`, string(res.Body))
})
}
func TestForwardGetModel_VertexUsesModelLocation(t *testing.T) {
upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}]}`)}
svc := newGeminiModelsEndpointsTestService(nil, upstream)
account := vertexModelSyncTestAccount(map[string]any{
"location": "europe-west4",
"vertex_model_locations": map[string]any{"gemini-3.8-flash": "global"},
})
res, err := svc.ForwardGetModel(context.Background(), account, "gemini-3.8-flash")
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
require.Equal(t, "https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300", upstream.lastReq.URL.String())
}
func TestForwardListModels_VertexMidPaginationErrorPassesThrough(t *testing.T) {
body := `{"error":{"code":503,"status":"UNAVAILABLE"}}`
upstream := &httpUpstreamRecorder{responses: []*http.Response{
vertexModelsJSONResponse(http.StatusOK, `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}],"nextPageToken":"page-2"}`),
vertexModelsJSONResponse(http.StatusServiceUnavailable, body),
}}
svc := newGeminiModelsEndpointsTestService(nil, upstream)
res, err := svc.ForwardListModels(context.Background(), vertexModelSyncTestAccount(nil))
require.NoError(t, err)
require.Equal(t, http.StatusServiceUnavailable, res.StatusCode)
require.JSONEq(t, body, string(res.Body))
require.Len(t, upstream.requests, 2)
}
func TestForwardListModels_ResponseOverReadLimitFails(t *testing.T) {
upstream := &httpUpstreamRecorder{resp: vertexModelsJSONResponse(http.StatusOK, `{"models":[{"name":"models/gemini-native"}]}`)}
svc := newGeminiModelsEndpointsTestService(nil, upstream)
svc.cfg.Gateway.ModelsListReadMaxBytes = 16
account := &Account{ID: 261, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "sk-pool"}}
_, err := svc.ForwardListModels(context.Background(), account)
require.ErrorContains(t, err, "exceeds 16 bytes")
}
+21 -10
View File
@@ -804,9 +804,7 @@ func (s *AccountTestService) readUpstreamModelsResponse(req *http.Request, accou
return body, nil
}
// fetchVertexGeminiUpstreamModels 按账号默认区域分页读取 Vertex 的 Google 发布方模型列表,只保留 gemini- 前缀的模型。
// 区域端点还会列出 Gemma、Veo、AutoML 等非 Gemini 的模型花园条目;global 端点只含 Gemini。
// 列表接口不接受 API Key。
// fetchVertexGeminiUpstreamModels 按账号默认区域读取 Vertex 上可用的 Gemini 模型。列表接口不接受 API Key。
func (s *AccountTestService) fetchVertexGeminiUpstreamModels(ctx context.Context, account *Account) ([]string, error) {
if account.IsVertexAPIKey() {
return nil, newUpstreamModelSyncUnsupportedError("Vertex API key accounts do not support upstream model listing", nil)
@@ -830,13 +828,7 @@ func (s *AccountTestService) fetchVertexGeminiUpstreamModels(ctx context.Context
return nil, newUpstreamModelSyncConfigError("No Vertex access token is available", nil)
}
models := make([]string, 0)
pageToken := ""
for page := 0; page < vertexPublisherModelsMaxPages; page++ {
listURL, err := buildVertexPublisherModelsURL(location, pageToken)
if err != nil {
return nil, newUpstreamModelSyncConfigError("Invalid Vertex location", err)
}
return listVertexGeminiPublisherModels(location, func(page int, listURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
if err != nil {
return nil, newUpstreamModelSyncConfigError("Invalid Vertex model list URL", err)
@@ -853,6 +845,25 @@ func (s *AccountTestService) fetchVertexGeminiUpstreamModels(ctx context.Context
}
return nil, err
}
return body, nil
})
}
// listVertexGeminiPublisherModels 按区域分页读取 Google 发布方模型列表,只保留 gemini- 前缀的模型(去重排序)。
// 区域端点还会列出 Gemma、Veo、AutoML 等非 Gemini 的模型花园条目;global 端点只含 Gemini。
// fetchPage 负责发送请求并处理非 2xx 响应,返回该页的响应体;它返回的错误原样透出。
func listVertexGeminiPublisherModels(location string, fetchPage func(page int, listURL string) ([]byte, error)) ([]string, error) {
models := make([]string, 0)
pageToken := ""
for page := 0; page < vertexPublisherModelsMaxPages; page++ {
listURL, err := buildVertexPublisherModelsURL(location, pageToken)
if err != nil {
return nil, newUpstreamModelSyncConfigError("Invalid Vertex location", err)
}
body, err := fetchPage(page, listURL)
if err != nil {
return nil, err
}
var response struct {
PublisherModels []struct {
Name string `json:"name"`