mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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")
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user