418 lines
13 KiB
Go
418 lines
13 KiB
Go
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
|
|
}
|