Files
sub2api/backend/internal/handler/gemini_mixed_models_test.go

137 lines
6.4 KiB
Go

package handler
import (
"encoding/json"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
"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"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGeminiNativeModelsUsesAccountMappings(t *testing.T) {
for _, tt := range []struct {
name string
forced, mixed, allowlist bool
status int
}{
{"mixed", false, true, false, 200},
{"mixed allowlist", false, true, true, 200},
{"disabled mixed", false, false, false, 503},
{"forced without mixed opt-in", true, false, false, 200},
{"forced allowlist", true, false, true, 200},
} {
t.Run(tt.name, func(t *testing.T) {
groupID := int64(45)
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{
groupID: {{ID: 1, Platform: service.PlatformAntigravity,
Extra: map[string]any{"mixed_scheduling": tt.mixed},
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high", "claude-custom": "claude-sonnet-4-6"}}}},
}}}
h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformGemini,
ModelAllowlist: service.GroupModelAllowlist{Enabled: tt.allowlist, Models: []string{"gemini-synced-custom"}},
}})
if tt.forced {
c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
}
h.GeminiV1BetaListModels(c)
require.Equal(t, tt.status, rec.Code, rec.Body.String())
if tt.status != 200 {
return
}
var got gemini.ModelsListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
names := []string{}
for _, model := range got.Models {
names = append(names, model.Name)
require.Contains(t, model.SupportedGenerationMethods, "generateContent")
}
require.Contains(t, names, "models/gemini-synced-custom")
require.NotContains(t, names, "models/claude-custom")
require.NotContains(t, names, "models/gemini-2.0-flash")
if tt.allowlist {
require.Equal(t, []string{"models/gemini-synced-custom"}, names)
}
})
}
}
func TestAppendUpstreamGeminiModelsPreservesMetadata(t *testing.T) {
body := []byte(`{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}}],"nextPageToken":"next","other":42}`)
extra := []gemini.Model{gemini.FallbackModel("gemini-native"), gemini.FallbackModel("gemini-synced-custom"), gemini.FallbackModel("gemini-synced-custom")}
merged, ok := appendUpstreamGeminiModels(body, extra)
require.True(t, ok)
require.JSONEq(t, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}},{"name":"models/gemini-synced-custom","supportedGenerationMethods":["generateContent","streamGenerateContent"]}],"nextPageToken":"next","other":42}`, string(merged))
filtered, dropped, ok := filterUpstreamGeminiModelsBody(merged, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-synced-*"}})
require.True(t, ok)
require.True(t, dropped)
require.NotContains(t, string(filtered), "gemini-native")
require.Contains(t, string(filtered), "gemini-synced-custom")
for _, invalid := range []string{`null`, `not-json`, `{"error":"bad"}`, `{"models":{}}`} {
got, ok := appendUpstreamGeminiModels([]byte(invalid), extra)
require.False(t, ok)
require.Equal(t, invalid, string(got))
}
}
// Exercise the real handler's native-upstream branch, including scope fallback.
type geminiMixedModelsUpstream struct {
service.HTTPUpstream
status int
body string
}
func (u *geminiMixedModelsUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
return &http.Response{StatusCode: u.status, Header: http.Header{"Content-Type": {"application/json"}, "X-Request-Id": {"native-id"}}, Body: io.NopCloser(strings.NewReader(u.body))}, nil
}
func TestGeminiNativeModelsMergesNativeUpstream(t *testing.T) {
for _, tt := range []struct {
name string
status int
body string
expectedNative string
}{
{"native", 200, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123}],"nextPageToken":"next"}`, "models/gemini-native"},
{"scope fallback", 403, `{"error":"insufficient authentication scopes"}`, "models/gemini-2.5-pro"},
{"upstream error", 429, `{"error":"rate limited"}`, ""},
} {
t.Run(tt.name, func(t *testing.T) {
id := int64(46)
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{id: {
{ID: 1, Platform: service.PlatformGemini, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test"}},
{ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high"}}},
}}}}
upstream := &geminiMixedModelsUpstream{status: tt.status, body: tt.body}
h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, upstream, nil, &config.Config{})}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &id, Group: &service.Group{ID: id, Platform: service.PlatformGemini}})
h.GeminiV1BetaListModels(c)
if tt.status == 429 {
require.Equal(t, 429, rec.Code)
require.JSONEq(t, tt.body, rec.Body.String())
return
}
require.Equal(t, 200, rec.Code, rec.Body.String())
require.Contains(t, rec.Body.String(), tt.expectedNative)
require.Contains(t, rec.Body.String(), "models/gemini-synced-custom")
if tt.status == 200 {
require.Contains(t, rec.Body.String(), `"inputTokenLimit":123`)
require.Contains(t, rec.Body.String(), `"nextPageToken":"next"`)
require.Equal(t, "native-id", rec.Header().Get("X-Request-Id"))
}
})
}
}