diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 514129ca..c0479b87 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -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 + } +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index ef2cc1f8..10d12c99 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -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 {