mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-14 03:24:42 +08:00
feat(models): expose CPA web search capability
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user