mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-05-20 09:17:43 +08:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ type claudeToResponsesState struct {
|
||||
FuncNames map[int]string // index -> function name
|
||||
FuncCallIDs map[int]string // index -> call id
|
||||
// message text aggregation
|
||||
TextBuf strings.Builder
|
||||
TextBuf strings.Builder
|
||||
CurrentTextBuf strings.Builder
|
||||
// reasoning state
|
||||
ReasoningActive bool
|
||||
ReasoningItemID string
|
||||
@@ -80,6 +81,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
st.CreatedAt = time.Now().Unix()
|
||||
// Reset per-message aggregation state
|
||||
st.TextBuf.Reset()
|
||||
st.CurrentTextBuf.Reset()
|
||||
st.ReasoningBuf.Reset()
|
||||
st.ReasoningActive = false
|
||||
st.InTextBlock = false
|
||||
@@ -128,6 +130,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
if typ == "text" {
|
||||
// open message item + content part
|
||||
st.InTextBlock = true
|
||||
st.CurrentTextBuf.Reset()
|
||||
st.CurrentMsgID = fmt.Sprintf("msg_%s_0", st.ResponseID)
|
||||
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
|
||||
item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
|
||||
@@ -189,6 +192,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
out = append(out, emitEvent("response.output_text.delta", msg))
|
||||
// aggregate text for response.output
|
||||
st.TextBuf.WriteString(t.String())
|
||||
st.CurrentTextBuf.WriteString(t.String())
|
||||
}
|
||||
} else if dt == "input_json_delta" {
|
||||
idx := int(root.Get("index").Int())
|
||||
@@ -220,17 +224,21 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
case "content_block_stop":
|
||||
idx := int(root.Get("index").Int())
|
||||
if st.InTextBlock {
|
||||
fullText := st.CurrentTextBuf.String()
|
||||
done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
|
||||
done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
|
||||
done, _ = sjson.SetBytes(done, "item_id", st.CurrentMsgID)
|
||||
done, _ = sjson.SetBytes(done, "text", fullText)
|
||||
out = append(out, emitEvent("response.output_text.done", done))
|
||||
partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
|
||||
partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
|
||||
partDone, _ = sjson.SetBytes(partDone, "item_id", st.CurrentMsgID)
|
||||
partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
|
||||
out = append(out, emitEvent("response.content_part.done", partDone))
|
||||
final := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","text":""}],"role":"assistant"}}`)
|
||||
final, _ = sjson.SetBytes(final, "sequence_number", nextSeq())
|
||||
final, _ = sjson.SetBytes(final, "item.id", st.CurrentMsgID)
|
||||
final, _ = sjson.SetBytes(final, "item.content.0.text", fullText)
|
||||
out = append(out, emitEvent("response.output_item.done", final))
|
||||
st.InTextBlock = false
|
||||
} else if st.InFuncBlock {
|
||||
|
||||
Reference in New Issue
Block a user