From 36c00374d30395c3bfbc75d14f2c52bd4835808a Mon Sep 17 00:00:00 2001 From: benjamin Date: Mon, 18 May 2026 19:01:33 +0800 Subject: [PATCH] feat: expose upstream model sync admin API Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../internal/handler/admin/account_handler.go | 42 +++++++++ .../account_handler_available_models_test.go | 92 +++++++++++++++++++ backend/internal/server/routes/admin.go | 1 + 3 files changed, 135 insertions(+) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index ffab74d6..7dddd828 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1994,6 +1994,48 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { response.Success(c, models) } +// SyncUpstreamModels handles syncing live supported models from an account's upstream. +// POST /api/v1/admin/accounts/:id/models/sync-upstream +func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + response.NotFound(c, "Account not found") + return + } + + if h.accountTestService == nil { + response.InternalError(c, "Account test service is not configured") + return + } + + models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account) + if err != nil { + var syncErr *service.UpstreamModelSyncError + if errors.As(err, &syncErr) { + switch syncErr.Kind { + case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported: + response.BadRequest(c, syncErr.SafeMessage()) + default: + slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind) + response.Error(c, http.StatusBadGateway, syncErr.SafeMessage()) + } + return + } + + slog.Warn("sync_upstream_models_failed", "account_id", accountID) + response.Error(c, http.StatusBadGateway, "Failed to sync upstream models from upstream") + return + } + + response.Success(c, gin.H{"models": models}) +} + // SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account // POST /api/v1/admin/accounts/:id/set-privacy func (h *AccountHandler) SetPrivacy(c *gin.Context) { diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go index c5f1e2d8..0efbd6d4 100644 --- a/backend/internal/handler/admin/account_handler_available_models_test.go +++ b/backend/internal/handler/admin/account_handler_available_models_test.go @@ -3,10 +3,14 @@ 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" @@ -33,6 +37,39 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine { return router } +type syncUpstreamHTTPUpstream struct { + resp *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 + } + 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, + upstream, + &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + nil, + ) + handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil) + router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels) + return router +} + func TestAccountHandlerGetAvailableModels_OpenAIOAuthUsesExplicitModelMapping(t *testing.T) { svc := &availableModelsAdminService{ stubAdminService: newStubAdminService(), @@ -103,3 +140,58 @@ func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefau require.NotEmpty(t, resp.Data) require.NotEqual(t, "gpt-5", resp.Data[0].ID) } + +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 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") +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 6e1059bc..92e2f5b6 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -303,6 +303,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.DELETE("/:id/temp-unschedulable", h.Admin.Account.ClearTempUnschedulable) accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable) accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels) + accounts.POST("/:id/models/sync-upstream", h.Admin.Account.SyncUpstreamModels) accounts.POST("/batch", h.Admin.Account.BatchCreate) accounts.GET("/data", h.Admin.Account.ExportData) accounts.POST("/data", h.Admin.Account.ImportData)