Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 108 additions & 11 deletions internal/translator/openai/claude/openai_claude_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"bytes"
"context"
"fmt"
"sort"
"strings"

"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
Expand Down Expand Up @@ -51,13 +52,16 @@ type ConvertOpenAIResponseToAnthropicParams struct {
ThinkingContentBlockIndex int
// Next available content block index
NextContentBlockIndex int
// Canonical tool name map from lowercase -> declared request tool name
CanonicalToolNameByLower map[string]string
}

// ToolCallAccumulator holds the state for accumulating tool call data
type ToolCallAccumulator struct {
ID string
Name string
Arguments strings.Builder
Started bool
}

// ConvertOpenAIResponseToClaude converts OpenAI streaming response format to Anthropic API format.
Expand Down Expand Up @@ -89,6 +93,7 @@ func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestR
TextContentBlockIndex: -1,
ThinkingContentBlockIndex: -1,
NextContentBlockIndex: 0,
CanonicalToolNameByLower: buildCanonicalToolNameByLower(originalRequestRawJSON),
}
}

Expand All @@ -105,7 +110,7 @@ func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestR

streamResult := gjson.GetBytes(originalRequestRawJSON, "stream")
if !streamResult.Exists() || (streamResult.Exists() && streamResult.Type == gjson.False) {
return convertOpenAINonStreamingToAnthropic(rawJSON)
return convertOpenAINonStreamingToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams).CanonicalToolNameByLower)
} else {
return convertOpenAIStreamingChunkToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams))
}
Expand Down Expand Up @@ -215,18 +220,29 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
// Handle function name
if function := toolCall.Get("function"); function.Exists() {
if name := function.Get("name"); name.Exists() {
accumulator.Name = name.String()
nameStr := strings.TrimSpace(name.String())
if nameStr != "" {
accumulator.Name = canonicalizeToolName(nameStr, param.CanonicalToolNameByLower)
}
}

// Emit tool_use start exactly once per tool call.
// Some OpenAI-compatible streams repeat function.name="" in later chunks;
// emitting start repeatedly breaks Claude Code tool execution.
if !accumulator.Started && accumulator.Name != "" {
stopThinkingContentBlock(param, &results)

stopTextContentBlock(param, &results)

// Send content_block_start for tool_use
if accumulator.ID == "" {
accumulator.ID = fmt.Sprintf("call_%d", index)
}

contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`
contentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, "index", blockIndex)
contentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, "content_block.id", accumulator.ID)
contentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, "content_block.name", accumulator.Name)
results = append(results, "event: content_block_start\ndata: "+contentBlockStartJSON+"\n\n")
accumulator.Started = true
}

// Handle function arguments
Expand Down Expand Up @@ -262,10 +278,26 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI

// Send content_block_stop for any tool calls
if !param.ContentBlocksStopped {
for index := range param.ToolCallsAccumulator {
for _, index := range sortedToolCallIndexes(param.ToolCallsAccumulator) {
accumulator := param.ToolCallsAccumulator[index]
blockIndex := param.toolContentBlockIndex(index)

if !accumulator.Started {
if strings.TrimSpace(accumulator.Name) == "" {
delete(param.ToolCallBlockIndexes, index)
continue
}
if accumulator.ID == "" {
accumulator.ID = fmt.Sprintf("call_%d", index)
}
contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`
contentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, "index", blockIndex)
contentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, "content_block.id", accumulator.ID)
contentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, "content_block.name", accumulator.Name)
results = append(results, "event: content_block_start\ndata: "+contentBlockStartJSON+"\n\n")
accumulator.Started = true
}

// Send complete input_json_delta with all accumulated arguments
if accumulator.Arguments.Len() > 0 {
inputDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
Expand Down Expand Up @@ -326,10 +358,26 @@ func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams)
stopTextContentBlock(param, &results)

if !param.ContentBlocksStopped {
for index := range param.ToolCallsAccumulator {
for _, index := range sortedToolCallIndexes(param.ToolCallsAccumulator) {
accumulator := param.ToolCallsAccumulator[index]
blockIndex := param.toolContentBlockIndex(index)

if !accumulator.Started {
if strings.TrimSpace(accumulator.Name) == "" {
delete(param.ToolCallBlockIndexes, index)
continue
}
if accumulator.ID == "" {
accumulator.ID = fmt.Sprintf("call_%d", index)
}
contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`
contentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, "index", blockIndex)
contentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, "content_block.id", accumulator.ID)
contentBlockStartJSON, _ = sjson.Set(contentBlockStartJSON, "content_block.name", accumulator.Name)
results = append(results, "event: content_block_start\ndata: "+contentBlockStartJSON+"\n\n")
accumulator.Started = true
}
Comment on lines +365 to +379
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block of logic for ensuring a tool_use content_block_start is emitted is duplicated from lines 285-299 in convertOpenAIStreamingChunkToAnthropic. To improve maintainability and reduce redundancy, consider extracting this logic into a helper function that can be called from both locations.


if accumulator.Arguments.Len() > 0 {
inputDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`
inputDeltaJSON, _ = sjson.Set(inputDeltaJSON, "index", blockIndex)
Expand Down Expand Up @@ -359,7 +407,7 @@ func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams)
}

// convertOpenAINonStreamingToAnthropic converts OpenAI non-streaming response to Anthropic format
func convertOpenAINonStreamingToAnthropic(rawJSON []byte) []string {
func convertOpenAINonStreamingToAnthropic(rawJSON []byte, canonicalToolNameByLower map[string]string) []string {
root := gjson.ParseBytes(rawJSON)

out := `{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`
Expand Down Expand Up @@ -392,7 +440,8 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) []string {
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
toolUseBlock := `{"type":"tool_use","id":"","name":"","input":{}}`
toolUseBlock, _ = sjson.Set(toolUseBlock, "id", toolCall.Get("id").String())
toolUseBlock, _ = sjson.Set(toolUseBlock, "name", toolCall.Get("function.name").String())
toolName := canonicalizeToolName(toolCall.Get("function.name").String(), canonicalToolNameByLower)
toolUseBlock, _ = sjson.Set(toolUseBlock, "name", toolName)

argsStr := util.FixJSON(toolCall.Get("function.arguments").String())
if argsStr != "" && gjson.Valid(argsStr) {
Expand Down Expand Up @@ -531,8 +580,8 @@ func stopTextContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results
// Returns:
// - string: An Anthropic-compatible JSON response.
func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
_ = originalRequestRawJSON
_ = requestRawJSON
canonicalToolNameByLower := buildCanonicalToolNameByLower(originalRequestRawJSON)

root := gjson.ParseBytes(rawJSON)
out := `{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`
Expand Down Expand Up @@ -590,7 +639,8 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina
hasToolCall = true
toolUse := `{"type":"tool_use","id":"","name":"","input":{}}`
toolUse, _ = sjson.Set(toolUse, "id", tc.Get("id").String())
toolUse, _ = sjson.Set(toolUse, "name", tc.Get("function.name").String())
toolName := canonicalizeToolName(tc.Get("function.name").String(), canonicalToolNameByLower)
toolUse, _ = sjson.Set(toolUse, "name", toolName)

argsStr := util.FixJSON(tc.Get("function.arguments").String())
if argsStr != "" && gjson.Valid(argsStr) {
Expand Down Expand Up @@ -647,7 +697,8 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina
hasToolCall = true
toolUseBlock := `{"type":"tool_use","id":"","name":"","input":{}}`
toolUseBlock, _ = sjson.Set(toolUseBlock, "id", toolCall.Get("id").String())
toolUseBlock, _ = sjson.Set(toolUseBlock, "name", toolCall.Get("function.name").String())
toolName := canonicalizeToolName(toolCall.Get("function.name").String(), canonicalToolNameByLower)
toolUseBlock, _ = sjson.Set(toolUseBlock, "name", toolName)

argsStr := util.FixJSON(toolCall.Get("function.arguments").String())
if argsStr != "" && gjson.Valid(argsStr) {
Expand Down Expand Up @@ -711,3 +762,49 @@ func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64) {

return inputTokens, outputTokens, cachedTokens
}

func sortedToolCallIndexes(toolCalls map[int]*ToolCallAccumulator) []int {
indexes := make([]int, 0, len(toolCalls))
for index := range toolCalls {
indexes = append(indexes, index)
}
sort.Ints(indexes)
return indexes
}

func buildCanonicalToolNameByLower(originalRequestRawJSON []byte) map[string]string {
tools := gjson.GetBytes(originalRequestRawJSON, "tools")
if !tools.Exists() || !tools.IsArray() {
return nil
}

out := make(map[string]string)
tools.ForEach(func(_, tool gjson.Result) bool {
name := strings.TrimSpace(tool.Get("name").String())
if name == "" {
return true
}
key := strings.ToLower(name)
// Preserve first declaration if collisions only differ by case.
if _, exists := out[key]; !exists {
out[key] = name
}
return true
})

if len(out) == 0 {
return nil
}
return out
}

func canonicalizeToolName(name string, canonicalToolNameByLower map[string]string) string {
name = strings.TrimSpace(name)
if name == "" || len(canonicalToolNameByLower) == 0 {
return name
}
if canonical, ok := canonicalToolNameByLower[strings.ToLower(name)]; ok {
return canonical
}
return name
}
86 changes: 86 additions & 0 deletions internal/translator/openai/claude/openai_claude_response_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package claude

import (
"context"
"strings"
"testing"

"github.com/tidwall/gjson"
)

func TestConvertOpenAIResponseToClaude_StreamToolStartEmittedOnceAndNameCanonicalized(t *testing.T) {
originalRequest := `{
"stream": true,
"tools": [
{
"name": "Bash",
"description": "run shell",
"input_schema": {"type":"object","properties":{"command":{"type":"string"}}}
}
]
}`

chunks := []string{
`data: {"id":"chatcmpl-1","model":"m","created":1,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"bash","arguments":""}}]}}]}`,
`data: {"id":"chatcmpl-1","model":"m","created":1,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"","arguments":"{\"command\":\"pwd\"}"}}]}}]}`,
`data: {"id":"chatcmpl-1","model":"m","created":1,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
`data: {"id":"chatcmpl-1","model":"m","created":1,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":2}}`,
`data: [DONE]`,
}

var param any
var outputs []string
for _, chunk := range chunks {
out := ConvertOpenAIResponseToClaude(context.Background(), "m", []byte(originalRequest), nil, []byte(chunk), &param)
outputs = append(outputs, out...)
}

joined := strings.Join(outputs, "")
if got := strings.Count(joined, `"content_block":{"type":"tool_use"`); got != 1 {
t.Fatalf("expected exactly 1 tool_use content_block_start, got %d\noutput:\n%s", got, joined)
}

if strings.Contains(joined, `"name":""`) {
t.Fatalf("tool_use block should not have empty name\noutput:\n%s", joined)
}

if !strings.Contains(joined, `"name":"Bash"`) {
t.Fatalf("expected canonical tool name Bash in stream output\noutput:\n%s", joined)
}
}

func TestConvertOpenAIResponseToClaudeNonStream_CanonicalizesToolName(t *testing.T) {
originalRequest := `{
"tools": [
{"name": "Bash", "input_schema": {"type":"object","properties":{"command":{"type":"string"}}}}
]
}`

openAIResponse := `{
"id":"chatcmpl-1",
"model":"m",
"choices":[
{
"finish_reason":"tool_calls",
"message":{
"content":"",
"tool_calls":[
{"id":"call_1","type":"function","function":{"name":"bash","arguments":"{\"command\":\"pwd\"}"}}
]
}
}
],
"usage":{"prompt_tokens":10,"completion_tokens":2}
}`

var param any
out := ConvertOpenAIResponseToClaudeNonStream(context.Background(), "m", []byte(originalRequest), nil, []byte(openAIResponse), &param)
result := gjson.Parse(out)

if got := result.Get("content.0.type").String(); got != "tool_use" {
t.Fatalf("expected first content block type tool_use, got %q", got)
}
if got := result.Get("content.0.name").String(); got != "Bash" {
t.Fatalf("expected canonical tool name %q, got %q", "Bash", got)
}
}
Loading