diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index 00726f5ef7..a5137e2305 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -674,7 +674,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion(t *testing.T) { direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") // Double-conversion bridge - responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) + responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") // Compare key fields @@ -988,7 +988,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion_CacheWriteTokens(t *te direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") - responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) + responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") require.Equal(t, double.Usage.InputTokens, direct.Usage.InputTokens) @@ -1058,7 +1058,7 @@ func TestDirectBridge_NonStreamingMatchesDoubleConversion_EmptyChoices(t *testin direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514") - responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, false, nil) + responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil) double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514") require.Equal(t, AnthropicStopReasonString(double.StopReason), AnthropicStopReasonString(direct.StopReason)) diff --git a/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go b/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go index 87c4f9e0ba..69d633f0a9 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_reasoning_alias_test.go @@ -44,7 +44,7 @@ func TestChatReasoningAlias_AnthropicStreaming(t *testing.T) { func TestChatReasoningAlias_ResponsesSharedPaths(t *testing.T) { var response ChatCompletionsResponse readIssue5302Fixture(t, "nonstream_reasoning.json", &response) - nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, false, nil) + nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, nil, false, nil) require.Len(t, nonStream.Output, 2) require.Equal(t, "reasoning", nonStream.Output[0].Type) require.Equal(t, "fallback reasoning", nonStream.Output[0].Summary[0].Text) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index e825cf36bc..de2b803012 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -159,6 +159,20 @@ func CustomToolNames(tools []ResponsesTool) map[string]bool { return out } +// FunctionToolNames collects explicitly declared top-level function tools. +func FunctionToolNames(tools []ResponsesTool) map[string]bool { + var out map[string]bool + for _, tool := range tools { + if tool.Type == "function" && tool.Name != "" { + if out == nil { + out = make(map[string]bool) + } + out[tool.Name] = true + } + } + return out +} + // NamespacedToolName 记录 namespace 子工具的原始归属(命名空间 + 裸子工具名)。 type NamespacedToolName struct { Namespace string @@ -197,6 +211,43 @@ func NamespaceToolNames(tools []ResponsesTool) map[string]NamespacedToolName { return out } +// customToolCallName restores both the exact downgraded custom-tool name and +// namespace-prefixed aliases that chat models sometimes infer from neighboring +// flattened namespace tools (for example functions__exec beside +// functions__wait). A real declared namespace child always owns its flattened +// name, and ambiguous aliases are left as ordinary function calls. +func customToolCallName(name string, customTools, functionTools map[string]bool, namespaceTools map[string]NamespacedToolName) (string, bool) { + if functionTools[name] { + return "", false + } + if customTools[name] { + return name, true + } + if _, ok := namespaceTools[name]; ok { + return "", false + } + match := "" + for customName := range customTools { + for _, namespaceTool := range namespaceTools { + if flattenNamespaceToolName(namespaceTool.Namespace, customName) != name { + continue + } + if match != "" && match != customName { + return "", false + } + match = customName + } + } + return match, match != "" +} + +func customNameForStreamTool(state *ChatCompletionsToResponsesStreamState, name string) string { + if customName, ok := customToolCallName(name, state.CustomTools, state.FunctionTools, state.NamespaceTools); ok { + return customName + } + return name +} + // HasToolSearchTool 判断 Responses 请求是否声明了 tool_search 服务端工具。chat 桥 // 回程时需据此把模型对代理工具的调用还原为 tool_search_call 项:codex 只在该项类型 // 且 execution=client 时执行 tool search,同名 function_call 会因 payload 不匹配 @@ -918,6 +969,9 @@ func responsesToolsToChatTools(tools []ResponsesTool) ([]ChatTool, error) { topLevel := make(map[string]bool) for _, tool := range tools { if (tool.Type == "function" || tool.Type == "custom") && tool.Name != "" { + if topLevel[tool.Name] { + return nil, fmt.Errorf("duplicate top-level executable tool name %q; this upstream cannot disambiguate duplicate names, rename one of the tools", tool.Name) + } topLevel[tool.Name] = true } } @@ -1148,7 +1202,7 @@ func extractCustomToolCallInput(arguments string) string { // toolSearch 表示客户端声明了 tool_search 工具(见 HasToolSearchTool),代理工具 // 的调用会还原为 tool_search_call 项;namespaceTools 是 namespace 子工具的摊平名 // 映射(见 NamespaceToolNames),命中的调用还原为带 namespace 字段的 function_call 项。 -func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse { +func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model string, customTools, functionTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) *ResponsesResponse { id := "" if resp != nil { id = resp.ID @@ -1173,7 +1227,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str if len(resp.Choices) > 0 { choice := resp.Choices[0] - out.Output = chatMessageToResponsesOutput(choice.Message, customTools, toolSearch, namespaceTools) + out.Output = chatMessageToResponsesOutput(choice.Message, customTools, functionTools, toolSearch, namespaceTools) if choice.FinishReason == "length" { out.Status = "incomplete" out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} @@ -1188,7 +1242,7 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str return out } -func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { +func chatMessageToResponsesOutput(message ChatMessage, customTools, functionTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput { var outputs []ResponsesOutput reasoning := message.reasoningText() if reasoning != "" { @@ -1224,12 +1278,12 @@ func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bo if strings.TrimSpace(arguments) == "" { arguments = "{}" } - if customTools[toolCall.Function.Name] { + if customName, ok := customToolCallName(toolCall.Function.Name, customTools, functionTools, namespaceTools); ok { outputs = append(outputs, ResponsesOutput{ Type: "custom_tool_call", ID: generateItemID(), CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customName, Input: extractCustomToolCallInput(arguments), Status: "completed", }) @@ -1396,6 +1450,9 @@ type ChatCompletionsToResponsesStreamState struct { // 路由回它注册的 custom 工具。 CustomTools map[string]bool + // FunctionTools is the set of explicitly declared top-level function tools. + FunctionTools map[string]bool + // ToolSearchDeclared 表示客户端请求声明了 tool_search 工具(见 // HasToolSearchTool)。命中的代理调用按 tool_search_call 项还原,codex 只按 // 该项类型(且 execution=client)执行 tool search。 @@ -1796,11 +1853,11 @@ func announceChatToolItem( if state.toolAnnounced[idx] { return nil } - if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) { + if !force && stored.Function.Name == "" && (len(state.CustomTools) > 0 || len(state.FunctionTools) > 0 || state.ToolSearchDeclared || len(state.NamespaceTools) > 0) { return nil } state.toolAnnounced[idx] = true - isCustom := state.CustomTools[stored.Function.Name] + customName, isCustom := customToolCallName(stored.Function.Name, state.CustomTools, state.FunctionTools, state.NamespaceTools) isToolSearch := !isCustom && state.ToolSearchDeclared && stored.Function.Name == toolSearchProxyName state.toolIsCustom[idx] = isCustom state.toolIsToolSearch[idx] = isToolSearch @@ -1814,6 +1871,9 @@ func announceChatToolItem( // namespace 子工具的调用仍按 function_call 生命周期下发,但 added/done 项要 // 还原为裸子工具名 + namespace 字段(codex 按 namespace+name 路由)。 itemName, itemNamespace := stored.Function.Name, "" + if isCustom { + itemName = customName + } if ns, ok := state.NamespaceTools[stored.Function.Name]; ok && !isCustom && !isToolSearch { state.toolNamespace[idx] = ns itemName, itemNamespace = ns.Name, ns.Namespace @@ -1883,7 +1943,7 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response OutputIndex: outputIndex, ItemID: itemID, CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customNameForStreamTool(state, toolCall.Function.Name), Input: input, }), chatToResponsesEvent(state, "response.output_item.done", &ResponsesStreamEvent{ @@ -1892,7 +1952,7 @@ func closeChatToolItems(state *ChatCompletionsToResponsesStreamState) []Response Type: "custom_tool_call", ID: itemID, CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customNameForStreamTool(state, toolCall.Function.Name), Input: input, Status: "completed", }, @@ -1983,7 +2043,7 @@ func (state *ChatCompletionsToResponsesStreamState) chatOutput() []ResponsesOutp Type: "custom_tool_call", ID: generateItemID(), CallID: toolCall.ID, - Name: toolCall.Function.Name, + Name: customNameForStreamTool(state, toolCall.Function.Name), Input: extractCustomToolCallInput(arguments), Status: "completed", }) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go index ade13d9826..ebf23fd9f9 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -34,6 +34,60 @@ func TestResponsesToChatCompletionsRequest_CustomToolBecomesFunctionTool(t *test assert.Equal(t, "wait", out.Tools[1].Function.Name) } +func TestResponsesChatBridge_MixedCustomAndNamespaceToolNames(t *testing.T) { + req := &ResponsesRequest{ + Model: "deepseek-test", + Input: json.RawMessage(`"run pwd"`), + Tools: []ResponsesTool{ + {Type: "custom", Name: "exec", Description: "Runs a command"}, + {Type: "namespace", Name: "functions", Tools: []ResponsesTool{ + {Type: "function", Name: "wait", Parameters: json.RawMessage(`{"type":"object"}`)}, + }}, + }, + } + + chatReq, err := ResponsesToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, chatReq.Tools, 2) + assert.Equal(t, "exec", chatReq.Tools[0].Function.Name) + assert.Equal(t, "functions__wait", chatReq.Tools[1].Function.Name) + + customTools := CustomToolNames(req.Tools) + namespaceTools := NamespaceToolNames(req.Tools) + resp := &ChatCompletionsResponse{Choices: []ChatChoice{{Message: ChatMessage{ToolCalls: []ChatToolCall{ + {ID: "call_exact", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input":"pwd"}`}}, + {ID: "call_wait", Function: ChatFunctionCall{Name: "functions__wait", Arguments: `{"cell_id":"1"}`}}, + {ID: "call_alias", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `not-json`}}, + }}}}} + + out := ChatCompletionsResponseToResponses(resp, req.Model, customTools, FunctionToolNames(req.Tools), false, namespaceTools) + require.Len(t, out.Output, 3) + assert.Equal(t, "custom_tool_call", out.Output[0].Type) + assert.Equal(t, "exec", out.Output[0].Name) + assert.Equal(t, "pwd", out.Output[0].Input) + assert.Equal(t, "function_call", out.Output[1].Type) + assert.Equal(t, "functions", out.Output[1].Namespace) + assert.Equal(t, "wait", out.Output[1].Name) + assert.Equal(t, "custom_tool_call", out.Output[2].Type) + assert.Equal(t, "exec", out.Output[2].Name) + assert.Equal(t, "not-json", out.Output[2].Input) +} + +func TestChatCompletionsResponseToResponses_ExplicitFunctionOwnsCustomAliasCollision(t *testing.T) { + resp := &ChatCompletionsResponse{Choices: []ChatChoice{{Message: ChatMessage{ToolCalls: []ChatToolCall{{ + ID: "call_function", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `{"path":"/tmp"}`}, + }}}}}} + + out := ChatCompletionsResponseToResponses(resp, "deepseek-test", + map[string]bool{"exec": true}, map[string]bool{"functions__exec": true}, false, + map[string]NamespacedToolName{"functions__wait": {Namespace: "functions", Name: "wait"}}) + + require.Len(t, out.Output, 1) + assert.Equal(t, "function_call", out.Output[0].Type) + assert.Equal(t, "functions__exec", out.Output[0].Name) + assert.Equal(t, `{"path":"/tmp"}`, out.Output[0].Arguments) +} + func TestResponsesToChatCompletionsRequest_AdditionalToolsItem(t *testing.T) { req := &ResponsesRequest{ Model: "gpt-test", @@ -173,7 +227,7 @@ func TestChatCompletionsResponseToResponses_CustomToolCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, false, nil) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", map[string]bool{"exec": true}, nil, false, nil) require.Len(t, out.Output, 2) assert.Equal(t, "custom_tool_call", out.Output[0].Type) @@ -264,6 +318,68 @@ func TestChatCompletionsChunkToResponsesEvents_CustomToolCallStream(t *testing.T assert.True(t, foundCustom, "response.completed 缺少 custom_tool_call 输出项") } +func TestChatCompletionsChunkToResponsesEvents_MixedCustomNamespaceAliasStream(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("deepseek-test") + state.CustomTools = map[string]bool{"exec": true} + state.NamespaceTools = map[string]NamespacedToolName{ + "functions__wait": {Namespace: "functions", Name: "wait"}, + } + + idx := 0 + chunk := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ToolCalls: []ChatToolCall{{ + Index: &idx, ID: "call_alias", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `not-json`}, + }}}}}} + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + for _, evt := range events { + if evt.Type == "response.output_item.added" && evt.Item != nil && evt.Item.Type == "custom_tool_call" { + assert.Equal(t, "exec", evt.Item.Name) + } + if evt.Type == "response.custom_tool_call_input.done" { + assert.Equal(t, "exec", evt.Name) + assert.Equal(t, "not-json", evt.Input) + } + if evt.Type == "response.output_item.done" && evt.Item != nil && evt.Item.Type == "custom_tool_call" { + assert.Equal(t, "exec", evt.Item.Name) + assert.Equal(t, "not-json", evt.Item.Input) + } + } + + final := events[len(events)-1] + require.Equal(t, "response.completed", final.Type) + require.Len(t, final.Response.Output, 1) + assert.Equal(t, "custom_tool_call", final.Response.Output[0].Type) + assert.Equal(t, "exec", final.Response.Output[0].Name) + assert.Equal(t, "not-json", final.Response.Output[0].Input) +} + +func TestChatCompletionsChunkToResponsesEvents_ExplicitFunctionOwnsCustomAliasCollision(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("deepseek-test") + state.CustomTools = map[string]bool{"exec": true} + state.FunctionTools = map[string]bool{"functions__exec": true} + state.NamespaceTools = map[string]NamespacedToolName{ + "functions__wait": {Namespace: "functions", Name: "wait"}, + } + + idx := 0 + chunk := &ChatCompletionsChunk{Choices: []ChatChunkChoice{{Delta: ChatDelta{ToolCalls: []ChatToolCall{{ + Index: &idx, ID: "call_function", Function: ChatFunctionCall{Name: "functions__exec", Arguments: `{"path":"/tmp"}`}, + }}}}}} + events := ChatCompletionsChunkToResponsesEvents(chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + for _, evt := range events { + assert.NotEqual(t, "response.custom_tool_call_input.done", evt.Type) + } + final := events[len(events)-1] + require.Equal(t, "response.completed", final.Type) + require.Len(t, final.Response.Output, 1) + assert.Equal(t, "function_call", final.Response.Output[0].Type) + assert.Equal(t, "functions__exec", final.Response.Output[0].Name) + assert.Equal(t, `{"path":"/tmp"}`, final.Response.Output[0].Arguments) +} + func TestResponsesToChatCompletionsRequest_ToolSearchToolBecomesProxyFunction(t *testing.T) { req := &ResponsesRequest{ Model: "glm-5.2", @@ -308,7 +424,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchCallOutputItem(t *testing. }}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, true, nil) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, true, nil) require.Len(t, out.Output, 1) item := out.Output[0] @@ -340,7 +456,7 @@ func TestChatCompletionsResponseToResponses_ToolSearchNotDeclaredKeepsFunctionCa } // 客户端未声明 type=tool_search 时,同名普通 function 工具不受影响。 - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, false, nil) require.Len(t, out.Output, 1) assert.Equal(t, "function_call", out.Output[0].Type) } @@ -656,6 +772,23 @@ func TestResponsesToChatCompletionsRequest_RejectsToolSearchNameConflict(t *test assert.Equal(t, "tool_search", out.Tools[0].Function.Name) } +func TestResponsesToChatCompletionsRequest_RejectsDuplicateTopLevelExecutableNames(t *testing.T) { + for _, tools := range [][]ResponsesTool{ + {{Type: "custom", Name: "exec"}, {Type: "function", Name: "exec"}}, + {{Type: "function", Name: "exec"}, {Type: "function", Name: "exec"}}, + {{Type: "custom", Name: "exec"}, {Type: "custom", Name: "exec"}}, + } { + _, err := ResponsesToChatCompletionsRequest(&ResponsesRequest{ + Model: "glm-5.2", + Input: json.RawMessage(`"hi"`), + Tools: tools, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "exec") + assert.Contains(t, err.Error(), "cannot disambiguate") + } +} + // tool_choice 指向被转换丢弃的工具(如 web_search)或不存在的名字时不能原样转发, // chat 上游会因选择项指向未声明工具而 400;字符串形式与指向幸存工具的选择保持转发。 func TestResponsesToChatCompletionsRequest_DropsToolChoiceForDroppedTool(t *testing.T) { @@ -807,7 +940,7 @@ func TestChatCompletionsResponseToResponses_NamespacedToolCallRestored(t *testin "mcp__svc__echo": {Namespace: "mcp__svc", Name: "echo"}, } - out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, false, nsTools) + out := ChatCompletionsResponseToResponses(resp, "glm-5.2", nil, nil, false, nsTools) require.Len(t, out.Output, 2) item := out.Output[0] diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go index 37800bb918..6060d02166 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_test.go @@ -65,7 +65,7 @@ func TestChatCompletionsResponseToResponses_SkipsInvalidFunctionArguments(t *tes }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-v4-flash", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "deepseek-v4-flash", nil, nil, false, nil) require.Equal(t, "incomplete", out.Status) require.Len(t, out.Output, 1) require.Equal(t, "function_call", out.Output[0].Type) diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 7a1584b529..a57bd73f91 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -532,7 +532,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningOnlyFallsBackToMess }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) @@ -566,7 +566,7 @@ func TestChatCompletionsResponseToResponses_DeepSeekReasoningToolCallDoesNotFall }}, } - out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, false, nil) + out := ChatCompletionsResponseToResponses(resp, "deepseek-reasoner", nil, nil, false, nil) require.Len(t, out.Output, 2) require.Equal(t, "reasoning", out.Output[0].Type) diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 4fab9cdb62..639d4a6bb3 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -50,6 +50,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( return nil, fmt.Errorf("resolve responses tools: %w", err) } customTools := apicompat.CustomToolNames(effectiveTools) + functionTools := apicompat.FunctionToolNames(effectiveTools) toolSearch := apicompat.HasToolSearchTool(effectiveTools) namespaceTools := apicompat.NamespaceToolNames(effectiveTools) @@ -120,9 +121,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } if clientStream { - return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } - return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) + return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( @@ -130,6 +131,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( resp *http.Response, originalModel string, customTools map[string]bool, + functionTools map[string]bool, toolSearch bool, namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, @@ -143,7 +145,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( if err != nil { return nil, err } - responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, toolSearch, namespaceTools) + responsesResp := apicompat.ChatCompletionsResponseToResponses(ccResp, originalModel, customTools, functionTools, toolSearch, namespaceTools) s.cacheReasoningItemsFromOutput(responsesResp.Output) if s.responseHeaderFilter != nil { @@ -169,6 +171,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( resp *http.Response, originalModel string, customTools map[string]bool, + functionTools map[string]bool, toolSearch bool, namespaceTools map[string]apicompat.NamespacedToolName, billingModel string, @@ -182,6 +185,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( state := apicompat.NewChatCompletionsToResponsesStreamState(originalModel) state.CustomTools = customTools + state.FunctionTools = functionTools state.ToolSearchDeclared = toolSearch state.NamespaceTools = namespaceTools clientDisconnected := false