| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528 |
- package admin
- import (
- "context"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- type availableModelsAdminService struct {
- *stubAdminService
- account service.Account
- }
- func (s *availableModelsAdminService) GetAccount(_ context.Context, id int64) (*service.Account, error) {
- if s.account.ID == id {
- acc := s.account
- return &acc, nil
- }
- return s.stubAdminService.GetAccount(context.Background(), id)
- }
- func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
- gin.SetMode(gin.TestMode)
- router := gin.New()
- handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
- router.GET("/api/v1/admin/accounts/:id/models", handler.GetAvailableModels)
- return router
- }
- type syncUpstreamHTTPUpstream struct {
- resp *http.Response
- responses []*http.Response
- err error
- }
- func (u *syncUpstreamHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
- if u.err != nil {
- return nil, u.err
- }
- if len(u.responses) > 0 {
- resp := u.responses[0]
- u.responses = u.responses[1:]
- return resp, nil
- }
- return u.resp, nil
- }
- func (u *syncUpstreamHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
- return u.Do(req, proxyURL, accountID, accountConcurrency)
- }
- func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream service.HTTPUpstream) *gin.Engine {
- gin.SetMode(gin.TestMode)
- router := gin.New()
- accountTestSvc := service.NewAccountTestService(
- nil,
- nil,
- nil,
- nil,
- nil,
- upstream,
- &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- nil,
- )
- handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
- router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels)
- router.POST("/api/v1/admin/accounts/models/sync-upstream-preview", handler.SyncUpstreamModelsPreview)
- return router
- }
- func TestAccountHandlerGetAvailableModels_GrokUsesXAIModels(t *testing.T) {
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 44,
- Name: "grok-oauth",
- Platform: service.PlatformGrok,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "grok-4.3": "grok-4.3",
- },
- },
- },
- }
- router := setupAvailableModelsRouter(svc)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- var resp struct {
- Data []struct {
- ID string `json:"id"`
- } `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- require.Len(t, resp.Data, 1)
- require.Equal(t, "grok-4.3", resp.Data[0].ID)
- }
- func TestAccountHandlerGetAvailableModels_GrokDefaultsToXAIModelsWithoutMapping(t *testing.T) {
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 45,
- Name: "grok-oauth-defaults",
- Platform: service.PlatformGrok,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- },
- }
- router := setupAvailableModelsRouter(svc)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- var resp struct {
- Data []struct {
- ID string `json:"id"`
- } `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- require.NotEmpty(t, resp.Data)
- var ids []string
- for _, model := range resp.Data {
- id := model.ID
- ids = append(ids, id)
- require.NotContains(t, strings.ToLower(id), "claude")
- }
- require.Contains(t, ids, "grok-4.3")
- require.Contains(t, ids, "grok-build-0.1")
- }
- func TestAccountHandlerGetAvailableModels_OpenAIOAuthUsesExplicitModelMapping(t *testing.T) {
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 42,
- Name: "openai-oauth",
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "gpt-5": "gpt-5.1",
- },
- },
- },
- }
- router := setupAvailableModelsRouter(svc)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/42/models", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- var resp struct {
- Data []struct {
- ID string `json:"id"`
- } `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- require.Len(t, resp.Data, 1)
- require.Equal(t, "gpt-5", resp.Data[0].ID)
- }
- func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefaults(t *testing.T) {
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 43,
- Name: "openai-oauth-passthrough",
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "gpt-5": "gpt-5.1",
- },
- },
- Extra: map[string]any{
- "openai_passthrough": true,
- },
- },
- }
- router := setupAvailableModelsRouter(svc)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/43/models", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- var resp struct {
- Data []struct {
- ID string `json:"id"`
- } `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- require.NotEmpty(t, resp.Data)
- require.NotEqual(t, "gpt-5", resp.Data[0].ID)
- }
- func TestAccountHandlerGetAvailableModels_OpenAIAPIKeyDefaultsToConcreteGPT56Sol(t *testing.T) {
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 46,
- Name: "openai-apikey",
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeAPIKey,
- Status: service.StatusActive,
- Credentials: map[string]any{
- "api_key": "test-key",
- },
- },
- }
- router := setupAvailableModelsRouter(svc)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/46/models", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- var resp struct {
- Data []struct {
- ID string `json:"id"`
- } `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- require.NotEmpty(t, resp.Data)
- require.Equal(t, "gpt-5.6-sol", resp.Data[0].ID)
- }
- func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels(t *testing.T) {
- parentID := int64(100)
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 44,
- Name: "openai-spark-shadow",
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- ParentAccountID: &parentID,
- QuotaDimension: service.QuotaDimensionSpark,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
- },
- },
- },
- }
- router := setupAvailableModelsRouter(svc)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- var resp struct {
- Data []struct {
- ID string `json:"id"`
- } `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- ids := make([]string, 0, len(resp.Data))
- for _, m := range resp.Data {
- ids = append(ids, m.ID)
- }
- require.ElementsMatch(t, []string{
- "gpt-5.3-codex-spark",
- }, ids, "影子可用模型由 model_mapping 派生(非写死)")
- }
- func TestAccountHandlerGetAvailableModels_GeminiGoogleOneUsesConservativeCatalog(t *testing.T) {
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 45,
- Name: "google-one",
- Platform: service.PlatformGemini,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- Credentials: map[string]any{
- "oauth_type": "google_one",
- },
- },
- }
- router := setupAvailableModelsRouter(svc)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- var resp struct {
- Data []struct {
- ID string `json:"id"`
- } `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- ids := make([]string, 0, len(resp.Data))
- for _, model := range resp.Data {
- ids = append(ids, model.ID)
- }
- require.ElementsMatch(t, []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"}, ids)
- require.NotContains(t, ids, "gemini-3.5-flash")
- require.NotContains(t, ids, "gemini-2.5-flash-image")
- }
- func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) {
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 44,
- Name: "openai-apikey-missing-key",
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeAPIKey,
- Status: service.StatusActive,
- Credentials: map[string]any{
- "base_url": "https://openai.example.com/v1",
- },
- },
- }
- router := setupSyncUpstreamModelsRouter(svc, &syncUpstreamHTTPUpstream{})
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/44/models/sync-upstream", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusBadRequest, rec.Code)
- require.Contains(t, rec.Body.String(), "No OpenAI API key is available")
- }
- func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) {
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 48, Name: "custom-openai", Platform: service.PlatformOpenAI,
- Type: service.AccountTypeAPIKey, Status: service.StatusActive,
- Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
- },
- }
- upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"models":[{
- "id":"custom-thinking-model",
- "reasoning":true,
- "default_reasoning_level":"high",
- "supported_reasoning_levels":["low","high"],
- "input_modalities":["text","image"],
- "context_window":256000
- }]}`)),
- }}
- router := setupSyncUpstreamModelsRouter(svc, upstream)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/48/models/sync-upstream", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- var resp struct {
- Data service.UpstreamModelCatalog `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- require.Equal(t, []string{"custom-thinking-model"}, resp.Data.Models)
- metadata := resp.Data.Metadata["custom-thinking-model"]
- require.NotNil(t, metadata.Reasoning)
- require.True(t, *metadata.Reasoning)
- require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels)
- require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
- }
- // Scenario: 创建账号 preview 将具体 mapping 传给 404/405 配置回退。
- func TestAccountHandlerSyncUpstreamModelsPreviewUsesProvidedModelMapping(t *testing.T) {
- upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
- {
- StatusCode: http.StatusNotFound,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{
- "configured-provider": {
- "api": "https://provider.example/v1",
- "models": {
- "glm-5.3": {
- "id": "glm-5.3",
- "reasoning": true,
- "reasoning_options": [{"type":"effort","values":["low","high"]}],
- "modalities": {"input":["text"],"output":["text"]},
- "limit": {"context":1000000,"output":131072}
- }
- }
- }
- }`)),
- },
- }}
- router := setupSyncUpstreamModelsRouter(newStubAdminService(), upstream)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(
- http.MethodPost,
- "/api/v1/admin/accounts/models/sync-upstream-preview",
- strings.NewReader(`{
- "platform":"openai",
- "type":"apikey",
- "base_url":"https://provider.example/v1",
- "api_key":"key",
- "model_mapping":{"public-glm":"glm-5.3"}
- }`),
- )
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- var resp struct {
- Data service.UpstreamModelCatalog `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- require.Equal(t, []string{"glm-5.3"}, resp.Data.Models)
- require.Equal(t, []string{"low", "high"}, resp.Data.Metadata["glm-5.3"].SupportedReasoningLevels)
- }
- func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) {
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 45,
- Name: "openai-apikey-upstream-error",
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeAPIKey,
- Status: service.StatusActive,
- Credentials: map[string]any{
- "api_key": "openai-key",
- "base_url": "https://openai.example.com/v1",
- },
- },
- }
- upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{
- StatusCode: http.StatusBadGateway,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"error":"SECRET_TOKEN should not be exposed"}`)),
- }}
- router := setupSyncUpstreamModelsRouter(svc, upstream)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/45/models/sync-upstream", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusBadGateway, rec.Code)
- require.Contains(t, rec.Body.String(), "Upstream model list request failed with HTTP 502")
- require.NotContains(t, rec.Body.String(), "SECRET_TOKEN")
- }
- // Scenario: 能力补全失败显示部分成功。
- func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarning(t *testing.T) {
- svc := &availableModelsAdminService{
- stubAdminService: newStubAdminService(),
- account: service.Account{
- ID: 46,
- Name: "opencode-id-only-model-list",
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeAPIKey,
- Status: service.StatusActive,
- Credentials: map[string]any{
- "api_key": "opencode-key",
- "base_url": "https://opencode.ai/zen/v1",
- },
- },
- }
- upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`)),
- },
- {
- StatusCode: http.StatusBadGateway,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`)),
- },
- }}
- router := setupSyncUpstreamModelsRouter(svc, upstream)
- rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/46/models/sync-upstream", nil)
- router.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
- var resp struct {
- Data struct {
- Models []string `json:"models"`
- Warnings []struct {
- Code string `json:"code"`
- } `json:"warnings"`
- } `json:"data"`
- }
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
- require.Equal(t, []string{"x-preview-f-free"}, resp.Data.Models)
- require.Len(t, resp.Data.Warnings, 1)
- require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code)
- }
|