From 22e1b8144acd88b1b922050dbc312433d961744a Mon Sep 17 00:00:00 2001 From: Long Li Date: Fri, 21 Aug 2026 01:41:04 +0900 Subject: [PATCH 01/11] feat(gateway): expose routed Codex model catalogs Codex model discovery expects a top-level models manifest, but Composite and other non-OpenAI groups either reached the OpenAI live-manifest handler or had no Codex-specific response at all. API key users also had no supported way to fetch that manifest and reference it from config.toml. Generate minimal manifests from each group's effective model list while preserving the official OpenAI live path and the ordinary /models response. Add the Use Key flow for authenticated catalog download and model_catalog_json configuration without writing the API key into the downloaded file. --- backend/internal/handler/gateway_handler.go | 53 +++++ .../internal/handler/gateway_models_test.go | 150 ++++++++++++ backend/internal/server/routes/gateway.go | 20 +- .../routes/gateway_codex_models_test.go | 40 ++++ .../service/openai_codex_models_service.go | 35 ++- .../openai_codex_models_service_test.go | 23 ++ frontend/src/api/__tests__/codex.spec.ts | 51 +++++ frontend/src/api/codex.ts | 56 +++++ frontend/src/components/keys/UseKeyModal.vue | 215 ++++++++++++++++++ .../keys/__tests__/UseKeyModal.spec.ts | 90 +++++++- frontend/src/i18n/locales/en/dashboard.ts | 21 ++ frontend/src/i18n/locales/zh/dashboard.ts | 21 ++ 12 files changed, 758 insertions(+), 17 deletions(-) create mode 100644 frontend/src/api/__tests__/codex.spec.ts create mode 100644 frontend/src/api/codex.ts 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 @@