diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index b058fdc0df..558c621ce5 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -93,6 +93,83 @@ func TestBuildCodexModelsManifestForGroupUsesNoneForExplicitNonReasoningMetadata require.Equal(t, []string{"none"}, effortsFromManifestModel(t, models[0])) } +func TestBuildCodexModelsManifestForGroupAdvertisesSearchOnlyForChatBridgeRoutes(t *testing.T) { + t.Parallel() + + newAccount := func(id int64, nativeResponses bool) Account { + return Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"company-coding-model": "company-coding-model"}, + }, + Extra: map[string]any{"openai_responses_supported": nativeResponses}, + } + } + + for _, tc := range []struct { + name string + accounts []Account + want bool + }{ + {name: "chat bridge", accounts: []Account{newAccount(80, false)}, want: true}, + {name: "native responses", accounts: []Account{newAccount(81, true)}, want: false}, + {name: "mixed routes", accounts: []Account{newAccount(82, false), newAccount(83, true)}, want: false}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + body, err := buildCodexModelsManifestForAccounts( + PlatformOpenAI, []string{"company-coding-model"}, tc.accounts, nil, true, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, tc.want, models[0]["supports_search_tool"]) + }) + } +} + +func TestCompleteAPIKeyCodexManifestSearchCapabilityPreservesUpstreamAndFailsClosed(t *testing.T) { + t.Parallel() + + nativeAccount := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + }, + Extra: map[string]any{"openai_responses_supported": true}, + } + body, err := completeAPIKeyCodexModelsManifestMetadata([]byte(`{"models":[ + {"slug":"explicit","supports_search_tool":true}, + {"slug":"missing"} + ]}`), true, nativeAccount) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Equal(t, true, models[0]["supports_search_tool"]) + require.Equal(t, false, models[1]["supports_search_tool"]) + + chatAccount := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + }, + Extra: map[string]any{"openai_responses_supported": false}, + } + body, err = completeAPIKeyCodexModelsManifestMetadata( + []byte(`{"models":[ + {"slug":"explicit-disabled","supports_search_tool":false}, + {"slug":"generated"} + ]}`), true, chatAccount, + ) + require.NoError(t, err) + models = decodeCodexManifestModels(t, body) + require.Equal(t, false, models[0]["supports_search_tool"]) + require.Equal(t, true, models[1]["supports_search_tool"]) +} + // Scenario: multiple schedulable accounts advertise only their shared capabilities. func TestBuildCodexModelsManifestForGroupIntersectsSyncedAccountMetadata(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 6c97eb9b84..d1abc54e01 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -734,7 +734,7 @@ func claudeCodexDisplayName(modelID string) string { // routed through a custom provider. The response is also suitable for saving // as model_catalog_json in clients that do not refresh custom-provider catalogs. func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) { - return buildCodexModelsManifest(modelIDs, nil, nil, nil) + return buildCodexModelsManifest(modelIDs, nil, nil, nil, nil) } // BuildCodexModelsManifestForGroup derives input capabilities from the @@ -789,6 +789,7 @@ func buildCodexModelsManifestForAccounts( compositeRoutesAvailable bool, ) ([]byte, error) { imageInputModels := make(map[string]bool, len(modelIDs)) + searchToolModels := make(map[string]bool, len(modelIDs)) metadataModels := codexCatalogMetadataModels( effectivePlatform, modelIDs, @@ -808,6 +809,15 @@ func buildCodexModelsManifestForAccounts( ) { imageInputModels[modelID] = true } + if groupCodexModelSupportsSearchTool( + effectivePlatform, + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) { + searchToolModels[modelID] = true + } if metadata, ok := groupCodexModelMetadata( effectivePlatform, modelID, @@ -818,12 +828,13 @@ func buildCodexModelsManifestForAccounts( modelMetadata[modelID] = metadata } } - return buildCodexModelsManifest(modelIDs, imageInputModels, metadataModels, modelMetadata) + return buildCodexModelsManifest(modelIDs, imageInputModels, searchToolModels, metadataModels, modelMetadata) } func buildCodexModelsManifest( modelIDs []string, imageInputModels map[string]bool, + searchToolModels map[string]bool, metadataModels map[string]string, modelMetadata map[string]codexModelMetadataOverride, ) ([]byte, error) { @@ -850,6 +861,7 @@ func buildCodexModelsManifest( if imageInputModels[modelID] { descriptor.InputModalities = []string{"text", "image"} } + descriptor.SupportsSearchTool = searchToolModels[modelID] if metadata, ok := modelMetadata[modelID]; ok { applyUpstreamModelMetadataToCodexDescriptor(&descriptor, metadata) } @@ -1005,6 +1017,52 @@ func groupCodexModelSupportsImageInput( return candidates > 0 } +// groupCodexModelSupportsSearchTool advertises client-side tool discovery only +// when every account that may serve the model uses the gateway's Responses to +// Chat Completions bridge. Native Responses routes must declare the capability +// in their upstream Codex manifest instead of having it inferred here. +func groupCodexModelSupportsSearchTool( + platform string, + modelID string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) bool { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return false + } + upstreamModel := modelID + if platform == PlatformComposite { + var resolved bool + platform, upstreamModel, resolved = resolveCodexCompositeModelTarget( + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + if !resolved { + return false + } + } + if platform != PlatformOpenAI { + return false + } + + candidates := 0 + for i := range accounts { + account := &accounts[i] + if account.Platform != platform || !account.IsModelSupported(upstreamModel) { + continue + } + candidates++ + if !shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + return false + } + } + return candidates > 0 +} + func resolveCodexCompositeModelTarget( modelID string, accounts []Account, @@ -1939,7 +1997,13 @@ func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Accou imageInputModels[modelID] = true } } - converted, err := buildCodexModelsManifest(modelIDs, imageInputModels, nil, nil) + searchToolModels := make(map[string]bool, len(modelIDs)) + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + for _, modelID := range modelIDs { + searchToolModels[modelID] = true + } + } + converted, err := buildCodexModelsManifest(modelIDs, imageInputModels, searchToolModels, nil, nil) if err != nil { return body } @@ -2141,6 +2205,7 @@ func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll bool, ac } descriptor := newConfiguredCodexModelDescriptor(slug) + descriptor.SupportsSearchTool = shouldForwardOpenAIResponsesViaRawChatCompletions(account) if accountCodexModelSupportsImageInput(account, slug) { descriptor.InputModalities = []string{"text", "image"} }