feat(gemini): add namespace-aware OpenAI Responses tool resolution and custom tool call conversion

- Add shared tool descriptor collection and winner selection for Responses tools (top-level vs `additional_tools`, direct vs namespace child, and ordering rules).
- Introduce sanitized Gemini function name mapping with collision disambiguation and 64-char-safe truncation.
- Build forward/reverse tool identity maps for restoring original tool identity (`name`, `namespace`, `custom`) during translation.
- Update Gemini→Responses streaming conversion to emit proper custom tool call events and identity-aware function call events.
- Add helpers for translating `tool_choice` to Gemini config and unwrapping custom tool input payloads.

Closes: #5088
This commit is contained in:
Luis Pater
2026-08-20 13:47:15 +08:00
parent 4874971764
commit 556328c122
9 changed files with 1406 additions and 135 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, &param) {
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, &param) {
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)
}
}

View File

@@ -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()) != "" {

View File

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

View File

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