From e471be73025282c12ee5952e15022a35fb527c0d Mon Sep 17 00:00:00 2001 From: Long Li Date: Fri, 21 Aug 2026 07:20:08 +0900 Subject: [PATCH] feat(codex): complete routed model catalogs Codex treats the downloaded model catalog as a capability contract. Slug-only entries hide supported reasoning levels, can advertise invalid image inputs, and expose automatic or media-only models in the model picker. Generate complete route-aware metadata, preserve official manifests, filter non-agent models, and compute validators from the final catalog. --- backend/internal/handler/gateway_handler.go | 28 +- .../internal/handler/gateway_models_test.go | 104 +- .../handler/openai_codex_models_handler.go | 14 +- .../openai_codex_models_handler_test.go | 130 +- backend/internal/pkg/claude/effort_catalog.go | 69 + .../pkg/claude/effort_catalog_test.go | 29 + backend/internal/pkg/xai/models.go | 24 + backend/internal/pkg/xai/models_test.go | 10 + .../service/openai_codex_models_service.go | 1113 ++++++++++++++++- .../openai_codex_models_service_test.go | 935 +++++++++++++- frontend/src/api/__tests__/codex.spec.ts | 30 +- .../keys/__tests__/UseKeyModal.spec.ts | 31 +- 12 files changed, 2420 insertions(+), 97 deletions(-) create mode 100644 backend/internal/pkg/claude/effort_catalog.go create mode 100644 backend/internal/pkg/claude/effort_catalog_test.go diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 14908e9bb0..4d65a6aeb8 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1150,22 +1150,42 @@ func (h *GatewayHandler) CodexModels(c *gin.Context) { return } - modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group) - body, err := service.BuildCodexModelsManifest(modelIDs) + 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) []string { +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 := group.Platform + platform := strings.TrimSpace(platformOverride) + if platform == "" { + platform = group.Platform + } if platform == service.PlatformComposite { availableModels := h.compositeAvailableModels(ctx, groupID) fallbackModels := defaultModelIDsForPlatform(service.PlatformComposite) diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 9ab9485626..15b43d62de 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -27,10 +27,20 @@ type gatewayModelsResponseForTest struct { type codexModelsResponseForTest struct { Models []struct { - Slug string `json:"slug"` + 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"` @@ -79,12 +89,33 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) { // 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 + name string + platform string + model string + efforts []string + modalities []string }{ - {name: "Grok", platform: service.PlatformGrok, model: "grok-4.6"}, - {name: "DeepSeek", platform: service.PlatformDeepseek, model: "deepseek-v4-pro"}, + { + 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 { @@ -119,6 +150,12 @@ func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) { 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) }) } } @@ -163,6 +200,45 @@ func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T 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) @@ -211,7 +287,13 @@ func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing. } func codexModelSlugsForTest(models []struct { - Slug string `json:"slug"` + 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 { @@ -220,6 +302,14 @@ func codexModelSlugsForTest(models []struct { 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) diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index 2714dc294f..b3762bd0cf 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -56,7 +56,10 @@ 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")) + ifNoneMatch := 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,6 +73,14 @@ 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 } @@ -79,6 +90,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { } if manifest.NotModified { c.Status(http.StatusNotModified) + c.Writer.WriteHeaderNow() return } c.Data(http.StatusOK, "application/json", manifest.Body) diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index f8d1fada6c..89dba6474a 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -43,6 +44,10 @@ 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 +} + type codexModelsFailoverHTTPUpstream struct { service.HTTPUpstream mu sync.Mutex @@ -116,6 +121,79 @@ 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", + "model_mapping": map[string]any{ + "codex-auto-review": "codex-auto-review", + }, + }, + }, + }} + 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 TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) { handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusServiceUnavailable) @@ -148,9 +226,7 @@ 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") }) } } @@ -183,9 +259,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) { @@ -300,23 +374,61 @@ 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 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 diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go new file mode 100644 index 0000000000..ab587a4c01 --- /dev/null +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -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 +} diff --git a/backend/internal/pkg/claude/effort_catalog_test.go b/backend/internal/pkg/claude/effort_catalog_test.go new file mode 100644 index 0000000000..718e20eb57 --- /dev/null +++ b/backend/internal/pkg/claude/effort_catalog_test.go @@ -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)) + }) + } +} diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index 6fab34dc39..65634598f1 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -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 { diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go index fb4860ee4d..f9d58e5a0c 100644 --- a/backend/internal/pkg/xai/models_test.go +++ b/backend/internal/pkg/xai/models_test.go @@ -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() diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 6ccc78d9f6..f0327eafa6 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -16,8 +16,11 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "golang.org/x/net/http2" "golang.org/x/sync/singleflight" ) @@ -33,8 +36,58 @@ const ( codexModelsManifestCacheTTL = 30 * time.Second codexModelsManifestCacheStaleTTL = 5 * time.Minute codexModelsManifestRequestTimeout = 15 * time.Second + codexAutoModelPrefix = "codex-auto-" ) +// FilterCodexModelIDsForGroup removes dedicated media-generation models and +// Codex automatic modes from a client catalog. Automatic modes are retained +// only when the group's enabled custom model list explicitly selects the exact +// slug; account model mappings describe routing and are not feature opt-ins. +func FilterCodexModelIDsForGroup(modelIDs []string, group *Group) []string { + explicitlyEnabled := make(map[string]struct{}) + if group != nil && group.CustomModelsListEnabled() { + for _, modelID := range group.ModelsListConfig.Models { + modelID = strings.TrimSpace(modelID) + if strings.HasPrefix(modelID, codexAutoModelPrefix) { + explicitlyEnabled[modelID] = struct{}{} + } + } + } + + filtered := make([]string, 0, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + if isCodexDedicatedMediaModel(modelID) { + continue + } + if strings.HasPrefix(modelID, codexAutoModelPrefix) { + if _, ok := explicitlyEnabled[modelID]; !ok { + continue + } + } + filtered = append(filtered, modelID) + } + return filtered +} + +func isCodexDedicatedMediaModel(modelID string) bool { + canonical := codexProviderQualifiedModelID(modelID) + return IsGPTImageGenerationModel(canonical) || + isImageGenerationModel(canonical) || + xai.IsGrokImagineModel(modelID) +} + +func codexProviderQualifiedModelID(modelID string) string { + modelID = strings.TrimSpace(modelID) + if slash := strings.LastIndexByte(modelID, '/'); slash >= 0 { + modelID = strings.TrimSpace(modelID[slash+1:]) + } + return strings.TrimPrefix(modelID, "models/") +} + // CodexModelsManifest carries the client representation plus caching metadata. type CodexModelsManifest struct { Body []byte @@ -43,6 +96,847 @@ type CodexModelsManifest struct { NotModified bool } +// MergeGroupConfiguredCodexModels adds account model aliases that are visible +// to the authenticated OpenAI group without discarding metadata from upstream +// Codex model entries. A group's custom models list also filters the picker, +// matching the standard /v1/models display policy. +func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( + ctx context.Context, + group *Group, + manifest *CodexModelsManifest, + ifNoneMatch string, +) error { + if s == nil || s.accountRepo == nil || group == nil || manifest == nil || manifest.NotModified { + return nil + } + if group.Platform != PlatformOpenAI || len(manifest.Body) == 0 { + return nil + } + + configuredModels, err := s.groupConfiguredCodexModelIDs(ctx, group.ID) + if err != nil { + return fmt.Errorf("load group configured Codex models: %w", err) + } + body, changed, err := mergeConfiguredCodexModelsManifest( + manifest.Body, + configuredModels, + group.ModelsListConfig.Models, + group.CustomModelsListEnabled(), + ) + if err != nil { + return fmt.Errorf("merge group configured Codex models: %w", err) + } + if changed { + manifest.Body = body + manifest.ETag = codexModelsManifestBodyETag(body) + } + if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { + manifest.Body = nil + manifest.NotModified = true + } + return nil +} + +func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context, groupID int64) ([]string, error) { + accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}) + models := make([]string, 0) + for i := range accounts { + account := &accounts[i] + if account.Platform != PlatformOpenAI { + continue + } + for modelID := range account.GetModelMapping() { + modelID = strings.TrimSpace(modelID) + if modelID == "" || strings.Contains(modelID, "*") { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + seen[modelID] = struct{}{} + models = append(models, modelID) + } + } + sort.Strings(models) + return models, nil +} + +const ( + configuredCodexModelPriority = 50 + configuredCodexFallbackContext = 272_000 + configuredCodexDeepSeekV4Context = 1_000_000 + configuredCodexGrokContext = 500_000 + configuredCodexGrokBuildContext = 256_000 + configuredCodexGPT56MaxContext = 872_000 + configuredCodexToolOutputMaxTokens = 10_000 +) + +type configuredCodexReasoningLevel struct { + Effort string `json:"effort"` + Description string `json:"description"` +} + +type configuredCodexTruncationPolicy struct { + Mode string `json:"mode"` + Limit int64 `json:"limit"` +} + +type configuredCodexModelMessages struct { + InstructionsTemplate string `json:"instructions_template"` + InstructionsVariables any `json:"instructions_variables"` + Approvals any `json:"approvals"` + CollaborationModes any `json:"collaboration_modes"` + AutoReview any `json:"auto_review"` + Permissions any `json:"permissions"` + MultiAgent any `json:"multi_agent"` + TokenBudget any `json:"token_budget"` + GuardianV2 any `json:"guardian_v2"` +} + +// configuredCodexModelDescriptor is the minimum complete ModelInfo contract +// understood by current Codex clients. Several nullable fields are intentionally +// emitted: unlike ordinary OpenAI /v1/models entries, the Codex manifest parser +// requires them to be present. +type configuredCodexModelDescriptor struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + DefaultReasoningLevel *string `json:"default_reasoning_level,omitempty"` + SupportedReasoningLevels []configuredCodexReasoningLevel `json:"supported_reasoning_levels"` + ShellType string `json:"shell_type"` + Visibility string `json:"visibility"` + SupportedInAPI bool `json:"supported_in_api"` + Priority int `json:"priority"` + AdditionalSpeedTiers []string `json:"additional_speed_tiers"` + ServiceTiers []any `json:"service_tiers"` + DefaultServiceTier any `json:"default_service_tier"` + AvailabilityNUX any `json:"availability_nux"` + Upgrade any `json:"upgrade"` + ModelMessages configuredCodexModelMessages `json:"model_messages"` + IncludeSkillsUsageInstructions bool `json:"include_skills_usage_instructions"` + IncludePluginUsageInstructions bool `json:"include_plugin_usage_instructions"` + IncludeAppsUsageInstructions bool `json:"include_apps_usage_instructions"` + SupportsReasoningSummaryParameter bool `json:"supports_reasoning_summary_parameter"` + DefaultReasoningSummary string `json:"default_reasoning_summary"` + SupportVerbosity bool `json:"support_verbosity"` + DefaultVerbosity *string `json:"default_verbosity"` + ApplyPatchToolType *string `json:"apply_patch_tool_type"` + WebSearchToolType string `json:"web_search_tool_type"` + TruncationPolicy configuredCodexTruncationPolicy `json:"truncation_policy"` + SupportsImageDetailOriginal bool `json:"supports_image_detail_original"` + SupportsParallelToolCalls bool `json:"supports_parallel_tool_calls"` + ContextWindow int64 `json:"context_window"` + MaxContextWindow int64 `json:"max_context_window"` + AutoCompactTokenLimit any `json:"auto_compact_token_limit"` + CompHash any `json:"comp_hash"` + EffectiveContextWindowPercent int64 `json:"effective_context_window_percent"` + ExperimentalSupportedTools []string `json:"experimental_supported_tools"` + InputModalities []string `json:"input_modalities"` + SupportsSearchTool bool `json:"supports_search_tool"` + UseResponsesLite bool `json:"use_responses_lite"` + NodeREPLAutoReviewRequired bool `json:"node_repl_auto_review_required"` + NodeREPLDisabled bool `json:"node_repl_disabled"` + AutoReviewModelOverride any `json:"auto_review_model_override"` + ModelSpecialty any `json:"model_specialty"` + ToolMode any `json:"tool_mode"` + MultiAgentVersion any `json:"multi_agent_version"` +} + +func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescriptor { + modelID = strings.TrimSpace(modelID) + descriptor := configuredCodexModelDescriptor{ + Slug: modelID, + DisplayName: modelID, + Description: "Custom model routed through Sub2API.", + SupportedReasoningLevels: []configuredCodexReasoningLevel{}, + ShellType: "unified_exec", + Visibility: "list", + SupportedInAPI: true, + Priority: configuredCodexModelPriority, + AdditionalSpeedTiers: []string{}, + ServiceTiers: []any{}, + ModelMessages: configuredCodexModelMessages{InstructionsTemplate: openai.CodexBaseInstructionsForModel(modelID)}, + SupportsReasoningSummaryParameter: true, + DefaultReasoningSummary: "auto", + WebSearchToolType: "text", + TruncationPolicy: configuredCodexTruncationPolicy{Mode: "bytes", Limit: configuredCodexToolOutputMaxTokens}, + ContextWindow: configuredCodexFallbackContext, + MaxContextWindow: configuredCodexFallbackContext, + EffectiveContextWindowPercent: 95, + ExperimentalSupportedTools: []string{}, + InputModalities: []string{"text"}, + } + + if isDeepSeekCodexModel(modelID) { + defaultReasoningLevel := "high" + descriptor.DisplayName = deepSeekCodexDisplayName(modelID) + descriptor.Description = "DeepSeek coding and reasoning model routed through Sub2API." + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + {Effort: "max", Description: "Maximum reasoning depth for complex tasks"}, + } + descriptor.SupportsParallelToolCalls = true + descriptor.ContextWindow = configuredCodexDeepSeekV4Context + descriptor.MaxContextWindow = configuredCodexDeepSeekV4Context + } + + if isGrokCodexModel(modelID) { + descriptor.DisplayName = grokCodexDisplayName(modelID) + descriptor.Description = "Grok coding and reasoning model routed through Sub2API." + descriptor.SupportsParallelToolCalls = true + descriptor.ContextWindow = grokCodexContextWindow(modelID) + descriptor.MaxContextWindow = descriptor.ContextWindow + if grokCodexSupportsReasoningEffort(modelID) { + defaultReasoningLevel := "high" + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = configuredCodexGrokReasoningLevels(modelID) + } + } + + if isClaudeCodexModel(modelID) { + descriptor.DisplayName = claudeCodexDisplayName(modelID) + descriptor.Description = "Claude coding and reasoning model routed through Sub2API." + descriptor.SupportsParallelToolCalls = true + if levels := configuredCodexClaudeReasoningLevels(modelID); len(levels) > 0 { + defaultReasoningLevel := claudeCodexDefaultReasoningLevel(levels) + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = levels + } + } + + if isOpenAICodexGPTModel(modelID) { + descriptor.DisplayName = openaiCodexDisplayName(modelID) + descriptor.Description = "OpenAI GPT coding model routed through Sub2API." + descriptor.SupportsParallelToolCalls = true + if isOpenAICodexReasoningGPTModel(modelID) { + defaultReasoningLevel := "medium" + if getNormalizedCodexModel(modelID) == "gpt-5.6-sol" { + defaultReasoningLevel = "low" + } + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = configuredCodexGPTReasoningLevels(modelID) + descriptor.DefaultReasoningSummary = "none" + descriptor.TruncationPolicy = configuredCodexTruncationPolicy{Mode: "tokens", Limit: configuredCodexToolOutputMaxTokens} + if isOpenAIGPT56Model(modelID) { + descriptor.MaxContextWindow = configuredCodexGPT56MaxContext + } + } + if SupportsVerbosity(modelID) { + defaultVerbosity := "low" + descriptor.SupportVerbosity = true + descriptor.DefaultVerbosity = &defaultVerbosity + } + } + + return descriptor +} + +func configuredCodexGrokReasoningLevels(modelID string) []configuredCodexReasoningLevel { + levels := []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "medium", Description: "Balanced reasoning for most coding tasks"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + } + if grokSupportsXHighReasoningEffort(modelID) { + levels = append(levels, configuredCodexReasoningLevel{ + Effort: "xhigh", + Description: "Extra-high reasoning depth for difficult tasks", + }) + } + return levels +} + +func configuredCodexClaudeReasoningLevels(modelID string) []configuredCodexReasoningLevel { + descriptions := map[string]string{ + "low": "Fast responses with lighter reasoning", + "medium": "Balanced reasoning for most coding tasks", + "high": "Greater reasoning depth for coding and agent tasks", + "xhigh": "Extra-high reasoning depth for difficult tasks", + "max": "Maximum reasoning depth for complex tasks", + } + levels := claude.EffortLevelsForModel(modelID) + out := make([]configuredCodexReasoningLevel, 0, len(levels)) + for _, effort := range levels { + out = append(out, configuredCodexReasoningLevel{ + Effort: effort, + Description: descriptions[effort], + }) + } + return out +} + +func claudeCodexDefaultReasoningLevel(levels []configuredCodexReasoningLevel) string { + for _, preferred := range []string{"medium", "high", "low"} { + for _, level := range levels { + if level.Effort == preferred { + return preferred + } + } + } + if len(levels) == 0 { + return "" + } + return levels[0].Effort +} + +func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasoningLevel { + levels := []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "medium", Description: "Balanced reasoning for most coding tasks"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + {Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"}, + } + normalized := getNormalizedCodexModel(modelID) + if isOpenAIGPT56Model(modelID) { + levels = append(levels, configuredCodexReasoningLevel{ + Effort: "max", + Description: "Maximum reasoning depth for complex tasks", + }) + } + if normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" { + levels = append(levels, configuredCodexReasoningLevel{ + Effort: "ultra", + Description: "Maximum reasoning with automatic task delegation", + }) + } + return levels +} + +func isOpenAICodexGPTModel(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + if normalized == "" || strings.HasPrefix(normalized, "gpt-image") { + return false + } + return strings.HasPrefix(normalized, "gpt-") +} + +func isOpenAICodexReasoningGPTModel(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + return strings.HasPrefix(normalized, "gpt-5") +} + +func isOpenAICodexImageInputModel(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + return strings.HasPrefix(normalized, "gpt-5") || + strings.HasPrefix(normalized, "gpt-4o") || + strings.HasPrefix(normalized, "gpt-4.1") || + strings.HasPrefix(normalized, "gpt-4.5") || + strings.HasPrefix(normalized, "gpt-4-turbo") || + strings.HasPrefix(normalized, "gpt-4-vision") +} + +func isOfficialOpenAICodexCatalogModel(modelID string) bool { + normalized := strings.ToLower(codexProviderQualifiedModelID(modelID)) + if normalized == "" || isCodexDedicatedMediaModel(normalized) { + return false + } + if strings.HasPrefix(normalized, "codex-") { + return true + } + if strings.HasPrefix(normalized, "o1") || strings.HasPrefix(normalized, "o3") || strings.HasPrefix(normalized, "o4") { + return true + } + if !strings.HasPrefix(normalized, "gpt-") { + return false + } + for _, incompatibleFamily := range []string{"audio", "realtime", "transcribe", "tts"} { + if strings.Contains(normalized, incompatibleFamily) { + return false + } + } + return true +} + +func openaiCodexDisplayName(modelID string) string { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + if normalized == "" { + return modelID + } + for _, model := range openai.DefaultModels { + if strings.EqualFold(model.ID, normalized) && strings.TrimSpace(model.DisplayName) != "" { + return model.DisplayName + } + } + return modelID +} + +func deepSeekCodexDisplayName(modelID string) string { + switch strings.ToLower(strings.TrimSpace(modelID)) { + case "deepseek-v4-pro", "deepseek-4-pro": + return "DeepSeek V4 Pro" + case "deepseek-v4-flash", "deepseek-4-flash": + return "DeepSeek V4 Flash" + default: + return modelID + } +} + +func isDeepSeekCodexModel(modelID string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), "deepseek-") +} + +func isGrokCodexModel(modelID string) bool { + return xai.IsGrokModelID(modelID) +} + +func grokCodexSupportsReasoningEffort(modelID string) bool { + if grokSupportsReasoningEffort(modelID) { + return true + } + canonical := xai.ResolveGrokTextResponsesModelID(modelID) + if canonical == "" || strings.EqualFold(canonical, modelID) { + return false + } + return grokSupportsReasoningEffort(canonical) +} + +func grokCodexDisplayName(modelID string) string { + normalized := strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(modelID))) + if normalized == "" { + return modelID + } + if name := grokDefaultDisplayName(normalized); name != "" { + return name + } + canonical := strings.ToLower(xai.ResolveGrokTextResponsesModelID(normalized)) + if canonical != "" && canonical != normalized { + if name := grokDefaultDisplayName(canonical); name != "" { + return name + } + } + return modelID +} + +func grokDefaultDisplayName(modelID string) string { + for _, model := range xai.DefaultModels() { + if model.ID == modelID { + return strings.TrimSpace(model.DisplayName) + } + } + return "" +} + +func grokCodexContextWindow(modelID string) int64 { + normalized := strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(modelID))) + if strings.HasPrefix(normalized, "grok-build") { + return configuredCodexGrokBuildContext + } + return configuredCodexGrokContext +} + +func isClaudeCodexModel(modelID string) bool { + platform, detected := DetectModelPlatform(modelID) + return detected && platform == PlatformAnthropic +} + +func claudeCodexDisplayName(modelID string) string { + normalized := strings.ToLower(codexProviderQualifiedModelID(modelID)) + normalized = strings.TrimPrefix(normalized, "anthropic.") + if normalized == "" { + return modelID + } + for _, model := range claude.DefaultModels { + if strings.EqualFold(model.ID, normalized) && strings.TrimSpace(model.DisplayName) != "" { + return model.DisplayName + } + } + if canonical, ok := claude.ModelIDOverrides[normalized]; ok { + for _, model := range claude.DefaultModels { + if model.ID == canonical && strings.TrimSpace(model.DisplayName) != "" { + return model.DisplayName + } + } + } + return modelID +} + +// BuildCodexModelsManifest builds a standalone Codex model catalog for models +// routed through a custom provider. The response is also suitable for saving +// as model_catalog_json in clients that do not refresh custom-provider catalogs. +func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) { + return buildCodexModelsManifest(modelIDs, nil) +} + +// BuildCodexModelsManifestForGroup derives input capabilities from the +// concrete Responses route and schedulable accounts behind a group. Unknown or +// mixed capabilities fail closed to the text-only descriptor used by the +// standalone builder. +func (s *GatewayService) BuildCodexModelsManifestForGroup( + ctx context.Context, + group *Group, + platformOverride string, + modelIDs []string, +) ([]byte, error) { + if s == nil || s.accountRepo == nil || group == nil { + return BuildCodexModelsManifest(modelIDs) + } + effectivePlatform := strings.TrimSpace(platformOverride) + if effectivePlatform == "" { + effectivePlatform = group.Platform + } + if effectivePlatform != PlatformOpenAI && effectivePlatform != PlatformGrok && effectivePlatform != PlatformComposite { + return BuildCodexModelsManifest(modelIDs) + } + + accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, group.ID) + if err != nil { + return BuildCodexModelsManifest(modelIDs) + } + var compositeRoutes []CompositeModelRoute + compositeRoutesAvailable := true + if effectivePlatform == PlatformComposite && s.compositeResolver != nil && s.compositeResolver.repo != nil { + compositeRoutes, err = s.compositeResolver.repo.ListByGroup(ctx, group.ID, false) + if err != nil { + compositeRoutesAvailable = false + } + } + imageInputModels := make(map[string]bool, len(modelIDs)) + for _, modelID := range modelIDs { + if groupCodexModelSupportsImageInput( + effectivePlatform, + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) { + imageInputModels[strings.TrimSpace(modelID)] = true + } + } + return buildCodexModelsManifest(modelIDs, imageInputModels) +} + +func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]bool) ([]byte, error) { + seen := make(map[string]struct{}, len(modelIDs)) + models := make([]configuredCodexModelDescriptor, 0, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + if isCodexDedicatedMediaModel(modelID) { + continue + } + seen[modelID] = struct{}{} + descriptor := newConfiguredCodexModelDescriptor(modelID) + if imageInputModels[modelID] { + descriptor.InputModalities = []string{"text", "image"} + } + models = append(models, descriptor) + } + return json.Marshal(struct { + Models []configuredCodexModelDescriptor `json:"models"` + }{Models: models}) +} + +func groupCodexModelSupportsImageInput( + platform string, + modelID string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) bool { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return false + } + upstreamModel := modelID + if platform == PlatformComposite { + var resolved bool + platform, upstreamModel, resolved = resolveCodexCompositeModelTarget( + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + if !resolved { + return false + } + } + if platform != PlatformOpenAI && platform != PlatformGrok { + return false + } + + candidates := 0 + for i := range accounts { + account := &accounts[i] + if account.Platform != platform || !account.IsModelSupported(upstreamModel) { + continue + } + candidates++ + if !accountCodexModelSupportsImageInput(account, account.GetMappedModel(upstreamModel)) { + return false + } + } + return candidates > 0 +} + +func resolveCodexCompositeModelTarget( + modelID string, + accounts []Account, + routes []CompositeModelRoute, + routesAvailable bool, +) (string, string, bool) { + if !routesAvailable { + return "", "", false + } + if route, matched := matchCompositeRoute(routes, modelID, CompositeRouteEndpointResponses); matched { + upstreamModel := strings.TrimSpace(route.UpstreamModel) + if upstreamModel == "" { + upstreamModel = modelID + } + return route.TargetPlatform, upstreamModel, true + } + if codexCompositeRouteMatchesModel(routes, modelID) { + return "", "", false + } + + claimedPlatforms := make(map[string]struct{}) + for _, account := range accounts { + platform := strings.TrimSpace(account.Platform) + if !isConcreteRequestPlatform(platform) || !codexExplicitModelMappingClaims(account, modelID) { + continue + } + claimedPlatforms[platform] = struct{}{} + } + if len(claimedPlatforms) > 1 { + return "", "", false + } + for platform := range claimedPlatforms { + return platform, modelID, true + } + + platform, detected := DetectModelPlatform(modelID) + if !detected { + return "", "", false + } + return platform, modelID, true +} + +func codexCompositeRouteMatchesModel(routes []CompositeModelRoute, modelID string) bool { + for _, route := range routes { + publicModel := strings.TrimSpace(route.PublicModel) + if publicModel == "" { + continue + } + switch normalizeCompositeRouteMatchType(route.MatchType) { + case CompositeRouteMatchPrefix: + if strings.HasPrefix(modelID, publicModel) { + return true + } + default: + if modelID == publicModel { + return true + } + } + } + return false +} + +func codexExplicitModelMappingClaims(account Account, modelID string) bool { + if account.Credentials == nil || strings.TrimSpace(modelID) == "" { + return false + } + mapped := strings.TrimSpace(account.GetModelMapping()[modelID]) + return mapped != "" +} + +func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) bool { + if account == nil { + return false + } + switch account.Platform { + case PlatformOpenAI: + if !isOpenAICodexImageInputModel(upstreamModel) { + return false + } + if account.IsOpenAIOAuth() { + return true + } + if !account.IsOpenAIApiKey() { + return false + } + baseURL := strings.TrimSpace(account.GetCredential("base_url")) + return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) + case PlatformGrok: + if !isOfficialGrokCodexBaseURL(account.GetGrokBaseURL()) { + return false + } + canonical := xai.ResolveGrokTextResponsesModelID(upstreamModel) + return isGrokCodexImageInputModel(canonical) + default: + return false + } +} + +func isGrokCodexImageInputModel(model string) bool { + switch strings.ToLower(strings.TrimSpace(model)) { + case "grok-4.3", + "grok-4.5", + "grok-4.6", + "grok-build-0.1", + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-4.20-multi-agent-0309": + return true + default: + return false + } +} + +func isOfficialGrokCodexBaseURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" { + return false + } + return xai.IsOfficialBaseURLHost(strings.TrimSuffix(parsed.Hostname(), ".")) +} + +// BuildDeepSeekCodexModelsManifest preserves the historical entry point for +// callers that still use the provider-specific function name. +func BuildDeepSeekCodexModelsManifest(modelIDs []string) ([]byte, error) { + return BuildCodexModelsManifest(modelIDs) +} + +func mergeConfiguredCodexModelsManifest( + body []byte, + configuredModels []string, + selectedModels []string, + filterBySelection bool, +) ([]byte, bool, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, false, err + } + var upstreamModels []json.RawMessage + if err := json.Unmarshal(envelope["models"], &upstreamModels); err != nil { + return nil, false, err + } + + selected := make(map[string]struct{}, len(selectedModels)) + for _, modelID := range selectedModels { + modelID = strings.TrimSpace(modelID) + if modelID != "" { + selected[modelID] = struct{}{} + } + } + seen := make(map[string]struct{}, len(upstreamModels)+len(configuredModels)) + merged := make([]json.RawMessage, 0, len(upstreamModels)+len(configuredModels)) + changed := false + for _, rawModel := range upstreamModels { + var descriptor struct { + Slug string `json:"slug"` + } + if err := json.Unmarshal(rawModel, &descriptor); err != nil || strings.TrimSpace(descriptor.Slug) == "" { + if filterBySelection { + changed = true + continue + } + merged = append(merged, rawModel) + continue + } + descriptor.Slug = strings.TrimSpace(descriptor.Slug) + if isCodexDedicatedMediaModel(descriptor.Slug) { + changed = true + continue + } + if filterBySelection { + if _, allowed := selected[descriptor.Slug]; !allowed { + changed = true + continue + } + } + if strings.HasPrefix(descriptor.Slug, codexAutoModelPrefix) { + _, explicitlyEnabled := selected[descriptor.Slug] + explicitlyEnabled = filterBySelection && explicitlyEnabled + if !explicitlyEnabled { + changed = true + continue + } + visibleModel, visibilityChanged, err := codexModelWithVisibility(rawModel, "list") + if err != nil { + return nil, false, err + } + rawModel = visibleModel + changed = changed || visibilityChanged + } + seen[descriptor.Slug] = struct{}{} + merged = append(merged, rawModel) + } + + for _, modelID := range configuredModels { + if isCodexDedicatedMediaModel(modelID) { + continue + } + if filterBySelection { + if _, allowed := selected[modelID]; !allowed { + continue + } + } + if strings.HasPrefix(modelID, codexAutoModelPrefix) { + if _, explicitlyEnabled := selected[modelID]; !filterBySelection || !explicitlyEnabled { + continue + } + } + if _, exists := seen[modelID]; exists { + continue + } + rawModel, err := json.Marshal(newConfiguredCodexModelDescriptor(modelID)) + if err != nil { + return nil, false, err + } + merged = append(merged, rawModel) + seen[modelID] = struct{}{} + changed = true + } + if !changed { + return body, false, nil + } + + rawModels, err := json.Marshal(merged) + if err != nil { + return nil, false, err + } + envelope["models"] = rawModels + mergedBody, err := json.Marshal(envelope) + if err != nil { + return nil, false, err + } + return mergedBody, true, nil +} + +func codexModelWithVisibility(rawModel json.RawMessage, visibility string) (json.RawMessage, bool, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(rawModel, &fields); err != nil { + return nil, false, err + } + var current string + if rawVisibility, ok := fields["visibility"]; ok { + if err := json.Unmarshal(rawVisibility, ¤t); err == nil && current == visibility { + return rawModel, false, nil + } + } + rawVisibility, err := json.Marshal(visibility) + if err != nil { + return nil, false, err + } + fields["visibility"] = rawVisibility + updated, err := json.Marshal(fields) + if err != nil { + return nil, false, err + } + return updated, true, nil +} + type codexModelsManifestUpstreamError struct { err error retryable bool @@ -256,14 +1150,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_TOKEN_MISSING", "account has no Codex backend access token") } case credAccount.IsOpenAIApiKey(): - baseURL := strings.TrimSpace(credAccount.GetCredential("base_url")) - if baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) { - return nil, infraerrors.New( - http.StatusBadGateway, - "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_UNSUPPORTED", - "Codex models manifest requires a custom API key upstream base URL", - ) - } + baseURL := strings.TrimSpace(credAccount.GetOpenAIBaseURL()) authToken = strings.TrimSpace(credAccount.GetOpenAIApiKey()) if authToken == "" { return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_API_KEY_MISSING", "account has no API key for the Codex models upstream") @@ -532,6 +1419,22 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } if request.useAPIKeyUpstream { + body, err = completeAPIKeyCodexModelsManifestMetadata( + body, + false, + request.credentialAccount != nil && isOfficialOpenAIModelsBaseURL(request.credentialAccount.GetOpenAIBaseURL()), + ) + if err != nil { + return nil, &codexModelsManifestUpstreamError{ + err: infraerrors.Newf( + http.StatusBadGateway, + "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST", + "codex models manifest upstream metadata could not be completed: %v", + err, + ), + retryable: true, + } + } body, err = adjustAPIKeyCodexModelsManifest(body) if err != nil { return nil, &codexModelsManifestUpstreamError{ @@ -561,6 +1464,12 @@ func codexModelsManifestBodyETag(body []byte) string { return fmt.Sprintf(`"%x"`, sum) } +// CodexModelsManifestETag returns the strong ETag for a generated client +// catalog. It is based on the final JSON body after local filtering. +func CodexModelsManifestETag(body []byte) string { + return codexModelsManifestBodyETag(body) +} + var apiKeyCodexModelsWithoutResponsesLite = map[string]struct{}{ "gpt-5.6-sol": {}, "gpt-5.6-terra": {}, @@ -624,9 +1533,8 @@ func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) { // convertOpenAIModelListToCodexManifest rewrites a standard OpenAI // GET /v1/models response ({"object":"list","data":[{"id":...},...]}) into the -// Codex manifest envelope ({"models":[{"slug":...},...]}) so custom API key -// upstreams that only implement the standard endpoint can serve Codex model -// discovery. Bodies that already carry a top-level models field, are not the +// same complete Codex manifest used by locally generated custom-provider +// catalogs. Bodies that already carry a top-level models field, are not the // standard list shape, or yield no usable model IDs are returned unchanged so // envelope validation reports the original payload. func convertOpenAIModelListToCodexManifest(body []byte) []byte { @@ -665,28 +1573,177 @@ func convertOpenAIModelListToCodexManifest(body []byte) []byte { return converted } -// BuildCodexModelsManifest creates the minimal manifest accepted by Codex -// custom providers while preserving the effective model order for the group. -func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) { - type codexModelEntry struct { - Slug string `json:"slug"` +// completeAPIKeyCodexModelsManifestMetadata fills fields omitted by standard +// OpenAI-compatible /models endpoints. Existing provider metadata always wins; +// only absent or null values are synthesized. +// CompleteAPIKeyCodexModelsManifestForClient fills the complete ModelInfo +// contract immediately before a group-specific API key manifest is returned. +// The shared upstream cache remains independent from local group policy. +func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manifest *CodexModelsManifest, account *Account) error { + if manifest == nil || account == nil || !account.IsOpenAIApiKey() || manifest.NotModified || len(manifest.Body) == 0 { + return nil + } + body, err := completeAPIKeyCodexModelsManifestMetadata( + manifest.Body, + true, + isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()), + ) + if err != nil { + return err + } + if !bytes.Equal(body, manifest.Body) { + manifest.Body = body + manifest.ETag = codexModelsManifestBodyETag(body) + } + return nil +} + +func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officialOpenAI bool) ([]byte, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("decode JSON object: %w", err) + } + var models []json.RawMessage + if err := json.Unmarshal(envelope["models"], &models); err != nil { + return nil, fmt.Errorf("decode top-level models array: %w", err) } - seen := make(map[string]struct{}, len(modelIDs)) - models := make([]codexModelEntry, 0, len(modelIDs)) - for _, rawModelID := range modelIDs { - modelID := strings.TrimSpace(rawModelID) - if modelID == "" { + changed := false + if officialOpenAI { + filtered := make([]json.RawMessage, 0, len(models)) + for _, rawModel := range models { + var model struct { + Slug string `json:"slug"` + } + if err := json.Unmarshal(rawModel, &model); err != nil || strings.TrimSpace(model.Slug) == "" { + filtered = append(filtered, rawModel) + continue + } + if !isOfficialOpenAICodexCatalogModel(model.Slug) { + changed = true + continue + } + filtered = append(filtered, rawModel) + } + models = filtered + } + for i, rawModel := range models { + var model map[string]json.RawMessage + if err := json.Unmarshal(rawModel, &model); err != nil || model == nil { continue } - if _, exists := seen[modelID]; exists { + var slug string + if err := json.Unmarshal(model["slug"], &slug); err != nil { continue } - seen[modelID] = struct{}{} - models = append(models, codexModelEntry{Slug: modelID}) + slug = strings.TrimSpace(slug) + if slug == "" { + continue + } + + completeDescriptor := completeAll || isDeepSeekCodexModel(slug) + forceOfficialImage := officialOpenAI && isOpenAICodexImageInputModel(slug) + if !completeDescriptor && !forceOfficialImage { + continue + } + + descriptor := newConfiguredCodexModelDescriptor(slug) + if forceOfficialImage { + descriptor.InputModalities = []string{"text", "image"} + descriptor.SupportsImageDetailOriginal = true + } + defaultBody, err := json.Marshal(descriptor) + if err != nil { + return nil, fmt.Errorf("encode default model %q: %w", slug, err) + } + var defaults map[string]json.RawMessage + if err := json.Unmarshal(defaultBody, &defaults); err != nil { + return nil, fmt.Errorf("decode default model %q: %w", slug, err) + } + + modelChanged := false + if completeDescriptor { + merged, err := mergeMissingCodexModelFields(model, defaults) + if err != nil { + return nil, fmt.Errorf("complete model %q: %w", slug, err) + } + modelChanged = merged + } + if forceOfficialImage { + modalities, err := json.Marshal([]string{"text", "image"}) + if err != nil { + return nil, fmt.Errorf("encode input modalities for model %q: %w", slug, err) + } + if !bytes.Equal(bytes.TrimSpace(model["input_modalities"]), modalities) { + model["input_modalities"] = modalities + modelChanged = true + } + imageDetailOriginal := json.RawMessage("true") + if !bytes.Equal(bytes.TrimSpace(model["supports_image_detail_original"]), imageDetailOriginal) { + model["supports_image_detail_original"] = imageDetailOriginal + modelChanged = true + } + } + if !modelChanged { + continue + } + encoded, err := json.Marshal(model) + if err != nil { + return nil, fmt.Errorf("encode completed model %q: %w", slug, err) + } + models[i] = encoded + changed = true + } + if !changed { + return body, nil } - return json.Marshal(map[string][]codexModelEntry{"models": models}) + encodedModels, err := json.Marshal(models) + if err != nil { + return nil, fmt.Errorf("encode top-level models array: %w", err) + } + envelope["models"] = encodedModels + completed, err := json.Marshal(envelope) + if err != nil { + return nil, fmt.Errorf("encode JSON object: %w", err) + } + return completed, nil +} + +func mergeMissingCodexModelFields(current, defaults map[string]json.RawMessage) (bool, error) { + changed := false + for key, defaultValue := range defaults { + currentValue, exists := current[key] + if !exists || (bytes.Equal(bytes.TrimSpace(currentValue), []byte("null")) && + !bytes.Equal(bytes.TrimSpace(defaultValue), []byte("null"))) { + current[key] = defaultValue + changed = true + continue + } + + var currentObject map[string]json.RawMessage + var defaultObject map[string]json.RawMessage + if err := json.Unmarshal(currentValue, ¤tObject); err != nil || currentObject == nil { + continue + } + if err := json.Unmarshal(defaultValue, &defaultObject); err != nil || defaultObject == nil { + continue + } + nestedChanged, err := mergeMissingCodexModelFields(currentObject, defaultObject) + if err != nil { + return false, err + } + if !nestedChanged { + continue + } + mergedValue, err := json.Marshal(currentObject) + if err != nil { + return false, fmt.Errorf("encode field %q: %w", key, err) + } + current[key] = mergedValue + changed = true + } + return changed, nil } func validateCodexModelsManifestEnvelope(body []byte) error { @@ -761,6 +1818,12 @@ func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool { return false } +// CodexModelsManifestETagMatches applies If-None-Match semantics to a Codex +// catalog ETag, including weak and comma-separated validators. +func CodexModelsManifestETagMatches(ifNoneMatch, etag string) bool { + return codexModelsManifestETagMatches(ifNoneMatch, etag) +} + func isOfficialOpenAIModelsBaseURL(raw string) bool { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index bad1488ef2..9581eecf87 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -24,32 +24,766 @@ import ( "golang.org/x/net/http2" ) -// Scenario: non-OpenAI groups expose their effective model IDs as a Codex manifest. -func TestBuildCodexModelsManifest(t *testing.T) { - body, err := BuildCodexModelsManifest([]string{ - " grok-4.6 ", - "deepseek-v4-pro", - "grok-4.6", - "", - }) - require.NoError(t, err) - - var got struct { - Models []struct { - Slug string `json:"slug"` - } `json:"models"` - } - require.NoError(t, json.Unmarshal(body, &got)) - require.Equal(t, []string{"grok-4.6", "deepseek-v4-pro"}, []string{ - got.Models[0].Slug, - got.Models[1].Slug, - }) -} - type codexModelsHTTPUpstreamStub struct { do func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) } +type codexModelsVisibilityAccountRepo struct { + AccountRepository + byGroup map[int64][]Account +} + +func (r codexModelsVisibilityAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) { + accounts := r.byGroup[groupID] + return append([]Account(nil), accounts...), nil +} + +type countingCodexModelsAccountRepo struct { + AccountRepository + accounts []Account + err error + calls atomic.Int32 +} + +func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) { + r.calls.Add(1) + if r.err != nil { + return nil, r.err + } + return append([]Account(nil), r.accounts...), nil +} + +func decodeCodexManifestModels(t *testing.T, body []byte) []map[string]any { + t.Helper() + + var envelope struct { + Models []map[string]any `json:"models"` + } + require.NoError(t, json.Unmarshal(body, &envelope)) + return envelope.Models +} + +func codexManifestModelSlugs(t *testing.T, body []byte) []string { + t.Helper() + + models := decodeCodexManifestModels(t, body) + slugs := make([]string, 0, len(models)) + for _, model := range models { + slug, ok := model["slug"].(string) + require.True(t, ok) + slugs = append(slugs, slug) + } + return slugs +} + +func requireCompleteConfiguredCodexModel(t *testing.T, model map[string]any, slug string) { + t.Helper() + + require.Equal(t, slug, model["slug"]) + require.NotEmpty(t, model["display_name"]) + require.NotEmpty(t, model["description"]) + require.Equal(t, "unified_exec", model["shell_type"]) + require.Equal(t, "list", model["visibility"]) + require.Equal(t, true, model["supported_in_api"]) + require.NotNil(t, model["priority"]) + require.Equal(t, []any{}, model["additional_speed_tiers"]) + require.Equal(t, []any{}, model["service_tiers"]) + require.Contains(t, model, "default_service_tier") + require.Contains(t, model, "availability_nux") + require.Contains(t, model, "upgrade") + require.Contains(t, model, "default_verbosity") + require.Contains(t, model, "apply_patch_tool_type") + require.Contains(t, model, "auto_compact_token_limit") + require.Contains(t, model, "comp_hash") + require.Contains(t, model, "auto_review_model_override") + require.Contains(t, model, "model_specialty") + require.Contains(t, model, "tool_mode") + require.Contains(t, model, "multi_agent_version") + require.Equal(t, true, model["supports_reasoning_summary_parameter"]) + require.Contains(t, model, "include_skills_usage_instructions") + require.Contains(t, model, "include_plugin_usage_instructions") + require.Contains(t, model, "include_apps_usage_instructions") + require.Contains(t, model, "supports_image_detail_original") + require.Contains(t, model, "node_repl_auto_review_required") + require.Contains(t, model, "node_repl_disabled") + require.Contains(t, model, "truncation_policy") + require.Contains(t, model, "supports_parallel_tool_calls") + require.Contains(t, model, "experimental_supported_tools") + modelMessages, ok := model["model_messages"].(map[string]any) + require.True(t, ok) + require.NotEmpty(t, modelMessages["instructions_template"]) + for _, key := range []string{ + "instructions_variables", + "approvals", + "collaboration_modes", + "auto_review", + "permissions", + "multi_agent", + "token_budget", + "guardian_v2", + } { + require.Contains(t, modelMessages, key) + } +} + +func effortsFromManifestModel(t *testing.T, model map[string]any) []string { + t.Helper() + + levels, ok := model["supported_reasoning_levels"].([]any) + require.True(t, ok) + efforts := make([]string, 0, len(levels)) + for _, rawLevel := range levels { + level, ok := rawLevel.(map[string]any) + require.True(t, ok) + effort, ok := level["effort"].(string) + require.True(t, ok) + efforts = append(efforts, effort) + } + return efforts +} + +func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t *testing.T) { + t.Parallel() + + deepSeek := newConfiguredCodexModelDescriptor("deepseek-v4-pro") + require.Equal(t, "DeepSeek V4 Pro", deepSeek.DisplayName) + require.Equal(t, int64(1_000_000), deepSeek.ContextWindow) + require.Equal(t, int64(1_000_000), deepSeek.MaxContextWindow) + require.NotNil(t, deepSeek.DefaultReasoningLevel) + require.Equal(t, "high", *deepSeek.DefaultReasoningLevel) + require.Equal(t, []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + {Effort: "max", Description: "Maximum reasoning depth for complex tasks"}, + }, deepSeek.SupportedReasoningLevels) + require.True(t, deepSeek.SupportsParallelToolCalls) + require.Equal(t, []string{"text"}, deepSeek.InputModalities) + + grok := newConfiguredCodexModelDescriptor("grok-4.6") + require.Equal(t, "Grok 4.6", grok.DisplayName) + require.Equal(t, int64(500_000), grok.ContextWindow) + require.Equal(t, int64(500_000), grok.MaxContextWindow) + require.NotNil(t, grok.DefaultReasoningLevel) + require.Equal(t, "high", *grok.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grok.SupportedReasoningLevels)) + require.True(t, grok.SupportsParallelToolCalls) + require.Equal(t, []string{"text"}, grok.InputModalities) + require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) + require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"}) + + grokAlias := newConfiguredCodexModelDescriptor("xai/grok-4.6-latest") + require.Equal(t, "Grok 4.6", grokAlias.DisplayName) + require.NotNil(t, grokAlias.DefaultReasoningLevel) + require.Equal(t, "high", *grokAlias.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grokAlias.SupportedReasoningLevels)) + + grok45 := newConfiguredCodexModelDescriptor("grok-4.5") + require.Equal(t, []string{"low", "medium", "high"}, effortsFromConfiguredCodexLevels(grok45.SupportedReasoningLevels)) + + grokNonReasoning := newConfiguredCodexModelDescriptor("grok-4.20-0309-non-reasoning") + require.Equal(t, "Grok 4.20 Non Reasoning", grokNonReasoning.DisplayName) + require.Nil(t, grokNonReasoning.DefaultReasoningLevel) + require.Empty(t, grokNonReasoning.SupportedReasoningLevels) + + claude := newConfiguredCodexModelDescriptor("claude-opus-4-6") + require.Equal(t, "Claude Opus 4.6", claude.DisplayName) + require.NotNil(t, claude.DefaultReasoningLevel) + require.Equal(t, "medium", *claude.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(claude.SupportedReasoningLevels)) + require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "xhigh"}) + require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) + require.True(t, claude.SupportsParallelToolCalls) + + claudeOpus5 := newConfiguredCodexModelDescriptor("claude-opus-5") + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeOpus5.SupportedReasoningLevels)) + + providerQualifiedClaude := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-4-6") + require.Equal(t, "Claude Sonnet 4.6", providerQualifiedClaude.DisplayName) + require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(providerQualifiedClaude.SupportedReasoningLevels)) + + claudeHaiku := newConfiguredCodexModelDescriptor("claude-haiku-4-5-20251001") + require.Equal(t, "Claude Haiku 4.5", claudeHaiku.DisplayName) + require.Nil(t, claudeHaiku.DefaultReasoningLevel) + require.Empty(t, claudeHaiku.SupportedReasoningLevels) + + gpt56 := newConfiguredCodexModelDescriptor("gpt-5.6-sol") + require.Equal(t, "GPT-5.6 Sol", gpt56.DisplayName) + require.Equal(t, "OpenAI GPT coding model routed through Sub2API.", gpt56.Description) + require.NotNil(t, gpt56.DefaultReasoningLevel) + require.Equal(t, "low", *gpt56.DefaultReasoningLevel) + require.Equal(t, configuredCodexGPTReasoningLevels("gpt-5.6-sol"), gpt56.SupportedReasoningLevels) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt56.SupportedReasoningLevels)) + require.True(t, gpt56.SupportsParallelToolCalls) + require.True(t, gpt56.SupportVerbosity) + require.Equal(t, []string{"text"}, gpt56.InputModalities) + require.Equal(t, int64(872_000), gpt56.MaxContextWindow) + require.Equal(t, configuredCodexTruncationPolicy{Mode: "tokens", Limit: 10_000}, gpt56.TruncationPolicy) + require.NotNil(t, gpt56.DefaultVerbosity) + require.Equal(t, "low", *gpt56.DefaultVerbosity) + require.True(t, gpt56.SupportsReasoningSummaryParameter) + require.Equal(t, "none", gpt56.DefaultReasoningSummary) + + gpt56Luna := newConfiguredCodexModelDescriptor("gpt-5.6-luna") + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels)) + require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel) + + gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5") + require.Equal(t, "GPT-5.5", gpt55.DisplayName) + require.NotNil(t, gpt55.DefaultReasoningLevel) + require.Equal(t, "medium", *gpt55.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(gpt55.SupportedReasoningLevels)) + require.NotContains(t, gpt55.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"}) + require.NotNil(t, gpt55.DefaultVerbosity) + require.Equal(t, "low", *gpt55.DefaultVerbosity) + + gpt4o := newConfiguredCodexModelDescriptor("gpt-4o") + require.Equal(t, "gpt-4o", gpt4o.DisplayName) + require.Nil(t, gpt4o.DefaultReasoningLevel) + require.Empty(t, gpt4o.SupportedReasoningLevels) + require.True(t, gpt4o.SupportsParallelToolCalls) + + image := newConfiguredCodexModelDescriptor("gpt-image-2") + require.Equal(t, "gpt-image-2", image.DisplayName) + require.Nil(t, image.DefaultReasoningLevel) + require.Empty(t, image.SupportedReasoningLevels) + + custom := newConfiguredCodexModelDescriptor("company-coding-model") + require.Equal(t, "company-coding-model", custom.DisplayName) + require.Equal(t, int64(272_000), custom.ContextWindow) + require.Nil(t, custom.DefaultReasoningLevel) + require.Empty(t, custom.SupportedReasoningLevels) + require.False(t, custom.SupportsParallelToolCalls) + require.NotEmpty(t, custom.ModelMessages.InstructionsTemplate) + require.Equal(t, "auto", custom.DefaultReasoningSummary) + require.Equal(t, configuredCodexTruncationPolicy{Mode: "bytes", Limit: 10_000}, custom.TruncationPolicy) +} + +func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) []string { + efforts := make([]string, 0, len(levels)) + for _, level := range levels { + efforts = append(efforts, level.Effort) + } + return efforts +} + +// Scenario: 专用图片生成模型不进入 Codex 主模型目录。 +func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{ + "grok-4.6", + "gpt-image-1", + "gpt-image-1.5", + "gpt-image-2", + "openai/gpt-image-2", + "gemini-2.5-flash-image", + "gemini-3.1-flash-image-preview", + "gemini-3-pro-image", + "google/gemini-3-pro-image", + "google/models/gemini-2.5-flash-image-preview", + "grok-imagine-image", + "grok-imagine-video", + "xai/grok-imagine-image-quality", + "grok-4.5", + }) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + slugs := make([]string, 0, len(models)) + for _, model := range models { + slug, _ := model["slug"].(string) + slugs = append(slugs, slug) + } + require.Equal(t, []string{"grok-4.6", "grok-4.5"}, slugs) +} + +func TestBuildCodexModelsManifestForGroupAdvertisesOfficialGrokResponsesImageInput(t *testing.T) { + t.Parallel() + + const groupID int64 = 701 + svc := &GatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 1, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token", + }, + }}, + }}, + } + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"grok-4.5"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageInput(t *testing.T) { + t.Parallel() + + const groupID int64 = 702 + svc := &GatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 2, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + }}, + }}, + } + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"gpt-5.6-sol"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabilities(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model string + accounts []Account + modalities []any + }{ + { + name: "official Grok 4.6", + model: "grok-4.6", + accounts: []Account{{ + ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "official Grok Build vision host", + model: "grok-build-0.1", + accounts: []Account{{ + ID: 11, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "official Grok 4.20 vision model", + model: "grok-4.20-0309-reasoning", + accounts: []Account{{ + ID: 23, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "Grok 3 Mini is text only", + model: "grok-3-mini", + accounts: []Account{{ + ID: 24, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text"}, + }, + { + name: "Grok Composer has only Chat image bridge", + model: "grok-composer-2.5-fast", + accounts: []Account{{ + ID: 12, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text"}, + }, + { + name: "custom Grok host", + model: "grok-4.5", + accounts: []Account{{ + ID: 13, Platform: PlatformGrok, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://relay.example.test/v1"}, + }}, + modalities: []any{"text"}, + }, + { + name: "malformed Grok host", + model: "grok-4.5", + accounts: []Account{{ + ID: 19, Platform: PlatformGrok, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "::invalid::url"}, + }}, + modalities: []any{"text"}, + }, + { + name: "mixed official and custom Grok candidates", + model: "grok-4.5", + accounts: []Account{ + {ID: 14, Platform: PlatformGrok, Type: AccountTypeOAuth}, + { + ID: 15, Platform: PlatformGrok, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://relay.example.test/v1"}, + }, + }, + modalities: []any{"text"}, + }, + { + name: "DeepSeek V4", + model: "deepseek-v4-pro", + accounts: []Account{{ + ID: 16, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + }}, + modalities: []any{"text"}, + }, + { + name: "official OpenAI API key", + model: "gpt-5.6-sol", + accounts: []Account{{ + ID: 17, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "official OpenAI legacy text model", + model: "gpt-3.5-turbo", + accounts: []Account{{ + ID: 20, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + }}, + modalities: []any{"text"}, + }, + { + name: "custom OpenAI-compatible host", + model: "gpt-5.6-sol", + accounts: []Account{{ + ID: 18, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"}, + }}, + modalities: []any{"text"}, + }, + } + + for i, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + groupID := int64(710 + i) + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: tt.accounts, + }}} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{tt.model}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, tt.modalities, models[0]["input_modalities"]) + }) + } +} + +func TestBuildCodexModelsManifestForGroupUsesExplicitCompositeResponsesRouteModel(t *testing.T) { + t.Parallel() + + const groupID int64 = 730 + routeRepo := compositeRouteRepoStub{routes: []CompositeModelRoute{{ + ID: 1, + GroupID: groupID, + PublicModel: "vision-alias", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformGrok, + UpstreamModel: "grok-4.5", + Endpoint: CompositeRouteEndpointResponses, + Enabled: true, + }}} + svc := &GatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ID: 20, Platform: PlatformGrok, Type: AccountTypeOAuth}}, + }}, + compositeResolver: NewCompositeRouteResolver(routeRepo), + } + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"vision-alias"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupUsesAccountMappingOwnershipAndMappedModel(t *testing.T) { + t.Parallel() + + const groupID int64 = 731 + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 21, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{"vision-alias": "grok-4.5"}, + }, + }}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"vision-alias"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) { + t.Parallel() + + const groupID int64 = 732 + repo := &countingCodexModelsAccountRepo{accounts: []Account{{ + ID: 22, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "vision-alias-a": "grok-4.5", + "vision-alias-b": "grok-4.6", + }, + }, + }}} + svc := &GatewayService{accountRepo: repo} + _, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"vision-alias-a", "vision-alias-b", "deepseek-v4-pro"}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), repo.calls.Load()) +} + +func TestBuildCodexModelsManifestForGroupSkipsCapabilityLookupForTextOnlyPlatform(t *testing.T) { + t.Parallel() + + repo := &countingCodexModelsAccountRepo{} + svc := &GatewayService{accountRepo: repo} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: 733, Platform: PlatformDeepseek}, + "", + []string{"deepseek-v4-pro"}, + ) + require.NoError(t, err) + require.Equal(t, int32(0), repo.calls.Load()) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupFallsBackWhenCapabilityLookupFails(t *testing.T) { + t.Parallel() + + repo := &countingCodexModelsAccountRepo{err: errors.New("account repository unavailable")} + svc := &GatewayService{accountRepo: repo} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: 734, Platform: PlatformComposite}, + "", + []string{"gpt-5.6-sol", "grok-4.5"}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), repo.calls.Load()) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 2) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.Equal(t, []any{"text"}, models[1]["input_modalities"]) +} + +func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) { + t.Parallel() + + const groupID int64 = 71 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "deepseek-4-pro": "deepseek-v4-pro", + }, + }, + }, + }, + 72: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"other-group-model": "upstream-model"}, + }, + }, + }, + }, + }} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6","unknown":{"kept":true}}],"metadata":{"version":1}}`), + } + + err := svc.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + manifest, + "", + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 2) + require.Equal(t, "gpt-5.6", models[0]["slug"]) + require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"]) + requireCompleteConfiguredCodexModel(t, models[1], "deepseek-4-pro") + require.EqualValues(t, 1_000_000, models[1]["context_window"]) + require.EqualValues(t, 1_000_000, models[1]["max_context_window"]) + require.Equal(t, "high", models[1]["default_reasoning_level"]) + require.Len(t, models[1]["supported_reasoning_levels"], 3) + require.NotContains(t, string(manifest.Body), "other-group-model") + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + +func TestMergeGroupConfiguredCodexModelsFiltersAutoReviewByDefault(t *testing.T) { + t.Parallel() + + const groupID int64 = 74 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"codex-auto-future","visibility":"list"},{"slug":"gpt-image-2","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), + } + + require.NoError(t, svc.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + manifest, + "", + )) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "gpt-5.6", models[0]["slug"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + +// Scenario: OpenAI 账号映射不启用 Auto Review。 +func TestMergeGroupConfiguredCodexModelsFiltersAccountMappedAutoReviewByDefault(t *testing.T) { + t.Parallel() + + const groupID int64 = 75 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + openai.CodexUsageProbeModel: openai.CodexUsageProbeModel, + }, + }, + }, + }, + }, + }} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"hide","model_messages":{"auto_review":{"enabled":true}}},{"slug":"gpt-5.6","visibility":"list"}]}`), + } + + require.NoError(t, svc.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + manifest, + "", + )) + require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body)) +} + +// Scenario: 启用的分组自定义列表允许 Auto Review。 +func TestMergeGroupConfiguredCodexModelsKeepsExplicitAutoReviewSelection(t *testing.T) { + t.Parallel() + + const groupID int64 = 76 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), + } + group := &Group{ + ID: groupID, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{openai.CodexUsageProbeModel}, + }, + } + + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, "")) + require.Equal(t, []string{"codex-auto-review"}, codexManifestModelSlugs(t, manifest.Body)) +} + +func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing.T) { + t.Parallel() + + const groupID int64 = 73 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "deepseek-4-pro": "deepseek-v4-pro", + "hidden-alias": "hidden-upstream", + }, + }, + }, + }, + }, + }} + group := &Group{ + ID: groupID, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"deepseek-4-pro"}, + }, + } + upstreamBody := []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6"}]}`) + manifest := &CodexModelsManifest{Body: upstreamBody} + + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, "")) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "deepseek-4-pro") + + finalETag := manifest.ETag + second := &CodexModelsManifest{Body: upstreamBody} + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, finalETag)) + require.True(t, second.NotModified) + require.Empty(t, second.Body) + require.Equal(t, finalETag, second.ETag) +} + type codexModelsBlockingBody struct { ctx context.Context readStarted chan struct{} @@ -429,7 +1163,7 @@ func TestFetchCodexModelsManifestMissingToken(t *testing.T) { } func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { - manifestBody := `{"models":[{"slug":"gpt-5.6"}]}` + manifestBody := `{"models":[{"slug":"deepseek-v4-pro"}]}` var gotRequest *http.Request var gotProxyURL string var gotAccountID int64 @@ -486,12 +1220,13 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { if gotProxyURL != "" || gotAccountID != 2 || gotConcurrency != 3 { t.Errorf("upstream routing metadata: proxy=%q account_id=%d concurrency=%d", gotProxyURL, gotAccountID, gotConcurrency) } - if string(manifest.Body) != manifestBody { - t.Errorf("body not passed through verbatim: got %q", manifest.Body) - } - if manifest.ETag != `W/"api-key-manifest"` { - t.Errorf("etag not passed through: got %q", manifest.ETag) - } + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-pro") + require.Equal(t, "DeepSeek V4 Pro", models[0]["display_name"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) + require.Equal(t, `W/"api-key-manifest"`, manifest.upstreamETag) } func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testing.T) { @@ -516,13 +1251,96 @@ func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testin if err != nil { t.Fatalf("FetchCodexModelsManifest returned error: %v", err) } - if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"},{"slug":"gpt-5.6-codex"}]}`; got != want { - t.Errorf("converted body: got %q, want %q", got, want) - } + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 2) + requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6") + requireCompleteConfiguredCodexModel(t, models[1], "gpt-5.6-codex") require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) require.Equal(t, `W/"openai-list"`, manifest.upstreamETag) } +func TestConvertOpenAIModelListToCodexManifestUsesCompleteDescriptors(t *testing.T) { + upstreamBody := `{"object":"list","data":[{"id":"gpt-5.5","object":"model"}]}` + + converted := convertOpenAIModelListToCodexManifest([]byte(upstreamBody)) + models := decodeCodexManifestModels(t, converted) + + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.5") + require.Equal(t, "GPT-5.5", models[0]["display_name"]) + require.Equal(t, "medium", models[0]["default_reasoning_level"]) + require.Len(t, models[0]["supported_reasoning_levels"], 4) +} + +func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`), + } + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "grok-4.6") + require.Equal(t, "Provider supplied", models[0]["description"]) + require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"]) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0])) + modelMessages, ok := models[0]["model_messages"].(map[string]any) + require.True(t, ok) + require.NotEmpty(t, modelMessages["instructions_template"]) + require.Equal(t, map[string]any{"enabled": true}, modelMessages["auto_review"]) + require.Equal(t, map[string]any{"mode": "tokens", "limit": float64(10_000)}, models[0]["truncation_policy"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) + + var envelope map[string]any + require.NoError(t, json.Unmarshal(manifest.Body, &envelope)) + require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"]) +} + +func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTImageInput(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)} + account := newCodexModelsAPIKeyTestAccount("") + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 4) + + bySlug := make(map[string]map[string]any, len(models)) + for _, model := range models { + slug, ok := model["slug"].(string) + require.True(t, ok) + bySlug[slug] = model + } + for _, slug := range []string{"gpt-5.6-sol", "gpt-4o"} { + require.Equal(t, []any{"text", "image"}, bySlug[slug]["input_modalities"]) + require.Equal(t, true, bySlug[slug]["supports_image_detail_original"]) + } + for _, slug := range []string{"gpt-3.5-turbo", "gpt-4"} { + require.Equal(t, []any{"text"}, bySlug[slug]["input_modalities"]) + require.Equal(t, false, bySlug[slug]["supports_image_detail_original"]) + } + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + +func TestCompleteAPIKeyCodexModelsManifestForClientFiltersOfficialNonAgentModels(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o-realtime-preview"},{"slug":"gpt-4o-mini-tts"},{"slug":"text-embedding-3-large"},{"slug":"omni-moderation-latest"},{"slug":"o4-mini"},{"slug":"codex-mini-latest"}]}`)} + account := newCodexModelsAPIKeyTestAccount("") + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + require.Equal(t, []string{"gpt-5.6-sol", "o4-mini", "codex-mini-latest"}, codexManifestModelSlugs(t, manifest.Body)) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) { tests := []struct { name string @@ -594,17 +1412,27 @@ func TestFetchCodexModelsManifestOAuthPreservesResponsesLite(t *testing.T) { require.Equal(t, manifestBody, string(manifest.Body)) } -func TestConvertOpenAIModelListToCodexManifest(t *testing.T) { +func TestConvertOpenAIModelListToCompleteCodexManifest(t *testing.T) { + t.Parallel() + + body := []byte(`{"object":"list","data":[{"id":"deepseek-v4-flash"},{"id":"deepseek-v4-pro"}]}`) + models := decodeCodexManifestModels(t, convertOpenAIModelListToCodexManifest(body)) + + require.Len(t, models, 2) + requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-flash") + requireCompleteConfiguredCodexModel(t, models[1], "deepseek-v4-pro") + require.Equal(t, "DeepSeek V4 Flash", models[0]["display_name"]) + require.Equal(t, "DeepSeek V4 Pro", models[1]["display_name"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) + require.EqualValues(t, 1_000_000, models[1]["context_window"]) +} + +func TestConvertOpenAIModelListToCodexManifestLeavesUnsupportedBodiesUnchanged(t *testing.T) { tests := []struct { name string body string want string }{ - { - name: "standard list", - body: `{"object":"list","data":[{"id":"m-1"},{"id":"m-2"}]}`, - want: `{"models":[{"slug":"m-1"},{"slug":"m-2"}]}`, - }, { name: "codex manifest unchanged", body: `{"models":[{"slug":"m-1"}]}`, @@ -1439,7 +2267,7 @@ func TestFetchCodexModelsManifestAPIKeyUpstreamError(t *testing.T) { } } -func TestFetchCodexModelsManifestAPIKeyRejectsOfficialOpenAIBaseURL(t *testing.T) { +func TestFetchCodexModelsManifestAPIKeyUsesOfficialOpenAIModelsEndpoint(t *testing.T) { tests := []struct { name string baseURL string @@ -1451,23 +2279,32 @@ func TestFetchCodexModelsManifestAPIKeyRejectsOfficialOpenAIBaseURL(t *testing.T for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { - t.Fatal("official OpenAI API key must not be used as a Codex manifest upstream") - return nil, nil + var gotURL string + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + gotURL = req.URL.String() + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol"}]}`)), + }, nil }}) - _, err := s.FetchCodexModelsManifest( + manifest, err := s.FetchCodexModelsManifest( context.Background(), newCodexModelsAPIKeyTestAccount(tt.baseURL), "0.144.0", "", ) - if err == nil { - t.Fatal("expected unsupported API key upstream error, got nil") - } - if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_UNSUPPORTED" { - t.Errorf("error reason: got %q", infraerrors.Reason(err)) - } + require.NoError(t, err) + parsedURL, parseErr := url.Parse(gotURL) + require.NoError(t, parseErr) + require.Equal(t, "api.openai.com", strings.ToLower(parsedURL.Hostname())) + require.Equal(t, "/v1/models", parsedURL.Path) + require.Equal(t, "0.144.0", parsedURL.Query().Get("client_version")) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6-sol") + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) }) } } diff --git a/frontend/src/api/__tests__/codex.spec.ts b/frontend/src/api/__tests__/codex.spec.ts index a6883ff5da..57b19d7557 100644 --- a/frontend/src/api/__tests__/codex.spec.ts +++ b/frontend/src/api/__tests__/codex.spec.ts @@ -16,10 +16,34 @@ describe('Codex models API', () => { }) it('fetches a manifest with the current API key without adding it to the catalog', async () => { + const manifest = { + models: [ + { + slug: 'grok-4.6', + default_reasoning_level: 'high', + supported_reasoning_levels: [ + { effort: 'low', description: 'Fast responses' }, + { effort: 'xhigh', description: 'Extra-high reasoning depth' } + ], + input_modalities: ['text', 'image'], + model_messages: { instructions_template: 'Use the routed model.' } + }, + { + slug: 'deepseek-v4-pro', + default_reasoning_level: 'high', + supported_reasoning_levels: [ + { effort: 'low', description: 'Fast responses' }, + { effort: 'max', description: 'Maximum reasoning depth' } + ], + input_modalities: ['text'], + model_messages: { instructions_template: 'Use the routed model.' } + } + ] + } const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: async () => ({ models: [{ slug: 'grok-4.6' }, { slug: 'deepseek-v4-pro' }] }) + json: async () => manifest }) vi.stubGlobal('fetch', fetchMock) @@ -35,6 +59,10 @@ describe('Codex models API', () => { }) ) expect(result.modelCount).toBe(2) + expect(JSON.parse(result.content)).toEqual(manifest) + expect(result.content).toContain('"effort": "xhigh"') + expect(result.content).toContain('"input_modalities"') + expect(result.content).toContain('"instructions_template"') expect(result.content).not.toContain('sk-user-test') }) diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index 3170243e6a..2a9f91c24f 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -25,6 +25,15 @@ vi.mock('file-saver', () => ({ import UseKeyModal from '../UseKeyModal.vue' +function readBlobAsText(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.addEventListener('load', () => resolve(String(reader.result || ''))) + reader.addEventListener('error', () => reject(reader.error)) + reader.readAsText(blob) + }) +} + describe('UseKeyModal', () => { afterEach(() => { vi.unstubAllGlobals() @@ -606,10 +615,28 @@ describe('UseKeyModal', () => { // Scenario: API Key users can fetch a routed group catalog and reference it from config.toml. it('offers a downloadable Codex catalog for Composite API keys', async () => { + const manifest = { + models: [ + { + slug: 'gpt-5.5', + default_reasoning_level: 'medium', + supported_reasoning_levels: [{ effort: 'xhigh', description: 'Extra-high reasoning depth' }], + input_modalities: ['text', 'image'], + model_messages: { instructions_template: 'Use the routed model.' } + }, + { + slug: 'grok-4.6', + default_reasoning_level: 'high', + supported_reasoning_levels: [{ effort: 'xhigh', description: 'Extra-high reasoning depth' }], + input_modalities: ['text'], + model_messages: { instructions_template: 'Use the routed model.' } + } + ] + } const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, - json: async () => ({ models: [{ slug: 'gpt-5.5' }, { slug: 'grok-4.6' }] }) + json: async () => manifest }) vi.stubGlobal('fetch', fetchMock) @@ -663,6 +690,8 @@ describe('UseKeyModal', () => { expect(downloadButton).toBeDefined() await downloadButton!.trigger('click') expect(saveAsMock).toHaveBeenCalledWith(expect.any(Blob), 'codex-models.json') + const downloadedBlob = saveAsMock.mock.calls[0]?.[0] as Blob + expect(JSON.parse(await readBlobAsText(downloadedBlob))).toEqual(manifest) const windowsTab = wrapper.findAll('button').find((button) => button.text().trim() === 'Windows') expect(windowsTab).toBeDefined()