From 294b7f5b191bb9312d5ed3d4652b2db0279afbf5 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sun, 13 Sep 2026 22:44:19 +0800 Subject: [PATCH] feat(models): expose CPA web search capability --- internal/api/server_routes.go | 40 +++++++- internal/api/server_test.go | 107 ++++++++++++++++++++ internal/client/codex/models/models.go | 57 ++++++++++- internal/client/codex/models/models_test.go | 79 +++++++++++++++ 4 files changed, 276 insertions(+), 7 deletions(-) diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go index 6483fb616..cb4e042d4 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -662,7 +662,21 @@ func (s *Server) handleHomeCodexClientModels(c *gin.Context, clientVersion strin models = append(models, formatHomeCodexModel(entry)) } - s.writeModelListResponse(c, "openai", codexmodels.BuildResponseForClient(models, nil, s.cfg.Codex.OptimizeMultiAgentV2, clientVersion)) + var cpaProvidersForModel codexmodels.ProvidersForModelFunc + if clientVersion == "cpa" { + cpaProvidersForModel = homeProvidersForModel(entries) + } + s.writeModelListResponse(c, "openai", codexmodels.BuildResponseForClientWithCPAProviders(models, nil, cpaProvidersForModel, s.cfg.Codex.OptimizeMultiAgentV2, clientVersion)) +} + +func homeProvidersForModel(entries []homeModelEntry) codexmodels.ProvidersForModelFunc { + providersByID := make(map[string][]string, len(entries)) + for _, entry := range entries { + providersByID[entry.id] = append([]string(nil), entry.providers...) + } + return func(id string) []string { + return append([]string(nil), providersByID[strings.TrimSpace(id)]...) + } } func formatHomeCodexModel(entry homeModelEntry) map[string]any { @@ -722,6 +736,7 @@ type homeModelEntry struct { contextLength int maxCompletionTokens int thinking *registry.ThinkingSupport + providers []string } func (s *Server) handleHomeModels(c *gin.Context) { @@ -990,9 +1005,10 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { return nil, fmt.Errorf("home models payload has no sections") } - seen := make(map[string]struct{}) + indexByID := make(map[string]int) out := make([]homeModelEntry, 0, 256) - for _, models := range bySection { + for section, models := range bySection { + provider := strings.ToLower(strings.TrimSpace(section)) for _, model := range models { id, _ := model["id"].(string) id = strings.TrimSpace(id) @@ -1004,10 +1020,10 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { if id == "" { continue } - if _, ok := seen[id]; ok { + if index, ok := indexByID[id]; ok { + out[index].providers = appendUniqueHomeProvider(out[index].providers, provider) continue } - seen[id] = struct{}{} ownedBy, _ := model["owned_by"].(string) ownedBy = strings.TrimSpace(ownedBy) @@ -1019,6 +1035,7 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { } thinking := homeModelThinkingSupport(model) + indexByID[id] = len(out) out = append(out, homeModelEntry{ id: id, created: homeModelInt64Value(model, "created"), @@ -1027,6 +1044,7 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { contextLength: int(homeModelInt64Value(model, "context_length", "contextLength", "inputTokenLimit", "max_input_tokens")), maxCompletionTokens: int(homeModelInt64Value(model, "max_completion_tokens", "maxCompletionTokens", "outputTokenLimit", "max_tokens")), thinking: thinking, + providers: appendUniqueHomeProvider(nil, provider), }) } } @@ -1038,6 +1056,18 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { return out, nil } +func appendUniqueHomeProvider(providers []string, provider string) []string { + if provider == "" { + return providers + } + for _, existing := range providers { + if existing == provider { + return providers + } + } + return append(providers, provider) +} + func homeModelThinkingSupport(model map[string]any) *registry.ThinkingSupport { raw, ok := model["thinking"] if !ok || raw == nil { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index a485a5b1b..78a786adf 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -19,6 +19,7 @@ import ( gin "github.com/gin-gonic/gin" managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management" claudemodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/claude/models" + codexmodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/models" proxyconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/home" internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" @@ -2968,3 +2969,109 @@ func TestServerCodexAPIKeyResponsesStreamingRequestLog(t *testing.T) { t.Fatalf("API RESPONSE section missing response chunk data:\n%s", apiResponseSection) } } + +func TestModelsForCPAClientSerializesWebSearchCapabilities(t *testing.T) { + modelRegistry := registry.GetGlobalRegistry() + registrations := []struct { + clientID string + provider string + modelID string + }{ + {"cpa-web-search-codex", "codex", "cpa-search-codex-model"}, + {"cpa-web-search-xai", "xai", "cpa-search-xai-model"}, + {"cpa-web-search-claude", "claude", "cpa-search-claude-model"}, + {"cpa-web-search-gemini", "gemini", "cpa-no-search-gemini-model"}, + {"cpa-web-search-mixed-codex", "codex", "cpa-mixed-search-model"}, + {"cpa-web-search-mixed-gemini", "gemini", "cpa-mixed-search-model"}, + {"cpa-web-search-prefixed", "codex", "team/cpa-prefixed-search-model"}, + } + for _, registration := range registrations { + modelRegistry.RegisterClient(registration.clientID, registration.provider, []*registry.ModelInfo{{ID: registration.modelID}}) + } + t.Cleanup(func() { + for _, registration := range registrations { + modelRegistry.UnregisterClient(registration.clientID) + } + }) + + server := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/v1/models?client_version=cpa", nil) + req.Header.Set("Authorization", "Bearer test-key") + recorder := httptest.NewRecorder() + server.engine.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + + var response struct { + Models []map[string]any `json:"models"` + } + if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &response); errUnmarshal != nil { + t.Fatalf("decode response: %v; body=%s", errUnmarshal, recorder.Body.String()) + } + entries := make(map[string]map[string]any, len(response.Models)) + for _, model := range response.Models { + slug, _ := model["slug"].(string) + entries[slug] = model + } + for _, modelID := range []string{"cpa-search-codex-model", "cpa-search-xai-model", "cpa-search-claude-model", "team/cpa-prefixed-search-model"} { + assertSerializedCPAWebSearch(t, entries[modelID], true) + } + for _, modelID := range []string{"cpa-no-search-gemini-model", "cpa-mixed-search-model"} { + assertSerializedCPAWebSearch(t, entries[modelID], false) + } +} + +func TestHomeModelsPreserveProvidersForCPAWebSearchCapability(t *testing.T) { + entries, errDecode := decodeHomeModels([]byte(`{ + "codex":[{"id":"home-codex"},{"id":"home-mixed"}], + "xai":[{"id":"home-xai"}], + "claude":[{"id":"home-claude"},{"id":"gpt-5.5"}], + "gemini":[{"id":"home-gemini"},{"id":"home-mixed"}], + "custom":[{"id":"home-unknown"}] + }`)) + if errDecode != nil { + t.Fatalf("decode Home models: %v", errDecode) + } + + models := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + models = append(models, formatHomeCodexModel(entry)) + } + response := codexmodels.BuildResponseForClientWithCPAProviders(models, nil, homeProvidersForModel(entries), false, "cpa") + catalog, ok := response["models"].([]map[string]any) + if !ok { + t.Fatalf("models = %#v, want []map[string]any", response["models"]) + } + bySlug := make(map[string]map[string]any, len(catalog)) + for _, model := range catalog { + slug, _ := model["slug"].(string) + bySlug[slug] = model + } + for _, modelID := range []string{"home-codex", "home-xai", "home-claude", "gpt-5.5"} { + assertSerializedCPAWebSearch(t, bySlug[modelID], true) + } + if supportsSearchTool, _ := bySlug["gpt-5.5"]["supports_search_tool"].(bool); !supportsSearchTool { + t.Fatal("CPA capability provider metadata changed legacy Home supports_search_tool") + } + for _, modelID := range []string{"home-gemini", "home-mixed"} { + assertSerializedCPAWebSearch(t, bySlug[modelID], false) + } + if _, exists := bySlug["home-unknown"]["cpa_capabilities"]; exists { + t.Fatalf("home-unknown cpa_capabilities = %#v, want omitted", bySlug["home-unknown"]["cpa_capabilities"]) + } +} + +func assertSerializedCPAWebSearch(t *testing.T, model map[string]any, want bool) { + t.Helper() + if model == nil { + t.Fatal("model entry is missing") + } + capabilities, ok := model["cpa_capabilities"].(map[string]any) + if !ok { + t.Fatalf("model %v cpa_capabilities = %#v, want object", model["slug"], model["cpa_capabilities"]) + } + if got, ok := capabilities["web_search"].(bool); !ok || got != want { + t.Fatalf("model %v web_search = %#v, want %v", model["slug"], capabilities["web_search"], want) + } +} diff --git a/internal/client/codex/models/models.go b/internal/client/codex/models/models.go index 147846c4e..819a5cc9a 100644 --- a/internal/client/codex/models/models.go +++ b/internal/client/codex/models/models.go @@ -55,12 +55,18 @@ func BuildResponse(availableModels []map[string]any, providersForModel Providers // BuildResponseForClient builds a Codex client model response from available models // tailored for a specific client version. func BuildResponseForClient(availableModels []map[string]any, providersForModel ProvidersForModelFunc, optimizeMultiAgentV2 bool, clientVersion string) map[string]any { + return BuildResponseForClientWithCPAProviders(availableModels, providersForModel, providersForModel, optimizeMultiAgentV2, clientVersion) +} + +// BuildResponseForClientWithCPAProviders builds a client response while allowing +// CPA-only capability metadata to use a provider source separate from legacy fields. +func BuildResponseForClientWithCPAProviders(availableModels []map[string]any, providersForModel, cpaProvidersForModel ProvidersForModelFunc, optimizeMultiAgentV2 bool, clientVersion string) map[string]any { return map[string]any{ - "models": buildCodexClientModels(availableModels, providersForModel, optimizeMultiAgentV2, clientVersion), + "models": buildCodexClientModels(availableModels, providersForModel, cpaProvidersForModel, optimizeMultiAgentV2, clientVersion), } } -func buildCodexClientModels(models []map[string]any, providersForModel ProvidersForModelFunc, optimizeMultiAgentV2 bool, clientVersion string) []map[string]any { +func buildCodexClientModels(models []map[string]any, providersForModel, cpaProvidersForModel ProvidersForModelFunc, optimizeMultiAgentV2 bool, clientVersion string) []map[string]any { templates, defaultTemplate, err := loadCodexClientModelTemplates() if err != nil || defaultTemplate == nil { return nil @@ -89,6 +95,7 @@ func buildCodexClientModels(models []map[string]any, providersForModel Providers applyCodexClientThinkingMetadata(entry, thinkingSupport, clientVersion) } applyCodexClientProviderCapabilities(entry, id, true, providersForModel) + applyCPAWebSearchCapability(entry, id, cpaProvidersForModel, clientVersion) sanitizeCodexClientReasoningMetadata(entry, clientVersion) applyCodexClientVisibilityOverride(entry, id) if optimizeMultiAgentV2 { @@ -102,6 +109,7 @@ func buildCodexClientModels(models []map[string]any, providersForModel Providers applyCodexClientModelMetadata(entry, id, model, optimizeMultiAgentV2, clientVersion) applyCodexClientMaxTokens(entry, model) applyCodexClientProviderCapabilities(entry, id, false, providersForModel) + applyCPAWebSearchCapability(entry, id, cpaProvidersForModel, clientVersion) sanitizeCodexClientReasoningMetadata(entry, clientVersion) applyCodexClientVisibilityOverride(entry, id) result = append(result, entry) @@ -404,6 +412,51 @@ func applyCodexClientMaxTokens(entry map[string]any, model map[string]any) { } } +func applyCPAWebSearchCapability(entry map[string]any, id string, providersForModel ProvidersForModelFunc, clientVersion string) { + if clientVersion != "cpa" || providersForModel == nil { + return + } + + providers := providersForPublicModel(id, providersForModel) + if len(providers) == 0 { + return + } + + hasUnknown := false + for _, rawProvider := range providers { + provider := strings.ToLower(strings.TrimSpace(rawProvider)) + switch provider { + case "codex", "xai", "claude": + // These providers support native web search through the existing Responses path. + case "openai", "openai-compatibility", "gemini", "aistudio", "vertex", "antigravity", "kimi", "interactions": + entry["cpa_capabilities"] = map[string]any{"web_search": false} + return + default: + if strings.HasPrefix(provider, "openai-compatible-") { + entry["cpa_capabilities"] = map[string]any{"web_search": false} + return + } + hasUnknown = true + } + } + if hasUnknown { + return + } + entry["cpa_capabilities"] = map[string]any{"web_search": true} +} + +func providersForPublicModel(id string, providersForModel ProvidersForModelFunc) []string { + if providersForModel == nil { + return nil + } + providers := providersForModel(id) + if len(providers) == 0 && strings.Contains(id, "/") { + _, base, _ := strings.Cut(id, "/") + providers = providersForModel(strings.TrimSpace(base)) + } + return providers +} + func applyCodexClientProviderCapabilities(entry map[string]any, id string, isTemplate bool, providersForModel ProvidersForModelFunc) { if !isTemplate { applyCodexClientSearchToolSupport(entry, id, false, providersForModel) diff --git a/internal/client/codex/models/models_test.go b/internal/client/codex/models/models_test.go index 723cf8111..a77fb1f5f 100644 --- a/internal/client/codex/models/models_test.go +++ b/internal/client/codex/models/models_test.go @@ -1391,3 +1391,82 @@ func TestCodexClientModelsResponse_OAuthAliasesInheritCompleteReasoningLevelsWit } } } + +func TestCodexClientModelsResponse_CPAWebSearchCapabilities(t *testing.T) { + providers := map[string][]string{ + "codex-model": {"codex"}, + "xai-model": {"xai"}, + "claude-model": {"claude"}, + "all-native-model": {"codex", "xai", "claude"}, + "mixed-model": {"codex", "gemini"}, + "unknown-model": {"third-party-provider"}, + "known-unsupported-model": {"openai-compatibility"}, + "native-and-unknown-model": {"xai", "third-party-provider"}, + "base-prefixed-native-model": {"claude"}, + } + providerLookup := func(id string) []string { + return append([]string(nil), providers[id]...) + } + availableModels := []map[string]any{ + {"id": "codex-model"}, + {"id": "xai-model"}, + {"id": "claude-model"}, + {"id": "all-native-model"}, + {"id": "mixed-model"}, + {"id": "unknown-model"}, + {"id": "known-unsupported-model"}, + {"id": "native-and-unknown-model"}, + {"id": "team/base-prefixed-native-model"}, + {"id": "no-provider-model"}, + } + + resp := BuildResponseForClient(availableModels, providerLookup, false, "cpa") + models, ok := resp["models"].([]map[string]any) + if !ok || len(models) != len(availableModels) { + t.Fatalf("models = %#v, want %d models", resp["models"], len(availableModels)) + } + entries := make(map[string]map[string]any, len(models)) + for _, model := range models { + entries[stringModelValue(model, "slug")] = model + } + + for _, id := range []string{"codex-model", "xai-model", "claude-model", "all-native-model", "team/base-prefixed-native-model"} { + assertCPAWebSearchCapability(t, entries[id], true, true) + } + for _, id := range []string{"mixed-model", "known-unsupported-model"} { + assertCPAWebSearchCapability(t, entries[id], false, true) + } + for _, id := range []string{"unknown-model", "native-and-unknown-model", "no-provider-model"} { + assertCPAWebSearchCapability(t, entries[id], false, false) + } +} + +func TestCodexClientModelsResponse_CPAWebSearchCapabilitiesOnlyForCPAClient(t *testing.T) { + providerLookup := func(string) []string { return []string{"codex"} } + for _, clientVersion := range []string{"", "0.153.4", "CPA", "cpa-preview"} { + resp := BuildResponseForClient([]map[string]any{{"id": "gpt-5.5"}}, providerLookup, false, clientVersion) + models, ok := resp["models"].([]map[string]any) + if !ok || len(models) != 1 { + t.Fatalf("client version %q models = %#v, want one model", clientVersion, resp["models"]) + } + assertCPAWebSearchCapability(t, models[0], false, false) + } +} + +func assertCPAWebSearchCapability(t *testing.T, model map[string]any, want bool, wantPresent bool) { + t.Helper() + raw, present := model["cpa_capabilities"] + if present != wantPresent { + t.Fatalf("model %q cpa_capabilities presence = %v, want %v", stringModelValue(model, "slug"), present, wantPresent) + } + if !wantPresent { + return + } + capabilities, ok := raw.(map[string]any) + if !ok { + t.Fatalf("model %q cpa_capabilities = %#v, want object", stringModelValue(model, "slug"), raw) + } + if got, ok := capabilities["web_search"].(bool); !ok || got != want { + t.Fatalf("model %q web_search = %#v, want %v", stringModelValue(model, "slug"), capabilities["web_search"], want) + } +}