diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go index f283ceba9..3e1483145 100644 --- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go +++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go @@ -355,3 +355,49 @@ func TestConvertOpenAIResponsesRequestToAntigravity_AttachesParallelToolImagesTo t.Fatalf("call_b image = %q, want BBB. Output: %s", got["call_b"], out) } } + +func TestConvertOpenAIResponsesRequestToAntigravity_PreservesAdditionalToolsAndToolConfig(t *testing.T) { + inputJSON := `{ + "model": "gemini-3-flash", + "input": [ + { + "type": "additional_tools", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [ + {"type": "custom", "name": "exec", "description": "Execute a command"}, + {"type": "function", "name": "continuity_probe", "description": "Probe", "parameters": {"type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"]}} + ] + } + ] + }, + {"role": "user", "content": [{"type": "input_text", "text": "test"}]} + ], + "tool_choice": { + "type": "function", + "name": "continuity_probe", + "namespace": "functions" + } + }` + + out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false) + if !gjson.ValidBytes(out) { + t.Fatalf("invalid JSON output: %s", out) + } + + decls := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array() + if len(decls) != 2 { + t.Fatalf("expected 2 functionDeclarations in request.tools, got %d; raw: %s", len(decls), out) + } + + mode := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String() + if mode != "ANY" { + t.Fatalf("mode = %q, want ANY", mode) + } + allowed := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String() + if allowed != "functions__continuity_probe" { + t.Fatalf("allowedFunctionNames.0 = %q, want functions__continuity_probe", allowed) + } +} diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go index 478c20fa1..13454a297 100644 --- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go +++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go @@ -85,3 +85,58 @@ func TestConvertAntigravityResponseToOpenAIResponsesNonStream_PreservesOpenAIToo t.Fatalf("output.0.arguments = %q, want JSON arguments with city Tokyo; output=%s", arguments, output) } } + +func TestConvertAntigravityResponseToOpenAIResponses_RestoresAdditionalNamespaceCustomToolCall(t *testing.T) { + originalRequest := []byte(`{ + "model": "gemini-3.5-flash-low", + "input": [{ + "type": "additional_tools", + "tools": [{ + "type": "namespace", + "name": "functions", + "tools": [{"type": "custom", "name": "exec"}] + }] + }] + }`) + rawResponse := []byte(`{ + "response": { + "responseId": "antigravity-custom-response", + "candidates": [{ + "content": { + "parts": [{ + "functionCall": { + "name": "functions__exec", + "args": {"input": "pwd"} + } + }] + }, + "finishReason": "STOP" + }] + } + }`) + + output := ConvertAntigravityResponseToOpenAIResponsesNonStream( + context.Background(), + "gemini-3.5-flash-low", + originalRequest, + nil, + rawResponse, + nil, + ) + + if !gjson.ValidBytes(output) { + t.Fatalf("invalid JSON output: %s", output) + } + if got := gjson.GetBytes(output, "output.0.type").String(); got != "custom_tool_call" { + t.Fatalf("output.0.type = %q, want custom_tool_call; output=%s", got, output) + } + if got := gjson.GetBytes(output, "output.0.name").String(); got != "exec" { + t.Fatalf("output.0.name = %q, want exec", got) + } + if got := gjson.GetBytes(output, "output.0.namespace").String(); got != "functions" { + t.Fatalf("output.0.namespace = %q, want functions", got) + } + if got := gjson.GetBytes(output, "output.0.input").String(); got != "pwd" { + t.Fatalf("output.0.input = %q, want pwd", got) + } +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index 4569ac800..452cf8f4e 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -26,6 +26,21 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte root := gjson.ParseBytes(rawJSON) + // Extract tools and forward map early so request contents and toolDeclarations use the exact same forward map + functionDeclarations, forwardMap, _ := util.BuildGeminiFunctionDeclarations(root) + if len(functionDeclarations) > 0 { + geminiTools := []byte(`[{"functionDeclarations":[]}]`) + geminiTools, _ = sjson.SetRawBytes(geminiTools, "0.functionDeclarations", translatorcommon.JoinRawArray(functionDeclarations)) + out, _ = sjson.SetRawBytes(out, "tools", geminiTools) + } + + // Handle tool_choice if present + if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { + if toolConfig, ok := util.ConvertResponsesToolChoiceToGemini(toolChoice, forwardMap); ok { + out, _ = sjson.SetRawBytes(out, "toolConfig.functionCallingConfig", toolConfig) + } + } + // Extract system instruction from OpenAI "instructions" field. systemParts := make([][]byte, 0, 2) if instructions := root.Get("instructions"); instructions.Exists() { @@ -45,10 +60,15 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte functionNamesByCallID := make(map[string]string) pendingFunctionCallIDs := make([]string, 0) for _, item := range items { - if item.Get("type").String() == "function_call" { + itemType := item.Get("type").String() + if itemType == "function_call" || itemType == "custom_tool_call" { callID := item.Get("call_id").String() if _, exists := functionNamesByCallID[callID]; !exists { - functionNamesByCallID[callID] = item.Get("name").String() + name := item.Get("name").String() + if ns := item.Get("namespace").String(); ns != "" { + name = util.QualifyResponsesNamespaceToolName(ns, name) + } + functionNamesByCallID[callID] = util.MapResponsesToolName(forwardMap, name) } } } @@ -211,23 +231,23 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte contentItems = append(contentItems, geminiContent(effRole, [][]byte{part})) } - case "function_call": + case "function_call", "custom_tool_call": signature := geminiResponsesThoughtSignature if rawSignature := strings.TrimSpace(item.Get("_cpa_reasoning_signature").String()); rawSignature != "" { signature = openAIResponsesGeminiThoughtSignature(rawSignature) } if thoughtText := item.Get("_cpa_reasoning_summary").String(); thoughtText != "" { - contentItems = append(contentItems, buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText, item, signature)) + contentItems = append(contentItems, buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText, item, signature, forwardMap)) } else if !useGeminiNativeReasoningLayout && strings.TrimSpace(item.Get("_cpa_reasoning_signature").String()) != "" { - contentItems = append(contentItems, buildOpenAIResponsesEmptyReasoningFunctionCallModelContent(item, signature)) + contentItems = append(contentItems, buildOpenAIResponsesEmptyReasoningFunctionCallModelContent(item, signature, forwardMap)) } else { - contentItems = append(contentItems, buildOpenAIResponsesFunctionCallModelContent(item, signature)) + contentItems = append(contentItems, buildOpenAIResponsesFunctionCallModelContent(item, signature, forwardMap)) } if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" { pendingFunctionCallIDs = append(pendingFunctionCallIDs, callID) } - case "function_call_output": + case "function_call_output", "custom_tool_call_output": orderedOutputs, consumedIndexes, remainingPending := collectOpenAIResponsesFunctionCallOutputs(normalized, i, pendingFunctionCallIDs) pendingFunctionCallIDs = remainingPending for consumedIndex := range consumedIndexes { @@ -263,8 +283,8 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte if visible, ok := openAIResponsesAssistantVisibleText(next); ok && canBindText { visibleText = visible i++ - } else if next.Get("type").String() == "function_call" && canBindFunction && strings.TrimSpace(next.Get("_cpa_reasoning_signature").String()) == "" && signature != geminiResponsesThoughtSignature { - contentItems = append(contentItems, buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText, next, signature)) + } else if (next.Get("type").String() == "function_call" || next.Get("type").String() == "custom_tool_call") && canBindFunction && strings.TrimSpace(next.Get("_cpa_reasoning_signature").String()) == "" && signature != geminiResponsesThoughtSignature { + contentItems = append(contentItems, buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText, next, signature, forwardMap)) if callID := strings.TrimSpace(next.Get("call_id").String()); callID != "" { pendingFunctionCallIDs = append(pendingFunctionCallIDs, callID) } @@ -290,36 +310,6 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte out, _ = sjson.SetRawBytes(out, "systemInstruction", geminiSystemInstruction(systemParts)) } - // Convert tools to Gemini functionDeclarations format - if tools := root.Get("tools"); tools.Exists() && tools.IsArray() { - var functionDeclarations [][]byte - tools.ForEach(func(_, tool gjson.Result) bool { - if tool.Get("type").String() == "function" { - funcDecl := []byte(`{"name":"","description":"","parametersJsonSchema":{}}`) - - if name := tool.Get("name"); name.Exists() { - funcDecl, _ = sjson.SetBytes(funcDecl, "name", util.SanitizeFunctionName(name.String())) - } - if desc := tool.Get("description"); desc.Exists() { - funcDecl, _ = sjson.SetBytes(funcDecl, "description", desc.String()) - } - if params := tool.Get("parameters"); params.Exists() { - funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(util.CleanJSONSchemaForGemini(params.Raw))) - } - - functionDeclarations = append(functionDeclarations, funcDecl) - } - return true - }) - - // Only add tools if there are function declarations. - if len(functionDeclarations) > 0 { - geminiTools := []byte(`[{"functionDeclarations":[]}]`) - geminiTools, _ = sjson.SetRawBytes(geminiTools, "0.functionDeclarations", translatorcommon.JoinRawArray(functionDeclarations)) - out, _ = sjson.SetRawBytes(out, "tools", geminiTools) - } - } - // Handle generation config from OpenAI format if maxOutputTokens := root.Get("max_output_tokens"); maxOutputTokens.Exists() { genConfig := []byte(`{"maxOutputTokens":0}`) @@ -473,7 +463,7 @@ func isTrailingOpenAIResponsesAssistantPrefill(items []gjson.Result, assistantIn itemType = "message" } switch itemType { - case "reasoning", "function_call", "function_call_output": + case "reasoning", "function_call", "custom_tool_call", "function_call_output", "custom_tool_call_output": return false case "message": if strings.EqualFold(itemRole, "system") || strings.EqualFold(itemRole, "developer") { @@ -533,37 +523,47 @@ func openAIResponsesAssistantVisibleText(item gjson.Result) (string, bool) { return strings.Join(textParts, "\n"), true } +func isOpenAIResponsesToolCall(item gjson.Result) bool { + t := item.Get("type").String() + return t == "function_call" || t == "custom_tool_call" +} + +func isOpenAIResponsesToolOutput(item gjson.Result) bool { + t := item.Get("type").String() + return t == "function_call_output" || t == "custom_tool_call_output" +} + func pairOpenAIResponsesReasoningWithFunctionCalls(items []gjson.Result) []gjson.Result { isDetachedCarrier := isOpenAIResponsesDetachedCarrier postCallSignature := make(map[int]string) postCallCarrier := make(map[int]bool) consumedPostCallCarrier := make(map[int]bool) for groupStart := 0; groupStart < len(items); { - if items[groupStart].Get("type").String() != "function_call" && !isDetachedCarrier(items[groupStart]) { + if !isOpenAIResponsesToolCall(items[groupStart]) && !isDetachedCarrier(items[groupStart]) { groupStart++ continue } groupEnd := groupStart hasFunctionCall := false - for groupEnd < len(items) && (items[groupEnd].Get("type").String() == "function_call" || isDetachedCarrier(items[groupEnd])) { - hasFunctionCall = hasFunctionCall || items[groupEnd].Get("type").String() == "function_call" + for groupEnd < len(items) && (isOpenAIResponsesToolCall(items[groupEnd]) || isDetachedCarrier(items[groupEnd])) { + hasFunctionCall = hasFunctionCall || isOpenAIResponsesToolCall(items[groupEnd]) groupEnd++ } - if !hasFunctionCall || groupEnd >= len(items) || items[groupEnd].Get("type").String() != "function_call_output" { + if !hasFunctionCall || groupEnd >= len(items) || !isOpenAIResponsesToolOutput(items[groupEnd]) { groupStart = groupEnd continue } outputEnd := groupEnd - for outputEnd < len(items) && items[outputEnd].Get("type").String() == "function_call_output" { + for outputEnd < len(items) && isOpenAIResponsesToolOutput(items[outputEnd]) { outputEnd++ } // A run beginning with a carrier uses leading-carrier semantics. A run // beginning with a call uses post-call semantics. This preserves both // carrier,call,carrier,call and call,carrier,call,carrier histories. - if items[groupStart].Get("type").String() == "function_call" { + if isOpenAIResponsesToolCall(items[groupStart]) { for callIndex := groupStart; callIndex < groupEnd; callIndex++ { item := items[callIndex] - if item.Get("type").String() != "function_call" || strings.TrimSpace(item.Get("_cpa_reasoning_signature").String()) != "" || callIndex+1 >= groupEnd || !isDetachedCarrier(items[callIndex+1]) { + if !isOpenAIResponsesToolCall(item) || strings.TrimSpace(item.Get("_cpa_reasoning_signature").String()) != "" || callIndex+1 >= groupEnd || !isDetachedCarrier(items[callIndex+1]) { continue } carrierDirection := geminiResponsesCarrierDirection(items[callIndex+1]) @@ -607,7 +607,7 @@ func pairOpenAIResponsesReasoningWithFunctionCalls(items []gjson.Result) []gjson carrierDirection := geminiResponsesCarrierDirection(item) carrierTarget := geminiResponsesCarrierTarget(item) canBindFollowingCall := carrierDirection == "" || (carrierDirection == geminiResponsesCarrierNext && (carrierTarget == geminiResponsesCarrierFunction || carrierTarget == geminiResponsesCarrierAny)) - if item.Get("type").String() == "reasoning" && !postCallCarrier[index] && canBindFollowingCall && !strings.Contains(item.Get("id").String(), "_detached_after_") && index+1 < len(items) && items[index+1].Get("type").String() == "function_call" { + if item.Get("type").String() == "reasoning" && !postCallCarrier[index] && canBindFollowingCall && !strings.Contains(item.Get("id").String(), "_detached_after_") && index+1 < len(items) && isOpenAIResponsesToolCall(items[index+1]) { rawSignature := strings.TrimSpace(item.Get("encrypted_content").String()) if rawSignature != "" { functionCall := []byte(items[index+1].Raw) @@ -655,7 +655,7 @@ func reorderOpenAIResponsesDetachedReasoning(items []gjson.Result) []gjson.Resul alreadyPairedFunction = priorBindsFollowing && (priorTarget == geminiResponsesCarrierFunction || priorTarget == geminiResponsesCarrierAny) } bindPreviousMessage := direction == geminiResponsesCarrierPrevious && (targetKind == geminiResponsesCarrierText || targetKind == geminiResponsesCarrierAny) && isAssistantMessage && !alreadyPairedText - bindPreviousFunction := direction == geminiResponsesCarrierPrevious && (targetKind == geminiResponsesCarrierFunction || targetKind == geminiResponsesCarrierAny) && previousType == "function_call" && strings.TrimSpace(previous.Get("_cpa_reasoning_signature").String()) == "" && !alreadyPairedFunction + bindPreviousFunction := direction == geminiResponsesCarrierPrevious && (targetKind == geminiResponsesCarrierFunction || targetKind == geminiResponsesCarrierAny) && (previousType == "function_call" || previousType == "custom_tool_call") && strings.TrimSpace(previous.Get("_cpa_reasoning_signature").String()) == "" && !alreadyPairedFunction if bindPreviousMessage || bindPreviousFunction { movedItemJSON, _ := sjson.SetBytes([]byte(item.Raw), geminiResponsesCarrierDirectionField, geminiResponsesCarrierNext) reordered[len(reordered)-1] = gjson.ParseBytes(movedItemJSON) @@ -675,7 +675,7 @@ func reorderOpenAIResponsesDetachedReasoning(items []gjson.Result) []gjson.Resul prior := reordered[len(reordered)-2] alreadyPaired = isOpenAIResponsesDetachedCarrier(prior) && strings.Contains(prior.Get("id").String(), "_detached_after_") } - if !alreadyPaired && (isAssistantMessage || (markedDetached && previousType == "function_call" && strings.TrimSpace(previous.Get("_cpa_reasoning_signature").String()) == "")) { + if !alreadyPaired && (isAssistantMessage || (markedDetached && (previousType == "function_call" || previousType == "custom_tool_call") && strings.TrimSpace(previous.Get("_cpa_reasoning_signature").String()) == "")) { reordered[len(reordered)-1] = item reordered = append(reordered, previous) continue @@ -686,16 +686,38 @@ func reorderOpenAIResponsesDetachedReasoning(items []gjson.Result) []gjson.Resul return reordered } -func buildOpenAIResponsesFunctionCallPart(item gjson.Result, signature string) []byte { - name := util.SanitizeFunctionName(item.Get("name").String()) - arguments := item.Get("arguments").String() +func buildOpenAIResponsesFunctionCallPart(item gjson.Result, signature string, forwardMap map[string]string) []byte { + name := item.Get("name").String() + if ns := item.Get("namespace").String(); ns != "" { + name = util.QualifyResponsesNamespaceToolName(ns, name) + } + name = util.MapResponsesToolName(forwardMap, name) functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`) functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", name) functionCall, _ = sjson.SetBytes(functionCall, "thoughtSignature", signature) functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", item.Get("call_id").String()) - if arguments != "" { - argsResult := gjson.Parse(arguments) - functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsResult.Raw)) + + if item.Get("type").String() == "custom_tool_call" { + inputVal := item.Get("input") + if inputVal.Exists() { + if inputVal.Type == gjson.String { + functionCall, _ = sjson.SetBytes(functionCall, "functionCall.args.input", inputVal.String()) + } else { + functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args.input", []byte(inputVal.Raw)) + } + } else { + functionCall, _ = sjson.SetBytes(functionCall, "functionCall.args.input", "") + } + } else { + arguments := item.Get("arguments").String() + if arguments != "" { + argsResult := gjson.Parse(arguments) + if argsResult.IsObject() || argsResult.IsArray() { + functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsResult.Raw)) + } else { + functionCall, _ = sjson.SetBytes(functionCall, "functionCall.args.arguments", arguments) + } + } } return functionCall } @@ -887,7 +909,7 @@ func buildOpenAIResponsesFunctionResponseParts(item gjson.Result, functionNamesB func collectOpenAIResponsesFunctionCallOutputs(items []gjson.Result, start int, pendingCallIDs []string) ([]gjson.Result, map[int]bool, []string) { end := start + 1 - for end < len(items) && items[end].Get("type").String() == "function_call_output" { + for end < len(items) && (items[end].Get("type").String() == "function_call_output" || items[end].Get("type").String() == "custom_tool_call_output") { end++ } outputs := items[start:end] @@ -926,29 +948,29 @@ func orderOpenAIResponsesFunctionCallOutputs(outputs []gjson.Result, pendingCall return ordered, remainingPending } -func buildOpenAIResponsesFunctionCallModelContent(item gjson.Result, signature string) []byte { +func buildOpenAIResponsesFunctionCallModelContent(item gjson.Result, signature string, forwardMap map[string]string) []byte { modelContent := []byte(`{"role":"model","parts":[]}`) - modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray([][]byte{buildOpenAIResponsesFunctionCallPart(item, signature)})) + modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray([][]byte{buildOpenAIResponsesFunctionCallPart(item, signature, forwardMap)})) return modelContent } -func buildOpenAIResponsesEmptyReasoningFunctionCallModelContent(item gjson.Result, signature string) []byte { +func buildOpenAIResponsesEmptyReasoningFunctionCallModelContent(item gjson.Result, signature string, forwardMap map[string]string) []byte { thought := []byte(`{"text":"","thought":true,"thoughtSignature":""}`) thought, _ = sjson.SetBytes(thought, "thoughtSignature", signature) - parts := [][]byte{thought, buildOpenAIResponsesFunctionCallPart(item, signature)} + parts := [][]byte{thought, buildOpenAIResponsesFunctionCallPart(item, signature, forwardMap)} modelContent := []byte(`{"role":"model","parts":[]}`) modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray(parts)) return modelContent } -func buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText string, item gjson.Result, signature string) []byte { +func buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText string, item gjson.Result, signature string, forwardMap map[string]string) []byte { parts := make([][]byte, 0, 2) if thoughtText != "" { thought := []byte(`{"text":"","thought":true}`) thought, _ = sjson.SetBytes(thought, "text", thoughtText) parts = append(parts, thought) } - parts = append(parts, buildOpenAIResponsesFunctionCallPart(item, signature)) + parts = append(parts, buildOpenAIResponsesFunctionCallPart(item, signature, forwardMap)) modelContent := []byte(`{"role":"model","parts":[]}`) modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray(parts)) return modelContent diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go index 43a5c7a26..a6066fd42 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go @@ -1357,3 +1357,205 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputVariations(t *t } }) } + +func TestConvertOpenAIResponsesRequestToGemini_AdditionalToolsNamespaceAndCustom(t *testing.T) { + inputJSON := `{ + "model": "gemini-2.5-flash", + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [ + { + "type": "custom", + "name": "exec", + "description": "Execute a command" + }, + { + "type": "function", + "name": "continuity_probe", + "description": "Return a continuity probe", + "parameters": { + "type": "object", + "properties": { + "value": {"type": "string"} + }, + "required": ["value"] + } + } + ] + } + ] + }, + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Run probe" + } + ] + } + ], + "tool_choice": { + "type": "function", + "name": "continuity_probe", + "namespace": "functions" + } + }` + + output := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false) + decls := gjson.GetBytes(output, "tools.0.functionDeclarations").Array() + if len(decls) != 2 { + t.Fatalf("expected 2 functionDeclarations, got %d; raw: %s", len(decls), output) + } + + execDecl := decls[0] + if got := execDecl.Get("name").String(); got != "functions__exec" { + t.Fatalf("decl 0 name = %q, want functions__exec", got) + } + if got := execDecl.Get("parametersJsonSchema.properties.input.type").String(); got != "string" { + t.Fatalf("decl 0 custom input schema missing: %s", execDecl.Raw) + } + + probeDecl := decls[1] + if got := probeDecl.Get("name").String(); got != "functions__continuity_probe" { + t.Fatalf("decl 1 name = %q, want functions__continuity_probe", got) + } + + mode := gjson.GetBytes(output, "toolConfig.functionCallingConfig.mode").String() + if mode != "ANY" { + t.Fatalf("toolConfig mode = %q, want ANY", mode) + } + allowed := gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames.0").String() + if allowed != "functions__continuity_probe" { + t.Fatalf("allowedFunctionNames = %q, want functions__continuity_probe", allowed) + } +} + +func TestConvertOpenAIResponsesRequestToGemini_ReplaysCustomToolCallAndOutput(t *testing.T) { + inputJSON := `{ + "model": "gemini-2.5-flash", + "input": [ + { + "type": "additional_tools", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [ + {"type": "custom", "name": "exec"} + ] + } + ] + }, + { + "type": "custom_tool_call", + "call_id": "call_1", + "name": "exec", + "namespace": "functions", + "input": "pwd" + }, + { + "type": "custom_tool_call_output", + "call_id": "call_1", + "output": "/workspace" + } + ] + }` + + output := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false) + contents := gjson.GetBytes(output, "contents").Array() + if len(contents) < 2 { + t.Fatalf("expected at least 2 contents, got %d; raw: %s", len(contents), output) + } + + callPart := contents[0].Get("parts.0.functionCall") + if !callPart.Exists() { + t.Fatalf("missing functionCall in content 0: %s", contents[0].Raw) + } + if got := callPart.Get("name").String(); got != "functions__exec" { + t.Fatalf("functionCall name = %q, want functions__exec", got) + } + if got := callPart.Get("args.input").String(); got != "pwd" { + t.Fatalf("functionCall args.input = %q, want pwd", got) + } + + respPart := contents[1].Get("parts.0.functionResponse") + if !respPart.Exists() { + t.Fatalf("missing functionResponse in content 1: %s", contents[1].Raw) + } + if got := respPart.Get("name").String(); got != "functions__exec" { + t.Fatalf("functionResponse name = %q, want functions__exec", got) + } +} + +func TestConvertOpenAIResponsesRequestToGemini_TwoTurnCustomToolRoundtripWithReasoning(t *testing.T) { + // Turn 2 request: includes reasoning carrier before custom_tool_call, then custom_tool_call_output + inputJSON := `{ + "model": "gemini-3.6-flash-high", + "input": [ + { + "type": "additional_tools", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [ + {"type": "custom", "name": "exec"} + ] + } + ] + }, + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Run pwd"}]}, + {"type": "reasoning", "encrypted_content": "` + testResponsesGeminiThoughtSignature + `", "summary": [{"type": "summary_text", "text": "executing pwd"}]}, + { + "type": "custom_tool_call", + "call_id": "call_1", + "name": "exec", + "namespace": "functions", + "input": "pwd" + }, + { + "type": "custom_tool_call_output", + "call_id": "call_1", + "output": "/workspace" + } + ] + }` + + output := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) + contents := gjson.GetBytes(output, "contents").Array() + if len(contents) != 3 { + t.Fatalf("expected 3 contents (user, model, user), got %d; raw: %s", len(contents), output) + } + + modelParts := contents[1].Get("parts").Array() + if len(modelParts) != 2 { + t.Fatalf("expected 2 parts in model content (thought + functionCall), got %d; raw: %s", len(modelParts), contents[1].Raw) + } + if !modelParts[0].Get("thought").Bool() || modelParts[0].Get("text").String() != "executing pwd" { + t.Fatalf("expected thought part with 'executing pwd', got: %s", modelParts[0].Raw) + } + if modelParts[1].Get("functionCall.name").String() != "functions__exec" { + t.Fatalf("expected functionCall name 'functions__exec', got: %s", modelParts[1].Raw) + } + if modelParts[1].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature { + t.Fatalf("expected thoughtSignature on functionCall, got: %s", modelParts[1].Raw) + } + + userRespParts := contents[2].Get("parts").Array() + if len(userRespParts) != 1 { + t.Fatalf("expected 1 part in user tool response, got %d; raw: %s", len(userRespParts), contents[2].Raw) + } + if userRespParts[0].Get("functionResponse.name").String() != "functions__exec" { + t.Fatalf("expected functionResponse name 'functions__exec', got: %s", userRespParts[0].Raw) + } + if userRespParts[0].Get("functionResponse.response.result").String() != "/workspace" { + t.Fatalf("expected functionResponse result '/workspace', got: %s", userRespParts[0].Raw) + } +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go index c974810a3..7d89393d7 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go @@ -65,10 +65,14 @@ type geminiToResponsesState struct { // function call aggregation (keyed by output_index) NextIndex int FuncArgsBuf map[int]*strings.Builder + FuncInputBuf map[int]string + FuncCustom map[int]bool FuncNames map[int]string + FuncNamespaces map[int]string FuncCallIDs map[int]string FuncDone map[int]bool SanitizedNameMap map[string]string + ToolIdentityMap map[string]util.ResponsesToolIdentity } // responseIDCounter provides a process-wide unique counter for synthesized response identifiers. @@ -116,10 +120,14 @@ func emitEvent(event string, payload []byte) []byte { // ConvertGeminiResponseToOpenAIResponses converts Gemini SSE chunks into OpenAI Responses SSE events. func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON) if *param == nil { *param = &geminiToResponsesState{ FuncArgsBuf: make(map[int]*strings.Builder), + FuncInputBuf: make(map[int]string), + FuncCustom: make(map[int]bool), FuncNames: make(map[int]string), + FuncNamespaces: make(map[int]string), FuncCallIDs: make(map[int]string), FuncDone: make(map[int]bool), DetachedReasoning: make(map[int]geminiDetachedReasoningItem), @@ -127,15 +135,25 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, CompletedReasoning: make(map[int]geminiCompletedReasoningItem), SeenReasoningSignatures: make(map[string]bool), SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), + ToolIdentityMap: util.ResponsesToolReverseIdentityMap(reqJSON), } } st := (*param).(*geminiToResponsesState) if st.FuncArgsBuf == nil { st.FuncArgsBuf = make(map[int]*strings.Builder) } + if st.FuncInputBuf == nil { + st.FuncInputBuf = make(map[int]string) + } + if st.FuncCustom == nil { + st.FuncCustom = make(map[int]bool) + } if st.FuncNames == nil { st.FuncNames = make(map[int]string) } + if st.FuncNamespaces == nil { + st.FuncNamespaces = make(map[int]string) + } if st.FuncCallIDs == nil { st.FuncCallIDs = make(map[int]string) } @@ -157,6 +175,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, if st.SanitizedNameMap == nil { st.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) } + if st.ToolIdentityMap == nil { + st.ToolIdentityMap = util.ResponsesToolReverseIdentityMap(reqJSON) + } if bytes.HasPrefix(rawJSON, []byte("data:")) { rawJSON = bytes.TrimSpace(rawJSON[5:]) @@ -571,7 +592,17 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, finalizeReasoning() finalizeMessage() st.LastSemanticKind = geminiResponsesCarrierFunction - name := util.RestoreSanitizedToolName(st.SanitizedNameMap, fc.Get("name").String()) + + rawName := fc.Get("name").String() + identity, hasIdentity := st.ToolIdentityMap[rawName] + if !hasIdentity { + restored := util.RestoreSanitizedToolName(st.SanitizedNameMap, rawName) + identity = util.ResponsesToolIdentity{Name: restored} + } + name := identity.Name + namespace := identity.Namespace + isCustom := identity.Custom + idx := st.NextIndex st.NextIndex++ // Ensure buffers @@ -582,6 +613,8 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, st.FuncCallIDs[idx] = fmt.Sprintf("call_%d_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1)) } st.FuncNames[idx] = name + st.FuncNamespaces[idx] = namespace + st.FuncCustom[idx] = isCustom argsJSON := "{}" if args := fc.Get("args"); args.Exists() { @@ -591,45 +624,80 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, st.FuncArgsBuf[idx].WriteString(argsJSON) } - // Emit item.added for function call - item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`) - item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) - item, _ = sjson.SetBytes(item, "output_index", idx) - item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) - item, _ = sjson.SetBytes(item, "item.call_id", st.FuncCallIDs[idx]) - item, _ = sjson.SetBytes(item, "item.name", name) - out = append(out, emitEvent("response.output_item.added", item)) + if isCustom { + inputStr := util.UnwrapResponsesCustomToolInput(argsJSON) + st.FuncInputBuf[idx] = inputStr - // Emit arguments delta (full args in one chunk). - // When Gemini omits args, emit "{}" to keep Responses streaming event order consistent. - if argsJSON != "" { - ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) - ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq()) - ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) - ad, _ = sjson.SetBytes(ad, "output_index", idx) - ad, _ = sjson.SetBytes(ad, "delta", argsJSON) - out = append(out, emitEvent("response.function_call_arguments.delta", ad)) - } + // Emit item.added for custom tool call + item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`) + item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) + item, _ = sjson.SetBytes(item, "output_index", idx) + item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx])) + item, _ = sjson.SetBytes(item, "item.call_id", st.FuncCallIDs[idx]) + item = translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, "item") + out = append(out, emitEvent("response.output_item.added", item)) - // Gemini emits the full function call payload at once, so we can finalize it immediately. - if !st.FuncDone[idx] { - fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) - fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) - fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) - fcDone, _ = sjson.SetBytes(fcDone, "output_index", idx) - fcDone, _ = sjson.SetBytes(fcDone, "arguments", argsJSON) - out = append(out, emitEvent("response.function_call_arguments.done", fcDone)) + // Emit custom tool call input.done + if !st.FuncDone[idx] { + inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) + inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) + inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx])) + inputDone, _ = sjson.SetBytes(inputDone, "output_index", idx) + inputDone, _ = sjson.SetBytes(inputDone, "input", inputStr) + out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone)) - itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`) - itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) - itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx) - itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) - itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", argsJSON) - itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx]) - itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[idx]) - out = append(out, emitEvent("response.output_item.done", itemDone)) + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx) + itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx])) + itemDone, _ = sjson.SetBytes(itemDone, "item.input", inputStr) + itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx]) + itemDone = translatorcommon.SetResponsesToolCallIdentity(itemDone, name, namespace, "item") + out = append(out, emitEvent("response.output_item.done", itemDone)) - st.FuncDone[idx] = true + st.FuncDone[idx] = true + } + } else { + // Emit item.added for function call + item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`) + item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) + item, _ = sjson.SetBytes(item, "output_index", idx) + item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) + item, _ = sjson.SetBytes(item, "item.call_id", st.FuncCallIDs[idx]) + item = translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, "item") + out = append(out, emitEvent("response.output_item.added", item)) + + // Emit arguments delta (full args in one chunk). + // When Gemini omits args, emit "{}" to keep Responses streaming event order consistent. + if argsJSON != "" { + ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) + ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq()) + ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) + ad, _ = sjson.SetBytes(ad, "output_index", idx) + ad, _ = sjson.SetBytes(ad, "delta", argsJSON) + out = append(out, emitEvent("response.function_call_arguments.delta", ad)) + } + + // Gemini emits the full function call payload at once, so we can finalize it immediately. + if !st.FuncDone[idx] { + fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) + fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) + fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) + fcDone, _ = sjson.SetBytes(fcDone, "output_index", idx) + fcDone, _ = sjson.SetBytes(fcDone, "arguments", argsJSON) + out = append(out, emitEvent("response.function_call_arguments.done", fcDone)) + + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx) + itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) + itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", argsJSON) + itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx]) + itemDone = translatorcommon.SetResponsesToolCallIdentity(itemDone, name, namespace, "item") + out = append(out, emitEvent("response.output_item.done", itemDone)) + + st.FuncDone[idx] = true + } } return true @@ -667,26 +735,44 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, if st.FuncDone[idx] { continue } - args := "{}" - if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 { - args = b.String() + if st.FuncCustom[idx] { + inputStr := st.FuncInputBuf[idx] + inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) + inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) + inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx])) + inputDone, _ = sjson.SetBytes(inputDone, "output_index", idx) + inputDone, _ = sjson.SetBytes(inputDone, "input", inputStr) + out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone)) + + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx) + itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx])) + itemDone, _ = sjson.SetBytes(itemDone, "item.input", inputStr) + itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx]) + itemDone = translatorcommon.SetResponsesToolCallIdentity(itemDone, st.FuncNames[idx], st.FuncNamespaces[idx], "item") + out = append(out, emitEvent("response.output_item.done", itemDone)) + } else { + args := "{}" + if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 { + args = b.String() + } + fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) + fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) + fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) + fcDone, _ = sjson.SetBytes(fcDone, "output_index", idx) + fcDone, _ = sjson.SetBytes(fcDone, "arguments", args) + out = append(out, emitEvent("response.function_call_arguments.done", fcDone)) + + itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`) + itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) + itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx) + itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) + itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args) + itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx]) + itemDone = translatorcommon.SetResponsesToolCallIdentity(itemDone, st.FuncNames[idx], st.FuncNamespaces[idx], "item") + out = append(out, emitEvent("response.output_item.done", itemDone)) } - fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`) - fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq()) - fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) - fcDone, _ = sjson.SetBytes(fcDone, "output_index", idx) - fcDone, _ = sjson.SetBytes(fcDone, "arguments", args) - out = append(out, emitEvent("response.function_call_arguments.done", fcDone)) - - itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`) - itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) - itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx) - itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) - itemDone, _ = sjson.SetBytes(itemDone, "item.arguments", args) - itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx]) - itemDone, _ = sjson.SetBytes(itemDone, "item.name", st.FuncNames[idx]) - out = append(out, emitEvent("response.output_item.done", itemDone)) - st.FuncDone[idx] = true } } @@ -790,16 +876,26 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, } if callID, ok := st.FuncCallIDs[idx]; ok && callID != "" { - args := "{}" - if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 { - args = b.String() + if st.FuncCustom[idx] { + inputStr := st.FuncInputBuf[idx] + item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) + item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID)) + item, _ = sjson.SetBytes(item, "input", inputStr) + item, _ = sjson.SetBytes(item, "call_id", callID) + item = translatorcommon.SetResponsesToolCallIdentity(item, st.FuncNames[idx], st.FuncNamespaces[idx], "") + outputs = append(outputs, item) + } else { + args := "{}" + if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 { + args = b.String() + } + item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) + item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID)) + item, _ = sjson.SetBytes(item, "arguments", args) + item, _ = sjson.SetBytes(item, "call_id", callID) + item = translatorcommon.SetResponsesToolCallIdentity(item, st.FuncNames[idx], st.FuncNamespaces[idx], "") + outputs = append(outputs, item) } - item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) - item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID)) - item, _ = sjson.SetBytes(item, "arguments", args) - item, _ = sjson.SetBytes(item, "call_id", callID) - item, _ = sjson.SetBytes(item, "name", st.FuncNames[idx]) - outputs = append(outputs, item) } } if len(outputs) > 0 { @@ -842,7 +938,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { root := gjson.ParseBytes(rawJSON) root = unwrapGeminiResponseRoot(root) + reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON) sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON) + toolIdentityMap := util.ResponsesToolReverseIdentityMap(reqJSON) // Base response scaffold resp := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null}`) @@ -1071,18 +1169,38 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string } flushReasoningOutput() flushMessageOutput() - name := util.RestoreSanitizedToolName(sanitizedNameMap, fc.Get("name").String()) + + rawName := fc.Get("name").String() + identity, hasIdentity := toolIdentityMap[rawName] + if !hasIdentity { + restored := util.RestoreSanitizedToolName(sanitizedNameMap, rawName) + identity = util.ResponsesToolIdentity{Name: restored} + } + name := identity.Name + namespace := identity.Namespace + isCustom := identity.Custom + args := fc.Get("args") - callID := fmt.Sprintf("call_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1)) - itemJSON := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) - itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("fc_%s", callID)) - itemJSON, _ = sjson.SetBytes(itemJSON, "call_id", callID) - itemJSON, _ = sjson.SetBytes(itemJSON, "name", name) argsStr := "" if args.Exists() { argsStr = args.Raw } - itemJSON, _ = sjson.SetBytes(itemJSON, "arguments", argsStr) + callID := fmt.Sprintf("call_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1)) + var itemJSON []byte + if isCustom { + inputStr := util.UnwrapResponsesCustomToolInput(argsStr) + itemJSON = []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) + itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("ctc_%s", callID)) + itemJSON, _ = sjson.SetBytes(itemJSON, "call_id", callID) + itemJSON, _ = sjson.SetBytes(itemJSON, "input", inputStr) + itemJSON = translatorcommon.SetResponsesToolCallIdentity(itemJSON, name, namespace, "") + } else { + itemJSON = []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`) + itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("fc_%s", callID)) + itemJSON, _ = sjson.SetBytes(itemJSON, "call_id", callID) + itemJSON, _ = sjson.SetBytes(itemJSON, "arguments", argsStr) + itemJSON = translatorcommon.SetResponsesToolCallIdentity(itemJSON, name, namespace, "") + } functionIndex := len(functionOutputs) functionOutputs = append(functionOutputs, nonStreamFunctionOutput{item: itemJSON, signature: signature}) outputOrder = append(outputOrder, nonStreamOutputOrder{kind: "function", index: functionIndex}) diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go index ff46a604e..056823d22 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go @@ -1319,3 +1319,185 @@ func TestConvertGeminiResponseToOpenAIResponses_ResponseOutputOrdering(t *testin t.Fatalf("expected response.completed after message added: msgAdded=%d completed=%d", posMsgAdded, posCompleted) } } + +func TestConvertGeminiResponseToOpenAIResponses_RestoresAdditionalNamespaceCustomToolCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"gemini-2.5-flash", + "input":[{"type":"additional_tools","role":"developer","tools":[ + {"type":"namespace","name":"functions","tools":[{"type":"custom","name":"exec"}]} + ]}] + }`) + chunks := [][]byte{ + []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"functions__exec","args":{"input":"pwd"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-2.5-flash","responseId":"resp_custom_stream"}`), + } + + var param any + var added, inputDone, done, completed gjson.Result + functionEvents := 0 + for _, chunk := range chunks { + for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", originalRequest, nil, chunk, ¶m) { + event, data := parseSSEEvent(t, output) + switch event { + case "response.output_item.added": + if data.Get("item.type").String() == "custom_tool_call" { + added = data + } + case "response.custom_tool_call_input.done": + inputDone = data + case "response.output_item.done": + if data.Get("item.type").String() == "custom_tool_call" { + done = data + } + case "response.function_call_arguments.delta", "response.function_call_arguments.done": + functionEvents++ + case "response.completed": + completed = data + } + } + } + + if !added.Exists() || !inputDone.Exists() || !done.Exists() || !completed.Exists() { + t.Fatalf("missing custom tool lifecycle events: added=%v input_done=%v done=%v completed=%v", added.Exists(), inputDone.Exists(), done.Exists(), completed.Exists()) + } + if functionEvents != 0 { + t.Fatalf("function call events = %d, want 0", functionEvents) + } + for _, test := range []struct { + label string + item gjson.Result + }{ + {label: "added", item: added.Get("item")}, + {label: "done", item: done.Get("item")}, + {label: "completed", item: completed.Get("response.output.0")}, + } { + if got := test.item.Get("name").String(); got != "exec" { + t.Fatalf("%s name = %q, want exec", test.label, got) + } + if got := test.item.Get("namespace").String(); got != "functions" { + t.Fatalf("%s namespace = %q, want functions", test.label, got) + } + } + if got := inputDone.Get("input").String(); got != "pwd" { + t.Fatalf("custom input.done input = %q, want pwd", got) + } + if got := done.Get("item.input").String(); got != "pwd" { + t.Fatalf("done input = %q, want pwd", got) + } + if got := completed.Get("response.output.0.type").String(); got != "custom_tool_call" { + t.Fatalf("completed output type = %q, want custom_tool_call", got) + } + if got := completed.Get("response.output.0.input").String(); got != "pwd" { + t.Fatalf("completed input = %q, want pwd", got) + } +} + +func TestConvertGeminiResponseToOpenAIResponsesNonStream_RestoresAdditionalNamespaceCustomToolCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"gemini-2.5-flash", + "input":[{"type":"additional_tools","role":"developer","tools":[ + {"type":"namespace","name":"functions","tools":[{"type":"custom","name":"exec"}]} + ]}] + }`) + raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"functions__exec","args":{"input":"pwd"}}}]}}],"modelVersion":"gemini-2.5-flash","responseId":"resp_custom_nonstream"}`) + + out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-2.5-flash", originalRequest, nil, raw, nil) + root := gjson.ParseBytes(out) + + if got := root.Get("output.0.type").String(); got != "custom_tool_call" { + t.Fatalf("non-stream output type = %q, want custom_tool_call; raw: %s", got, out) + } + if got := root.Get("output.0.name").String(); got != "exec" { + t.Fatalf("non-stream output name = %q, want exec", got) + } + if got := root.Get("output.0.namespace").String(); got != "functions" { + t.Fatalf("non-stream output namespace = %q, want functions", got) + } + if got := root.Get("output.0.input").String(); got != "pwd" { + t.Fatalf("non-stream output input = %q, want pwd", got) + } +} + +func TestConvertGeminiResponseToOpenAIResponses_RestoresAdditionalNamespaceFunctionCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"gemini-2.5-flash", + "input":[{"type":"additional_tools","role":"developer","tools":[ + {"type":"namespace","name":"functions","tools":[{"type":"function","name":"continuity_probe","parameters":{"type":"object","properties":{"value":{"type":"string"}}}}]}] + }] + }`) + chunks := [][]byte{ + []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"functions__continuity_probe","args":{"value":"PROBE"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-2.5-flash","responseId":"resp_func_stream"}`), + } + + var param any + var added, argDone, done, completed gjson.Result + for _, chunk := range chunks { + for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", originalRequest, nil, chunk, ¶m) { + event, data := parseSSEEvent(t, output) + switch event { + case "response.output_item.added": + if data.Get("item.type").String() == "function_call" { + added = data + } + case "response.function_call_arguments.done": + argDone = data + case "response.output_item.done": + if data.Get("item.type").String() == "function_call" { + done = data + } + case "response.completed": + completed = data + } + } + } + + if !added.Exists() || !argDone.Exists() || !done.Exists() || !completed.Exists() { + t.Fatalf("missing function tool lifecycle events: added=%v arg_done=%v done=%v completed=%v", added.Exists(), argDone.Exists(), done.Exists(), completed.Exists()) + } + for _, test := range []struct { + label string + item gjson.Result + }{ + {label: "added", item: added.Get("item")}, + {label: "done", item: done.Get("item")}, + {label: "completed", item: completed.Get("response.output.0")}, + } { + if got := test.item.Get("name").String(); got != "continuity_probe" { + t.Fatalf("%s name = %q, want continuity_probe", test.label, got) + } + if got := test.item.Get("namespace").String(); got != "functions" { + t.Fatalf("%s namespace = %q, want functions", test.label, got) + } + } + if got := completed.Get("response.output.0.type").String(); got != "function_call" { + t.Fatalf("completed output type = %q, want function_call", got) + } + if got := gjson.Get(completed.Get("response.output.0.arguments").String(), "value").String(); got != "PROBE" { + t.Fatalf("completed value = %q, want PROBE", got) + } +} + +func TestConvertGeminiResponseToOpenAIResponsesNonStream_RestoresAdditionalNamespaceFunctionCall(t *testing.T) { + originalRequest := []byte(`{ + "model":"gemini-2.5-flash", + "input":[{"type":"additional_tools","role":"developer","tools":[ + {"type":"namespace","name":"functions","tools":[{"type":"function","name":"continuity_probe","parameters":{"type":"object","properties":{"value":{"type":"string"}}}}]}] + }] + }`) + raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"functions__continuity_probe","args":{"value":"PROBE"}}}]}}],"modelVersion":"gemini-2.5-flash","responseId":"resp_func_nonstream"}`) + + out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-2.5-flash", originalRequest, nil, raw, nil) + root := gjson.ParseBytes(out) + + if got := root.Get("output.0.type").String(); got != "function_call" { + t.Fatalf("non-stream output type = %q, want function_call; raw: %s", got, out) + } + if got := root.Get("output.0.name").String(); got != "continuity_probe" { + t.Fatalf("non-stream output name = %q, want continuity_probe", got) + } + if got := root.Get("output.0.namespace").String(); got != "functions" { + t.Fatalf("non-stream output namespace = %q, want functions", got) + } + if got := gjson.Get(root.Get("output.0.arguments").String(), "value").String(); got != "PROBE" { + t.Fatalf("non-stream output value = %q, want PROBE", got) + } +} diff --git a/internal/translator/gemini/openai/responses/signature_carrier.go b/internal/translator/gemini/openai/responses/signature_carrier.go index 64029e75b..ebb7842c3 100644 --- a/internal/translator/gemini/openai/responses/signature_carrier.go +++ b/internal/translator/gemini/openai/responses/signature_carrier.go @@ -78,7 +78,7 @@ func compatibleGeminiResponsesCarrierSignature(rawSignature, targetKind string) func geminiResponsesCarrierSemanticTarget(item gjson.Result) string { switch item.Get("type").String() { - case "function_call": + case "function_call", "custom_tool_call": return geminiResponsesCarrierFunction case "reasoning": if strings.TrimSpace(item.Get("summary.0.text").String()) != "" { diff --git a/internal/util/responses_tools.go b/internal/util/responses_tools.go new file mode 100644 index 000000000..dbae0afc3 --- /dev/null +++ b/internal/util/responses_tools.go @@ -0,0 +1,418 @@ +package util + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// ResponsesToolIdentity represents the resolved identity of a tool in OpenAI Responses format. +type ResponsesToolIdentity struct { + Name string + Namespace string + Custom bool +} + +// ResponsesToolDescriptor is an internal representation of a tool declaration in a Responses request. +type ResponsesToolDescriptor struct { + Name string // Qualified name (e.g. "functions__exec" or "exec") + LocalName string // Local name without namespace (e.g. "exec") + Namespace string // Namespace if any (e.g. "functions") + ToolType string // "function", "custom", etc. + Tool gjson.Result + SourcePriority int // 0 for top-level tools, 1 for additional_tools + Direct bool // true if declared directly, false if declared as namespace child + Order int // original discovery order +} + +// QualifyResponsesNamespaceToolName qualifies a child tool name with its namespace. +func QualifyResponsesNamespaceToolName(namespaceName, childName string) string { + childName = strings.TrimSpace(childName) + namespaceName = strings.TrimSpace(namespaceName) + if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") { + return childName + } + if childName == namespaceName || strings.HasPrefix(childName, namespaceName+"__") { + return childName + } + if strings.HasSuffix(namespaceName, "__") { + return namespaceName + childName + } + return namespaceName + "__" + childName +} + +func responsesToolSources(root gjson.Result) []struct { + tools gjson.Result + priority int +} { + var sources []struct { + tools gjson.Result + priority int + } + appendSource := func(tools gjson.Result, priority int) { + if tools.Exists() && tools.IsArray() { + sources = append(sources, struct { + tools gjson.Result + priority int + }{tools: tools, priority: priority}) + } + } + appendSource(root.Get("tools"), 0) + if input := root.Get("input"); input.Exists() && input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "additional_tools" { + appendSource(item.Get("tools"), 1) + } + return true + }) + } + return sources +} + +func responsesToolName(tool gjson.Result) string { + if name := strings.TrimSpace(tool.Get("name").String()); name != "" { + return name + } + return strings.TrimSpace(tool.Get("function.name").String()) +} + +func responsesToolDescription(tool gjson.Result) string { + if description := tool.Get("description").String(); description != "" { + return description + } + return tool.Get("function.description").String() +} + +func responsesToolParameters(tool gjson.Result) gjson.Result { + for _, path := range []string{ + "parameters", + "parametersJsonSchema", + "input_schema", + "function.parameters", + "function.parametersJsonSchema", + } { + if parameters := tool.Get(path); parameters.Exists() { + return parameters + } + } + return gjson.Result{} +} + +// CollectResponsesToolDescriptors extracts all tool descriptors from a Responses request root. +func CollectResponsesToolDescriptors(root gjson.Result) []ResponsesToolDescriptor { + var descriptors []ResponsesToolDescriptor + appendDescriptor := func(tool gjson.Result, name, localName, namespace string, toolType string, sourcePriority int, direct bool) { + if name == "" { + return + } + descriptors = append(descriptors, ResponsesToolDescriptor{ + Name: name, + LocalName: localName, + Namespace: namespace, + ToolType: toolType, + Tool: tool, + SourcePriority: sourcePriority, + Direct: direct, + Order: len(descriptors), + }) + } + appendNamespaceChildren := func(namespaceTool gjson.Result, sourcePriority int) { + namespaceName := strings.TrimSpace(namespaceTool.Get("name").String()) + children := namespaceTool.Get("tools") + if !children.Exists() || !children.IsArray() { + return + } + children.ForEach(func(_, child gjson.Result) bool { + childName := responsesToolName(child) + if childName == "" { + return true + } + qualifiedName := QualifyResponsesNamespaceToolName(namespaceName, childName) + switch strings.TrimSpace(child.Get("type").String()) { + case "", "function": + appendDescriptor(child, qualifiedName, childName, namespaceName, "function", sourcePriority, false) + case "custom": + appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false) + } + return true + }) + } + for _, source := range responsesToolSources(root) { + source.tools.ForEach(func(_, tool gjson.Result) bool { + toolType := strings.TrimSpace(tool.Get("type").String()) + switch toolType { + case "", "function": + name := responsesToolName(tool) + appendDescriptor(tool, name, name, "", "function", source.priority, true) + case "custom": + name := responsesToolName(tool) + appendDescriptor(tool, name, name, "", "custom", source.priority, true) + case "namespace": + appendNamespaceChildren(tool, source.priority) + } + return true + }) + } + return descriptors +} + +func responsesToolDescriptorPrecedes(left, right ResponsesToolDescriptor) bool { + if left.SourcePriority != right.SourcePriority { + return left.SourcePriority < right.SourcePriority + } + if left.Direct != right.Direct { + return left.Direct + } + return left.Order < right.Order +} + +// CollectResponsesToolWinners collects deduplicated winning descriptors for each qualified tool name. +func CollectResponsesToolWinners(root gjson.Result) map[string]ResponsesToolDescriptor { + winners := map[string]ResponsesToolDescriptor{} + for _, descriptor := range CollectResponsesToolDescriptors(root) { + current, exists := winners[descriptor.Name] + if !exists || responsesToolDescriptorPrecedes(descriptor, current) { + winners[descriptor.Name] = descriptor + } + } + return winners +} + +func sanitizeResponsesToolNames(names []string) map[string]string { + if len(names) == 0 { + return nil + } + uniqueNames := make(map[string]struct{}, len(names)) + baseCounts := make(map[string]int, len(names)) + for _, name := range names { + if name == "" { + continue + } + if _, exists := uniqueNames[name]; exists { + continue + } + uniqueNames[name] = struct{}{} + baseCounts[SanitizeFunctionName(name)]++ + } + + sortedNames := make([]string, 0, len(uniqueNames)) + for name := range uniqueNames { + sortedNames = append(sortedNames, name) + } + sort.Strings(sortedNames) + + out := make(map[string]string, len(sortedNames)) + used := make(map[string]string, len(sortedNames)) + for _, name := range sortedNames { + base := SanitizeFunctionName(name) + mapped := base + _, baseUsed := used[base] + if baseCounts[base] > 1 || baseUsed { + mapped = disambiguateResponsesSanitizedName(base, name, used) + } + out[name] = mapped + used[mapped] = name + } + return out +} + +func disambiguateResponsesSanitizedName(base, original string, used map[string]string) string { + for attempt := 0; ; attempt++ { + digest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d", original, attempt))) + suffix := "_" + hex.EncodeToString(digest[:6]) + prefix := base + if maxPrefix := 64 - len(suffix); len(prefix) > maxPrefix { + prefix = prefix[:maxPrefix] + } + candidate := prefix + suffix + if _, exists := used[candidate]; !exists { + return candidate + } + } +} + +// BuildGeminiFunctionDeclarations builds Gemini function declarations, forward name mapping, and reverse identity mapping. +func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]string, map[string]ResponsesToolIdentity) { + descriptors := CollectResponsesToolDescriptors(root) + winners := CollectResponsesToolWinners(root) + + seenNames := make(map[string]struct{}) + var winningList []ResponsesToolDescriptor + for _, descriptor := range descriptors { + winner, ok := winners[descriptor.Name] + if !ok || winner.Order != descriptor.Order { + continue + } + if _, seen := seenNames[descriptor.Name]; seen { + continue + } + seenNames[descriptor.Name] = struct{}{} + winningList = append(winningList, descriptor) + } + + if len(winningList) == 0 { + return nil, nil, nil + } + + qualifiedNames := make([]string, 0, len(winningList)) + for _, desc := range winningList { + qualifiedNames = append(qualifiedNames, desc.Name) + } + sanitizedMap := sanitizeResponsesToolNames(qualifiedNames) + + forwardMap := make(map[string]string, len(winningList)*2) + reverseMap := make(map[string]ResponsesToolIdentity, len(winningList)*2) + var declarations [][]byte + + for _, desc := range winningList { + geminiName := desc.Name + if mapped, ok := sanitizedMap[desc.Name]; ok && mapped != "" { + geminiName = mapped + } else { + geminiName = SanitizeFunctionName(desc.Name) + } + + forwardMap[desc.Name] = geminiName + if desc.LocalName != "" && desc.LocalName != desc.Name { + if _, exists := forwardMap[desc.LocalName]; !exists { + forwardMap[desc.LocalName] = geminiName + } + } + + identity := ResponsesToolIdentity{ + Name: desc.LocalName, + Namespace: desc.Namespace, + Custom: desc.ToolType == "custom", + } + reverseMap[geminiName] = identity + if desc.Name != geminiName { + reverseMap[desc.Name] = identity + } + + funcDecl := []byte(`{"name":"","description":"","parametersJsonSchema":{}}`) + funcDecl, _ = sjson.SetBytes(funcDecl, "name", geminiName) + if descStr := responsesToolDescription(desc.Tool); descStr != "" { + funcDecl, _ = sjson.SetBytes(funcDecl, "description", descStr) + } + + if desc.ToolType == "custom" { + funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(`{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`)) + } else { + params := responsesToolParameters(desc.Tool) + if params.Exists() { + funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(CleanJSONSchemaForGemini(params.Raw))) + } + } + declarations = append(declarations, funcDecl) + } + + return declarations, forwardMap, reverseMap +} + +// ResponsesToolReverseIdentityMap builds a Gemini function name -> ResponsesToolIdentity map from a Responses request raw JSON. +func ResponsesToolReverseIdentityMap(rawJSON []byte) map[string]ResponsesToolIdentity { + if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) { + return nil + } + root := gjson.ParseBytes(rawJSON) + if req := root.Get("request"); req.Exists() && (req.Get("model").Exists() || req.Get("input").Exists() || req.Get("tools").Exists()) { + root = req + } + _, _, reverseMap := BuildGeminiFunctionDeclarations(root) + return reverseMap +} + +// MapResponsesToolName returns the mapped Gemini function name if present in forwardMap, else sanitized name. +func MapResponsesToolName(forwardMap map[string]string, name string) string { + if mapped, ok := forwardMap[name]; ok && mapped != "" { + return mapped + } + return SanitizeFunctionName(name) +} + +// ConvertResponsesToolChoiceToGemini translates Responses tool_choice into Gemini functionCallingConfig JSON. +func ConvertResponsesToolChoiceToGemini(toolChoice gjson.Result, forwardMap map[string]string) ([]byte, bool) { + if !toolChoice.Exists() { + return nil, false + } + mode := "" + var allowedNames []string + if toolChoice.Type == gjson.String { + switch strings.ToLower(strings.TrimSpace(toolChoice.String())) { + case "none": + mode = "NONE" + case "auto": + mode = "AUTO" + case "required", "any": + mode = "ANY" + } + } else if toolChoice.IsObject() { + toolType := strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String())) + switch toolType { + case "none": + mode = "NONE" + case "auto": + mode = "AUTO" + case "required", "any": + mode = "ANY" + case "function", "custom", "tool", "": + mode = "ANY" + name := strings.TrimSpace(toolChoice.Get("name").String()) + if name == "" { + name = strings.TrimSpace(toolChoice.Get("function.name").String()) + } + if name == "" { + name = strings.TrimSpace(toolChoice.Get("custom.name").String()) + } + namespace := strings.TrimSpace(toolChoice.Get("namespace").String()) + if namespace == "" { + namespace = strings.TrimSpace(toolChoice.Get("function.namespace").String()) + } + if namespace == "" { + namespace = strings.TrimSpace(toolChoice.Get("custom.namespace").String()) + } + if namespace != "" { + name = QualifyResponsesNamespaceToolName(namespace, name) + } + if name != "" { + geminiName := MapResponsesToolName(forwardMap, name) + allowedNames = append(allowedNames, geminiName) + } + } + } + if mode == "" { + return nil, false + } + cfg := []byte(`{"mode":""}`) + cfg, _ = sjson.SetBytes(cfg, "mode", mode) + if len(allowedNames) > 0 { + cfg, _ = sjson.SetBytes(cfg, "allowedFunctionNames", allowedNames) + } + return cfg, true +} + +// UnwrapResponsesCustomToolInput extracts the raw input string from custom tool arguments JSON or plain string. +func UnwrapResponsesCustomToolInput(arguments string) string { + arguments = strings.TrimSpace(arguments) + if arguments == "" || arguments == "{}" { + return "" + } + if gjson.Valid(arguments) { + parsed := gjson.Parse(arguments) + if v := parsed.Get("input"); v.Exists() { + if v.Type == gjson.String { + return v.String() + } + return v.Raw + } + if parsed.Type == gjson.String { + return parsed.String() + } + } + return arguments +} diff --git a/internal/util/responses_tools_test.go b/internal/util/responses_tools_test.go new file mode 100644 index 000000000..39187baea --- /dev/null +++ b/internal/util/responses_tools_test.go @@ -0,0 +1,228 @@ +package util + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestCollectResponsesToolDescriptors_PriorityAndNamespace(t *testing.T) { + raw := `{ + "tools": [ + {"type": "function", "name": "top_fn", "description": "top function"} + ], + "input": [ + { + "type": "additional_tools", + "tools": [ + { + "type": "namespace", + "name": "ns1", + "tools": [ + {"type": "function", "name": "child_fn", "description": "child function"}, + {"type": "custom", "name": "child_custom", "description": "child custom"} + ] + }, + {"type": "custom", "name": "direct_custom"} + ] + } + ] + }` + + root := gjson.Parse(raw) + descriptors := CollectResponsesToolDescriptors(root) + if len(descriptors) != 4 { + t.Fatalf("expected 4 descriptors, got %d", len(descriptors)) + } + + decls, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(root) + if len(decls) != 4 { + t.Fatalf("expected 4 declarations, got %d", len(decls)) + } + + if forwardMap["ns1__child_fn"] != "ns1__child_fn" { + t.Fatalf("forwardMap['ns1__child_fn'] = %q, want ns1__child_fn", forwardMap["ns1__child_fn"]) + } + + childCustomIdentity := reverseMap["ns1__child_custom"] + if childCustomIdentity.Name != "child_custom" || childCustomIdentity.Namespace != "ns1" || !childCustomIdentity.Custom { + t.Fatalf("unexpected reverseMap for ns1__child_custom: %+v", childCustomIdentity) + } + + topFnIdentity := reverseMap["top_fn"] + if topFnIdentity.Name != "top_fn" || topFnIdentity.Namespace != "" || topFnIdentity.Custom { + t.Fatalf("unexpected reverseMap for top_fn: %+v", topFnIdentity) + } +} + +func TestResponsesToolWinners_TopLevelBeatsAdditionalTools(t *testing.T) { + raw := `{ + "tools": [ + {"type": "function", "name": "shared_fn", "description": "top level"} + ], + "input": [ + { + "type": "additional_tools", + "tools": [ + {"type": "function", "name": "shared_fn", "description": "additional"} + ] + } + ] + }` + + root := gjson.Parse(raw) + winners := CollectResponsesToolWinners(root) + winner := winners["shared_fn"] + if winner.SourcePriority != 0 { + t.Fatalf("winner priority = %d, want 0", winner.SourcePriority) + } + if winner.Tool.Get("description").String() != "top level" { + t.Fatalf("winner description = %q, want 'top level'", winner.Tool.Get("description").String()) + } +} + +func TestResponsesToolWinners_DirectBeatsNamespaceChild(t *testing.T) { + raw := `{ + "tools": [ + {"type": "namespace", "name": "n", "tools": [{"type": "function", "name": "x", "description": "namespace child"}]}, + {"type": "custom", "name": "n__x", "description": "direct"} + ] + }` + + root := gjson.Parse(raw) + winners := CollectResponsesToolWinners(root) + winner := winners["n__x"] + if !winner.Direct { + t.Fatalf("winner direct = %v, want true", winner.Direct) + } + if winner.ToolType != "custom" { + t.Fatalf("winner toolType = %q, want custom", winner.ToolType) + } +} + +func TestConvertResponsesToolChoiceToGemini(t *testing.T) { + tests := []struct { + name string + choiceJSON string + forwardMap map[string]string + wantMode string + wantNames []string + }{ + { + name: "auto string", + choiceJSON: `"auto"`, + wantMode: "AUTO", + }, + { + name: "none string", + choiceJSON: `"none"`, + wantMode: "NONE", + }, + { + name: "required string", + choiceJSON: `"required"`, + wantMode: "ANY", + }, + { + name: "function object with namespace", + choiceJSON: `{"type": "function", "name": "my_fn", "namespace": "my_ns"}`, + forwardMap: map[string]string{"my_ns__my_fn": "my_ns__my_fn"}, + wantMode: "ANY", + wantNames: []string{"my_ns__my_fn"}, + }, + { + name: "custom object", + choiceJSON: `{"type": "custom", "name": "exec", "namespace": "functions"}`, + forwardMap: map[string]string{"functions__exec": "functions__exec"}, + wantMode: "ANY", + wantNames: []string{"functions__exec"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + choice := gjson.Parse(tt.choiceJSON) + out, ok := ConvertResponsesToolChoiceToGemini(choice, tt.forwardMap) + if !ok { + t.Fatalf("ConvertResponsesToolChoiceToGemini returned false") + } + mode := gjson.GetBytes(out, "mode").String() + if mode != tt.wantMode { + t.Fatalf("mode = %q, want %q", mode, tt.wantMode) + } + if len(tt.wantNames) > 0 { + names := gjson.GetBytes(out, "allowedFunctionNames").Array() + if len(names) != len(tt.wantNames) { + t.Fatalf("allowedFunctionNames count = %d, want %d", len(names), len(tt.wantNames)) + } + for i, want := range tt.wantNames { + if names[i].String() != want { + t.Fatalf("allowedFunctionNames[%d] = %q, want %q", i, names[i].String(), want) + } + } + } + }) + } +} + +func TestUnwrapResponsesCustomToolInput(t *testing.T) { + tests := []struct { + input string + want string + }{ + {input: `{"input":"pwd"}`, want: "pwd"}, + {input: `{"input":{"cmd":"ls"}}`, want: `{"cmd":"ls"}`}, + {input: `"direct text"`, want: "direct text"}, + {input: `{}`, want: ""}, + {input: ``, want: ""}, + } + + for _, tt := range tests { + got := UnwrapResponsesCustomToolInput(tt.input) + if got != tt.want { + t.Errorf("UnwrapResponsesCustomToolInput(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestBuildGeminiFunctionDeclarations_DisambiguationAndLongNames(t *testing.T) { + // Two tools that genuinely collide after sanitization (e.g. "read/file" vs "read_file"), and one > 64 chars + raw := `{ + "tools": [ + {"type": "function", "name": "read/file", "description": "tool with slash"}, + {"type": "function", "name": "read_file", "description": "tool with underscore"}, + {"type": "custom", "name": "mcp__very_very_very_very_very_very_long_namespace_name__very_very_very_long_custom_tool_name_that_exceeds_sixty_four_chars"} + ] + }` + + root := gjson.Parse(raw) + decls, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(root) + if len(decls) != 3 { + t.Fatalf("expected 3 decls, got %d", len(decls)) + } + + name1 := forwardMap["read/file"] + name2 := forwardMap["read_file"] + if name1 == name2 { + t.Fatalf("colliding tools mapped to identical name: %q", name1) + } + + identity1 := reverseMap[name1] + if identity1.Name != "read/file" { + t.Fatalf("reverseMap[%q].Name = %q, want read/file", name1, identity1.Name) + } + identity2 := reverseMap[name2] + if identity2.Name != "read_file" { + t.Fatalf("reverseMap[%q].Name = %q, want read_file", name2, identity2.Name) + } + + longName := forwardMap["mcp__very_very_very_very_very_very_long_namespace_name__very_very_very_long_custom_tool_name_that_exceeds_sixty_four_chars"] + if len(longName) > 64 { + t.Fatalf("long tool name length = %d > 64: %q", len(longName), longName) + } + + identityLong := reverseMap[longName] + if !identityLong.Custom || identityLong.Name != "mcp__very_very_very_very_very_very_long_namespace_name__very_very_very_long_custom_tool_name_that_exceeds_sixty_four_chars" { + t.Fatalf("unexpected reverse identity for long name: %+v", identityLong) + } +}