mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) != "" {
|
||||
|
||||
418
internal/util/responses_tools.go
Normal file
418
internal/util/responses_tools.go
Normal 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
|
||||
}
|
||||
228
internal/util/responses_tools_test.go
Normal file
228
internal/util/responses_tools_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user