feat: enhance tool mapping with namespace and web search support

- Added functions to handle tool conversion, including namespace-based tools and web search tools.
- Improved parameter normalization and tool input schema standardization.
- Integrated logic to handle qualified tool names and map override functionality.
- Refactored existing tool processing for better extensibility and maintainability.

Fixed: #3199
This commit is contained in:
Luis Pater
2026-05-03 22:26:23 +08:00
parent 756b600b7a
commit af65908cb0
2 changed files with 197 additions and 19 deletions
@@ -339,25 +339,21 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
})
}
includedToolNames := map[string]struct{}{}
toolNameMap := map[string]string{}
// tools mapping: parameters -> input_schema
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
toolsJSON := []byte("[]")
tools.ForEach(func(_, tool gjson.Result) bool {
tJSON := []byte(`{"name":"","description":"","input_schema":{}}`)
if n := tool.Get("name"); n.Exists() {
tJSON, _ = sjson.SetBytes(tJSON, "name", n.String())
convertedTools := convertResponsesToolToClaudeTools(tool, toolNameMap)
for _, tJSON := range convertedTools {
toolName := gjson.GetBytes(tJSON, "name").String()
if toolName != "" {
includedToolNames[toolName] = struct{}{}
}
toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON)
}
if d := tool.Get("description"); d.Exists() {
tJSON, _ = sjson.SetBytes(tJSON, "description", d.String())
}
if params := tool.Get("parameters"); params.Exists() {
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", []byte(params.Raw))
} else if params = tool.Get("parametersJsonSchema"); params.Exists() {
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", []byte(params.Raw))
}
toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON)
return true
})
if parsedTools := gjson.ParseBytes(toolsJSON); parsedTools.IsArray() && len(parsedTools.Array()) > 0 {
@@ -375,14 +371,24 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
case "none":
// Leave unset; implies no tools
case "required":
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
if len(includedToolNames) > 0 {
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
}
}
case gjson.JSON:
if toolChoice.Get("type").String() == "function" {
fn := toolChoice.Get("function.name").String()
toolChoiceJSON := []byte(`{"name":"","type":"tool"}`)
toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn)
out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
if fn == "" {
fn = toolChoice.Get("name").String()
}
if mappedName := toolNameMap[fn]; mappedName != "" {
fn = mappedName
}
if _, ok := includedToolNames[fn]; ok {
toolChoiceJSON := []byte(`{"name":"","type":"tool"}`)
toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn)
out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
}
}
default:
@@ -391,3 +397,167 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
return out
}
func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte {
toolType := strings.TrimSpace(tool.Get("type").String())
switch toolType {
case "", "function":
if tJSON, ok := convertResponsesFunctionToolToClaude(tool, ""); ok {
return [][]byte{tJSON}
}
case "namespace":
return convertResponsesNamespaceToolToClaude(tool, toolNameMap)
case "web_search":
if tJSON, ok := convertResponsesWebSearchToolToClaude(tool); ok {
if name := gjson.GetBytes(tJSON, "name").String(); name != "" {
toolNameMap[name] = name
}
return [][]byte{tJSON}
}
default:
if isUnsupportedOpenAIBuiltinToolType(toolType) {
return nil
}
if tool.Get("name").String() != "" {
return [][]byte{[]byte(tool.Raw)}
}
}
return nil
}
func convertResponsesNamespaceToolToClaude(tool gjson.Result, toolNameMap map[string]string) [][]byte {
namespaceName := strings.TrimSpace(tool.Get("name").String())
children := tool.Get("tools")
if !children.Exists() || !children.IsArray() {
return nil
}
var out [][]byte
children.ForEach(func(_, child gjson.Result) bool {
childName := responsesToolName(child)
qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName)
if tJSON, ok := convertResponsesFunctionToolToClaude(child, qualifiedName); ok {
out = append(out, tJSON)
toolNameMap[qualifiedName] = qualifiedName
if childName != "" {
toolNameMap[childName] = qualifiedName
}
}
return true
})
return out
}
func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {
name := strings.TrimSpace(overrideName)
if name == "" {
name = responsesToolName(tool)
}
if name == "" {
return nil, false
}
tJSON := []byte(`{"name":"","description":"","input_schema":{}}`)
tJSON, _ = sjson.SetBytes(tJSON, "name", name)
if d := responsesToolDescription(tool); d != "" {
tJSON, _ = sjson.SetBytes(tJSON, "description", d)
}
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", normalizeClaudeToolInputSchema(responsesToolParameters(tool)))
return tJSON, true
}
func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) {
if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() {
return nil, false
}
name := strings.TrimSpace(tool.Get("name").String())
if name == "" {
name = "web_search"
}
tJSON := []byte(`{"type":"web_search_20250305","name":""}`)
tJSON, _ = sjson.SetBytes(tJSON, "name", name)
if maxUses := tool.Get("max_uses"); maxUses.Exists() {
tJSON, _ = sjson.SetBytes(tJSON, "max_uses", maxUses.Int())
}
if allowedDomains := tool.Get("filters.allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
tJSON, _ = sjson.SetRawBytes(tJSON, "allowed_domains", []byte(allowedDomains.Raw))
}
if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
tJSON, _ = sjson.SetRawBytes(tJSON, "user_location", []byte(userLocation.Raw))
}
return tJSON, true
}
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{}
}
func normalizeClaudeToolInputSchema(parameters gjson.Result) []byte {
raw := strings.TrimSpace(parameters.Raw)
if raw == "" || raw == "null" || !gjson.Valid(raw) {
return []byte(`{"type":"object","properties":{}}`)
}
result := gjson.Parse(raw)
if !result.IsObject() {
return []byte(`{"type":"object","properties":{}}`)
}
schema := []byte(raw)
schemaType := result.Get("type").String()
if schemaType == "" {
schema, _ = sjson.SetBytes(schema, "type", "object")
schemaType = "object"
}
if schemaType == "object" && !result.Get("properties").Exists() {
schema, _ = sjson.SetRawBytes(schema, "properties", []byte(`{}`))
}
return schema
}
func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
childName = strings.TrimSpace(childName)
if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {
return childName
}
if strings.HasPrefix(childName, namespaceName) {
return childName
}
if strings.HasSuffix(namespaceName, "__") {
return namespaceName + childName
}
return namespaceName + "__" + childName
}
func isUnsupportedOpenAIBuiltinToolType(toolType string) bool {
switch toolType {
case "image_generation", "file_search", "code_interpreter", "computer_use_preview":
return true
default:
return false
}
}