feat(models): expose CPA web search capability

This commit is contained in:
Supra4E8C
2026-09-13 22:44:19 +08:00
parent d23ba5ee05
commit 294b7f5b19
4 changed files with 276 additions and 7 deletions

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)
}
}