mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 04:58:56 +00:00
Merge branch 'main' into fix/issue-6240-preserve-multimodal-tool-output
This commit is contained in:
@@ -2774,13 +2774,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(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())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2793,29 +2795,35 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"models": models})
|
||||
response.Success(c, catalog)
|
||||
}
|
||||
|
||||
// SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed).
|
||||
// POST /api/v1/admin/accounts/models/sync-upstream-preview
|
||||
func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
var req struct {
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key" binding:"required"`
|
||||
ModelMapping map[string]string `json:"model_mapping"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
modelMapping := make(map[string]any, len(req.ModelMapping))
|
||||
for sourceModel, upstreamModel := range req.ModelMapping {
|
||||
modelMapping[sourceModel] = upstreamModel
|
||||
}
|
||||
|
||||
tempAccount := &service.Account{
|
||||
Platform: req.Platform,
|
||||
Type: req.Type,
|
||||
Credentials: map[string]any{
|
||||
"api_key": req.APIKey,
|
||||
"base_url": req.BaseURL,
|
||||
"api_key": req.APIKey,
|
||||
"base_url": req.BaseURL,
|
||||
"model_mapping": modelMapping,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2824,13 +2832,15 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount)
|
||||
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())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2843,7 +2853,7 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"models": models})
|
||||
response.Success(c, catalog)
|
||||
}
|
||||
|
||||
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
|
||||
|
||||
@@ -38,14 +38,20 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
|
||||
}
|
||||
|
||||
type syncUpstreamHTTPUpstream struct {
|
||||
resp *http.Response
|
||||
err error
|
||||
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
|
||||
}
|
||||
|
||||
@@ -68,6 +74,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -347,6 +354,99 @@ func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testin
|
||||
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(),
|
||||
@@ -377,3 +477,52 @@ func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *test
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -312,6 +312,14 @@ func GetInboundEndpoint(c *gin.Context) string {
|
||||
// and the account platform. Handlers call this after scheduling an
|
||||
// account, passing account.Platform.
|
||||
func GetUpstreamEndpoint(c *gin.Context, platform string) string {
|
||||
// OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
|
||||
// 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
|
||||
// ForwardResult,不能把入站 /v1/responses 误报成上游端点。
|
||||
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) {
|
||||
if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
if c != nil {
|
||||
if value, ok := c.Get(ctxKeyActualUpstreamEndpoint); ok {
|
||||
if endpoint, ok := value.(string); ok && endpoint != "" {
|
||||
|
||||
@@ -184,6 +184,16 @@ func TestGetUpstreamEndpointPrefersRuntimeOverride(t *testing.T) {
|
||||
require.Equal(t, EndpointMessages, GetUpstreamEndpoint(c, service.PlatformAntigravity))
|
||||
}
|
||||
|
||||
func TestGetUpstreamEndpointUsesOpenAIRuntimeOverride(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
|
||||
c.Set(ctxKeyInboundEndpoint, EndpointResponses)
|
||||
|
||||
service.SetActualOpenAIUpstreamEndpoint(c, EndpointChatCompletions)
|
||||
require.Equal(t, EndpointChatCompletions, GetUpstreamEndpoint(c, service.PlatformOpenAI))
|
||||
}
|
||||
|
||||
func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -1140,6 +1140,79 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// CodexModels returns the effective group model list using the manifest shape
|
||||
// expected by Codex custom providers. Official OpenAI groups continue to use
|
||||
// OpenAIGatewayHandler.CodexModels so their live upstream metadata is preserved.
|
||||
func (h *GatewayHandler) CodexModels(c *gin.Context) {
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok || apiKey == nil || apiKey.Group == nil {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required")
|
||||
return
|
||||
}
|
||||
|
||||
forcedPlatform := ""
|
||||
if value, exists := middleware2.GetForcePlatformFromContext(c); exists {
|
||||
forcedPlatform = strings.TrimSpace(value)
|
||||
}
|
||||
modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group, forcedPlatform)
|
||||
modelIDs = service.FilterCodexModelIDsForGroup(modelIDs, apiKey.Group)
|
||||
body, err := h.gatewayService.BuildCodexModelsManifestForGroup(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
forcedPlatform,
|
||||
modelIDs,
|
||||
)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
etag := service.CodexModelsManifestETag(body)
|
||||
c.Header("ETag", etag)
|
||||
if service.CodexModelsManifestETagMatches(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", body)
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *service.Group, platformOverride string) []string {
|
||||
if h == nil || h.gatewayService == nil || group == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
groupID := &group.ID
|
||||
platform := strings.TrimSpace(platformOverride)
|
||||
if platform == "" {
|
||||
platform = group.Platform
|
||||
}
|
||||
if platform == service.PlatformComposite {
|
||||
availableModels := h.compositeAvailableModels(ctx, groupID)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
|
||||
if group.CustomModelsListEnabled() {
|
||||
return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models)
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
}
|
||||
return fallbackModels
|
||||
}
|
||||
|
||||
availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(platform)
|
||||
if group.CustomModelsListEnabled() {
|
||||
return filterModelsByCustomList(
|
||||
customModelsListSource(platform, availableModels, fallbackModels),
|
||||
fallbackModels,
|
||||
group.ModelsListConfig.Models,
|
||||
)
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
}
|
||||
return fallbackModels
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string {
|
||||
if h == nil || h.gatewayService == nil {
|
||||
return nil
|
||||
@@ -1340,9 +1413,26 @@ func customModelsListAllowsModel(availablePatterns []string, model string) bool
|
||||
return true
|
||||
}
|
||||
}
|
||||
normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
|
||||
if normalizedClaudeModel != model {
|
||||
for _, pattern := range availablePatterns {
|
||||
if pattern == normalizedClaudeModel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultCodexModelIDsForPlatform(platform string) []string {
|
||||
switch platform {
|
||||
case service.PlatformDeepseek:
|
||||
return []string{"deepseek-v4-pro", "deepseek-v4-flash"}
|
||||
default:
|
||||
return defaultModelIDsForPlatform(platform)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultModelIDsForPlatform(platform string) []string {
|
||||
switch platform {
|
||||
case service.PlatformOpenAI:
|
||||
@@ -1361,14 +1451,7 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
}
|
||||
return ids
|
||||
case service.PlatformAnthropic:
|
||||
ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels()))
|
||||
for _, model := range claude.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
for _, model := range antigravity.DefaultModels() {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return mergeModelIDs(ids, nil)
|
||||
return claude.DefaultModelIDs()
|
||||
case service.PlatformGrok:
|
||||
return xai.DefaultModelIDs()
|
||||
case service.PlatformComposite:
|
||||
|
||||
@@ -25,6 +25,22 @@ type gatewayModelsResponseForTest struct {
|
||||
Data []gatewayModelItemForTest `json:"data"`
|
||||
}
|
||||
|
||||
type codexModelsResponseForTest struct {
|
||||
Models []struct {
|
||||
Slug string `json:"slug"`
|
||||
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
ModelMessages map[string]json.RawMessage `json:"model_messages"`
|
||||
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
|
||||
AvailabilityNUX json.RawMessage `json:"availability_nux"`
|
||||
Upgrade json.RawMessage `json:"upgrade"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
type codexReasoningLevelForTest struct {
|
||||
Effort string `json:"effort"`
|
||||
}
|
||||
|
||||
type gatewayModelItemForTest struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
@@ -52,6 +68,10 @@ func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Cont
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) {
|
||||
return s.ListSchedulableByGroupID(ctx, groupID)
|
||||
}
|
||||
|
||||
func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler {
|
||||
return &GatewayHandler{
|
||||
gatewayService: service.NewGatewayService(
|
||||
@@ -70,6 +90,247 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) {
|
||||
require.Contains(t, compositeIDs, antigravityIDs[0])
|
||||
}
|
||||
|
||||
// Scenario: Anthropic defaults contain only Claude while Antigravity keeps its own Gemini models.
|
||||
func TestDefaultModelIDsForAnthropicExcludeAntigravityGemini(t *testing.T) {
|
||||
anthropicIDs := defaultModelIDsForPlatform(service.PlatformAnthropic)
|
||||
require.Contains(t, anthropicIDs, "claude-opus-4-6")
|
||||
require.NotContains(t, anthropicIDs, "gemini-2.5-flash")
|
||||
|
||||
antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity)
|
||||
require.Contains(t, antigravityIDs, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
// Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list.
|
||||
func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
platform string
|
||||
model string
|
||||
efforts []string
|
||||
modalities []string
|
||||
}{
|
||||
{
|
||||
name: "Grok",
|
||||
platform: service.PlatformGrok,
|
||||
model: "grok-4.6",
|
||||
efforts: []string{"low", "medium", "high", "xhigh"},
|
||||
modalities: []string{"text", "image"},
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
platform: service.PlatformDeepseek,
|
||||
model: "deepseek-v4-pro",
|
||||
efforts: []string{"low", "high", "max"},
|
||||
modalities: []string{"text"},
|
||||
},
|
||||
{
|
||||
name: "provider-qualified Claude",
|
||||
platform: service.PlatformAnthropic,
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
efforts: []string{"low", "medium", "high", "max"},
|
||||
modalities: []string{"text"},
|
||||
},
|
||||
}
|
||||
|
||||
for index, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(100 + index)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: tt.platform,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{tt.model: tt.model},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: tt.platform},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Len(t, got.Models, 1)
|
||||
require.Equal(t, tt.model, got.Models[0].Slug)
|
||||
require.NotEmpty(t, got.Models[0].ModelMessages)
|
||||
require.NotEmpty(t, got.Models[0].TruncationPolicy)
|
||||
require.NotNil(t, got.Models[0].AvailabilityNUX)
|
||||
require.NotNil(t, got.Models[0].Upgrade)
|
||||
require.Equal(t, tt.efforts, codexReasoningEffortsForTest(got.Models[0].SupportedReasoningLevels))
|
||||
require.Equal(t, tt.modalities, got.Models[0].InputModalities)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: Composite manifests aggregate only administrator-configured models.
|
||||
func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 120
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 3,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{},
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: service.PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models))
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 122
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{
|
||||
ID: 1,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"deepseek-v4-pro": "deepseek-v4-pro"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
group := &service.Group{ID: groupID, Platform: service.PlatformDeepseek}
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
firstContext, _ := gin.CreateTestContext(first)
|
||||
firstContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
firstContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
h.CodexModels(firstContext)
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
etag := first.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag)
|
||||
require.Equal(t, service.CodexModelsManifestETag(first.Body.Bytes()), etag)
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
secondContext, _ := gin.CreateTestContext(second)
|
||||
secondContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
secondContext.Request.Header.Set("If-None-Match", "W/"+etag)
|
||||
secondContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
h.CodexModels(secondContext)
|
||||
|
||||
require.Equal(t, http.StatusNotModified, second.Code)
|
||||
require.Empty(t, second.Body.Bytes())
|
||||
require.Equal(t, etag, second.Header().Get("ETag"))
|
||||
}
|
||||
|
||||
// Scenario: group models_list_config limits the generated Codex manifest.
|
||||
func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 121
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: service.PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformComposite,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"grok-4.6"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"grok-4.6"}, codexModelSlugsForTest(got.Models))
|
||||
}
|
||||
|
||||
func codexModelSlugsForTest(models []struct {
|
||||
Slug string `json:"slug"`
|
||||
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
ModelMessages map[string]json.RawMessage `json:"model_messages"`
|
||||
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
|
||||
AvailabilityNUX json.RawMessage `json:"availability_nux"`
|
||||
Upgrade json.RawMessage `json:"upgrade"`
|
||||
}) []string {
|
||||
slugs := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
func codexReasoningEffortsForTest(levels []codexReasoningLevelForTest) []string {
|
||||
efforts := make([]string, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
efforts = append(efforts, level.Effort)
|
||||
}
|
||||
return efforts
|
||||
}
|
||||
|
||||
func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -193,6 +454,62 @@ func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
|
||||
require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
// Scenario: a Composite group with only Anthropic accounts must not inherit Antigravity Gemini defaults.
|
||||
func TestGatewayCodexModels_CompositeAnthropicDoesNotAdvertiseAntigravityDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(64)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformAnthropic}},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
slugs := codexModelSlugsForTest(got.Models)
|
||||
require.Contains(t, slugs, "claude-opus-4-6")
|
||||
require.NotContains(t, slugs, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
// Scenario: Antigravity retains its own Claude and Gemini defaults inside Composite groups.
|
||||
func TestGatewayModels_CompositeAntigravityAdvertisesAntigravityDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(65)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformAntigravity}},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.Models(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
ids := modelIDsForTest(got.Data)
|
||||
require.Contains(t, ids, "claude-opus-4-6")
|
||||
require.Contains(t, ids, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -457,6 +774,89 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
|
||||
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
|
||||
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 130
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
slugs := make([]string, 0, len(got.Models))
|
||||
for _, model := range got.Models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
require.Contains(t, slugs, "deepseek-v4-pro")
|
||||
require.Contains(t, slugs, "deepseek-v4-flash")
|
||||
require.NotContains(t, slugs, "claude-sonnet-4-6")
|
||||
require.NotContains(t, slugs, "claude-opus-4-6")
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_OmitsWildcardMappingKeys(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 131
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"foo-*": "deepseek-v4-pro",
|
||||
"deepseek-v4-pro": "deepseek-v4-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
slugs := make([]string, 0, len(got.Models))
|
||||
for _, model := range got.Models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
require.Equal(t, []string{"deepseek-v4-pro"}, slugs)
|
||||
}
|
||||
|
||||
func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
// Codex CLI and the Codex desktop app refresh their model picker from
|
||||
// GET {base_url}/models?client_version=... (custom provider mode) or
|
||||
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
|
||||
// here. ChatGPT manifests are proxied verbatim; custom API key manifests receive
|
||||
// provider-compatibility normalization and use a short-lived, asynchronously
|
||||
// revalidated cache to tolerate canceled client requests.
|
||||
// here. Groups with explicit account model mappings are generated locally;
|
||||
// otherwise ChatGPT manifests are proxied verbatim and custom API key manifests
|
||||
// receive provider-compatibility normalization plus short-lived caching.
|
||||
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
@@ -32,6 +32,24 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ifNoneMatch := c.GetHeader("If-None-Match")
|
||||
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
ifNoneMatch,
|
||||
)
|
||||
if err != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if configured {
|
||||
writeCodexModelsManifestResponse(c, configuredManifest)
|
||||
return
|
||||
}
|
||||
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
if maxAccountSwitches <= 0 {
|
||||
maxAccountSwitches = 3
|
||||
@@ -56,7 +74,9 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
// 让 ops 错误日志携带实际选中的上游账号,便于定位失效账号(#4544)。
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match"))
|
||||
// The client ETag represents the final group-specific body, so fetch the
|
||||
// source manifest before applying local filtering and alias metadata.
|
||||
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), "")
|
||||
if err != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
@@ -70,18 +90,31 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
|
||||
return
|
||||
}
|
||||
if err := h.gatewayService.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest")
|
||||
return
|
||||
}
|
||||
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if manifest.ETag != "" {
|
||||
c.Header("ETag", manifest.ETag)
|
||||
}
|
||||
if manifest.NotModified {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", manifest.Body)
|
||||
writeCodexModelsManifestResponse(c, manifest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) {
|
||||
if manifest.ETag != "" {
|
||||
c.Header("ETag", manifest.ETag)
|
||||
}
|
||||
if manifest.NotModified {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", manifest.Body)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
middleware2 "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 codexModelsFailoverAccountRepo struct {
|
||||
@@ -43,6 +45,14 @@ func (r codexModelsFailoverAccountRepo) ListSchedulableByPlatform(_ context.Cont
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (r codexModelsFailoverAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]service.Account, error) {
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64) ([]service.Account, error) {
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
type codexModelsFailoverHTTPUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
@@ -116,6 +126,236 @@ func TestCodexModelsCanceledRequestDoesNotWriteResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(43)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "custom-openai",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://upstream.example/v1",
|
||||
},
|
||||
},
|
||||
}}
|
||||
upstream := &codexModelsFailoverHTTPUpstream{
|
||||
firstBody: `{"object":"list","data":[{"id":"codex-auto-review"},{"id":"gpt-5.6"}]}`,
|
||||
}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"codex-auto-review", "gpt-5.6"},
|
||||
},
|
||||
}
|
||||
|
||||
first := performCodexModelsRequestForGroup(t, handler, group, "")
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first status: got %d, want %d; body=%s", first.Code, http.StatusOK, first.Body.String())
|
||||
}
|
||||
if body := first.Body.String(); !strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") {
|
||||
t.Fatalf("first body did not include the explicitly selected models: %s", body)
|
||||
}
|
||||
oldETag := first.Header().Get("ETag")
|
||||
if oldETag == "" {
|
||||
t.Fatal("first response did not include an ETag")
|
||||
}
|
||||
|
||||
group.ModelsListConfig.Enabled = false
|
||||
second := performCodexModelsRequestForGroup(t, handler, group, oldETag)
|
||||
if second.Code != http.StatusOK {
|
||||
t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String())
|
||||
}
|
||||
if body := second.Body.String(); strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") {
|
||||
t.Fatalf("second body was not the filtered manifest: %s", body)
|
||||
}
|
||||
if newETag := second.Header().Get("ETag"); newETag == "" || newETag == oldETag {
|
||||
t.Fatalf("second ETag: got %q, want a new final-body ETag", newETag)
|
||||
}
|
||||
|
||||
third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag"))
|
||||
if third.Code != http.StatusNotModified {
|
||||
t.Fatalf("third status: got %d, want %d; body=%s", third.Code, http.StatusNotModified, third.Body.String())
|
||||
}
|
||||
if third.Body.Len() != 0 {
|
||||
t.Fatalf("third body: got %q, want empty", third.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "shared-api-key",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-shared",
|
||||
"base_url": "https://upstream.example/v1",
|
||||
},
|
||||
},
|
||||
}}
|
||||
upstream := &codexModelsFailoverHTTPUpstream{
|
||||
firstBody: `{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`,
|
||||
}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
|
||||
groupA := &service.Group{
|
||||
ID: 91,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"model-a"},
|
||||
},
|
||||
}
|
||||
groupB := &service.Group{
|
||||
ID: 92,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"model-b"},
|
||||
},
|
||||
}
|
||||
|
||||
firstA := performCodexModelsRequestForGroup(t, handler, groupA, "")
|
||||
require.Equal(t, http.StatusOK, firstA.Code, firstA.Body.String())
|
||||
require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, firstA))
|
||||
|
||||
firstB := performCodexModelsRequestForGroup(t, handler, groupB, "")
|
||||
require.Equal(t, http.StatusOK, firstB.Code, firstB.Body.String())
|
||||
require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, firstB))
|
||||
|
||||
etagA := firstA.Header().Get("ETag")
|
||||
require.NotEmpty(t, etagA)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]*httptest.ResponseRecorder, 8)
|
||||
for i := range results {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
if index%2 == 0 {
|
||||
results[index] = performCodexModelsRequestForGroup(t, handler, groupA, etagA)
|
||||
return
|
||||
}
|
||||
results[index] = performCodexModelsRequestForGroup(t, handler, groupB, "")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sawGroupB := false
|
||||
for _, recorder := range results {
|
||||
require.NotNil(t, recorder)
|
||||
switch recorder.Code {
|
||||
case http.StatusNotModified:
|
||||
require.Empty(t, recorder.Body.Bytes())
|
||||
case http.StatusOK:
|
||||
slugs := codexHandlerManifestSlugs(t, recorder)
|
||||
if len(slugs) == 1 && slugs[0] == "model-b" {
|
||||
sawGroupB = true
|
||||
continue
|
||||
}
|
||||
require.Equal(t, []string{"model-a"}, slugs)
|
||||
default:
|
||||
t.Fatalf("unexpected status %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
require.True(t, sawGroupB)
|
||||
}
|
||||
|
||||
// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。
|
||||
func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(44)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "ark-compatible",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Priority: 0,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-ark",
|
||||
"base_url": "https://ark.example/v1",
|
||||
"model_mapping": map[string]any{
|
||||
"glm-5.3": "glm-5.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Name: "chatgpt-oauth",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Priority: 1,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-test",
|
||||
},
|
||||
},
|
||||
}}
|
||||
upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
|
||||
|
||||
recorder := performCodexModelsRequestForGroup(t, handler, &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
}, "")
|
||||
|
||||
if got := upstream.calls(); len(got) != 0 {
|
||||
t.Fatalf("upstream account calls: got %v, want none", got)
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" {
|
||||
t.Fatalf("models: got %v, want only glm-5.3", envelope.Models)
|
||||
}
|
||||
if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok {
|
||||
t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) {
|
||||
handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusServiceUnavailable)
|
||||
|
||||
@@ -148,8 +388,23 @@ func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
|
||||
t.Fatalf("body: got %q, want %q", got, want)
|
||||
requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: an API-key upstream without /models is excluded only for this discovery request.
|
||||
func TestCodexModelsFailsOverWhenAPIKeyModelsEndpointIsUnavailable(t *testing.T) {
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusMethodNotAllowed} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
handler, upstream, groupID := newCodexModelsFailoverTestHandler(status)
|
||||
recorder := performCodexModelsRequest(t, handler, groupID)
|
||||
|
||||
if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) {
|
||||
t.Fatalf("upstream account calls: got %v, want %v", got, want)
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -183,9 +438,7 @@ func TestCodexModelsFailsOverFromInvalidManifestEnvelope(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
|
||||
t.Fatalf("body: got %q, want %q", got, want)
|
||||
}
|
||||
requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol")
|
||||
}
|
||||
|
||||
func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) {
|
||||
@@ -193,7 +446,6 @@ func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) {
|
||||
http.StatusBadRequest,
|
||||
http.StatusUnauthorized,
|
||||
http.StatusForbidden,
|
||||
http.StatusNotFound,
|
||||
600,
|
||||
}
|
||||
for _, status := range statuses {
|
||||
@@ -300,23 +552,79 @@ func newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, accountCount
|
||||
}
|
||||
|
||||
func performCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, groupID int64) *httptest.ResponseRecorder {
|
||||
return performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformOpenAI)
|
||||
return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: service.PlatformOpenAI}, "")
|
||||
}
|
||||
|
||||
func performCodexModelsRequestForPlatform(t *testing.T, handler *OpenAIGatewayHandler, groupID int64, platform string) *httptest.ResponseRecorder {
|
||||
return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: platform}, "")
|
||||
}
|
||||
|
||||
func performCodexModelsRequestForGroup(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil)
|
||||
if etag != "" {
|
||||
c.Request.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, Platform: platform},
|
||||
GroupID: &group.ID,
|
||||
Group: group,
|
||||
})
|
||||
|
||||
handler.CodexModels(c)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func codexHandlerManifestSlugs(t *testing.T, recorder *httptest.ResponseRecorder) []string {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Models []struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
|
||||
}
|
||||
slugs := make([]string, 0, len(envelope.Models))
|
||||
for _, model := range envelope.Models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.ResponseRecorder, slug string) {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if len(envelope.Models) != 1 {
|
||||
t.Fatalf("models count: got %d, want 1; body=%s", len(envelope.Models), recorder.Body.String())
|
||||
}
|
||||
model := envelope.Models[0]
|
||||
if got := model["slug"]; got != slug {
|
||||
t.Fatalf("slug: got %v, want %q", got, slug)
|
||||
}
|
||||
if levels, ok := model["supported_reasoning_levels"].([]any); !ok || len(levels) == 0 {
|
||||
t.Fatalf("supported_reasoning_levels must be populated: %v", model["supported_reasoning_levels"])
|
||||
}
|
||||
if messages, ok := model["model_messages"].(map[string]any); !ok || messages["instructions_template"] == "" {
|
||||
t.Fatalf("model_messages.instructions_template must be populated: %v", model["model_messages"])
|
||||
}
|
||||
if policy, ok := model["truncation_policy"].(map[string]any); !ok || len(policy) == 0 {
|
||||
t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"])
|
||||
}
|
||||
modalities, ok := model["input_modalities"].([]any)
|
||||
if !ok || len(modalities) != 1 || modalities[0] != "text" {
|
||||
t.Fatalf("custom OpenAI-compatible endpoint modalities: got %v, want [text]", model["input_modalities"])
|
||||
}
|
||||
}
|
||||
|
||||
func equalInt64Slices(got, want []int64) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
effortLowMediumHigh = []string{"low", "medium", "high"}
|
||||
effortLowMediumHighMax = []string{"low", "medium", "high", "max"}
|
||||
effortLowMediumHighXHighMax = []string{"low", "medium", "high", "xhigh", "max"}
|
||||
)
|
||||
|
||||
var effortFamilies = []struct {
|
||||
family string
|
||||
levels []string
|
||||
}{
|
||||
{family: "claude-mythos-preview", levels: effortLowMediumHighMax},
|
||||
{family: "claude-mythos-5", levels: effortLowMediumHighXHighMax},
|
||||
{family: "claude-fable-5", levels: effortLowMediumHighXHighMax},
|
||||
{family: "claude-sonnet-4-6", levels: effortLowMediumHighMax},
|
||||
{family: "claude-sonnet-5", levels: effortLowMediumHighXHighMax},
|
||||
{family: "claude-opus-4-8", levels: effortLowMediumHighXHighMax},
|
||||
{family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax},
|
||||
{family: "claude-opus-4-6", levels: effortLowMediumHighMax},
|
||||
{family: "claude-opus-4-5", levels: effortLowMediumHigh},
|
||||
{family: "claude-opus-5", levels: effortLowMediumHighXHighMax},
|
||||
}
|
||||
|
||||
// EffortLevelsForModel returns the output_config.effort values accepted by a
|
||||
// Claude model, ordered from the lightest to the deepest reasoning level.
|
||||
func EffortLevelsForModel(model string) []string {
|
||||
id := normalizeEffortModelID(model)
|
||||
for _, entry := range effortFamilies {
|
||||
if id == entry.family || strings.HasPrefix(id, entry.family+"-") {
|
||||
return append([]string(nil), entry.levels...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEffortModelID(model string) string {
|
||||
id := strings.ToLower(strings.TrimSpace(model))
|
||||
id = strings.TrimPrefix(id, "models/")
|
||||
if slash := strings.IndexByte(id, '/'); slash >= 0 {
|
||||
id = strings.TrimPrefix(strings.TrimSpace(id[slash+1:]), "models/")
|
||||
}
|
||||
id = strings.TrimPrefix(id, "anthropic.")
|
||||
id = strings.TrimSuffix(id, "-thinking")
|
||||
if mapped, ok := ModelIDReverseOverrides[id]; ok {
|
||||
id = mapped
|
||||
}
|
||||
if len(id) >= 9 {
|
||||
suffix := id[len(id)-9:]
|
||||
if suffix[0] == '-' {
|
||||
digits := true
|
||||
for _, r := range suffix[1:] {
|
||||
if !unicode.IsDigit(r) {
|
||||
digits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if digits {
|
||||
id = id[:len(id)-9]
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEffortLevelsForModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
model string
|
||||
want []string
|
||||
}{
|
||||
{model: "claude-opus-4-6", want: []string{"low", "medium", "high", "max"}},
|
||||
{model: "anthropic/claude-sonnet-4-6", want: []string{"low", "medium", "high", "max"}},
|
||||
{model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
|
||||
{model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}},
|
||||
{model: "claude-haiku-4-5-20251001", want: nil},
|
||||
{model: "gpt-5.6", want: nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.model, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.want, EffortLevelsForModel(tt.model))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,30 @@ func IsGrokModelID(model string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsGrokImagineModel reports whether model is a Grok Imagine image or video
|
||||
// model. These media models cannot act as the primary Codex agent model.
|
||||
func IsGrokImagineModel(model string) bool {
|
||||
normalized := strings.ToLower(StripGrokProviderPrefix(model))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(normalized, "imagine") {
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case normalized == "grok-imagine",
|
||||
normalized == "grok-imagine-1",
|
||||
normalized == "grok-imagine-edit",
|
||||
normalized == "grok-video-1.5":
|
||||
return true
|
||||
case strings.HasPrefix(normalized, "grok-imagine-image"),
|
||||
strings.HasPrefix(normalized, "grok-imagine-video"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsGrokTextResponsesModelID reports whether model is a known Grok text model
|
||||
// for the Responses API. Imagine image/video and unknown custom ids return false.
|
||||
func IsGrokTextResponsesModelID(model string) bool {
|
||||
|
||||
@@ -58,6 +58,16 @@ func TestIsGrokModelID(t *testing.T) {
|
||||
require.False(t, IsGrokModelID("claude-sonnet-4"))
|
||||
}
|
||||
|
||||
func TestIsGrokImagineModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.True(t, IsGrokImagineModel("grok-imagine-image"))
|
||||
require.True(t, IsGrokImagineModel("grok-imagine-video-1.5-preview"))
|
||||
require.True(t, IsGrokImagineModel("xai/grok-imagine-image-quality"))
|
||||
require.True(t, IsGrokImagineModel("grok-video-1.5"))
|
||||
require.False(t, IsGrokImagineModel("grok-4.6"))
|
||||
require.False(t, IsGrokImagineModel("grok-build-0.1"))
|
||||
}
|
||||
|
||||
func TestDefaultModelsIncludesGrok46(t *testing.T) {
|
||||
t.Parallel()
|
||||
ids := DefaultModelIDs()
|
||||
|
||||
@@ -65,13 +65,13 @@ func RegisterGatewayRoutes(
|
||||
h.Gateway.CountTokens(c)
|
||||
}
|
||||
}
|
||||
codexModelsHandler := func(c *gin.Context) {
|
||||
dispatchCodexModelsGateway(c, h.OpenAIGateway.CodexModels, h.Gateway.CodexModels)
|
||||
}
|
||||
modelsHandler := func(c *gin.Context) {
|
||||
if c.Query("client_version") != "" {
|
||||
switch getGroupPlatform(c) {
|
||||
case service.PlatformOpenAI, service.PlatformComposite:
|
||||
h.OpenAIGateway.CodexModels(c)
|
||||
return
|
||||
}
|
||||
codexModelsHandler(c)
|
||||
return
|
||||
}
|
||||
h.Gateway.Models(c)
|
||||
}
|
||||
@@ -377,7 +377,7 @@ func RegisterGatewayRoutes(
|
||||
codexDirect.GET("/responses", func(c *gin.Context) {
|
||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||
})
|
||||
codexDirect.GET("/models", h.OpenAIGateway.CodexModels)
|
||||
codexDirect.GET("/models", codexModelsHandler)
|
||||
}
|
||||
// OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform
|
||||
r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
|
||||
@@ -504,6 +504,14 @@ func RegisterGatewayRoutes(
|
||||
|
||||
}
|
||||
|
||||
func dispatchCodexModelsGateway(c *gin.Context, openAIHandler, generatedHandler gin.HandlerFunc) {
|
||||
if getGroupPlatform(c) == service.PlatformOpenAI {
|
||||
openAIHandler(c)
|
||||
return
|
||||
}
|
||||
generatedHandler(c)
|
||||
}
|
||||
|
||||
// getGroupPlatform extracts the group platform from the API Key stored in context.
|
||||
func getGroupPlatform(c *gin.Context) string {
|
||||
apiKey, ok := middleware.GetAPIKeyFromContext(c)
|
||||
|
||||
@@ -2,8 +2,12 @@ package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -22,3 +26,39 @@ func TestGatewayRoutesCodexModelsManifestPathIsRegistered(t *testing.T) {
|
||||
require.NotEmpty(t, registered["/models"], "GET /models should be registered")
|
||||
require.Equal(t, registered["/v1/models"], registered["/models"], "root alias should use the same platform-aware handler")
|
||||
}
|
||||
|
||||
func TestDispatchCodexModelsGatewayKeepsOnlyOpenAIOnLiveManifestHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
platform string
|
||||
wantOpenAI bool
|
||||
}{
|
||||
{platform: service.PlatformOpenAI, wantOpenAI: true},
|
||||
{platform: service.PlatformComposite},
|
||||
{platform: service.PlatformGrok},
|
||||
{platform: service.PlatformDeepseek},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.platform, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{Platform: tt.platform},
|
||||
})
|
||||
called := ""
|
||||
|
||||
dispatchCodexModelsGateway(c,
|
||||
func(c *gin.Context) { called = "openai" },
|
||||
func(c *gin.Context) { called = "generated" },
|
||||
)
|
||||
|
||||
if tt.wantOpenAI {
|
||||
require.Equal(t, "openai", called)
|
||||
} else {
|
||||
require.Equal(t, "generated", called)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,9 @@ type AccountTestService struct {
|
||||
cfg *config.Config
|
||||
settingService *SettingService
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
modelMetadataRegistryMu sync.Mutex
|
||||
modelMetadataRegistry map[string]modelsDevProvider
|
||||
modelMetadataRegistryAt time.Time
|
||||
pluginManager *PluginManager
|
||||
agentIdentityTaskMu sync.Mutex
|
||||
agentIdentityWS agentIdentityWSConnectionInvalidator
|
||||
|
||||
@@ -23,8 +23,19 @@ const (
|
||||
|
||||
CompositeRouteSourceExplicit = "route"
|
||||
CompositeRouteSourceDetector = "detector"
|
||||
CompositeRouteSourceAccount = "account_model"
|
||||
)
|
||||
|
||||
// CompositeModelOwnership identifies the concrete provider that exposes a
|
||||
// public model through an account-level exact mapping.
|
||||
type CompositeModelOwnership struct {
|
||||
TargetPlatform string
|
||||
Matched bool
|
||||
Ambiguous bool
|
||||
}
|
||||
|
||||
type CompositeModelOwnershipResolver func(context.Context, int64, string) (CompositeModelOwnership, error)
|
||||
|
||||
var (
|
||||
ErrCompositeRouteNotFound = infraerrors.NotFound("COMPOSITE_ROUTE_NOT_FOUND", "composite route not found")
|
||||
ErrCompositeRouteExists = infraerrors.Conflict("COMPOSITE_ROUTE_EXISTS", "composite route already exists")
|
||||
|
||||
@@ -8,6 +8,150 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type compositeOwnershipAccountRepo struct {
|
||||
AccountRepository
|
||||
accounts []Account
|
||||
}
|
||||
|
||||
func (r *compositeOwnershipAccountRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) {
|
||||
return r.accounts, nil
|
||||
}
|
||||
|
||||
// Scenario: 唯一平台的精确别名可路由
|
||||
func TestResolveCompositeModelOwnershipKeepsProviderAccountsIsolated(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
repo := &compositeOwnershipAccountRepo{
|
||||
accounts: []Account{
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"gpt-public": "gpt-5"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformDeepseek,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"reasoning-alias": "deepseek-v4-pro"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{accountRepo: repo}
|
||||
|
||||
deepSeekOwnership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "reasoning-alias")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, deepSeekOwnership)
|
||||
|
||||
openAIOwnership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "gpt-public")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, openAIOwnership)
|
||||
}
|
||||
|
||||
// Scenario: 通配符和空映射不声明所有权
|
||||
func TestResolveCompositeModelOwnershipRequiresNonEmptyExactMappings(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
repo := &compositeOwnershipAccountRepo{
|
||||
accounts: []Account{
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"*": "gpt-5", "gpt-*": "gpt-5", "empty-alias": ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"grok-public": "grok-4"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{accountRepo: repo}
|
||||
|
||||
for _, model := range []string{"gpt-5", "empty-alias", "unknown-alias"} {
|
||||
ownership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, model)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, CompositeModelOwnership{}, ownership, "model=%s", model)
|
||||
}
|
||||
|
||||
ownership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "grok-public")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformGrok, Matched: true}, ownership)
|
||||
}
|
||||
|
||||
func TestResolveCompositeModelOwnershipAllowsSamePlatformAndRejectsCrossPlatformAliases(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
repo := &compositeOwnershipAccountRepo{
|
||||
accounts: []Account{
|
||||
{ID: 1, Platform: PlatformOpenAI, Credentials: map[string]any{"model_mapping": map[string]any{"shared-openai": "gpt-5", "ambiguous": "gpt-5"}}},
|
||||
{ID: 2, Platform: PlatformOpenAI, Credentials: map[string]any{"model_mapping": map[string]any{"shared-openai": "gpt-5.1"}}},
|
||||
{ID: 3, Platform: PlatformDeepseek, Credentials: map[string]any{"model_mapping": map[string]any{"ambiguous": "deepseek-v4-pro"}}},
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{accountRepo: repo}
|
||||
|
||||
samePlatform, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "shared-openai")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, samePlatform)
|
||||
|
||||
ambiguous, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "ambiguous")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, CompositeModelOwnership{Ambiguous: true}, ambiguous)
|
||||
}
|
||||
|
||||
func TestNewGatewayServiceWiresCompositeModelOwnershipResolver(t *testing.T) {
|
||||
groupID := int64(7)
|
||||
repo := &compositeOwnershipAccountRepo{
|
||||
accounts: []Account{{
|
||||
ID: 1,
|
||||
Platform: PlatformDeepseek,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"reasoning-alias": "deepseek-v4-pro"}},
|
||||
}},
|
||||
}
|
||||
resolver := NewCompositeRouteResolver(nil)
|
||||
svc := NewGatewayService(
|
||||
repo,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
resolver,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
require.Same(t, resolver, svc.compositeResolver)
|
||||
|
||||
decision, err := resolver.Resolve(context.Background(), groupID, "reasoning-alias", CompositeRouteEndpointResponses)
|
||||
require.NoError(t, err)
|
||||
require.True(t, decision.Matched)
|
||||
require.Equal(t, CompositeRouteSourceAccount, decision.Source)
|
||||
require.Equal(t, PlatformDeepseek, decision.TargetPlatform)
|
||||
}
|
||||
|
||||
func TestDetectModelPlatform(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -8,13 +8,20 @@ import (
|
||||
)
|
||||
|
||||
type CompositeRouteResolver struct {
|
||||
repo CompositeModelRouteRepository
|
||||
repo CompositeModelRouteRepository
|
||||
modelOwnershipResolver CompositeModelOwnershipResolver
|
||||
}
|
||||
|
||||
func NewCompositeRouteResolver(repo CompositeModelRouteRepository) *CompositeRouteResolver {
|
||||
return &CompositeRouteResolver{repo: repo}
|
||||
}
|
||||
|
||||
func (r *CompositeRouteResolver) SetModelOwnershipResolver(resolver CompositeModelOwnershipResolver) {
|
||||
if r != nil {
|
||||
r.modelOwnershipResolver = resolver
|
||||
}
|
||||
}
|
||||
|
||||
func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, model, endpoint string) (CompositeRouteDecision, error) {
|
||||
model = strings.TrimSpace(model)
|
||||
endpoint = normalizeCompositeRouteEndpoint(endpoint)
|
||||
@@ -51,6 +58,35 @@ func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, mod
|
||||
}
|
||||
}
|
||||
|
||||
if r != nil && r.modelOwnershipResolver != nil && groupID > 0 {
|
||||
ownership, err := r.modelOwnershipResolver(ctx, groupID, model)
|
||||
if err != nil {
|
||||
// A recognizable model can still use the existing detector when the
|
||||
// account catalog is temporarily unavailable. Unknown aliases cannot.
|
||||
if _, detectable := DetectModelPlatform(model); !detectable {
|
||||
return decision, fmt.Errorf("resolve account model ownership: %w", err)
|
||||
}
|
||||
} else if ownership.Ambiguous {
|
||||
decision.Reason = "model is exposed by multiple provider platforms"
|
||||
return decision, nil
|
||||
} else if ownership.Matched {
|
||||
platform := strings.TrimSpace(ownership.TargetPlatform)
|
||||
if !isConcreteRequestPlatform(platform) {
|
||||
decision.Reason = "account model ownership has no concrete target platform"
|
||||
return decision, nil
|
||||
}
|
||||
return CompositeRouteDecision{
|
||||
Matched: true,
|
||||
Source: CompositeRouteSourceAccount,
|
||||
GroupID: groupID,
|
||||
PublicModel: model,
|
||||
TargetPlatform: platform,
|
||||
UpstreamModel: model,
|
||||
Endpoint: endpoint,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if platform, ok := DetectModelPlatform(model); ok {
|
||||
return CompositeRouteDecision{
|
||||
Matched: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -69,6 +70,98 @@ func TestCompositeRouteResolverExplicitExactRouteRewritesModel(t *testing.T) {
|
||||
require.Equal(t, int64(10), decision.Route.ID)
|
||||
}
|
||||
|
||||
// Scenario: 唯一平台的精确别名可路由
|
||||
func TestCompositeRouteResolverUsesAccountModelOwnershipForUnprefixedAlias(t *testing.T) {
|
||||
resolver := NewCompositeRouteResolver(nil)
|
||||
resolver.SetModelOwnershipResolver(func(_ context.Context, groupID int64, model string) (CompositeModelOwnership, error) {
|
||||
require.Equal(t, int64(7), groupID)
|
||||
require.Equal(t, "reasoning-alias", model)
|
||||
return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil
|
||||
})
|
||||
|
||||
decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, decision.Matched)
|
||||
require.Equal(t, CompositeRouteSourceAccount, decision.Source)
|
||||
require.Equal(t, PlatformDeepseek, decision.TargetPlatform)
|
||||
require.Equal(t, "reasoning-alias", decision.UpstreamModel)
|
||||
}
|
||||
|
||||
func TestCompositeRouteResolverAccountOwnershipOverridesBuiltInDetector(t *testing.T) {
|
||||
resolver := NewCompositeRouteResolver(nil)
|
||||
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
|
||||
return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil
|
||||
})
|
||||
|
||||
decision, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, decision.Matched)
|
||||
require.Equal(t, CompositeRouteSourceAccount, decision.Source)
|
||||
require.Equal(t, PlatformDeepseek, decision.TargetPlatform)
|
||||
}
|
||||
|
||||
// Scenario: 显式路由保持最高优先级
|
||||
func TestCompositeRouteResolverExplicitRouteBeatsAccountOwnership(t *testing.T) {
|
||||
resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
|
||||
routes: []CompositeModelRoute{{
|
||||
ID: 10,
|
||||
GroupID: 7,
|
||||
PublicModel: "reasoning-alias",
|
||||
MatchType: CompositeRouteMatchExact,
|
||||
TargetPlatform: PlatformOpenAI,
|
||||
UpstreamModel: "gpt-5",
|
||||
Endpoint: CompositeRouteEndpointAny,
|
||||
Enabled: true,
|
||||
}},
|
||||
})
|
||||
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
|
||||
return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil
|
||||
})
|
||||
|
||||
decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, decision.Matched)
|
||||
require.Equal(t, CompositeRouteSourceExplicit, decision.Source)
|
||||
require.Equal(t, PlatformOpenAI, decision.TargetPlatform)
|
||||
require.Equal(t, "gpt-5", decision.UpstreamModel)
|
||||
}
|
||||
|
||||
// Scenario: 跨平台同名别名不被猜测
|
||||
func TestCompositeRouteResolverDoesNotGuessAmbiguousAccountOwnership(t *testing.T) {
|
||||
resolver := NewCompositeRouteResolver(nil)
|
||||
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
|
||||
return CompositeModelOwnership{Ambiguous: true}, nil
|
||||
})
|
||||
|
||||
decision, err := resolver.Resolve(context.Background(), 7, "shared-alias", CompositeRouteEndpointChatCompletions)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, decision.Matched)
|
||||
require.Empty(t, decision.TargetPlatform)
|
||||
require.Equal(t, "model is exposed by multiple provider platforms", decision.Reason)
|
||||
}
|
||||
|
||||
func TestCompositeRouteResolverOwnershipLookupErrorFallsBackOnlyForDetectableModels(t *testing.T) {
|
||||
lookupErr := errors.New("account catalog unavailable")
|
||||
resolver := NewCompositeRouteResolver(nil)
|
||||
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
|
||||
return CompositeModelOwnership{}, lookupErr
|
||||
})
|
||||
|
||||
detected, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses)
|
||||
require.NoError(t, err)
|
||||
require.True(t, detected.Matched)
|
||||
require.Equal(t, CompositeRouteSourceDetector, detected.Source)
|
||||
require.Equal(t, PlatformOpenAI, detected.TargetPlatform)
|
||||
|
||||
unknown, err := resolver.Resolve(context.Background(), 7, "company-model", CompositeRouteEndpointResponses)
|
||||
require.ErrorIs(t, err, lookupErr)
|
||||
require.False(t, unknown.Matched)
|
||||
}
|
||||
|
||||
func TestCompositeRouteResolverPrefersEndpointSpecificLongestPrefix(t *testing.T) {
|
||||
resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
|
||||
routes: []CompositeModelRoute{
|
||||
|
||||
@@ -564,6 +564,46 @@ func TestGetAvailableModels_UsesShortCacheAndSupportsInvalidation(t *testing.T)
|
||||
require.Equal(t, int64(2), store)
|
||||
}
|
||||
|
||||
// Scenario: 账号模型变更会失效所属平台缓存
|
||||
func TestResolveCompositeModelOwnershipUsesModelsCacheInvalidation(t *testing.T) {
|
||||
groupID := int64(9)
|
||||
repo := &modelsListAccountRepoStub{
|
||||
byGroup: map[int64][]Account{
|
||||
groupID: {{
|
||||
ID: 1,
|
||||
Platform: PlatformDeepseek,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"company-model": "deepseek-v4-pro"}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
modelsListCache: gocache.New(time.Minute, time.Minute),
|
||||
modelsListCacheTTL: time.Minute,
|
||||
}
|
||||
|
||||
first, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, first)
|
||||
require.Equal(t, int64(1), repo.listByGroupCalls.Load())
|
||||
|
||||
repo.byGroup[groupID] = []Account{{
|
||||
ID: 2,
|
||||
Platform: PlatformOpenAI,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"company-model": "gpt-5"}},
|
||||
}}
|
||||
cached, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first, cached)
|
||||
require.Equal(t, int64(1), repo.listByGroupCalls.Load())
|
||||
|
||||
svc.InvalidateAvailableModelsCache(&groupID, PlatformDeepseek)
|
||||
refreshed, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, refreshed)
|
||||
require.Equal(t, int64(2), repo.listByGroupCalls.Load())
|
||||
}
|
||||
|
||||
func TestGetAvailableModels_ErrorAndGlobalListBranches(t *testing.T) {
|
||||
resetGatewayHotpathStatsForTest()
|
||||
|
||||
|
||||
@@ -395,6 +395,61 @@ func TestGatewayService_SelectAccountForModelWithPlatform_Anthropic(t *testing.T
|
||||
require.Equal(t, PlatformAnthropic, acc.Platform, "应只返回 anthropic 平台账户")
|
||||
}
|
||||
|
||||
// Scenario: account-owned Composite aliases are scheduled only to accounts that declare the exact mapping.
|
||||
func TestGatewayService_SelectAccountForModelWithExclusions_CompositeAliasRequiresOwningAccount(t *testing.T) {
|
||||
groupID := int64(77)
|
||||
repo := &mockAccountRepoForPlatform{
|
||||
accounts: []Account{
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Priority: 1,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
AccountGroups: []AccountGroup{{GroupID: groupID}},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Priority: 2,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"},
|
||||
},
|
||||
AccountGroups: []AccountGroup{{GroupID: groupID}},
|
||||
},
|
||||
},
|
||||
accountsByID: map[int64]*Account{},
|
||||
}
|
||||
for i := range repo.accounts {
|
||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||
}
|
||||
|
||||
group := &Group{ID: groupID, Platform: PlatformComposite, Status: StatusActive, Hydrated: true}
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
groupRepo: &mockGroupRepoForGateway{groups: map[int64]*Group{groupID: group}},
|
||||
cfg: testConfig(),
|
||||
}
|
||||
ctx := WithCompositeRouteDecision(context.Background(), CompositeRouteDecision{
|
||||
Matched: true,
|
||||
Source: CompositeRouteSourceAccount,
|
||||
GroupID: groupID,
|
||||
PublicModel: "reasoning-alias",
|
||||
TargetPlatform: PlatformAnthropic,
|
||||
UpstreamModel: "reasoning-alias",
|
||||
Endpoint: CompositeRouteEndpointResponses,
|
||||
})
|
||||
|
||||
account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "reasoning-alias", nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, account)
|
||||
require.Equal(t, int64(2), account.ID)
|
||||
}
|
||||
|
||||
// TestGatewayService_SelectAccountForModelWithPlatform_Antigravity 测试 antigravity 单平台选择
|
||||
func TestGatewayService_SelectAccountForModelWithPlatform_Antigravity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -2532,6 +2532,11 @@ func summarizeSelectionFailureStats(stats selectionFailureStats) string {
|
||||
// isModelSupportedByAccountWithContext 根据账户平台检查模型支持(带 context)
|
||||
// 对于 Antigravity 平台,会先获取映射后的最终模型名(包括 thinking 后缀)再检查支持
|
||||
func (s *GatewayService) isModelSupportedByAccountWithContext(ctx context.Context, account *Account, requestedModel string) bool {
|
||||
if source, ok := CompositeRouteSourceFromContext(ctx); ok && source == CompositeRouteSourceAccount {
|
||||
if publicModel, modelOK := RequestedPublicModelFromContext(ctx); modelOK && !explicitModelMappingClaims(*account, publicModel) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if account.Platform == PlatformAntigravity {
|
||||
if strings.TrimSpace(requestedModel) == "" {
|
||||
return true
|
||||
|
||||
@@ -71,8 +71,9 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
cacheTTLTarget5m = "5m"
|
||||
cacheTTLTarget1h = "1h"
|
||||
cacheTTLTarget5m = "5m"
|
||||
cacheTTLTarget1h = "1h"
|
||||
compositeModelOwnershipCachePrefix = "composite-owner|"
|
||||
)
|
||||
|
||||
// ForceCacheBillingContextKey 强制缓存计费上下文键
|
||||
@@ -528,6 +529,10 @@ func modelsListCacheKey(groupID *int64, platform string) string {
|
||||
return fmt.Sprintf("%d|%s", derefGroupID(groupID), strings.TrimSpace(platform))
|
||||
}
|
||||
|
||||
func compositeModelOwnershipCacheKey(groupID int64, model string) string {
|
||||
return fmt.Sprintf("%s%d|%s", compositeModelOwnershipCachePrefix, groupID, strings.TrimSpace(model))
|
||||
}
|
||||
|
||||
func prefetchedStickyGroupIDFromContext(ctx context.Context) (int64, bool) {
|
||||
return PrefetchedStickyGroupIDFromContext(ctx)
|
||||
}
|
||||
@@ -858,6 +863,9 @@ func NewGatewayService(
|
||||
balanceNotifyService: balanceNotifyService,
|
||||
userPlatformQuotaRepo: userPlatformQuotaRepo,
|
||||
}
|
||||
if compositeResolver != nil {
|
||||
compositeResolver.SetModelOwnershipResolver(svc.resolveCompositeModelOwnership)
|
||||
}
|
||||
svc.userGroupRateResolver = newUserGroupRateResolver(
|
||||
userGroupRateRepo,
|
||||
svc.userGroupRateCache,
|
||||
@@ -1447,6 +1455,59 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64,
|
||||
return cloneStringSlice(models)
|
||||
}
|
||||
|
||||
func (s *GatewayService) resolveCompositeModelOwnership(ctx context.Context, groupID int64, model string) (CompositeModelOwnership, error) {
|
||||
model = strings.TrimSpace(model)
|
||||
if s == nil || s.accountRepo == nil || groupID <= 0 || model == "" {
|
||||
return CompositeModelOwnership{}, nil
|
||||
}
|
||||
|
||||
cacheKey := compositeModelOwnershipCacheKey(groupID, model)
|
||||
if s.modelsListCache != nil {
|
||||
if cached, found := s.modelsListCache.Get(cacheKey); found {
|
||||
if ownership, ok := cached.(CompositeModelOwnership); ok {
|
||||
return ownership, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID)
|
||||
if err != nil {
|
||||
return CompositeModelOwnership{}, err
|
||||
}
|
||||
|
||||
platforms := make(map[string]struct{})
|
||||
for _, account := range accounts {
|
||||
platform := strings.TrimSpace(account.Platform)
|
||||
if !isConcreteRequestPlatform(platform) || !explicitModelMappingClaims(account, model) {
|
||||
continue
|
||||
}
|
||||
platforms[platform] = struct{}{}
|
||||
}
|
||||
|
||||
ownership := CompositeModelOwnership{}
|
||||
if len(platforms) == 1 {
|
||||
for platform := range platforms {
|
||||
ownership.TargetPlatform = platform
|
||||
}
|
||||
ownership.Matched = true
|
||||
} else if len(platforms) > 1 {
|
||||
ownership.Ambiguous = true
|
||||
}
|
||||
|
||||
if s.modelsListCache != nil {
|
||||
s.modelsListCache.Set(cacheKey, ownership, s.modelsListCacheTTL)
|
||||
}
|
||||
return ownership, nil
|
||||
}
|
||||
|
||||
func explicitModelMappingClaims(account Account, model string) bool {
|
||||
if account.Credentials == nil || model == "" {
|
||||
return false
|
||||
}
|
||||
mapped, ok := stringMappingFromRaw(account.Credentials["model_mapping"])[model]
|
||||
return ok && strings.TrimSpace(mapped) != ""
|
||||
}
|
||||
|
||||
// GetSchedulablePlatforms returns the concrete platforms that currently have
|
||||
// schedulable accounts in the target group.
|
||||
func (s *GatewayService) GetSchedulablePlatforms(ctx context.Context, groupID *int64) map[string]struct{} {
|
||||
@@ -1479,6 +1540,7 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform
|
||||
if s == nil || s.modelsListCache == nil {
|
||||
return
|
||||
}
|
||||
s.invalidateCompositeModelOwnershipCache(groupID)
|
||||
|
||||
normalizedPlatform := strings.TrimSpace(platform)
|
||||
// 完整匹配时精准失效;否则按维度批量失效。
|
||||
@@ -1507,6 +1569,26 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GatewayService) invalidateCompositeModelOwnershipCache(groupID *int64) {
|
||||
for key := range s.modelsListCache.Items() {
|
||||
if !strings.HasPrefix(key, compositeModelOwnershipCachePrefix) {
|
||||
continue
|
||||
}
|
||||
if groupID == nil {
|
||||
s.modelsListCache.Delete(key)
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(key, compositeModelOwnershipCachePrefix), "|", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
cachedGroupID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && cachedGroupID == *groupID {
|
||||
s.modelsListCache.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debugGatewayBodyDefaultFilename = "gateway_debug.log"
|
||||
|
||||
// initDebugGatewayBodyFile 初始化网关调试日志文件。
|
||||
|
||||
@@ -133,6 +133,17 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont
|
||||
return false
|
||||
}
|
||||
|
||||
// Self-built images requests always carry a matching image_generation tool, so a
|
||||
// "tool choice not found in 'tools'" 400 means upstream revoked this account's
|
||||
// image capability. Gated on the self-built marker: passthrough clients control
|
||||
// their own tools/tool_choice and could otherwise poison a healthy account.
|
||||
if isOpenAIImagesSelfBuiltRequest(ctx) && isOpenAIImageCapabilityLossError(statusCode, responseBody) {
|
||||
if s != nil && s.rateLimitService != nil {
|
||||
_ = s.rateLimitService.HandleOpenAIImageCapabilityLoss(stateCtx, account, statusCode, responseBody)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if s == nil || account == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
func groupCodexModelMetadata(
|
||||
platform string,
|
||||
modelID string,
|
||||
accounts []Account,
|
||||
compositeRoutes []CompositeModelRoute,
|
||||
compositeRoutesAvailable bool,
|
||||
) (codexModelMetadataOverride, bool) {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if modelID == "" {
|
||||
return codexModelMetadataOverride{}, false
|
||||
}
|
||||
upstreamModel := modelID
|
||||
if platform == PlatformComposite {
|
||||
var resolved bool
|
||||
platform, upstreamModel, resolved = resolveCodexCompositeModelTarget(
|
||||
modelID,
|
||||
accounts,
|
||||
compositeRoutes,
|
||||
compositeRoutesAvailable,
|
||||
)
|
||||
if !resolved {
|
||||
if codexExplicitModelTargetsConflict(accounts, modelID) {
|
||||
return codexModelMetadataOverride{
|
||||
reasoningConflict: true,
|
||||
inputModalitiesConflict: true,
|
||||
}, true
|
||||
}
|
||||
return codexModelMetadataOverride{}, false
|
||||
}
|
||||
}
|
||||
if !isConcreteRequestPlatform(platform) {
|
||||
return codexModelMetadataOverride{}, false
|
||||
}
|
||||
|
||||
explicitClaims := false
|
||||
if upstreamModel == modelID {
|
||||
for _, account := range accounts {
|
||||
if account.Platform == platform && codexExplicitModelMappingClaims(account, modelID) {
|
||||
explicitClaims = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
explicitTargetsConflict := explicitClaims && codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID)
|
||||
publicAlias := upstreamModel != modelID
|
||||
candidates := make([]UpstreamModelMetadata, 0)
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.Platform != platform {
|
||||
continue
|
||||
}
|
||||
var lookupModel string
|
||||
if explicitClaims {
|
||||
if !codexExplicitModelMappingClaims(*account, modelID) {
|
||||
continue
|
||||
}
|
||||
lookupModel = account.GetMappedModel(modelID)
|
||||
} else {
|
||||
if !account.IsModelSupported(upstreamModel) {
|
||||
continue
|
||||
}
|
||||
lookupModel = account.GetMappedModel(upstreamModel)
|
||||
}
|
||||
if strings.TrimSpace(lookupModel) != modelID {
|
||||
publicAlias = true
|
||||
}
|
||||
metadata, ok := account.GetUpstreamModelMetadata(lookupModel)
|
||||
if !ok {
|
||||
if explicitTargetsConflict {
|
||||
return codexModelMetadataOverride{
|
||||
reasoningConflict: true,
|
||||
inputModalitiesConflict: true,
|
||||
}, true
|
||||
}
|
||||
return codexModelMetadataOverride{}, false
|
||||
}
|
||||
candidates = append(candidates, metadata)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return codexModelMetadataOverride{}, false
|
||||
}
|
||||
metadata := intersectUpstreamModelMetadata(modelID, candidates)
|
||||
if publicAlias {
|
||||
metadata.DisplayName = modelID
|
||||
metadata.Description = configuredCodexCustomDescription
|
||||
}
|
||||
return metadata, true
|
||||
}
|
||||
|
||||
func codexExplicitModelTargetsConflict(accounts []Account, modelID string) bool {
|
||||
targets := make(map[string]struct{})
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
mappedModel, matched := account.ResolveMappedModel(modelID)
|
||||
mappedModel = strings.TrimSpace(mappedModel)
|
||||
if !matched || mappedModel == "" {
|
||||
continue
|
||||
}
|
||||
targets[strings.TrimSpace(account.Platform)+"\x00"+mappedModel] = struct{}{}
|
||||
}
|
||||
return len(targets) > 1
|
||||
}
|
||||
|
||||
func codexExplicitModelTargetsConflictForPlatform(accounts []Account, platform, modelID string) bool {
|
||||
targets := make(map[string]struct{})
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.Platform != platform {
|
||||
continue
|
||||
}
|
||||
mappedModel, matched := account.ResolveMappedModel(modelID)
|
||||
mappedModel = strings.TrimSpace(mappedModel)
|
||||
if !matched || mappedModel == "" {
|
||||
continue
|
||||
}
|
||||
targets[mappedModel] = struct{}{}
|
||||
}
|
||||
return len(targets) > 1
|
||||
}
|
||||
|
||||
func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMetadata) codexModelMetadataOverride {
|
||||
result := codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ID: strings.TrimSpace(modelID)}}
|
||||
for _, candidate := range candidates {
|
||||
if result.DisplayName == "" && strings.TrimSpace(candidate.DisplayName) != "" {
|
||||
result.DisplayName = strings.TrimSpace(candidate.DisplayName)
|
||||
}
|
||||
if result.Description == "" && strings.TrimSpace(candidate.Description) != "" {
|
||||
result.Description = strings.TrimSpace(candidate.Description)
|
||||
}
|
||||
}
|
||||
|
||||
reasoningKnown := true
|
||||
reasoningValue := false
|
||||
for i, candidate := range candidates {
|
||||
if candidate.Reasoning == nil {
|
||||
reasoningKnown = false
|
||||
break
|
||||
}
|
||||
if i == 0 {
|
||||
reasoningValue = *candidate.Reasoning
|
||||
continue
|
||||
}
|
||||
if reasoningValue != *candidate.Reasoning {
|
||||
reasoningKnown = false
|
||||
result.reasoningConflict = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if reasoningKnown {
|
||||
result.Reasoning = &reasoningValue
|
||||
if reasoningValue {
|
||||
levels := normalizeReasoningLevels(candidates[0].SupportedReasoningLevels)
|
||||
for _, candidate := range candidates[1:] {
|
||||
levels = intersectOrderedStrings(levels, normalizeReasoningLevels(candidate.SupportedReasoningLevels))
|
||||
}
|
||||
result.SupportedReasoningLevels = levels
|
||||
if len(levels) == 0 {
|
||||
result.reasoningConflict = true
|
||||
} else {
|
||||
sharedDefault := normalizeReasoningLevel(candidates[0].DefaultReasoningLevel)
|
||||
for _, candidate := range candidates[1:] {
|
||||
if normalizeReasoningLevel(candidate.DefaultReasoningLevel) != sharedDefault {
|
||||
sharedDefault = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
if !stringSliceContains(levels, sharedDefault) {
|
||||
sharedDefault = levels[0]
|
||||
}
|
||||
result.DefaultReasoningLevel = sharedDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modalitiesKnown := true
|
||||
modalities := normalizeCodexInputModalities(candidates[0].InputModalities)
|
||||
if len(modalities) == 0 {
|
||||
modalitiesKnown = false
|
||||
}
|
||||
for _, candidate := range candidates[1:] {
|
||||
candidateModalities := normalizeCodexInputModalities(candidate.InputModalities)
|
||||
if len(candidateModalities) == 0 {
|
||||
modalitiesKnown = false
|
||||
break
|
||||
}
|
||||
modalities = intersectOrderedStrings(modalities, candidateModalities)
|
||||
}
|
||||
if modalitiesKnown && len(modalities) > 0 {
|
||||
result.InputModalities = modalities
|
||||
} else if modalitiesKnown {
|
||||
result.inputModalitiesConflict = true
|
||||
}
|
||||
|
||||
contextKnown := true
|
||||
for i, candidate := range candidates {
|
||||
if candidate.ContextWindow <= 0 {
|
||||
contextKnown = false
|
||||
break
|
||||
}
|
||||
if i == 0 || candidate.ContextWindow < result.ContextWindow {
|
||||
result.ContextWindow = candidate.ContextWindow
|
||||
}
|
||||
}
|
||||
if !contextKnown {
|
||||
result.ContextWindow = 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyUpstreamModelMetadataToCodexDescriptor(
|
||||
descriptor *configuredCodexModelDescriptor,
|
||||
metadata codexModelMetadataOverride,
|
||||
) {
|
||||
if descriptor == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(metadata.DisplayName) != "" {
|
||||
descriptor.DisplayName = strings.TrimSpace(metadata.DisplayName)
|
||||
}
|
||||
if strings.TrimSpace(metadata.Description) != "" {
|
||||
descriptor.Description = strings.TrimSpace(metadata.Description)
|
||||
}
|
||||
if metadata.reasoningConflict {
|
||||
descriptor.DefaultReasoningLevel = nil
|
||||
descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{}
|
||||
} else if metadata.Reasoning != nil && !*metadata.Reasoning {
|
||||
none := "none"
|
||||
descriptor.DefaultReasoningLevel = &none
|
||||
descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{{
|
||||
Effort: "none",
|
||||
Description: configuredCodexReasoningLevelDescription("none"),
|
||||
}}
|
||||
} else if metadata.Reasoning != nil && *metadata.Reasoning {
|
||||
levels := normalizeReasoningLevels(metadata.SupportedReasoningLevels)
|
||||
if len(levels) == 0 {
|
||||
descriptor.DefaultReasoningLevel = nil
|
||||
descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{}
|
||||
} else {
|
||||
defaultLevel := normalizeReasoningLevel(metadata.DefaultReasoningLevel)
|
||||
if !stringSliceContains(levels, defaultLevel) {
|
||||
defaultLevel = levels[0]
|
||||
}
|
||||
descriptor.DefaultReasoningLevel = &defaultLevel
|
||||
descriptor.SupportedReasoningLevels = make([]configuredCodexReasoningLevel, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
descriptor.SupportedReasoningLevels = append(descriptor.SupportedReasoningLevels, configuredCodexReasoningLevel{
|
||||
Effort: level,
|
||||
Description: configuredCodexReasoningLevelDescription(level),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if metadata.inputModalitiesConflict {
|
||||
descriptor.InputModalities = []string{"text"}
|
||||
} else if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 {
|
||||
descriptor.InputModalities = modalities
|
||||
}
|
||||
if metadata.ContextWindow > 0 {
|
||||
descriptor.ContextWindow = metadata.ContextWindow
|
||||
descriptor.MaxContextWindow = metadata.ContextWindow
|
||||
}
|
||||
}
|
||||
|
||||
func configuredCodexReasoningLevelDescription(level string) string {
|
||||
switch level {
|
||||
case "none":
|
||||
return "Use the model's default behavior without configurable reasoning"
|
||||
case "minimal":
|
||||
return "Minimal reasoning for the fastest responses"
|
||||
case "low":
|
||||
return "Fast responses with lighter reasoning"
|
||||
case "medium":
|
||||
return "Balanced reasoning for most coding tasks"
|
||||
case "high":
|
||||
return "Greater reasoning depth for coding and agent tasks"
|
||||
case "xhigh":
|
||||
return "Extra-high reasoning depth for difficult tasks"
|
||||
case "max":
|
||||
return "Maximum reasoning depth for complex tasks"
|
||||
default:
|
||||
return "Reasoning effort supported by the upstream model"
|
||||
}
|
||||
}
|
||||
|
||||
func intersectOrderedStrings(left, right []string) []string {
|
||||
rightSet := make(map[string]struct{}, len(right))
|
||||
for _, value := range right {
|
||||
rightSet[value] = struct{}{}
|
||||
}
|
||||
intersection := make([]string, 0, len(left))
|
||||
for _, value := range left {
|
||||
if _, ok := rightSet[value]; ok {
|
||||
intersection = append(intersection, value)
|
||||
}
|
||||
}
|
||||
return intersection
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, target string) bool {
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Scenario: mixed groups prefer capability metadata synced for the routed account.
|
||||
func TestBuildCodexModelsManifestForGroupUsesSyncedAccountMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 735
|
||||
account := Account{
|
||||
ID: 25,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
"model_mapping": map[string]any{"x-preview-f-free": "x-preview-f-free"},
|
||||
},
|
||||
Extra: map[string]any{
|
||||
UpstreamModelMetadataExtraKey: map[string]any{
|
||||
"source": "models.dev",
|
||||
"models": map[string]any{
|
||||
"x-preview-f-free": map[string]any{
|
||||
"id": "x-preview-f-free",
|
||||
"display_name": "Ox Alpha Free (Unlimited)",
|
||||
"description": "Stealth reasoning model",
|
||||
"reasoning": true,
|
||||
"supported_reasoning_levels": []any{"low", "high", "max"},
|
||||
"input_modalities": []any{"text", "image"},
|
||||
"context_window": float64(1_000_000),
|
||||
"max_output_tokens": float64(131_072),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {account},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"x-preview-f-free"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "Ox Alpha Free (Unlimited)", models[0]["display_name"])
|
||||
require.Equal(t, "low", models[0]["default_reasoning_level"])
|
||||
require.Equal(t, []string{"low", "high", "max"}, effortsFromManifestModel(t, models[0]))
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
// Scenario: an explicitly non-reasoning model remains directly selectable in Codex.
|
||||
func TestBuildCodexModelsManifestForGroupUsesNoneForExplicitNonReasoningMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 737
|
||||
reasoning := false
|
||||
account := Account{
|
||||
ID: 28, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{"company-coding-model": "company-coding-model"},
|
||||
},
|
||||
}
|
||||
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
||||
"company-coding-model": {
|
||||
ID: "company-coding-model", Reasoning: &reasoning,
|
||||
InputModalities: []string{"text"}, ContextWindow: 64_000,
|
||||
},
|
||||
}})
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {account},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"company-coding-model"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "none", models[0]["default_reasoning_level"])
|
||||
require.Equal(t, []string{"none"}, effortsFromManifestModel(t, models[0]))
|
||||
}
|
||||
|
||||
// Scenario: multiple schedulable accounts advertise only their shared capabilities.
|
||||
func TestBuildCodexModelsManifestForGroupIntersectsSyncedAccountMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 736
|
||||
reasoning := true
|
||||
newAccount := func(id int64, levels, modalities []string, contextWindow int64) Account {
|
||||
account := Account{
|
||||
ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{"shared-model": "shared-model"},
|
||||
},
|
||||
}
|
||||
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
||||
"shared-model": {
|
||||
ID: "shared-model", Reasoning: &reasoning,
|
||||
SupportedReasoningLevels: levels,
|
||||
InputModalities: modalities,
|
||||
ContextWindow: contextWindow,
|
||||
},
|
||||
}})
|
||||
return account
|
||||
}
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {
|
||||
newAccount(26, []string{"low", "high"}, []string{"text", "image"}, 256_000),
|
||||
newAccount(27, []string{"high", "max"}, []string{"text"}, 128_000),
|
||||
},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-model"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, []string{"high"}, effortsFromManifestModel(t, models[0]))
|
||||
require.Equal(t, "high", models[0]["default_reasoning_level"])
|
||||
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
||||
require.EqualValues(t, 128_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
// Scenario: the same public alias may target different models on one platform when complete snapshots can be intersected.
|
||||
func TestBuildCodexModelsManifestForGroupIntersectsDifferentMappedTargetsWithoutLeakingAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 739
|
||||
reasoning := true
|
||||
newAccount := func(id int64, target, displayName, description string, levels, modalities []string, contextWindow int64) Account {
|
||||
account := Account{
|
||||
ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{"my-coder": target},
|
||||
},
|
||||
}
|
||||
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
||||
target: {
|
||||
ID: target, DisplayName: displayName, Description: description, Reasoning: &reasoning,
|
||||
SupportedReasoningLevels: levels,
|
||||
InputModalities: modalities,
|
||||
ContextWindow: contextWindow,
|
||||
},
|
||||
}})
|
||||
return account
|
||||
}
|
||||
openAIAccount := newAccount(
|
||||
31,
|
||||
"gpt-5.6-sol",
|
||||
"GPT-5.6 Sol",
|
||||
"OpenAI upstream model",
|
||||
[]string{"low", "medium", "high", "xhigh"},
|
||||
[]string{"text", "image"},
|
||||
272_000,
|
||||
)
|
||||
arkAccount := newAccount(
|
||||
32,
|
||||
"glm-5.3",
|
||||
"GLM 5.3",
|
||||
"Ark upstream model",
|
||||
[]string{"low", "medium", "high"},
|
||||
[]string{"text"},
|
||||
1_000_000,
|
||||
)
|
||||
|
||||
for _, accounts := range [][]Account{{openAIAccount, arkAccount}, {arkAccount, openAIAccount}} {
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: accounts,
|
||||
}}}
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "my-coder", models[0]["slug"])
|
||||
require.Equal(t, "my-coder", models[0]["display_name"])
|
||||
require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"])
|
||||
require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
|
||||
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
||||
require.EqualValues(t, 272_000, models[0]["context_window"])
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: temporarily unschedulable mapped accounts still participate in capability intersection.
|
||||
func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 741
|
||||
schedulable := newCodexCatalogMappedAccount(
|
||||
41,
|
||||
"gpt-5.6-sol",
|
||||
"GPT-5.6 Sol",
|
||||
[]string{"low", "medium", "high", "xhigh"},
|
||||
[]string{"text", "image"},
|
||||
1_000_000,
|
||||
true,
|
||||
nil,
|
||||
)
|
||||
unschedulable := newCodexCatalogMappedAccount(
|
||||
42,
|
||||
"glm-5.3",
|
||||
"GLM 5.3",
|
||||
[]string{"low", "medium", "high"},
|
||||
[]string{"text"},
|
||||
272_000,
|
||||
false,
|
||||
map[string]any{"exclusive-model": "exclusive-upstream"},
|
||||
)
|
||||
svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{
|
||||
schedulable: map[int64][]Account{groupID: {schedulable}},
|
||||
catalog: map[int64][]Account{groupID: {schedulable, unschedulable}},
|
||||
}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "my-coder", models[0]["slug"])
|
||||
require.Equal(t, "my-coder", models[0]["display_name"])
|
||||
require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
|
||||
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
||||
require.EqualValues(t, 272_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
// Scenario: deleting an account can widen the advertised contract.
|
||||
func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemoved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 742
|
||||
remaining := newCodexCatalogMappedAccount(
|
||||
41,
|
||||
"gpt-5.6-sol",
|
||||
"GPT-5.6 Sol",
|
||||
[]string{"low", "medium", "high", "xhigh"},
|
||||
[]string{"text", "image"},
|
||||
1_000_000,
|
||||
true,
|
||||
nil,
|
||||
)
|
||||
svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{
|
||||
schedulable: map[int64][]Account{groupID: {remaining}},
|
||||
catalog: map[int64][]Account{groupID: {remaining}},
|
||||
}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenListByGroupFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 743
|
||||
repo := &countingCodexModelsAccountRepo{
|
||||
accounts: []Account{newCodexCatalogMappedAccount(
|
||||
41,
|
||||
"gpt-5.6-sol",
|
||||
"GPT-5.6 Sol",
|
||||
[]string{"low", "medium", "high", "xhigh"},
|
||||
[]string{"text", "image"},
|
||||
1_000_000,
|
||||
true,
|
||||
nil,
|
||||
)},
|
||||
listByGroupErr: errors.New("group listing unavailable"),
|
||||
}
|
||||
svc := &GatewayService{accountRepo: repo}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(1), repo.calls.Load())
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
// Scenario: a Composite alias claimed across platforms remains ambiguous and fails closed.
|
||||
func TestBuildCodexModelsManifestForGroupKeepsCrossPlatformAliasAmbiguityClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 740
|
||||
reasoning := true
|
||||
newAccount := func(id int64, platform, target string) Account {
|
||||
account := Account{
|
||||
ID: id, Platform: platform, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{"shared-alias": target}},
|
||||
}
|
||||
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
||||
target: {
|
||||
ID: target, DisplayName: target, Reasoning: &reasoning,
|
||||
SupportedReasoningLevels: []string{"low", "high"},
|
||||
InputModalities: []string{"text", "image"},
|
||||
ContextWindow: 128_000,
|
||||
},
|
||||
}})
|
||||
return account
|
||||
}
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {
|
||||
newAccount(33, PlatformOpenAI, "gpt-5.6-sol"),
|
||||
newAccount(34, PlatformGrok, "grok-4.6"),
|
||||
},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-alias"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "shared-alias", models[0]["display_name"])
|
||||
require.Empty(t, effortsFromManifestModel(t, models[0]))
|
||||
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupDoesNotAdvertiseNoneWhenAccountReasoningConflicts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 738
|
||||
reasoning := true
|
||||
noReasoning := false
|
||||
newAccount := func(id int64, metadata UpstreamModelMetadata) Account {
|
||||
account := Account{
|
||||
ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{"shared-model": "shared-model"},
|
||||
},
|
||||
}
|
||||
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
||||
"shared-model": metadata,
|
||||
}})
|
||||
return account
|
||||
}
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {
|
||||
newAccount(29, UpstreamModelMetadata{
|
||||
ID: "shared-model", Reasoning: &reasoning,
|
||||
SupportedReasoningLevels: []string{"low", "high"},
|
||||
InputModalities: []string{"text"}, ContextWindow: 128_000,
|
||||
}),
|
||||
newAccount(30, UpstreamModelMetadata{
|
||||
ID: "shared-model", Reasoning: &noReasoning,
|
||||
InputModalities: []string{"text"}, ContextWindow: 128_000,
|
||||
}),
|
||||
},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-model"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
_, hasDefault := models[0]["default_reasoning_level"]
|
||||
require.False(t, hasDefault)
|
||||
require.Empty(t, models[0]["supported_reasoning_levels"])
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -186,6 +186,10 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build upstream request: %w", err)
|
||||
}
|
||||
// 记录本次实际选择的协议端点,供错误日志和用量日志在没有
|
||||
// OpenAIForwardResult(例如 503/传输失败)时使用。每次发送都覆盖,
|
||||
// 避免 Gin context 在账号 failover 尝试之间残留旧端点。
|
||||
SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
|
||||
upstreamReq = upstreamReq.WithContext(WithHTTPUpstreamProfile(upstreamReq.Context(), HTTPUpstreamProfileOpenAI))
|
||||
upstreamReq.Header.Set("Content-Type", "application/json")
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+bearerToken)
|
||||
|
||||
@@ -60,6 +60,10 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||
defaultMappedModel string,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
beginUpstreamResponseModelObservation(c)
|
||||
ClearActualOpenAIUpstreamEndpoint(c)
|
||||
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
||||
SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
|
||||
}
|
||||
setCodexToolNameReverse(c, nil)
|
||||
if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -20,6 +20,15 @@ import (
|
||||
// Forward forwards request to OpenAI API
|
||||
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
|
||||
beginUpstreamResponseModelObservation(c)
|
||||
ClearActualOpenAIUpstreamEndpoint(c)
|
||||
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
||||
SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
|
||||
}
|
||||
filteredBody, filterErr := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body)
|
||||
if filterErr != nil {
|
||||
return nil, filterErr
|
||||
}
|
||||
body = filteredBody
|
||||
clearGrokResponsesClientToolMapping(c)
|
||||
clearOpenAIResponsesClientToolMapping(c)
|
||||
clearOpenAIResponsesNamespaceNames(c)
|
||||
@@ -112,7 +121,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
|
||||
keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
account, wsDecision.Transport, passthroughEnabled, compactPath,
|
||||
account, wsDecision.Transport, passthroughEnabled, compactPath, body,
|
||||
)
|
||||
body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
|
||||
if err != nil {
|
||||
@@ -173,6 +182,17 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
|
||||
}
|
||||
if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) {
|
||||
normalizedReasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningContentReplay(body)
|
||||
if reasoningErr != nil {
|
||||
return nil, fmt.Errorf("normalize OpenAI Responses reasoning content replay: %w", reasoningErr)
|
||||
}
|
||||
if reasoningChanged {
|
||||
body = normalizedReasoningBody
|
||||
originalBody = normalizedReasoningBody
|
||||
requestView = newOpenAIRequestView(normalizedReasoningBody)
|
||||
reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
|
||||
originalModel = reqModel
|
||||
}
|
||||
sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body)
|
||||
if sanitizeErr != nil {
|
||||
return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr)
|
||||
@@ -463,13 +483,29 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
// Responses OAuth 与 Chat 兼容入口保持一致:纯文本 system 可以无损提升后删除,
|
||||
// JSON object 模式仍需在 input 中保留 JSON 指令供上游兼容校验。
|
||||
omitPromotedSystemMessages := !strings.EqualFold(
|
||||
strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String()),
|
||||
"json_object",
|
||||
)
|
||||
codexResult := codexTransformResult{}
|
||||
if compatMessagesBridge {
|
||||
codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{IsCodexCLI: isCodexCLI, IsCompact: isCompactRequest, SkipDefaultInstructions: true, PreserveToolCallIDs: true})
|
||||
codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{
|
||||
IsCodexCLI: isCodexCLI,
|
||||
IsCompact: isCompactRequest,
|
||||
SkipDefaultInstructions: true,
|
||||
PreserveToolCallIDs: true,
|
||||
OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages,
|
||||
})
|
||||
ensureCodexOAuthInstructionsField(decoded)
|
||||
markDecodedModified()
|
||||
} else {
|
||||
codexResult = applyCodexOAuthTransform(decoded, isCodexCLI, isCompactRequest)
|
||||
codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{
|
||||
IsCodexCLI: isCodexCLI,
|
||||
IsCompact: isCompactRequest,
|
||||
OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages,
|
||||
})
|
||||
}
|
||||
if codexResult.Error != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": codexResult.Error.Error()}})
|
||||
|
||||
@@ -34,6 +34,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
defaultMappedModel string,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
beginUpstreamResponseModelObservation(c)
|
||||
ClearActualOpenAIUpstreamEndpoint(c)
|
||||
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
||||
SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
|
||||
}
|
||||
setCodexToolNameReverse(c, nil)
|
||||
if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -55,6 +55,69 @@ func buildOpenAIResponsesURLForPlatform(platform string, base string) string {
|
||||
return buildOpenAIResponsesURL(base)
|
||||
}
|
||||
|
||||
func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
if account.IsOpenAIOAuthLike() {
|
||||
return true
|
||||
}
|
||||
if !account.IsOpenAIApiKey() {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.TrimSpace(account.GetCredential("base_url"))
|
||||
return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL)
|
||||
}
|
||||
|
||||
// Codex 0.149.0 needs a single advertised effort to directly select a visible
|
||||
// non-reasoning model. Treat that catalog-only "none" value as omission for
|
||||
// compatible upstreams, while preserving official OpenAI request semantics.
|
||||
func filterOpenAIResponsesNoneReasoningEffortForAccount(account *Account, body []byte) ([]byte, error) {
|
||||
if len(body) == 0 || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
out := body
|
||||
for _, path := range []string{"reasoning.effort", "reasoning_effort"} {
|
||||
effort := gjson.GetBytes(out, path)
|
||||
if effort.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(effort.String()), "none") {
|
||||
continue
|
||||
}
|
||||
next, err := sjson.DeleteBytes(out, path)
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("strip %s none placeholder: %w", path, err)
|
||||
}
|
||||
out = next
|
||||
}
|
||||
if reasoning := gjson.GetBytes(out, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 {
|
||||
next, err := sjson.DeleteBytes(out, "reasoning")
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("strip empty reasoning object: %w", err)
|
||||
}
|
||||
out = next
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func deleteOpenAIResponsesNoneReasoningEffortFromObject(account *Account, body map[string]any) {
|
||||
if body == nil || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) {
|
||||
return
|
||||
}
|
||||
if effort, ok := body["reasoning_effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") {
|
||||
delete(body, "reasoning_effort")
|
||||
}
|
||||
reasoning, ok := body["reasoning"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if effort, ok := reasoning["effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") {
|
||||
delete(reasoning, "effort")
|
||||
}
|
||||
if len(reasoning) == 0 {
|
||||
delete(body, "reasoning")
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeDeepSeekResponsesRequestBody 适配 DeepSeek 无状态 Responses 端点:
|
||||
// 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端
|
||||
// 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。
|
||||
@@ -395,6 +458,67 @@ func openAIRequestBodyHasTools(body []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeOpenAIResponsesReasoningContentReplay removes non-portable
|
||||
// reasoning.content arrays before history is sent to a real OpenAI Responses
|
||||
// endpoint. Compatible providers may return visible reasoning blocks there,
|
||||
// while OpenAI accepts only an empty array when the item is replayed.
|
||||
//
|
||||
// Keep the reasoning item and its portable fields (summary, encrypted_content,
|
||||
// ids, and opaque extensions). Callers scope this normalization to OpenAI
|
||||
// destinations; compatible providers may still consume their own content.
|
||||
func normalizeOpenAIResponsesReasoningContentReplay(body []byte) ([]byte, bool, error) {
|
||||
input := gjson.GetBytes(body, "input")
|
||||
if !input.IsArray() {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
needsNormalization := false
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
if strings.TrimSpace(item.Get("type").String()) != "reasoning" {
|
||||
return true
|
||||
}
|
||||
content := item.Get("content")
|
||||
if content.IsArray() && len(content.Array()) > 0 {
|
||||
needsNormalization = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !needsNormalization {
|
||||
return body, false, nil
|
||||
}
|
||||
|
||||
var reqBody map[string]any
|
||||
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
|
||||
return body, false, fmt.Errorf("normalize OpenAI reasoning content replay: %w", err)
|
||||
}
|
||||
items, ok := reqBody["input"].([]any)
|
||||
if !ok {
|
||||
return body, false, nil
|
||||
}
|
||||
changed := false
|
||||
for _, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "reasoning" {
|
||||
continue
|
||||
}
|
||||
content, ok := item["content"].([]any)
|
||||
if !ok || len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
delete(item, "content")
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return body, false, nil
|
||||
}
|
||||
normalized, err := marshalOpenAIUpstreamJSON(reqBody)
|
||||
if err != nil {
|
||||
return body, false, fmt.Errorf("serialize normalized OpenAI reasoning content replay: %w", err)
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) {
|
||||
if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False {
|
||||
return body, false, nil
|
||||
@@ -955,6 +1079,12 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
|
||||
return body, false, err
|
||||
}
|
||||
}
|
||||
if next, normalizedReasoningContent, err := normalizeOpenAIResponsesReasoningContentReplay(normalized); err != nil {
|
||||
return body, false, err
|
||||
} else if normalizedReasoningContent {
|
||||
normalized = next
|
||||
changed = true
|
||||
}
|
||||
if account.IsOpenAIApiKey() {
|
||||
if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, responsesLite); err != nil {
|
||||
return body, false, err
|
||||
|
||||
@@ -271,6 +271,66 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) {
|
||||
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
|
||||
}
|
||||
|
||||
func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
body string
|
||||
wantNested bool
|
||||
wantFlat bool
|
||||
wantSummary bool
|
||||
wantReasoning bool
|
||||
}{
|
||||
{
|
||||
name: "custom compatible endpoint strips none placeholders",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}},
|
||||
body: `{"reasoning":{"effort":"none"},"reasoning_effort":"NONE"}`,
|
||||
wantReasoning: false,
|
||||
},
|
||||
{
|
||||
name: "third-party platform keeps other reasoning members",
|
||||
account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey},
|
||||
body: `{"reasoning":{"effort":" none ","summary":"auto"}}`,
|
||||
wantSummary: true,
|
||||
wantReasoning: true,
|
||||
},
|
||||
{
|
||||
name: "non-none effort is unchanged",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}},
|
||||
body: `{"reasoning":{"effort":"high"},"reasoning_effort":"low"}`,
|
||||
wantNested: true,
|
||||
wantFlat: true,
|
||||
wantReasoning: true,
|
||||
},
|
||||
{
|
||||
name: "official OpenAI API key preserves none",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
body: `{"reasoning":{"effort":"none"},"reasoning_effort":"none"}`,
|
||||
wantNested: true,
|
||||
wantFlat: true,
|
||||
wantReasoning: true,
|
||||
},
|
||||
{
|
||||
name: "OpenAI OAuth preserves none",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
|
||||
body: `{"reasoning":{"effort":"none"}}`,
|
||||
wantNested: true,
|
||||
wantReasoning: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(tt.account, []byte(tt.body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantNested, gjson.GetBytes(got, "reasoning.effort").Exists())
|
||||
require.Equal(t, tt.wantFlat, gjson.GetBytes(got, "reasoning_effort").Exists())
|
||||
require.Equal(t, tt.wantSummary, gjson.GetBytes(got, "reasoning.summary").Exists())
|
||||
require.Equal(t, tt.wantReasoning, gjson.GetBytes(got, "reasoning").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。
|
||||
func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) {
|
||||
liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`)
|
||||
@@ -293,3 +353,60 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAddition
|
||||
require.False(t, changed)
|
||||
require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type)
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesReasoningContentReplayStripsCrossProviderArray(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.6-sol","input":[` +
|
||||
`{"type":"message","role":"user","content":"one"},` +
|
||||
`{"type":"message","role":"assistant","content":"two"},` +
|
||||
`{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},` +
|
||||
`{"type":"function_call_output","call_id":"call_1","output":"ok"},` +
|
||||
`{"type":"message","role":"user","content":"five"},` +
|
||||
`{"type":"reasoning","id":"rs_provider","summary":[{"type":"summary_text","text":"portable"}],"content":[{"type":"reasoning_text","text":"visible reasoning"}],"opaque":9007199254740993},` +
|
||||
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}` +
|
||||
`]}`)
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesReasoningContentReplay(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "reasoning", gjson.GetBytes(normalized, "input.5.type").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "input.5.content").Exists())
|
||||
require.Equal(t, "portable", gjson.GetBytes(normalized, "input.5.summary.0.text").String())
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(normalized, "input.5.opaque").Raw)
|
||||
require.Equal(t, "answer", gjson.GetBytes(normalized, "input.6.content.0.text").String())
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesReasoningContentReplayKeepsPortableShapes(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"input":[{"type":"reasoning","summary":[]}]}`,
|
||||
`{"input":[{"type":"reasoning","content":[],"summary":[]}]}`,
|
||||
`{"input":[{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`,
|
||||
} {
|
||||
normalized, changed, err := normalizeOpenAIResponsesReasoningContentReplay([]byte(body))
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.JSONEq(t, body, string(normalized))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyStripsReasoningContentOnlyForOpenAI(t *testing.T) {
|
||||
body := []byte(`{"type":"response.create","model":"gpt-5.6-sol","store":true,"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"keep"}],"content":[{"type":"reasoning_text","text":"remove"}]}]}`)
|
||||
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: accountType,
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(normalized, "input.0.content").Exists())
|
||||
require.Equal(t, "keep", gjson.GetBytes(normalized, "input.0.summary.0.text").String())
|
||||
}
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformZhipu,
|
||||
Type: AccountTypeAPIKey,
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.JSONEq(t, string(body), string(normalized))
|
||||
}
|
||||
|
||||
@@ -39,11 +39,13 @@ func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletion
|
||||
cfg: rawChatCompletionsTestConfig(),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
SetActualOpenAIUpstreamEndpoint(c, "/v1/responses")
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
|
||||
require.Equal(t, "/v1/chat/completions", GetActualOpenAIUpstreamEndpoint(c))
|
||||
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
|
||||
require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
@@ -55,6 +57,36 @@ func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletion
|
||||
require.False(t, result.Stream)
|
||||
}
|
||||
|
||||
// Scenario: 第三方无推理模型不收到兼容档位。
|
||||
func TestForwardResponses_ForceChatCompletionsOmitsNoneReasoningEffort(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"company-coding-model","input":"hello","reasoning":{"effort":"none"},"stream":false}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"id":"chatcmpl_none","object":"chat.completion","model":"company-coding-model","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
|
||||
)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: rawChatCompletionsTestConfig(),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "company-coding-model", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning_effort").Exists())
|
||||
require.Nil(t, result.ReasoningEffort)
|
||||
}
|
||||
|
||||
func TestForwardResponses_PassthroughFlagWithUnsupportedResponsesUsesAccountMapping(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -319,6 +319,16 @@ func SetActualOpenAIUpstreamEndpoint(c *gin.Context, endpoint string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ClearActualOpenAIUpstreamEndpoint 清理当前转发尝试记录的端点。
|
||||
// Handler 会在账号 failover 尝试间复用同一个 Gin context,因此每次尝试
|
||||
// 都必须从无残留状态开始。
|
||||
func ClearActualOpenAIUpstreamEndpoint(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.Set(openAIUpstreamEndpointContextKey, "")
|
||||
}
|
||||
|
||||
// GetActualOpenAIUpstreamEndpoint returns the endpoint recorded by the latest
|
||||
// forwarding attempt in this request.
|
||||
func GetActualOpenAIUpstreamEndpoint(c *gin.Context) string {
|
||||
|
||||
@@ -37,6 +37,13 @@ type OpenAIImagesUpstreamError struct {
|
||||
Message string
|
||||
Param string
|
||||
UpstreamRequestID string
|
||||
|
||||
// SynthesizedFromModelText marks an error the gateway inferred from the
|
||||
// model's plain-text output instead of reading it off a structured upstream
|
||||
// error frame. Such a verdict describes this one turn ("the model answered
|
||||
// with words instead of an image"), not the account — see
|
||||
// shouldCoolOpenAIImagesToolForError.
|
||||
SynthesizedFromModelText bool
|
||||
}
|
||||
|
||||
func (e *OpenAIImagesUpstreamError) Error() string {
|
||||
@@ -328,6 +335,26 @@ func openAIImageUploadToDataURL(upload OpenAIImagesUpload) (string, error) {
|
||||
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(upload.Data), nil
|
||||
}
|
||||
|
||||
// openAIImagesSelfBuiltRequestContextKey marks a request whose upstream body was
|
||||
// fully constructed by buildOpenAIImagesResponsesRequest, i.e. tool_choice and the
|
||||
// matching image_generation tool are always both present and never client-controlled.
|
||||
type openAIImagesSelfBuiltRequestContextKey struct{}
|
||||
|
||||
func withOpenAIImagesSelfBuiltRequest(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, openAIImagesSelfBuiltRequestContextKey{}, true)
|
||||
}
|
||||
|
||||
func isOpenAIImagesSelfBuiltRequest(ctx context.Context) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
selfBuilt, _ := ctx.Value(openAIImagesSelfBuiltRequestContextKey{}).(bool)
|
||||
return selfBuilt
|
||||
}
|
||||
|
||||
func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel string) ([]byte, error) {
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("parsed images request is required")
|
||||
@@ -711,6 +738,10 @@ func openAIImagesTextFallbackErrorForText(text string) *OpenAIImagesUpstreamErro
|
||||
ErrorType: "upstream_error",
|
||||
Code: "image_generation_unavailable",
|
||||
Message: "Upstream did not execute image generation",
|
||||
// Inferred from the model's own words, not from an upstream error frame:
|
||||
// good enough to fail this turn over to another account, not evidence that
|
||||
// this account's image tool is down for the next 30 minutes.
|
||||
SynthesizedFromModelText: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1775,6 +1806,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
upstreamCtx = withOpenAIImagesSelfBuiltRequest(upstreamCtx)
|
||||
upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, true, parsed.StickySessionSeed(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1922,6 +1954,26 @@ const (
|
||||
openAIImagesOAuthUnavailableReason = "openai_images_oauth_tool_unavailable"
|
||||
)
|
||||
|
||||
// shouldCoolOpenAIImagesToolForError decides whether an image_generation_unavailable
|
||||
// verdict is durable enough to park the account's image tool for
|
||||
// openAIImagesOAuthUnavailableCooldown.
|
||||
//
|
||||
// Only an upstream error frame that names the condition qualifies. A verdict the
|
||||
// gateway synthesized from the model's plain-text reply does not: it merely says
|
||||
// this prompt produced words instead of an image, which is prompt-dependent and
|
||||
// happens on healthy accounts. Writing a 30-minute account-level cooldown from it
|
||||
// is doubly wrong because the very same error is classified retryable
|
||||
// (IsOpenAIImagesRetryableUpstreamError: status >= 500) and drives
|
||||
// newOpenAIAccountFailoverError — so one such reply walks the pool and cools every
|
||||
// account the retry touches.
|
||||
//
|
||||
// This mirrors the rule the alpha/search path already states in words: a
|
||||
// tool-endpoint failure "仍允许本次请求换号,但不修改任何账号状态"
|
||||
// (see shouldApplyOpenAIAlphaSearchAccountErrorSideEffects).
|
||||
func shouldCoolOpenAIImagesToolForError(upstreamErr *OpenAIImagesUpstreamError) bool {
|
||||
return upstreamErr != nil && !upstreamErr.SynthesizedFromModelText
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) coolOpenAIImagesOAuthTool(ctx context.Context, account *Account) {
|
||||
if s == nil || s.accountRepo == nil || account == nil || account.Platform != PlatformOpenAI {
|
||||
return
|
||||
@@ -2017,7 +2069,9 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthResponseError(
|
||||
|
||||
responseBody := openAIImagesUpstreamErrorResponseBody(upstreamErr)
|
||||
if upstreamErr.Code == "image_generation_unavailable" {
|
||||
s.coolOpenAIImagesOAuthTool(ctx, account)
|
||||
if shouldCoolOpenAIImagesToolForError(upstreamErr) {
|
||||
s.coolOpenAIImagesOAuthTool(ctx, account)
|
||||
}
|
||||
if responseWritten {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// issue #6171:v0.1.181 起,/v1/images/generations 只要上游"回文字没回图",账号就被
|
||||
// 写 30 分钟 openai:image_generation 模型级冷却。该判据是**请求级**的(这个 prompt
|
||||
// 这一轮模型选择了说话),却被当成**账号级**能力失效;又因为同一个错误被判为
|
||||
// 可重试(502)并驱动 failover,一次闲聊回复会沿着号池逐个把账号冷却掉。
|
||||
|
||||
// countingModelRateLimitRepo 记录 SetModelRateLimit 调用,用于断言"没写账号状态"。
|
||||
type countingModelRateLimitRepo struct {
|
||||
accountRepoStub
|
||||
calls int
|
||||
scopes []string
|
||||
}
|
||||
|
||||
func (r *countingModelRateLimitRepo) SetModelRateLimit(_ context.Context, _ int64, scope string, _ time.Time, _ ...string) error {
|
||||
r.calls++
|
||||
r.scopes = append(r.scopes, scope)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newImagesCooldownContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func imagesCooldownAccount() *Account {
|
||||
return &Account{ID: 77, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "img-oauth"}
|
||||
}
|
||||
|
||||
func TestShouldCoolOpenAIImagesToolForError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err *OpenAIImagesUpstreamError
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil_error",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// 网关从模型文字里推断出来的判据:只说明这一轮没出图。
|
||||
name: "synthesized_from_model_text",
|
||||
err: &OpenAIImagesUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Code: "image_generation_unavailable",
|
||||
SynthesizedFromModelText: true,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// 上游自己在 error 帧里点名该状态:这才是账号级证据,保持冷却。
|
||||
name: "structured_upstream_error_frame",
|
||||
err: &OpenAIImagesUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Code: "image_generation_unavailable",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, shouldCoolOpenAIImagesToolForError(tc.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 主复现:文字兜底判据不得写账号级冷却。
|
||||
func TestHandleOpenAIImagesOAuthResponseError_TextFallbackDoesNotCoolAccount(t *testing.T) {
|
||||
c, _ := newImagesCooldownContext(t)
|
||||
repo := &countingModelRateLimitRepo{}
|
||||
svc := &OpenAIGatewayService{accountRepo: repo}
|
||||
account := imagesCooldownAccount()
|
||||
|
||||
upstreamErr := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.")
|
||||
require.NotNil(t, upstreamErr)
|
||||
require.Equal(t, "image_generation_unavailable", upstreamErr.Code)
|
||||
|
||||
err := svc.handleOpenAIImagesOAuthResponseError(
|
||||
context.Background(), c, account, "gpt-image-2", "https://upstream.example/v1/responses",
|
||||
&http.Response{StatusCode: http.StatusOK, Header: http.Header{}},
|
||||
OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), upstreamErr,
|
||||
)
|
||||
|
||||
require.Zero(t, repo.calls, "模型闲聊不构成账号级证据,不得写 30 分钟冷却")
|
||||
|
||||
// 换号行为必须原样保留:本 PR 只撤销账号状态写入,不动 failover。
|
||||
var failover *UpstreamFailoverError
|
||||
require.True(t, errors.As(err, &failover), "仍应触发换号,got %T", err)
|
||||
}
|
||||
|
||||
// 对照不变式:上游 error 帧点名该状态时仍然冷却,否则等于把功能整个废掉。
|
||||
func TestHandleOpenAIImagesOAuthResponseError_StructuredUnavailableStillCoolsAccount(t *testing.T) {
|
||||
c, _ := newImagesCooldownContext(t)
|
||||
repo := &countingModelRateLimitRepo{}
|
||||
svc := &OpenAIGatewayService{accountRepo: repo}
|
||||
account := imagesCooldownAccount()
|
||||
|
||||
upstreamErr := &OpenAIImagesUpstreamError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ErrorType: "upstream_error",
|
||||
Code: "image_generation_unavailable",
|
||||
Message: "image generation tool is not available for this account",
|
||||
}
|
||||
|
||||
_ = svc.handleOpenAIImagesOAuthResponseError(
|
||||
context.Background(), c, account, "gpt-image-2", "https://upstream.example/v1/responses",
|
||||
&http.Response{StatusCode: http.StatusOK, Header: http.Header{}},
|
||||
OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), upstreamErr,
|
||||
)
|
||||
|
||||
require.Equal(t, 1, repo.calls, "结构化上游证据仍须写冷却")
|
||||
require.Equal(t, []string{openAIImageGenerationRateLimitKey}, repo.scopes)
|
||||
}
|
||||
|
||||
// 标记必须打在文字兜底的两个入口上,且不影响违规拦截分支的判定。
|
||||
func TestOpenAIImagesTextFallback_MarksSynthesizedVerdicts(t *testing.T) {
|
||||
t.Run("plain_text_reply_is_synthesized", func(t *testing.T) {
|
||||
err := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.")
|
||||
require.NotNil(t, err)
|
||||
require.True(t, err.SynthesizedFromModelText)
|
||||
require.Equal(t, "image_generation_unavailable", err.Code)
|
||||
require.Equal(t, http.StatusBadGateway, err.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("body_entrypoint_is_synthesized", func(t *testing.T) {
|
||||
body := []byte("event: response.completed\n" +
|
||||
`data: {"type":"response.completed","response":{"id":"r","status":"completed",` +
|
||||
`"output":[{"type":"message","content":[{"type":"output_text","text":"I drafted a prompt for you."}]}]}}` +
|
||||
"\n\n")
|
||||
err := openAIImagesTextFallbackError(body)
|
||||
require.NotNil(t, err)
|
||||
require.True(t, err.SynthesizedFromModelText)
|
||||
})
|
||||
|
||||
t.Run("content_policy_branch_unchanged", func(t *testing.T) {
|
||||
err := openAIImagesTextFallbackErrorForText("Blocked by our content policy.")
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, "content_policy_violation", err.Code)
|
||||
require.Equal(t, http.StatusBadRequest, err.StatusCode)
|
||||
// 该分支本来就不走冷却(Code 不匹配),标记与否都不改变行为;
|
||||
// 断言它没有被顺手打标,避免语义漂移。
|
||||
require.False(t, err.SynthesizedFromModelText)
|
||||
})
|
||||
|
||||
t.Run("empty_text_yields_no_error", func(t *testing.T) {
|
||||
require.Nil(t, openAIImagesTextFallbackErrorForText(" "))
|
||||
})
|
||||
}
|
||||
|
||||
// 级联的前提条件:该错误确实是可重试的,所以会带着"已写冷却"的副作用换号。
|
||||
// 这条用例把前提钉死,避免以后有人把 502 改成非重试后误以为本修复多余。
|
||||
func TestOpenAIImagesTextFallback_RemainsRetryableAndThusCascades(t *testing.T) {
|
||||
err := openAIImagesTextFallbackErrorForText("Here's a polished image prompt for your request.")
|
||||
require.NotNil(t, err)
|
||||
require.True(t, IsOpenAIImagesRetryableUpstreamError(err),
|
||||
"文字兜底判据是可重试的——正因如此,写账号冷却会沿号池级联")
|
||||
}
|
||||
@@ -132,6 +132,44 @@ func TestOpenAIGatewayService_ResponsesUnknownModelDoesNotFallbackToGPT54(t *tes
|
||||
require.True(t, rec.Code >= http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthResponsesPromotesSystemMessageWithoutDuplication(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const systemPrompt = "Unique system prefix for Responses token accounting."
|
||||
const existingInstructions = "Existing instructions."
|
||||
body := []byte(`{"model":"gpt-5.4","stream":false,"instructions":"` + existingInstructions + `","input":[{"role":"system","content":"` + systemPrompt + `"},{"role":"user","content":"hello"}]}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 124,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
"chatgpt_account_id": "chatgpt-acc",
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.NotEmpty(t, upstream.lastBody)
|
||||
require.Equal(t, systemPrompt+"\n\n"+existingInstructions, gjson.GetBytes(upstream.lastBody, "instructions").String())
|
||||
require.Equal(t, int64(1), gjson.GetBytes(upstream.lastBody, "input.#").Int())
|
||||
require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
|
||||
require.Equal(t, 1, strings.Count(string(upstream.lastBody), systemPrompt))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_NativeResponsesBodyModificationPreservesHTMLChars(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -77,25 +77,48 @@ func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenA
|
||||
// 故 OAuth 非 compact 请求必须保留。
|
||||
// - compact 端点的 schema 不含该字段,携带即 400 `Unknown parameter:
|
||||
// input[N].namespace`(issue #4761 正文),故 compact 一律清理。
|
||||
// - API Key 出口是标准 Responses API(api.openai.com 或自定义 base_url),同样
|
||||
// 不认识该字段,维持全量清理;否则只能退化成
|
||||
// openai_responses_rejected_field_retry 的逐项删除,6 次上限根本盖不住长历史。
|
||||
// - API Key 出口默认按标准 Responses API 处理并清理该字段;但当请求本身声明
|
||||
// namespace 工具时,上游显然使用了 namespace 扩展,此时必须保留调用项上的
|
||||
// namespace,否则声明与历史调用会失配并触发 Missing namespace。
|
||||
// - 摊平模式下调用项已被改写成平名,残留 namespace 指向的声明已不存在,一律清理。
|
||||
func shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
account *Account,
|
||||
transport OpenAIUpstreamTransport,
|
||||
passthroughEnabled bool,
|
||||
compactPath bool,
|
||||
body []byte,
|
||||
) bool {
|
||||
if account == nil || !account.IsOpenAIOAuthLike() {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
if compactPath {
|
||||
return false
|
||||
}
|
||||
if account.IsOpenAIApiKey() {
|
||||
return hasOpenAIResponsesNamespaceToolDeclaration(body)
|
||||
}
|
||||
if !account.IsOpenAIOAuthLike() {
|
||||
return false
|
||||
}
|
||||
return !shouldFlattenOpenAIResponsesNamespaces(account, transport, passthroughEnabled, compactPath)
|
||||
}
|
||||
|
||||
func hasOpenAIResponsesNamespaceToolDeclaration(body []byte) bool {
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if !tools.IsArray() {
|
||||
return false
|
||||
}
|
||||
found := false
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(tool.Get("type").String()), "namespace") {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// openAIResponsesToolCallItemTypes 是携带 namespace 的调用项类型集合。与
|
||||
// removeOpenAIResponsesRejectedNamespaceAtIndex 的反应式白名单保持一致;codex-rs
|
||||
// protocol/src/models.rs 中只有 FunctionCall 与 CustomToolCall 序列化 namespace,
|
||||
|
||||
@@ -66,6 +66,29 @@ func TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools(t *testing.T) {
|
||||
require.Empty(t, openAIResponsesNamespaceNames(c))
|
||||
}
|
||||
|
||||
// API Key 自定义上游若接受 namespace 工具声明,也要求历史 function_call 原样携带
|
||||
// namespace。声明仍为命名空间工具却清掉调用项字段,会触发 Missing namespace。
|
||||
func TestOpenAIGatewayService_APIKeyPreservesDeclaredNamespaceToolCalls(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, newOpenAIRejectedFieldTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
forwarded := upstream.bodies[0]
|
||||
|
||||
require.True(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(forwarded, "input.0.namespace").String())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||||
}
|
||||
|
||||
// compact 端点 schema 更窄:input[].namespace 会 400 Unknown parameter(issue #4761),
|
||||
// 且没有证据表明它接受 namespace 工具声明。compact 只做历史摘要、不需要模型寻址工具,
|
||||
// 因此保持既有的摊平 + 全量清理行为,不随默认值翻转扩大风险面。
|
||||
|
||||
@@ -78,6 +78,7 @@ func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
|
||||
transport OpenAIUpstreamTransport
|
||||
passthroughEnabled bool
|
||||
compactPath bool
|
||||
body []byte
|
||||
want bool
|
||||
}{
|
||||
// 上游按 namespace 解析历史调用,缺字段会 400 "Missing namespace for function_call"。
|
||||
@@ -92,15 +93,20 @@ func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
|
||||
// WSv2 + compact 是唯一「不摊平但仍必须清理」的组合,钉住 compact 判定本身,
|
||||
// 使其不会被误当成可由 shouldFlatten 推导出的冗余分支。
|
||||
{name: "oauth_compact_wsv2_strips", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
|
||||
// API Key 出口是标准 Responses API,不认识该字段。
|
||||
{name: "apikey_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
// API Key 默认按标准 Responses API 清理;请求显式声明 namespace 工具时,
|
||||
// 自定义上游需要原样接收对应的历史调用。
|
||||
{name: "apikey_without_namespace_tool_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "apikey_with_namespace_tool_keeps", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"tools":[{"type":"namespace","name":"mcp__codex_app","tools":[]}]}`), want: true},
|
||||
{name: "apikey_with_mixed_case_namespace_tool_keeps", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"tools":[{"type":" Namespace ","name":"mcp__codex_app","tools":[]}]}`), want: true},
|
||||
{name: "apikey_function_tool_with_namespace_field_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, body: []byte(`{"tools":[{"type":"function","name":"automation_update","namespace":"mcp__codex_app"}]}`), want: false},
|
||||
{name: "apikey_compact_with_namespace_tool_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, body: []byte(`{"tools":[{"type":"namespace","name":"mcp__codex_app","tools":[]}]}`), want: false},
|
||||
{name: "setup_token_keeps", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
|
||||
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath, tt.body,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -538,6 +538,34 @@ func TestOpenAIGatewayService_APIKeyStripsAllIndexedNamespacesBeforeFirstForward
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceProactivelyStripsCrossProviderReasoningContent(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.5","stream":false,"store":true,"input":[` +
|
||||
`{"type":"message","role":"user","content":"one"},` +
|
||||
`{"type":"message","role":"assistant","content":"two"},` +
|
||||
`{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},` +
|
||||
`{"type":"function_call_output","call_id":"call_1","output":"ok"},` +
|
||||
`{"type":"message","role":"user","content":"five"},` +
|
||||
`{"type":"reasoning","summary":[{"type":"summary_text","text":"keep"}],"content":[{"type":"reasoning_text","text":"remove"}]}` +
|
||||
`]}`)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
|
||||
}}
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(),
|
||||
newOpenAIRejectedFieldTestContext(body),
|
||||
newOpenAIRejectedFieldTestAccount(),
|
||||
body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1, "reasoning content should be normalized before the first upstream request")
|
||||
require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[0], "input.5.type").String())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.5.content").Exists())
|
||||
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[0], "input.5.summary.0.text").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OpenAIHTTPStripsInputNamespacesBeforeFirstForward(t *testing.T) {
|
||||
accounts := []struct {
|
||||
name string
|
||||
|
||||
@@ -804,6 +804,73 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
|
||||
require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "conv-oauth-1"), captureDialer.lastHeaders.Get("conversation_id"))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_WSv2_OAuthSanitizesInvalidNativeToolItemID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
|
||||
groupID := int64(5662)
|
||||
c.Set("api_key", &APIKey{GroupID: &groupID})
|
||||
|
||||
cfg := newOpenAIWSV2TestConfig()
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
|
||||
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
|
||||
cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
|
||||
cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
|
||||
cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
|
||||
cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3
|
||||
cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
|
||||
|
||||
captureConn := &openAIWSCaptureConn{events: [][]byte{
|
||||
[]byte(`{"type":"response.completed","response":{"id":"resp_oauth_tool_history","model":"gpt-5.6-sol","usage":{"input_tokens":1,"output_tokens":1}}}`),
|
||||
}}
|
||||
captureDialer := &openAIWSCaptureDialer{conn: captureConn}
|
||||
pool := newOpenAIWSConnPool(cfg)
|
||||
pool.setClientDialerForTest(captureDialer)
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
httpUpstream: &httpUpstreamRecorder{},
|
||||
cache: &stubGatewayCache{},
|
||||
openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
|
||||
toolCorrector: NewCodexToolCorrector(),
|
||||
openaiWSPool: pool,
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 5662,
|
||||
Name: "openai-oauth-ws-tool-history",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "test-oauth-token"},
|
||||
Extra: map[string]any{
|
||||
"responses_websockets_v2_enabled": true,
|
||||
},
|
||||
}
|
||||
|
||||
body := []byte(`{"model":"gpt-5.6-sol","stream":false,"instructions":"Continue the task.","input":[{"type":"custom_tool_call","id":"fc_hotfix_probe","call_id":"fc_hotfix","name":"exec","input":"pwd","status":"completed"},{"type":"custom_tool_call_output","call_id":"fc_hotfix","output":"done"}]}`)
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
captureConn.mu.Lock()
|
||||
requestPayload := cloneMapStringAny(captureConn.lastWrite)
|
||||
captureConn.mu.Unlock()
|
||||
requestJSON := requestToJSONString(requestPayload)
|
||||
require.Equal(t, "response.create", gjson.Get(requestJSON, "type").String())
|
||||
require.False(t, gjson.Get(requestJSON, "input.0.id").Exists(), "stale fc_* ID must not be replayed as a native custom_tool_call ID")
|
||||
require.Equal(t, "ctc_hotfix", gjson.Get(requestJSON, "input.0.call_id").String())
|
||||
require.Equal(t, "custom_tool_call_output", gjson.Get(requestJSON, "input.1.type").String())
|
||||
require.Equal(t, "ctc_hotfix", gjson.Get(requestJSON, "input.1.call_id").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ func (s *OpenAIGatewayService) shouldBridgeOpenAIWSHTTP(account *Account, payloa
|
||||
return threshold > 0 && int64(payloadBytes) >= threshold
|
||||
}
|
||||
|
||||
func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) {
|
||||
func prepareOpenAIWSHTTPBridgeBody(account *Account, payload []byte) ([]byte, error) {
|
||||
var body map[string]any
|
||||
if err := decodeOpenAIJSONUseNumber(payload, &body); err != nil {
|
||||
return nil, err
|
||||
@@ -126,6 +126,7 @@ func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) {
|
||||
delete(body, "type")
|
||||
delete(body, "generate")
|
||||
delete(body, "previous_response_id")
|
||||
deleteOpenAIResponsesNoneReasoningEffortFromObject(account, body)
|
||||
body["stream"] = true
|
||||
return json.Marshal(body)
|
||||
}
|
||||
@@ -305,7 +306,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
}
|
||||
responseModelObserver := &upstreamResponseModelObserver{}
|
||||
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(payload)
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(account, payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare http bridge body: %w", err)
|
||||
}
|
||||
@@ -836,7 +837,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
}
|
||||
|
||||
func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, seedPayload, currentPayload []byte, originalModel string) (string, error) {
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(seedPayload)
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(account, seedPayload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestResolveOpenAIWSClientFirstMessageTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) {
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi","sequence":900719925474099312345}`))
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(nil, []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi","sequence":900719925474099312345}`))
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(body, "type").Exists())
|
||||
require.False(t, gjson.GetBytes(body, "generate").Exists())
|
||||
@@ -40,10 +40,26 @@ func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) {
|
||||
require.True(t, gjson.GetBytes(body, "stream").Bool())
|
||||
require.Equal(t, "hi", gjson.GetBytes(body, "input").String())
|
||||
require.Equal(t, "900719925474099312345", gjson.GetBytes(body, "sequence").Raw)
|
||||
_, err = prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create"}{"trailing":true}`))
|
||||
_, err = prepareOpenAIWSHTTPBridgeBody(nil, []byte(`{"type":"response.create"}{"trailing":true}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPrepareOpenAIWSHTTPBridgeBodyStripsNoneReasoningForCompatibleEndpoint(t *testing.T) {
|
||||
payload := []byte(`{"type":"response.create","model":"company-coding-model","reasoning":{"effort":"none"},"input":"hi"}`)
|
||||
compatible := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{
|
||||
"base_url": "https://compat.example/v1",
|
||||
}}
|
||||
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(compatible, payload)
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(body, "reasoning.effort").Exists())
|
||||
require.False(t, gjson.GetBytes(body, "reasoning").Exists())
|
||||
|
||||
officialBody, err := prepareOpenAIWSHTTPBridgeBody(&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "none", gjson.GetBytes(officialBody, "reasoning.effort").String())
|
||||
}
|
||||
|
||||
func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultServiceTierWinsOverRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ const (
|
||||
const (
|
||||
openAIImageRateLimitDefaultCooldown = time.Minute
|
||||
openAIImageRateLimitReason = "openai_image_rate_limited"
|
||||
openAIImageCapabilityLossCooldown = 30 * time.Minute
|
||||
openAIImageCapabilityLossReason = "openai_image_capability_lost"
|
||||
)
|
||||
|
||||
var openAIImageTryAgainPattern = regexp.MustCompile(`(?i)try again in\s+([0-9]+(?:\.[0-9]+)?)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes)`)
|
||||
@@ -2190,6 +2192,44 @@ func (s *RateLimitService) HandleOpenAIImageRateLimit(ctx context.Context, accou
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *RateLimitService) HandleOpenAIImageCapabilityLoss(ctx context.Context, account *Account, statusCode int, responseBody []byte) bool {
|
||||
if s == nil || account == nil || s.accountRepo == nil {
|
||||
return false
|
||||
}
|
||||
if account.Platform != PlatformOpenAI {
|
||||
return false
|
||||
}
|
||||
if !account.ShouldHandleErrorCode(statusCode) {
|
||||
slog.Info("openai_image_capability_loss_skipped_by_error_code_policy", "account_id", account.ID, "status_code", statusCode)
|
||||
return false
|
||||
}
|
||||
if !isOpenAIImageCapabilityLossError(statusCode, responseBody) {
|
||||
return false
|
||||
}
|
||||
|
||||
resetAt := time.Now().Add(openAIImageCapabilityLossCooldown)
|
||||
if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, openAIImageGenerationRateLimitKey, resetAt, openAIImageCapabilityLossReason); err != nil {
|
||||
slog.Warn("openai_image_capability_loss_set_model_rate_limit_failed", "account_id", account.ID, "scope", openAIImageGenerationRateLimitKey, "error", err)
|
||||
return true
|
||||
}
|
||||
slog.Info("openai_image_capability_lost", "account_id", account.ID, "scope", openAIImageGenerationRateLimitKey, "reset_at", resetAt, "reset_in", time.Until(resetAt).Truncate(time.Second))
|
||||
return true
|
||||
}
|
||||
|
||||
// isOpenAIImageCapabilityLossError reports whether upstream rejected the
|
||||
// image_generation tool choice that sub2api itself put into the request body.
|
||||
// Only meaningful for self-built images requests, where tools always carries a
|
||||
// matching image_generation entry — upstream saying otherwise means the account
|
||||
// lost the capability.
|
||||
func isOpenAIImageCapabilityLossError(statusCode int, body []byte) bool {
|
||||
if statusCode != http.StatusBadRequest || len(body) == 0 {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(string(body))
|
||||
return strings.Contains(lower, "image_generation") &&
|
||||
strings.Contains(lower, "not found in 'tools' parameter")
|
||||
}
|
||||
|
||||
func isOpenAIImageRateLimitError(statusCode int, body []byte) bool {
|
||||
if statusCode != http.StatusTooManyRequests || len(body) == 0 {
|
||||
return false
|
||||
|
||||
@@ -120,7 +120,11 @@ func TestOpenAIGatewayServiceForwardImages_ImageRateLimitReturnsFailoverAndCools
|
||||
require.Equal(t, openAIImageGenerationRateLimitKey, repo.modelRateLimitCalls[0].scope)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *testing.T) {
|
||||
// issue #6171:上游"回文字没回图"是**这一轮**的结果(模型选择了说话),不是账号能力
|
||||
// 失效。它同时被判为可重试(502)并驱动 failover,若还写 30 分钟账号级冷却,一次闲聊
|
||||
// 回复就会沿号池把每个被重试到的账号依次冷却掉。冷却仍保留给结构化上游证据,见
|
||||
// TestOpenAIGatewayServiceForwardImages_StructuredUnavailableCoolsImageCapability。
|
||||
func TestOpenAIGatewayServiceForwardImages_TextFallbackDoesNotCoolImageCapability(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
|
||||
@@ -154,7 +158,6 @@ func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *t
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
@@ -162,6 +165,56 @@ func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *t
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount)
|
||||
// 换号行为不变:该判据仍足以放弃本账号重试这一次请求……
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
|
||||
// ……但不再写任何账号级状态,否则重试会把冷却一路刷到整个号池。
|
||||
require.Empty(t, repo.modelRateLimitCalls,
|
||||
"模型回文字只说明这一轮没出图,不构成账号 30 分钟不可用的证据")
|
||||
}
|
||||
|
||||
// 对照不变式:上游 error 帧点名 image_generation_unavailable 时仍写冷却,
|
||||
// 保证 #6171 的修复没有把这项能力保护整个废掉。
|
||||
func TestOpenAIGatewayServiceForwardImages_StructuredUnavailableCoolsImageCapability(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
|
||||
upstreamSSE := "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"r\",\"error\":" +
|
||||
"{\"type\":\"upstream_error\",\"code\":\"image_generation_unavailable\"," +
|
||||
"\"message\":\"image generation tool is not available for this account\"}}}\n\n"
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: repo,
|
||||
httpUpstream: &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
},
|
||||
},
|
||||
}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
account := &Account{
|
||||
ID: 206,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Len(t, repo.modelRateLimitCalls, 1)
|
||||
call := repo.modelRateLimitCalls[0]
|
||||
require.Equal(t, account.ID, call.accountID)
|
||||
@@ -169,3 +222,120 @@ func TestOpenAIGatewayServiceForwardImages_TextFallbackCoolsImageCapability(t *t
|
||||
require.Equal(t, openAIImagesOAuthUnavailableReason, call.reason)
|
||||
require.WithinDuration(t, before.Add(openAIImagesOAuthUnavailableCooldown), call.resetAt, time.Second)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForwardImages_CapabilityLossCoolsImageScope(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
|
||||
errorBody := `{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
rateLimitService: &RateLimitService{accountRepo: repo},
|
||||
httpUpstream: &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"X-Request-Id": []string{"req_img_capability_lost"}},
|
||||
Body: io.NopCloser(strings.NewReader(errorBody)),
|
||||
},
|
||||
},
|
||||
}
|
||||
parsed, err := svc.ParseOpenAIImagesRequest(c, body)
|
||||
require.NoError(t, err)
|
||||
account := &Account{
|
||||
ID: 205,
|
||||
Name: "openai-oauth",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token-123",
|
||||
},
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
|
||||
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
require.Len(t, repo.modelRateLimitCalls, 1)
|
||||
call := repo.modelRateLimitCalls[0]
|
||||
require.Equal(t, account.ID, call.accountID)
|
||||
require.Equal(t, openAIImageGenerationRateLimitKey, call.scope)
|
||||
require.Equal(t, openAIImageCapabilityLossReason, call.reason)
|
||||
require.WithinDuration(t, before.Add(openAIImageCapabilityLossCooldown), call.resetAt, time.Second)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceHandleUpstreamError_PassthroughCapabilityLossDoesNotCool(t *testing.T) {
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
svc := &OpenAIGatewayService{rateLimitService: &RateLimitService{accountRepo: repo}}
|
||||
account := &Account{ID: 206, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
body := []byte(`{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`)
|
||||
|
||||
disabled := svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadRequest, http.Header{}, body, "gpt-5.5")
|
||||
|
||||
require.False(t, disabled)
|
||||
require.Empty(t, repo.modelRateLimitCalls)
|
||||
_, wholeAccountBlocked := svc.openaiAccountRuntimeBlockUntil.Load(account.ID)
|
||||
require.False(t, wholeAccountBlocked)
|
||||
}
|
||||
|
||||
func TestRateLimitServiceHandleOpenAIImageCapabilityLoss_IgnoresGenericBadRequest(t *testing.T) {
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
svc := &RateLimitService{accountRepo: repo}
|
||||
account := &Account{ID: 207, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
body := []byte(`{"error":{"message":"Invalid type for input[0].arguments"}}`)
|
||||
|
||||
handled := svc.HandleOpenAIImageCapabilityLoss(context.Background(), account, http.StatusBadRequest, body)
|
||||
|
||||
require.False(t, handled)
|
||||
require.Empty(t, repo.modelRateLimitCalls)
|
||||
}
|
||||
|
||||
func TestRateLimitServiceHandleOpenAIImageCapabilityLoss_RespectsPlatformAndErrorCodePolicy(t *testing.T) {
|
||||
body := []byte(`{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`)
|
||||
|
||||
t.Run("non_openai_platform", func(t *testing.T) {
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
svc := &RateLimitService{accountRepo: repo}
|
||||
account := &Account{ID: 208, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
|
||||
|
||||
handled := svc.HandleOpenAIImageCapabilityLoss(context.Background(), account, http.StatusBadRequest, body)
|
||||
|
||||
require.False(t, handled)
|
||||
require.Empty(t, repo.modelRateLimitCalls)
|
||||
})
|
||||
|
||||
t.Run("custom_error_code_policy_excludes_400", func(t *testing.T) {
|
||||
repo := &modelNotFoundAccountRepoStub{}
|
||||
svc := &RateLimitService{accountRepo: repo}
|
||||
account := &Account{
|
||||
ID: 209,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"custom_error_codes_enabled": true,
|
||||
"custom_error_codes": []any{float64(http.StatusTooManyRequests)},
|
||||
},
|
||||
}
|
||||
|
||||
require.False(t, account.ShouldHandleErrorCode(http.StatusBadRequest))
|
||||
handled := svc.HandleOpenAIImageCapabilityLoss(context.Background(), account, http.StatusBadRequest, body)
|
||||
|
||||
require.False(t, handled)
|
||||
require.Empty(t, repo.modelRateLimitCalls)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsOpenAIImageCapabilityLossError(t *testing.T) {
|
||||
capabilityLossBody := []byte(`{"error":{"message":"Tool choice 'image_generation' not found in 'tools' parameter.","param":"tool_choice","type":"invalid_request_error"}}`)
|
||||
genericBadRequestBody := []byte(`{"error":{"message":"Invalid type for input[0].arguments"}}`)
|
||||
|
||||
require.True(t, isOpenAIImageCapabilityLossError(http.StatusBadRequest, capabilityLossBody))
|
||||
require.False(t, isOpenAIImageCapabilityLossError(http.StatusBadRequest, genericBadRequestBody))
|
||||
require.False(t, isOpenAIImageCapabilityLossError(http.StatusTooManyRequests, capabilityLossBody))
|
||||
}
|
||||
|
||||
@@ -3,17 +3,128 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
)
|
||||
|
||||
const (
|
||||
upstreamModelsBodyLimit int64 = 8 << 20
|
||||
modelsDevRegistryURL = "https://models.dev/api.json"
|
||||
modelsDevRegistryTTL = 6 * time.Hour
|
||||
UpstreamModelMetadataExtraKey = "upstream_model_metadata"
|
||||
UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete"
|
||||
)
|
||||
|
||||
type UpstreamModelMetadata struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Reasoning *bool `json:"reasoning,omitempty"`
|
||||
DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"`
|
||||
SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"`
|
||||
InputModalities []string `json:"input_modalities,omitempty"`
|
||||
ContextWindow int64 `json:"context_window,omitempty"`
|
||||
MaxOutputTokens int64 `json:"max_output_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type UpstreamModelMetadataSnapshot struct {
|
||||
Source string `json:"source"`
|
||||
SyncedAt string `json:"synced_at"`
|
||||
Models map[string]UpstreamModelMetadata `json:"models"`
|
||||
}
|
||||
|
||||
type UpstreamModelCatalog struct {
|
||||
Models []string `json:"models"`
|
||||
Metadata map[string]UpstreamModelMetadata `json:"metadata,omitempty"`
|
||||
Warnings []UpstreamModelSyncWarning `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type UpstreamModelSyncWarning struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type modelsDevProvider struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
API string `json:"api"`
|
||||
Models map[string]modelsDevModel `json:"models"`
|
||||
}
|
||||
|
||||
type modelsDevModel struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Reasoning *bool `json:"reasoning"`
|
||||
ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"`
|
||||
Modalities modelsDevModalities `json:"modalities"`
|
||||
Limit modelsDevLimit `json:"limit"`
|
||||
}
|
||||
|
||||
type modelsDevReasoningOption struct {
|
||||
Type string `json:"type"`
|
||||
Values []any `json:"values"`
|
||||
}
|
||||
|
||||
type modelsDevModalities struct {
|
||||
Input []string `json:"input"`
|
||||
Output []string `json:"output"`
|
||||
}
|
||||
|
||||
type modelsDevLimit struct {
|
||||
Context int64 `json:"context"`
|
||||
Output int64 `json:"output"`
|
||||
}
|
||||
|
||||
func (a *Account) SetUpstreamModelMetadataSnapshot(snapshot UpstreamModelMetadataSnapshot) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
if a.Extra == nil {
|
||||
a.Extra = make(map[string]any)
|
||||
}
|
||||
a.Extra[UpstreamModelMetadataExtraKey] = snapshot
|
||||
}
|
||||
|
||||
func (a *Account) GetUpstreamModelMetadataSnapshot() *UpstreamModelMetadataSnapshot {
|
||||
if a == nil || a.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := a.Extra[UpstreamModelMetadataExtraKey]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
body, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var snapshot UpstreamModelMetadataSnapshot
|
||||
if err := json.Unmarshal(body, &snapshot); err != nil || len(snapshot.Models) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &snapshot
|
||||
}
|
||||
|
||||
func (a *Account) GetUpstreamModelMetadata(modelID string) (UpstreamModelMetadata, bool) {
|
||||
snapshot := a.GetUpstreamModelMetadataSnapshot()
|
||||
if snapshot == nil {
|
||||
return UpstreamModelMetadata{}, false
|
||||
}
|
||||
metadata, ok := snapshot.Models[strings.TrimSpace(modelID)]
|
||||
return metadata, ok
|
||||
}
|
||||
|
||||
// UpstreamModelSyncErrorKind classifies model sync failures for safe HTTP mapping.
|
||||
type UpstreamModelSyncErrorKind string
|
||||
|
||||
@@ -24,13 +135,16 @@ const (
|
||||
UpstreamModelSyncErrorUnsupported UpstreamModelSyncErrorKind = "unsupported"
|
||||
// UpstreamModelSyncErrorUpstream means the configured upstream failed or returned an unusable response.
|
||||
UpstreamModelSyncErrorUpstream UpstreamModelSyncErrorKind = "upstream"
|
||||
// UpstreamModelSyncErrorInternal means local persistence or service state failed after a valid upstream response.
|
||||
UpstreamModelSyncErrorInternal UpstreamModelSyncErrorKind = "internal"
|
||||
)
|
||||
|
||||
// UpstreamModelSyncError keeps internal failure details wrapped while exposing a safe client message.
|
||||
type UpstreamModelSyncError struct {
|
||||
Kind UpstreamModelSyncErrorKind
|
||||
Message string
|
||||
Err error
|
||||
Kind UpstreamModelSyncErrorKind
|
||||
Message string
|
||||
StatusCode int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *UpstreamModelSyncError) Error() string {
|
||||
@@ -70,49 +184,432 @@ func newUpstreamModelSyncUpstreamError(message string, err error) error {
|
||||
return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorUpstream, Message: message, Err: err}
|
||||
}
|
||||
|
||||
// FetchUpstreamSupportedModels fetches the live model list from the account's upstream API format.
|
||||
func newUpstreamModelSyncInternalError(message string, err error) error {
|
||||
return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorInternal, Message: message, Err: err}
|
||||
}
|
||||
|
||||
// FetchUpstreamSupportedModels fetches only live model IDs. The admin sync path
|
||||
// uses SyncUpstreamModelCatalog so capability metadata can also be persisted.
|
||||
func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, account *Account) ([]string, error) {
|
||||
models, _, err := s.fetchUpstreamModelList(ctx, account)
|
||||
return models, err
|
||||
}
|
||||
|
||||
// SyncUpstreamModelCatalog fetches the account's live model list, enriches
|
||||
// missing capability fields from the provider registry used by the upstream,
|
||||
// and persists a normalized account snapshot when metadata is available.
|
||||
func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, account *Account) (*UpstreamModelCatalog, error) {
|
||||
models, body, err := s.fetchUpstreamModelList(ctx, account)
|
||||
if err != nil {
|
||||
configuredModels := configuredUpstreamModelsForCapabilitySync(account)
|
||||
if !upstreamModelListEndpointUnsupported(err) || len(configuredModels) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
models = configuredModels
|
||||
body = nil
|
||||
slog.Info("upstream model list endpoint unavailable; using configured models for capability sync",
|
||||
"account_id", upstreamModelSyncAccountID(account),
|
||||
"platform", upstreamModelSyncPlatform(account),
|
||||
"status_code", upstreamModelSyncStatusCode(err),
|
||||
"model_count", len(models),
|
||||
)
|
||||
}
|
||||
catalog := &UpstreamModelCatalog{Models: models, Metadata: make(map[string]UpstreamModelMetadata)}
|
||||
if len(body) > 0 {
|
||||
_, directMetadata, parseErr := extractUpstreamModelCatalog(body, account != nil && account.IsGrok())
|
||||
if parseErr == nil {
|
||||
catalog.Metadata = directMetadata
|
||||
}
|
||||
}
|
||||
|
||||
source := "upstream"
|
||||
metadataIncomplete := upstreamCatalogNeedsRegistry(models, catalog.Metadata)
|
||||
if metadataIncomplete {
|
||||
if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, models); registryErr == nil {
|
||||
for modelID, fallback := range registryMetadata {
|
||||
current := catalog.Metadata[modelID]
|
||||
merged, changed := mergeUpstreamModelMetadata(current, fallback)
|
||||
catalog.Metadata[modelID] = merged
|
||||
if changed {
|
||||
source = "models.dev"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Warn("upstream model capability metadata enrichment failed",
|
||||
"account_id", upstreamModelSyncAccountID(account),
|
||||
"platform", upstreamModelSyncPlatform(account),
|
||||
"error", registryErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if upstreamCatalogNeedsRegistry(models, catalog.Metadata) {
|
||||
catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{
|
||||
Code: UpstreamModelMetadataIncompleteCode,
|
||||
Message: "Model IDs were synced, but capability metadata is incomplete.",
|
||||
})
|
||||
return catalog, nil
|
||||
}
|
||||
if len(catalog.Metadata) == 0 || account == nil || account.ID <= 0 || s.accountRepo == nil {
|
||||
return catalog, nil
|
||||
}
|
||||
snapshot := UpstreamModelMetadataSnapshot{
|
||||
Source: source,
|
||||
SyncedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Models: catalog.Metadata,
|
||||
}
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil {
|
||||
return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err)
|
||||
}
|
||||
account.SetUpstreamModelMetadataSnapshot(snapshot)
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func upstreamModelSyncStatusCode(err error) int {
|
||||
var syncErr *UpstreamModelSyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
return syncErr.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func upstreamModelListEndpointUnsupported(err error) bool {
|
||||
statusCode := upstreamModelSyncStatusCode(err)
|
||||
return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed
|
||||
}
|
||||
|
||||
func configuredUpstreamModelsForCapabilitySync(account *Account) []string {
|
||||
if account == nil {
|
||||
return nil
|
||||
}
|
||||
models := make([]string, 0)
|
||||
for _, mappedModel := range account.GetModelMapping() {
|
||||
mappedModel = strings.TrimSpace(mappedModel)
|
||||
if mappedModel == "" || strings.Contains(mappedModel, "*") {
|
||||
continue
|
||||
}
|
||||
models = append(models, mappedModel)
|
||||
}
|
||||
return dedupeAndSortModelIDs(models)
|
||||
}
|
||||
|
||||
func upstreamModelSyncAccountID(account *Account) int64 {
|
||||
if account == nil {
|
||||
return 0
|
||||
}
|
||||
return account.ID
|
||||
}
|
||||
|
||||
func upstreamModelSyncPlatform(account *Account) string {
|
||||
if account == nil {
|
||||
return ""
|
||||
}
|
||||
return account.Platform
|
||||
}
|
||||
|
||||
func upstreamCatalogNeedsRegistry(models []string, metadata map[string]UpstreamModelMetadata) bool {
|
||||
for _, modelID := range models {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
model, ok := metadata[modelID]
|
||||
if !ok || !upstreamModelMetadataIsUseful(model) {
|
||||
return true
|
||||
}
|
||||
if model.Reasoning == nil || len(model.InputModalities) == 0 || model.ContextWindow <= 0 {
|
||||
return true
|
||||
}
|
||||
if *model.Reasoning && len(model.SupportedReasoningLevels) == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool {
|
||||
return strings.TrimSpace(metadata.DisplayName) != "" ||
|
||||
strings.TrimSpace(metadata.Description) != "" ||
|
||||
metadata.Reasoning != nil ||
|
||||
len(metadata.SupportedReasoningLevels) > 0 ||
|
||||
len(metadata.InputModalities) > 0 ||
|
||||
metadata.ContextWindow > 0 ||
|
||||
metadata.MaxOutputTokens > 0
|
||||
}
|
||||
|
||||
func mergeUpstreamModelMetadata(primary, fallback UpstreamModelMetadata) (UpstreamModelMetadata, bool) {
|
||||
merged := primary
|
||||
changed := false
|
||||
if strings.TrimSpace(merged.ID) == "" && strings.TrimSpace(fallback.ID) != "" {
|
||||
merged.ID = strings.TrimSpace(fallback.ID)
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(merged.DisplayName) == "" && strings.TrimSpace(fallback.DisplayName) != "" {
|
||||
merged.DisplayName = strings.TrimSpace(fallback.DisplayName)
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(merged.Description) == "" && strings.TrimSpace(fallback.Description) != "" {
|
||||
merged.Description = strings.TrimSpace(fallback.Description)
|
||||
changed = true
|
||||
}
|
||||
if merged.Reasoning == nil && fallback.Reasoning != nil {
|
||||
reasoning := *fallback.Reasoning
|
||||
merged.Reasoning = &reasoning
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(merged.DefaultReasoningLevel) == "" && strings.TrimSpace(fallback.DefaultReasoningLevel) != "" {
|
||||
merged.DefaultReasoningLevel = strings.TrimSpace(fallback.DefaultReasoningLevel)
|
||||
changed = true
|
||||
}
|
||||
if len(merged.SupportedReasoningLevels) == 0 && len(fallback.SupportedReasoningLevels) > 0 {
|
||||
merged.SupportedReasoningLevels = append([]string(nil), fallback.SupportedReasoningLevels...)
|
||||
changed = true
|
||||
}
|
||||
if len(merged.InputModalities) == 0 && len(fallback.InputModalities) > 0 {
|
||||
merged.InputModalities = append([]string(nil), fallback.InputModalities...)
|
||||
changed = true
|
||||
}
|
||||
if merged.ContextWindow <= 0 && fallback.ContextWindow > 0 {
|
||||
merged.ContextWindow = fallback.ContextWindow
|
||||
changed = true
|
||||
}
|
||||
if merged.MaxOutputTokens <= 0 && fallback.MaxOutputTokens > 0 {
|
||||
merged.MaxOutputTokens = fallback.MaxOutputTokens
|
||||
changed = true
|
||||
}
|
||||
return merged, changed
|
||||
}
|
||||
|
||||
func (s *AccountTestService) fetchModelsDevMetadata(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
modelIDs []string,
|
||||
) (map[string]UpstreamModelMetadata, error) {
|
||||
if s == nil || s.httpUpstream == nil || account == nil {
|
||||
return nil, fmt.Errorf("model metadata registry is not configured")
|
||||
}
|
||||
registry, err := s.fetchModelsDevRegistry(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
provider, ok := matchModelsDevProvider(registry, upstreamModelRegistryBaseURL(account))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no model metadata provider matches account base URL")
|
||||
}
|
||||
|
||||
metadata := make(map[string]UpstreamModelMetadata)
|
||||
for _, modelID := range modelIDs {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
model, found := provider.Models[modelID]
|
||||
if !found {
|
||||
for candidateID, candidate := range provider.Models {
|
||||
if strings.EqualFold(strings.TrimSpace(candidateID), modelID) || strings.EqualFold(strings.TrimSpace(candidate.ID), modelID) {
|
||||
model = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
entry := upstreamMetadataFromModelsDevModel(modelID, model)
|
||||
if upstreamModelMetadataIsUseful(entry) {
|
||||
metadata[modelID] = entry
|
||||
}
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func (s *AccountTestService) fetchModelsDevRegistry(ctx context.Context, account *Account) (map[string]modelsDevProvider, error) {
|
||||
now := time.Now()
|
||||
s.modelMetadataRegistryMu.Lock()
|
||||
if len(s.modelMetadataRegistry) > 0 && now.Sub(s.modelMetadataRegistryAt) < modelsDevRegistryTTL {
|
||||
cached := s.modelMetadataRegistry
|
||||
s.modelMetadataRegistryMu.Unlock()
|
||||
return cached, nil
|
||||
}
|
||||
s.modelMetadataRegistryMu.Unlock()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsDevRegistryURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := s.doUpstreamModelsRequest(req, upstreamModelsProxyURL(account), account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("model metadata registry returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(body)) > upstreamModelsBodyLimit {
|
||||
return nil, fmt.Errorf("model metadata registry response exceeds %d bytes", upstreamModelsBodyLimit)
|
||||
}
|
||||
var registry map[string]modelsDevProvider
|
||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||
return nil, fmt.Errorf("parse model metadata registry: %w", err)
|
||||
}
|
||||
if len(registry) == 0 {
|
||||
return nil, fmt.Errorf("model metadata registry is empty")
|
||||
}
|
||||
|
||||
s.modelMetadataRegistryMu.Lock()
|
||||
s.modelMetadataRegistry = registry
|
||||
s.modelMetadataRegistryAt = now
|
||||
s.modelMetadataRegistryMu.Unlock()
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func upstreamMetadataFromModelsDevModel(modelID string, model modelsDevModel) UpstreamModelMetadata {
|
||||
levels := reasoningLevelsFromModelsDevOptions(model.ReasoningOptions)
|
||||
reasoning := model.Reasoning
|
||||
if reasoning == nil && len(levels) > 0 {
|
||||
inferred := true
|
||||
reasoning = &inferred
|
||||
}
|
||||
metadata := UpstreamModelMetadata{
|
||||
ID: strings.TrimSpace(modelID),
|
||||
DisplayName: strings.TrimSpace(model.Name),
|
||||
Description: strings.TrimSpace(model.Description),
|
||||
Reasoning: reasoning,
|
||||
SupportedReasoningLevels: levels,
|
||||
InputModalities: normalizeCodexInputModalities(model.Modalities.Input),
|
||||
ContextWindow: model.Limit.Context,
|
||||
MaxOutputTokens: model.Limit.Output,
|
||||
}
|
||||
if len(levels) > 0 {
|
||||
metadata.DefaultReasoningLevel = levels[0]
|
||||
}
|
||||
if strings.TrimSpace(model.ID) != "" {
|
||||
metadata.ID = strings.TrimSpace(model.ID)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func reasoningLevelsFromModelsDevOptions(options []modelsDevReasoningOption) []string {
|
||||
levels := make([]string, 0)
|
||||
for _, option := range options {
|
||||
if !strings.EqualFold(strings.TrimSpace(option.Type), "effort") {
|
||||
continue
|
||||
}
|
||||
for _, value := range option.Values {
|
||||
if value == nil {
|
||||
levels = append(levels, "none")
|
||||
continue
|
||||
}
|
||||
if effort, ok := value.(string); ok {
|
||||
levels = append(levels, effort)
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalizeReasoningLevels(levels)
|
||||
}
|
||||
|
||||
func upstreamModelRegistryBaseURL(account *Account) string {
|
||||
if account == nil {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case account.IsOpenAI() || account.IsCNProvider():
|
||||
return account.GetOpenAIFormatBaseURL()
|
||||
case account.IsGrok():
|
||||
return account.GetGrokBaseURL()
|
||||
case account.IsGemini():
|
||||
return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
|
||||
case account.IsAnthropic():
|
||||
return account.GetBaseURL()
|
||||
case account.Platform == PlatformAntigravity:
|
||||
return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
|
||||
default:
|
||||
return strings.TrimSpace(account.GetCredential("base_url"))
|
||||
}
|
||||
}
|
||||
|
||||
func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) {
|
||||
accountBaseURL = normalizeModelRegistryBaseURL(accountBaseURL)
|
||||
if accountBaseURL == "" {
|
||||
return modelsDevProvider{}, false
|
||||
}
|
||||
var best modelsDevProvider
|
||||
bestScore := -1
|
||||
for _, provider := range registry {
|
||||
providerBaseURL := normalizeModelRegistryBaseURL(provider.API)
|
||||
if providerBaseURL == "" {
|
||||
continue
|
||||
}
|
||||
if accountBaseURL != providerBaseURL &&
|
||||
!strings.HasPrefix(accountBaseURL, providerBaseURL+"/") &&
|
||||
!strings.HasPrefix(providerBaseURL, accountBaseURL+"/") {
|
||||
continue
|
||||
}
|
||||
if len(providerBaseURL) > bestScore {
|
||||
best = provider
|
||||
bestScore = len(providerBaseURL)
|
||||
}
|
||||
}
|
||||
return best, bestScore >= 0
|
||||
}
|
||||
|
||||
func normalizeModelRegistryBaseURL(raw string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
path := strings.TrimRight(parsed.Path, "/")
|
||||
if strings.HasSuffix(strings.ToLower(path), "/models") {
|
||||
path = strings.TrimRight(path[:len(path)-len("/models")], "/")
|
||||
}
|
||||
return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host) + path
|
||||
}
|
||||
|
||||
func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account *Account) ([]string, []byte, error) {
|
||||
if s == nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil)
|
||||
}
|
||||
if account == nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Account is required", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Account is required", nil)
|
||||
}
|
||||
|
||||
if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey {
|
||||
return s.fetchAntigravityOAuthUpstreamModels(ctx, account)
|
||||
models, err := s.fetchAntigravityOAuthUpstreamModels(ctx, account)
|
||||
return models, nil, err
|
||||
}
|
||||
|
||||
if s.httpUpstream == nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil)
|
||||
}
|
||||
|
||||
req, err := s.buildUpstreamModelsRequest(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
proxyURL := upstreamModelsProxyURL(account)
|
||||
resp, err := s.doUpstreamModelsRequest(req, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
bodyLimit := resolveModelsListReadLimit(s.cfg)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err)
|
||||
}
|
||||
if int64(len(body)) > bodyLimit {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit))
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit))
|
||||
}
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, newUpstreamModelSyncUpstreamError(
|
||||
fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode),
|
||||
fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode),
|
||||
)
|
||||
return nil, nil, &UpstreamModelSyncError{
|
||||
Kind: UpstreamModelSyncErrorUpstream,
|
||||
Message: fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode),
|
||||
StatusCode: resp.StatusCode,
|
||||
Err: fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode),
|
||||
}
|
||||
}
|
||||
|
||||
extractModels := extractUpstreamModelIDs
|
||||
@@ -121,13 +618,13 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a
|
||||
}
|
||||
models, err := extractModels(body)
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err)
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil)
|
||||
}
|
||||
|
||||
return models, nil
|
||||
return models, body, nil
|
||||
}
|
||||
|
||||
func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) {
|
||||
@@ -577,12 +1074,29 @@ type upstreamModelEntry struct {
|
||||
|
||||
type upstreamModelEntryMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Model string `json:"model"`
|
||||
ModelID string `json:"modelId"`
|
||||
ModelIDSnake string `json:"model_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type upstreamModelCapabilityEntry struct {
|
||||
upstreamModelEntry
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Reasoning *bool `json:"reasoning"`
|
||||
DefaultReasoningLevel string `json:"default_reasoning_level"`
|
||||
SupportedReasoningLevels []json.RawMessage `json:"supported_reasoning_levels"`
|
||||
ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"`
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
Modalities modelsDevModalities `json:"modalities"`
|
||||
ContextWindow int64 `json:"context_window"`
|
||||
MaxContextWindow int64 `json:"max_context_window"`
|
||||
MaxOutputTokens int64 `json:"max_output_tokens"`
|
||||
Limit modelsDevLimit `json:"limit"`
|
||||
}
|
||||
|
||||
func extractUpstreamModelIDs(body []byte) ([]string, error) {
|
||||
return extractUpstreamModelIDsWithSelector(body, upstreamModelEntryID)
|
||||
}
|
||||
@@ -591,6 +1105,166 @@ func extractGrokUpstreamModelIDs(body []byte) ([]string, error) {
|
||||
return extractUpstreamModelIDsWithSelector(body, grokUpstreamModelEntryID)
|
||||
}
|
||||
|
||||
func extractUpstreamModelCatalog(body []byte, grok bool) ([]string, map[string]UpstreamModelMetadata, error) {
|
||||
entries, err := extractUpstreamModelRawEntries(body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
selectID := upstreamModelEntryID
|
||||
if grok {
|
||||
selectID = grokUpstreamModelEntryID
|
||||
}
|
||||
|
||||
models := make([]string, 0, len(entries))
|
||||
metadata := make(map[string]UpstreamModelMetadata)
|
||||
for _, raw := range entries {
|
||||
var capability upstreamModelCapabilityEntry
|
||||
if err := json.Unmarshal(raw, &capability); err != nil {
|
||||
continue
|
||||
}
|
||||
modelID := strings.TrimSpace(selectID(capability.upstreamModelEntry))
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
models = append(models, modelID)
|
||||
entry := upstreamMetadataFromCapabilityEntry(modelID, capability)
|
||||
if upstreamModelMetadataIsUseful(entry) {
|
||||
metadata[modelID] = entry
|
||||
}
|
||||
}
|
||||
return dedupeAndSortModelIDs(models), metadata, nil
|
||||
}
|
||||
|
||||
func extractUpstreamModelRawEntries(body []byte) ([]json.RawMessage, error) {
|
||||
var response struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
Models []json.RawMessage `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &response); err == nil && (response.Data != nil || response.Models != nil) {
|
||||
entries := make([]json.RawMessage, 0, len(response.Data)+len(response.Models))
|
||||
entries = append(entries, response.Data...)
|
||||
entries = append(entries, response.Models...)
|
||||
return entries, nil
|
||||
}
|
||||
var entries []json.RawMessage
|
||||
if err := json.Unmarshal(body, &entries); err != nil {
|
||||
return nil, fmt.Errorf("parse upstream model catalog: %w", err)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapabilityEntry) UpstreamModelMetadata {
|
||||
levels := reasoningLevelsFromRawEntries(entry.SupportedReasoningLevels)
|
||||
if len(levels) == 0 {
|
||||
levels = reasoningLevelsFromModelsDevOptions(entry.ReasoningOptions)
|
||||
}
|
||||
reasoning := entry.Reasoning
|
||||
if reasoning == nil && len(levels) > 0 {
|
||||
inferred := len(levels) != 1 || levels[0] != "none"
|
||||
reasoning = &inferred
|
||||
}
|
||||
modalities := entry.InputModalities
|
||||
if len(modalities) == 0 {
|
||||
modalities = entry.Modalities.Input
|
||||
}
|
||||
contextWindow := entry.ContextWindow
|
||||
if contextWindow <= 0 {
|
||||
contextWindow = entry.MaxContextWindow
|
||||
}
|
||||
if contextWindow <= 0 {
|
||||
contextWindow = entry.Limit.Context
|
||||
}
|
||||
maxOutputTokens := entry.MaxOutputTokens
|
||||
if maxOutputTokens <= 0 {
|
||||
maxOutputTokens = entry.Limit.Output
|
||||
}
|
||||
defaultReasoningLevel := normalizeReasoningLevel(entry.DefaultReasoningLevel)
|
||||
if defaultReasoningLevel == "" && len(levels) > 0 {
|
||||
defaultReasoningLevel = levels[0]
|
||||
}
|
||||
displayName := strings.TrimSpace(entry.DisplayName)
|
||||
if displayName == "" && strings.TrimSpace(entry.Name) != "" && strings.TrimSpace(entry.Name) != modelID {
|
||||
displayName = strings.TrimSpace(entry.Name)
|
||||
}
|
||||
return UpstreamModelMetadata{
|
||||
ID: modelID,
|
||||
DisplayName: displayName,
|
||||
Description: strings.TrimSpace(entry.Description),
|
||||
Reasoning: reasoning,
|
||||
DefaultReasoningLevel: defaultReasoningLevel,
|
||||
SupportedReasoningLevels: levels,
|
||||
InputModalities: normalizeCodexInputModalities(modalities),
|
||||
ContextWindow: contextWindow,
|
||||
MaxOutputTokens: maxOutputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
func reasoningLevelsFromRawEntries(entries []json.RawMessage) []string {
|
||||
levels := make([]string, 0, len(entries))
|
||||
for _, raw := range entries {
|
||||
var effort string
|
||||
if err := json.Unmarshal(raw, &effort); err == nil {
|
||||
levels = append(levels, effort)
|
||||
continue
|
||||
}
|
||||
var level struct {
|
||||
Effort string `json:"effort"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &level); err == nil {
|
||||
levels = append(levels, level.Effort)
|
||||
}
|
||||
}
|
||||
return normalizeReasoningLevels(levels)
|
||||
}
|
||||
|
||||
func normalizeReasoningLevels(levels []string) []string {
|
||||
seen := make(map[string]struct{}, len(levels))
|
||||
normalized := make([]string, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
level = normalizeReasoningLevel(level)
|
||||
if level == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[level]; exists {
|
||||
continue
|
||||
}
|
||||
seen[level] = struct{}{}
|
||||
normalized = append(normalized, level)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeReasoningLevel(level string) string {
|
||||
level = strings.ToLower(strings.TrimSpace(level))
|
||||
switch level {
|
||||
case "off", "disabled":
|
||||
return "none"
|
||||
case "extra-high", "extra_high":
|
||||
return "xhigh"
|
||||
case "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra":
|
||||
return level
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCodexInputModalities(modalities []string) []string {
|
||||
seen := make(map[string]struct{}, len(modalities))
|
||||
normalized := make([]string, 0, len(modalities))
|
||||
for _, modality := range modalities {
|
||||
modality = strings.ToLower(strings.TrimSpace(modality))
|
||||
if modality != "text" && modality != "image" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[modality]; exists {
|
||||
continue
|
||||
}
|
||||
seen[modality] = struct{}{}
|
||||
normalized = append(normalized, modality)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamModelEntry) string) ([]string, error) {
|
||||
var response struct {
|
||||
Data []upstreamModelEntry `json:"data"`
|
||||
@@ -646,6 +1320,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string {
|
||||
entry.ModelID,
|
||||
entry.ModelIDSnake,
|
||||
entry.ID,
|
||||
entry.Slug,
|
||||
}
|
||||
if len(entry.Meta) > 0 {
|
||||
var meta upstreamModelEntryMetadata
|
||||
@@ -655,6 +1330,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string {
|
||||
meta.ModelID,
|
||||
meta.ModelIDSnake,
|
||||
meta.ID,
|
||||
meta.Slug,
|
||||
meta.Name,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,6 +14,28 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type upstreamModelMetadataRepoStub struct {
|
||||
AccountRepository
|
||||
accountID int64
|
||||
updates map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
func headerValuesEqualFold(header http.Header, name string) []string {
|
||||
for key, values := range header {
|
||||
if strings.EqualFold(key, name) {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *upstreamModelMetadataRepoStub) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
|
||||
r.accountID = id
|
||||
r.updates = updates
|
||||
return r.err
|
||||
}
|
||||
|
||||
func upstreamModelSyncTestConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
@@ -394,6 +417,384 @@ func TestFetchUpstreamSupportedModelsParsesOpenAIResponse(t *testing.T) {
|
||||
require.Equal(t, "Bearer openai-key", upstream.lastReq.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
// Scenario: ID-only 模型列表从 Models.dev 补齐能力。
|
||||
func TestSyncUpstreamModelCatalogEnrichesOpenCodeIDOnlyListAndPersistsSnapshot(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"x-preview-f-free","object":"model"}]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"opencode": {
|
||||
"id": "opencode",
|
||||
"name": "OpenCode Zen",
|
||||
"api": "https://opencode.ai/zen/v1",
|
||||
"models": {
|
||||
"x-preview-f-free": {
|
||||
"id": "x-preview-f-free",
|
||||
"name": "Ox Alpha Free (Unlimited)",
|
||||
"description": "Stealth reasoning model for coding, agentic tasks, and tool use",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","high","max"]}],
|
||||
"modalities": {"input":["text","image","video"],"output":["text"]},
|
||||
"limit": {"context":1000000,"output":131072}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{
|
||||
accountRepo: repo,
|
||||
httpUpstream: upstream,
|
||||
cfg: upstreamModelSyncTestConfig(),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 91,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "opencode-key",
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
"header_override_enabled": true,
|
||||
"header_overrides": map[string]any{
|
||||
"X-Custom-Account-Header": "account-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"x-preview-f-free"}, catalog.Models)
|
||||
require.Len(t, upstream.requests, 2)
|
||||
require.Equal(t, "https://opencode.ai/zen/v1/models", upstream.requests[0].URL.String())
|
||||
require.Equal(t, []string{"account-secret"}, headerValuesEqualFold(upstream.requests[0].Header, "X-Custom-Account-Header"))
|
||||
require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String())
|
||||
require.Empty(t, upstream.requests[1].Header.Get("Authorization"))
|
||||
require.Empty(t, upstream.requests[1].Header.Get("x-api-key"))
|
||||
require.Empty(t, headerValuesEqualFold(upstream.requests[1].Header, "X-Custom-Account-Header"))
|
||||
|
||||
metadata := catalog.Metadata["x-preview-f-free"]
|
||||
require.Equal(t, "Ox Alpha Free (Unlimited)", metadata.DisplayName)
|
||||
require.NotNil(t, metadata.Reasoning)
|
||||
require.True(t, *metadata.Reasoning)
|
||||
require.Equal(t, []string{"low", "high", "max"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
|
||||
require.Equal(t, int64(1_000_000), metadata.ContextWindow)
|
||||
require.Equal(t, int64(131_072), metadata.MaxOutputTokens)
|
||||
require.Equal(t, int64(91), repo.accountID)
|
||||
|
||||
rawSnapshot, ok := repo.updates[UpstreamModelMetadataExtraKey]
|
||||
require.True(t, ok)
|
||||
encoded, err := json.Marshal(rawSnapshot)
|
||||
require.NoError(t, err)
|
||||
var snapshot UpstreamModelMetadataSnapshot
|
||||
require.NoError(t, json.Unmarshal(encoded, &snapshot))
|
||||
require.Equal(t, "models.dev", snapshot.Source)
|
||||
require.Equal(t, metadata, snapshot.Models["x-preview-f-free"])
|
||||
}
|
||||
|
||||
// Scenario: 不提供 /models 的兼容上游使用管理员已配置模型继续同步能力。
|
||||
func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{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": {
|
||||
"id": "configured-provider",
|
||||
"name": "Configured Provider",
|
||||
"api": "https://provider.example/v1",
|
||||
"models": {
|
||||
"glm-5.3": {
|
||||
"id": "glm-5.3",
|
||||
"name": "GLM-5.3",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
|
||||
"modalities": {"input":["text"],"output":["text"]},
|
||||
"limit": {"context":1000000,"output":131072}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
account := &Account{
|
||||
ID: 97, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "key",
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{
|
||||
"public-glm": "glm-5.3",
|
||||
"duplicate": "glm-5.3",
|
||||
"wildcard": "glm-*",
|
||||
"empty": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"glm-5.3"}, catalog.Models)
|
||||
require.Empty(t, catalog.Warnings)
|
||||
require.Len(t, upstream.requests, 2)
|
||||
require.Equal(t, "https://provider.example/v1/models", upstream.requests[0].URL.String())
|
||||
require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String())
|
||||
metadata := catalog.Metadata["glm-5.3"]
|
||||
require.Equal(t, []string{"low", "medium", "high"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text"}, metadata.InputModalities)
|
||||
require.Equal(t, int64(1_000_000), metadata.ContextWindow)
|
||||
require.NotNil(t, repo.updates)
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogDoesNotUseConfiguredModelsForRealUpstreamFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
}{
|
||||
{name: "unauthorized", statusCode: http.StatusUnauthorized},
|
||||
{name: "rate limited", statusCode: http.StatusTooManyRequests},
|
||||
{name: "server error", statusCode: http.StatusBadGateway},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: tt.statusCode,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"failed"}`)),
|
||||
}}
|
||||
svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
_, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 98, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "key",
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{"public-glm": "glm-5.3"},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Len(t, upstream.requests, 1)
|
||||
require.Equal(t, tt.statusCode, upstreamModelSyncStatusCode(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogRequiresConfiguredModelsForUnsupportedListEndpoint(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusMethodNotAllowed,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"method not allowed"}`)),
|
||||
}}
|
||||
svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
_, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 99, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusMethodNotAllowed, upstreamModelSyncStatusCode(err))
|
||||
require.Len(t, upstream.requests, 1)
|
||||
}
|
||||
|
||||
// Scenario: 完整上游模型清单优先保存能力。
|
||||
func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"slug":"custom-thinking-model",
|
||||
"display_name":"Upstream Display",
|
||||
"description":"Upstream description",
|
||||
"default_reasoning_level":"high",
|
||||
"supported_reasoning_levels":[{"effort":"low"},{"effort":"high"},{"effort":"ultra"}],
|
||||
"input_modalities":["text","image"],
|
||||
"context_window":256000
|
||||
}]}`)),
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 92, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, upstream.requests, 1, "complete upstream metadata must not be replaced by a registry fetch")
|
||||
metadata := catalog.Metadata["custom-thinking-model"]
|
||||
require.Equal(t, "Upstream Display", metadata.DisplayName)
|
||||
require.Equal(t, "high", metadata.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"low", "high", "ultra"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
|
||||
require.Equal(t, int64(256_000), metadata.ContextWindow)
|
||||
}
|
||||
|
||||
// Scenario: 上游 /models 增删型号后,正式同步用最新清单替换能力快照。
|
||||
func TestSyncUpstreamModelCatalogReplacesSnapshotWhenUpstreamModelsChange(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[
|
||||
{"id":"old-model","reasoning":false,"input_modalities":["text"],"context_window":128000},
|
||||
{"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000}
|
||||
]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[
|
||||
{"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000},
|
||||
{"id":"new-model","reasoning":false,"input_modalities":["text"],"context_window":256000}
|
||||
]}`)),
|
||||
},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
account := &Account{
|
||||
ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "key",
|
||||
"base_url": "https://provider.example/v1",
|
||||
},
|
||||
}
|
||||
|
||||
first, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"kept-model", "old-model"}, first.Models)
|
||||
|
||||
second, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"kept-model", "new-model"}, second.Models)
|
||||
require.NotContains(t, second.Metadata, "old-model")
|
||||
require.Contains(t, second.Metadata, "new-model")
|
||||
|
||||
encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
|
||||
require.NoError(t, err)
|
||||
var snapshot UpstreamModelMetadataSnapshot
|
||||
require.NoError(t, json.Unmarshal(encoded, &snapshot))
|
||||
require.NotContains(t, snapshot.Models, "old-model")
|
||||
require.Contains(t, snapshot.Models, "new-model")
|
||||
}
|
||||
|
||||
// Scenario: 上游明确声明无推理能力时保存 false。
|
||||
func TestSyncUpstreamModelCatalogPersistsExplicitNonReasoningCapability(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"id":"company-coding-model",
|
||||
"display_name":"Company Coding Model",
|
||||
"reasoning":false,
|
||||
"input_modalities":["text"],
|
||||
"context_window":64000
|
||||
}]}`)),
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 94, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, upstream.requests, 1)
|
||||
metadata := catalog.Metadata["company-coding-model"]
|
||||
require.NotNil(t, metadata.Reasoning)
|
||||
require.False(t, *metadata.Reasoning)
|
||||
require.Empty(t, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text"}, metadata.InputModalities)
|
||||
require.Equal(t, int64(64_000), metadata.ContextWindow)
|
||||
require.NotNil(t, repo.updates)
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogClassifiesSnapshotPersistenceFailureAsInternal(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"id":"company-coding-model",
|
||||
"reasoning":false,
|
||||
"input_modalities":["text"],
|
||||
"context_window":64000
|
||||
}]}`)),
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{err: errors.New("database unavailable")}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
_, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 95, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
var syncErr *UpstreamModelSyncError
|
||||
require.ErrorAs(t, err, &syncErr)
|
||||
require.Equal(t, UpstreamModelSyncErrorInternal, syncErr.Kind)
|
||||
}
|
||||
|
||||
// Scenario: 元数据源失败时保留已有快照。
|
||||
func TestSyncUpstreamModelCatalogDoesNotOverwriteSnapshotWhenRegistryFails(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`))},
|
||||
{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 93, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://opencode.ai/zen/v1"},
|
||||
Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{
|
||||
"source": "models.dev", "models": map[string]any{"x-preview-f-free": map[string]any{"reasoning": true}},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"x-preview-f-free"}, catalog.Models)
|
||||
require.Empty(t, catalog.Metadata)
|
||||
require.Equal(t, []UpstreamModelSyncWarning{{
|
||||
Code: UpstreamModelMetadataIncompleteCode,
|
||||
Message: "Model IDs were synced, but capability metadata is incomplete.",
|
||||
}}, catalog.Warnings)
|
||||
require.Nil(t, repo.updates, "a failed metadata enrichment must not erase a previously saved snapshot")
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"id":"partially-described-model",
|
||||
"display_name":"Partial Model"
|
||||
}]}`))},
|
||||
{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{
|
||||
"source": "upstream", "models": map[string]any{"partially-described-model": map[string]any{
|
||||
"reasoning": true, "supported_reasoning_levels": []any{"low", "high"},
|
||||
}},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"partially-described-model"}, catalog.Models)
|
||||
require.Equal(t, "Partial Model", catalog.Metadata["partially-described-model"].DisplayName)
|
||||
require.Equal(t, UpstreamModelMetadataIncompleteCode, catalog.Warnings[0].Code)
|
||||
require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot")
|
||||
}
|
||||
|
||||
func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user