diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 1128329be8..14908e9bb0 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1140,6 +1140,59 @@ func (h *GatewayHandler) Models(c *gin.Context) { }) } +// CodexModels returns the effective group model list using the manifest shape +// expected by Codex custom providers. Official OpenAI groups continue to use +// OpenAIGatewayHandler.CodexModels so their live upstream metadata is preserved. +func (h *GatewayHandler) CodexModels(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey == nil || apiKey.Group == nil { + h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required") + return + } + + modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group) + body, err := service.BuildCodexModelsManifest(modelIDs) + if err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } + c.Data(http.StatusOK, "application/json", body) +} + +func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *service.Group) []string { + if h == nil || h.gatewayService == nil || group == nil { + return nil + } + + groupID := &group.ID + platform := group.Platform + if platform == service.PlatformComposite { + availableModels := h.compositeAvailableModels(ctx, groupID) + fallbackModels := defaultModelIDsForPlatform(service.PlatformComposite) + if group.CustomModelsListEnabled() { + return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models) + } + if len(availableModels) > 0 { + return availableModels + } + return fallbackModels + } + + availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) + fallbackModels := defaultModelIDsForPlatform(platform) + if group.CustomModelsListEnabled() { + return filterModelsByCustomList( + customModelsListSource(platform, availableModels, fallbackModels), + fallbackModels, + group.ModelsListConfig.Models, + ) + } + if len(availableModels) > 0 { + return availableModels + } + return fallbackModels +} + func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string { if h == nil || h.gatewayService == nil { return nil diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 313b3216fb..9ab9485626 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -25,6 +25,12 @@ type gatewayModelsResponseForTest struct { Data []gatewayModelItemForTest `json:"data"` } +type codexModelsResponseForTest struct { + Models []struct { + Slug string `json:"slug"` + } `json:"models"` +} + type gatewayModelItemForTest struct { ID string `json:"id"` Object string `json:"object"` @@ -70,6 +76,150 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) { require.Contains(t, compositeIDs, antigravityIDs[0]) } +// 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: "Grok", platform: service.PlatformGrok, model: "grok-4.6"}, + {name: "DeepSeek", platform: service.PlatformDeepseek, model: "deepseek-v4-pro"}, + } + + for index, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + groupID := int64(100 + index) + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: tt.platform, + Credentials: map[string]any{ + "model_mapping": map[string]any{tt.model: tt.model}, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: tt.platform}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Len(t, got.Models, 1) + require.Equal(t, tt.model, got.Models[0].Slug) + }) + } +} + +// Scenario: Composite manifests aggregate models across routed platforms. +func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 120 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"}, + }, + }, + { + ID: 2, + Platform: service.PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-4.6": "grok-4.6"}, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models)) +} + +// Scenario: group models_list_config limits the generated Codex manifest. +func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 121 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"}, + }, + }, + { + ID: 2, + Platform: service.PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-4.6": "grok-4.6"}, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformComposite, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"grok-4.6"}, + }, + }, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"grok-4.6"}, codexModelSlugsForTest(got.Models)) +} + +func codexModelSlugsForTest(models []struct { + Slug string `json:"slug"` +}) []string { + slugs := make([]string, 0, len(models)) + for _, model := range models { + slugs = append(slugs, model.Slug) + } + return slugs +} + func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 6c0bfd908d..fe753b2389 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -65,13 +65,13 @@ func RegisterGatewayRoutes( h.Gateway.CountTokens(c) } } + codexModelsHandler := func(c *gin.Context) { + dispatchCodexModelsGateway(c, h.OpenAIGateway.CodexModels, h.Gateway.CodexModels) + } modelsHandler := func(c *gin.Context) { if c.Query("client_version") != "" { - switch getGroupPlatform(c) { - case service.PlatformOpenAI, service.PlatformComposite: - h.OpenAIGateway.CodexModels(c) - return - } + codexModelsHandler(c) + return } h.Gateway.Models(c) } @@ -377,7 +377,7 @@ func RegisterGatewayRoutes( codexDirect.GET("/responses", func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) }) - codexDirect.GET("/models", h.OpenAIGateway.CodexModels) + codexDirect.GET("/models", codexModelsHandler) } // OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { @@ -504,6 +504,14 @@ func RegisterGatewayRoutes( } +func dispatchCodexModelsGateway(c *gin.Context, openAIHandler, generatedHandler gin.HandlerFunc) { + if getGroupPlatform(c) == service.PlatformOpenAI { + openAIHandler(c) + return + } + generatedHandler(c) +} + // getGroupPlatform extracts the group platform from the API Key stored in context. func getGroupPlatform(c *gin.Context) string { apiKey, ok := middleware.GetAPIKeyFromContext(c) diff --git a/backend/internal/server/routes/gateway_codex_models_test.go b/backend/internal/server/routes/gateway_codex_models_test.go index 04a8b8fa67..0c6542a0fa 100644 --- a/backend/internal/server/routes/gateway_codex_models_test.go +++ b/backend/internal/server/routes/gateway_codex_models_test.go @@ -2,8 +2,12 @@ package routes import ( "net/http" + "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -22,3 +26,39 @@ func TestGatewayRoutesCodexModelsManifestPathIsRegistered(t *testing.T) { require.NotEmpty(t, registered["/models"], "GET /models should be registered") require.Equal(t, registered["/v1/models"], registered["/models"], "root alias should use the same platform-aware handler") } + +func TestDispatchCodexModelsGatewayKeepsOnlyOpenAIOnLiveManifestHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + platform string + wantOpenAI bool + }{ + {platform: service.PlatformOpenAI, wantOpenAI: true}, + {platform: service.PlatformComposite}, + {platform: service.PlatformGrok}, + {platform: service.PlatformDeepseek}, + } + + for _, tt := range tests { + t.Run(tt.platform, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{Platform: tt.platform}, + }) + called := "" + + dispatchCodexModelsGateway(c, + func(c *gin.Context) { called = "openai" }, + func(c *gin.Context) { called = "generated" }, + ) + + if tt.wantOpenAI { + require.Equal(t, "openai", called) + } else { + require.Equal(t, "generated", called) + } + }) + } +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index ee52003017..6ccc78d9f6 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -647,27 +647,48 @@ func convertOpenAIModelListToCodexManifest(body []byte) []byte { if err := json.Unmarshal(data, &entries); err != nil { return body } - type codexModelEntry struct { - Slug string `json:"slug"` - } - models := make([]codexModelEntry, 0, len(entries)) + modelIDs := make([]string, 0, len(entries)) for _, entry := range entries { id := strings.TrimSpace(entry.ID) if id == "" { continue } - models = append(models, codexModelEntry{Slug: id}) + modelIDs = append(modelIDs, id) } - if len(models) == 0 { + if len(modelIDs) == 0 { return body } - converted, err := json.Marshal(map[string][]codexModelEntry{"models": models}) + converted, err := BuildCodexModelsManifest(modelIDs) if err != nil { return body } 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"` + } + + seen := make(map[string]struct{}, len(modelIDs)) + models := make([]codexModelEntry, 0, len(modelIDs)) + for _, rawModelID := range modelIDs { + modelID := strings.TrimSpace(rawModelID) + if modelID == "" { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + seen[modelID] = struct{}{} + models = append(models, codexModelEntry{Slug: modelID}) + } + + return json.Marshal(map[string][]codexModelEntry{"models": models}) +} + func validateCodexModelsManifestEnvelope(body []byte) error { var envelope map[string]json.RawMessage if err := json.Unmarshal(body, &envelope); 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 8f4c495755..bad1488ef2 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -23,6 +24,28 @@ 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) } diff --git a/frontend/src/api/__tests__/codex.spec.ts b/frontend/src/api/__tests__/codex.spec.ts new file mode 100644 index 0000000000..a6883ff5da --- /dev/null +++ b/frontend/src/api/__tests__/codex.spec.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildCodexModelsManifestUrl, + fetchCodexModelsManifest +} from '../codex' + +describe('Codex models API', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('builds the authenticated Codex manifest endpoint from the public API base', () => { + expect(buildCodexModelsManifestUrl('https://example.com/api/v1/')).toBe( + 'https://example.com/api/v1/models?client_version=0.147.0' + ) + }) + + it('fetches a manifest with the current API key without adding it to the catalog', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ models: [{ slug: 'grok-4.6' }, { slug: 'deepseek-v4-pro' }] }) + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await fetchCodexModelsManifest('https://example.com/v1', 'sk-user-test') + + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/v1/models?client_version=0.147.0', + expect.objectContaining({ + headers: { + Accept: 'application/json', + Authorization: 'Bearer sk-user-test' + } + }) + ) + expect(result.modelCount).toBe(2) + expect(result.content).not.toContain('sk-user-test') + }) + + it('rejects a successful response that is not a Codex manifest', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ object: 'list', data: [] }) + })) + + await expect(fetchCodexModelsManifest('https://example.com/v1', 'sk-user-test')) + .rejects.toThrow('valid manifest') + }) +}) diff --git a/frontend/src/api/codex.ts b/frontend/src/api/codex.ts new file mode 100644 index 0000000000..948fa1eee9 --- /dev/null +++ b/frontend/src/api/codex.ts @@ -0,0 +1,56 @@ +export interface CodexModelsManifestResult { + content: string + modelCount: number +} + +const DEFAULT_CODEX_CLIENT_VERSION = '0.147.0' + +function normalizeCodexBaseUrl(baseUrl: string): string { + const fallback = typeof window !== 'undefined' ? window.location.origin : '' + const value = (baseUrl || fallback).trim().replace(/\/+$/, '') + if (!value) return '/v1' + return /\/v1$/i.test(value) ? value : `${value}/v1` +} + +export function buildCodexModelsManifestUrl( + baseUrl: string, + clientVersion = DEFAULT_CODEX_CLIENT_VERSION +): string { + const url = normalizeCodexBaseUrl(baseUrl) + const params = new URLSearchParams({ client_version: clientVersion }) + return `${url}/models?${params.toString()}` +} + +function isCodexModelsManifest(value: unknown): value is { models: unknown[] } { + return typeof value === 'object' && value !== null && Array.isArray((value as { models?: unknown }).models) +} + +export async function fetchCodexModelsManifest( + baseUrl: string, + apiKey: string, + signal?: AbortSignal +): Promise { + const response = await fetch(buildCodexModelsManifestUrl(baseUrl), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}` + }, + cache: 'no-store', + signal + }) + + if (!response.ok) { + throw new Error(`Codex models request failed with status ${response.status}`) + } + + const payload: unknown = await response.json() + if (!isCodexModelsManifest(payload)) { + throw new Error('Codex models response is not a valid manifest') + } + + return { + content: JSON.stringify(payload, null, 2), + modelCount: payload.models.length + } +} diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index c8ae79d229..9646667bbf 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -172,6 +172,65 @@ +
+
+
+

+ {{ t('keys.useKeyModal.codexModelCatalog.title') }} +

+

+ {{ t('keys.useKeyModal.codexModelCatalog.description') }} +

+

+ {{ codexModelCatalogPath }} +

+
+ + +
+

+ {{ t('keys.useKeyModal.codexModelCatalog.modelsCount', { count: codexModelManifestModelCount }) }} +

+

+ {{ t('keys.useKeyModal.codexModelCatalog.errorDescription') }} +

+
+
@@ -198,9 +257,11 @@