From 9df04d71e26fbb8ea2a17043dcc67618481993d1 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 23 Sep 2025 20:42:48 +0800 Subject: [PATCH] feat(translators/claude): implement non-streaming response parsing for various translator types - Added `ConvertCodexResponseToClaudeNonStream`, `ConvertGeminiCLIResponseToClaudeNonStream`, `ConvertGeminiResponseToClaudeNonStream`, and `ConvertOpenAIResponseToClaudeNonStream` methods for handling non-streaming JSON response conversion. - Introduced logic for parsing and structuring content, handling reasoning, text, and tool usage blocks. - Enhanced support for stop reasons and refined token usage data aggregation. --- .../codex/claude/codex_claude_response.go | 170 ++++++++++++++++- .../claude/gemini-cli_claude_response.go | 126 ++++++++++++- .../gemini/claude/gemini_claude_response.go | 126 ++++++++++++- .../openai/claude/openai_claude_response.go | 176 +++++++++++++++++- 4 files changed, 591 insertions(+), 7 deletions(-) diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 64d4cc67..e78eae05 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -7,9 +7,12 @@ package claude import ( + "bufio" "bytes" "context" + "encoding/json" "fmt" + "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -176,7 +179,172 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa // // Returns: // - string: A Claude Code-compatible JSON response containing all message content and metadata -func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, _ []byte, _ *any) string { +func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, _ *any) string { + scanner := bufio.NewScanner(bytes.NewReader(rawJSON)) + buffer := make([]byte, 10240*1024) + scanner.Buffer(buffer, 10240*1024) + revNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON) + + for scanner.Scan() { + line := scanner.Bytes() + if !bytes.HasPrefix(line, dataTag) { + continue + } + payload := bytes.TrimSpace(line[len(dataTag):]) + if len(payload) == 0 { + continue + } + + rootResult := gjson.ParseBytes(payload) + if rootResult.Get("type").String() != "response.completed" { + continue + } + + responseData := rootResult.Get("response") + if !responseData.Exists() { + continue + } + + response := map[string]interface{}{ + "id": responseData.Get("id").String(), + "type": "message", + "role": "assistant", + "model": responseData.Get("model").String(), + "content": []interface{}{}, + "stop_reason": nil, + "stop_sequence": nil, + "usage": map[string]interface{}{ + "input_tokens": responseData.Get("usage.input_tokens").Int(), + "output_tokens": responseData.Get("usage.output_tokens").Int(), + }, + } + + var contentBlocks []interface{} + hasToolCall := false + + if output := responseData.Get("output"); output.Exists() && output.IsArray() { + output.ForEach(func(_, item gjson.Result) bool { + switch item.Get("type").String() { + case "reasoning": + thinkingBuilder := strings.Builder{} + if summary := item.Get("summary"); summary.Exists() { + if summary.IsArray() { + summary.ForEach(func(_, part gjson.Result) bool { + if txt := part.Get("text"); txt.Exists() { + thinkingBuilder.WriteString(txt.String()) + } else { + thinkingBuilder.WriteString(part.String()) + } + return true + }) + } else { + thinkingBuilder.WriteString(summary.String()) + } + } + if thinkingBuilder.Len() == 0 { + if content := item.Get("content"); content.Exists() { + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + if txt := part.Get("text"); txt.Exists() { + thinkingBuilder.WriteString(txt.String()) + } else { + thinkingBuilder.WriteString(part.String()) + } + return true + }) + } else { + thinkingBuilder.WriteString(content.String()) + } + } + } + if thinkingBuilder.Len() > 0 { + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "thinking", + "thinking": thinkingBuilder.String(), + }) + } + case "message": + if content := item.Get("content"); content.Exists() { + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() == "output_text" { + text := part.Get("text").String() + if text != "" { + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "text", + "text": text, + }) + } + } + return true + }) + } else { + text := content.String() + if text != "" { + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "text", + "text": text, + }) + } + } + } + case "function_call": + hasToolCall = true + name := item.Get("name").String() + if original, ok := revNames[name]; ok { + name = original + } + + toolBlock := map[string]interface{}{ + "type": "tool_use", + "id": item.Get("call_id").String(), + "name": name, + "input": map[string]interface{}{}, + } + + if argsStr := item.Get("arguments").String(); argsStr != "" { + var args interface{} + if err := json.Unmarshal([]byte(argsStr), &args); err == nil { + toolBlock["input"] = args + } + } + + contentBlocks = append(contentBlocks, toolBlock) + } + return true + }) + } + + if len(contentBlocks) > 0 { + response["content"] = contentBlocks + } + + if stopReason := responseData.Get("stop_reason"); stopReason.Exists() && stopReason.String() != "" { + response["stop_reason"] = stopReason.String() + } else if hasToolCall { + response["stop_reason"] = "tool_use" + } else { + response["stop_reason"] = "end_turn" + } + + if stopSequence := responseData.Get("stop_sequence"); stopSequence.Exists() && stopSequence.String() != "" { + response["stop_sequence"] = stopSequence.Value() + } + + if responseData.Get("usage.input_tokens").Exists() || responseData.Get("usage.output_tokens").Exists() { + response["usage"] = map[string]interface{}{ + "input_tokens": responseData.Get("usage.input_tokens").Int(), + "output_tokens": responseData.Get("usage.output_tokens").Int(), + } + } + + responseJSON, err := json.Marshal(response) + if err != nil { + return "" + } + return string(responseJSON) + } + return "" } diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go index 7c53c9fc..8f0b3829 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go @@ -9,7 +9,9 @@ package claude import ( "bytes" "context" + "encoding/json" "fmt" + "strings" "time" "github.com/tidwall/gjson" @@ -251,6 +253,126 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque // // Returns: // - string: A Claude-compatible JSON response. -func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, _ []byte, _ *any) string { - return "" +func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string { + _ = originalRequestRawJSON + _ = requestRawJSON + + root := gjson.ParseBytes(rawJSON) + + response := map[string]interface{}{ + "id": root.Get("response.responseId").String(), + "type": "message", + "role": "assistant", + "model": root.Get("response.modelVersion").String(), + "content": []interface{}{}, + "stop_reason": nil, + "stop_sequence": nil, + "usage": map[string]interface{}{ + "input_tokens": root.Get("response.usageMetadata.promptTokenCount").Int(), + "output_tokens": root.Get("response.usageMetadata.candidatesTokenCount").Int() + root.Get("response.usageMetadata.thoughtsTokenCount").Int(), + }, + } + + parts := root.Get("response.candidates.0.content.parts") + var contentBlocks []interface{} + textBuilder := strings.Builder{} + thinkingBuilder := strings.Builder{} + toolIDCounter := 0 + hasToolCall := false + + flushText := func() { + if textBuilder.Len() == 0 { + return + } + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "text", + "text": textBuilder.String(), + }) + textBuilder.Reset() + } + + flushThinking := func() { + if thinkingBuilder.Len() == 0 { + return + } + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "thinking", + "thinking": thinkingBuilder.String(), + }) + thinkingBuilder.Reset() + } + + if parts.IsArray() { + for _, part := range parts.Array() { + if text := part.Get("text"); text.Exists() && text.String() != "" { + if part.Get("thought").Bool() { + flushText() + thinkingBuilder.WriteString(text.String()) + continue + } + flushThinking() + textBuilder.WriteString(text.String()) + continue + } + + if functionCall := part.Get("functionCall"); functionCall.Exists() { + flushThinking() + flushText() + hasToolCall = true + + name := functionCall.Get("name").String() + toolIDCounter++ + toolBlock := map[string]interface{}{ + "type": "tool_use", + "id": fmt.Sprintf("tool_%d", toolIDCounter), + "name": name, + "input": map[string]interface{}{}, + } + + if args := functionCall.Get("args"); args.Exists() { + var parsed interface{} + if err := json.Unmarshal([]byte(args.Raw), &parsed); err == nil { + toolBlock["input"] = parsed + } + } + + contentBlocks = append(contentBlocks, toolBlock) + continue + } + } + } + + flushThinking() + flushText() + + response["content"] = contentBlocks + + stopReason := "end_turn" + if hasToolCall { + stopReason = "tool_use" + } else { + if finish := root.Get("response.candidates.0.finishReason"); finish.Exists() { + switch finish.String() { + case "MAX_TOKENS": + stopReason = "max_tokens" + case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN": + stopReason = "end_turn" + default: + stopReason = "end_turn" + } + } + } + response["stop_reason"] = stopReason + + if usage := response["usage"].(map[string]interface{}); usage["input_tokens"] == int64(0) && usage["output_tokens"] == int64(0) { + if usageMeta := root.Get("response.usageMetadata"); !usageMeta.Exists() { + delete(response, "usage") + } + } + + encoded, err := json.Marshal(response) + if err != nil { + return "" + } + return string(encoded) } diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go index 9ae43de8..824e3519 100644 --- a/internal/translator/gemini/claude/gemini_claude_response.go +++ b/internal/translator/gemini/claude/gemini_claude_response.go @@ -9,7 +9,9 @@ package claude import ( "bytes" "context" + "encoding/json" "fmt" + "strings" "time" "github.com/tidwall/gjson" @@ -245,6 +247,126 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR // // Returns: // - string: A Claude-compatible JSON response. -func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, _ []byte, _ *any) string { - return "" +func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string { + _ = originalRequestRawJSON + _ = requestRawJSON + + root := gjson.ParseBytes(rawJSON) + + response := map[string]interface{}{ + "id": root.Get("responseId").String(), + "type": "message", + "role": "assistant", + "model": root.Get("modelVersion").String(), + "content": []interface{}{}, + "stop_reason": nil, + "stop_sequence": nil, + "usage": map[string]interface{}{ + "input_tokens": root.Get("usageMetadata.promptTokenCount").Int(), + "output_tokens": root.Get("usageMetadata.candidatesTokenCount").Int() + root.Get("usageMetadata.thoughtsTokenCount").Int(), + }, + } + + parts := root.Get("candidates.0.content.parts") + var contentBlocks []interface{} + textBuilder := strings.Builder{} + thinkingBuilder := strings.Builder{} + toolIDCounter := 0 + hasToolCall := false + + flushText := func() { + if textBuilder.Len() == 0 { + return + } + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "text", + "text": textBuilder.String(), + }) + textBuilder.Reset() + } + + flushThinking := func() { + if thinkingBuilder.Len() == 0 { + return + } + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "thinking", + "thinking": thinkingBuilder.String(), + }) + thinkingBuilder.Reset() + } + + if parts.IsArray() { + for _, part := range parts.Array() { + if text := part.Get("text"); text.Exists() && text.String() != "" { + if part.Get("thought").Bool() { + flushText() + thinkingBuilder.WriteString(text.String()) + continue + } + flushThinking() + textBuilder.WriteString(text.String()) + continue + } + + if functionCall := part.Get("functionCall"); functionCall.Exists() { + flushThinking() + flushText() + hasToolCall = true + + name := functionCall.Get("name").String() + toolIDCounter++ + toolBlock := map[string]interface{}{ + "type": "tool_use", + "id": fmt.Sprintf("tool_%d", toolIDCounter), + "name": name, + "input": map[string]interface{}{}, + } + + if args := functionCall.Get("args"); args.Exists() { + var parsed interface{} + if err := json.Unmarshal([]byte(args.Raw), &parsed); err == nil { + toolBlock["input"] = parsed + } + } + + contentBlocks = append(contentBlocks, toolBlock) + continue + } + } + } + + flushThinking() + flushText() + + response["content"] = contentBlocks + + stopReason := "end_turn" + if hasToolCall { + stopReason = "tool_use" + } else { + if finish := root.Get("candidates.0.finishReason"); finish.Exists() { + switch finish.String() { + case "MAX_TOKENS": + stopReason = "max_tokens" + case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN": + stopReason = "end_turn" + default: + stopReason = "end_turn" + } + } + } + response["stop_reason"] = stopReason + + if usage := response["usage"].(map[string]interface{}); usage["input_tokens"] == int64(0) && usage["output_tokens"] == int64(0) { + if usageMeta := root.Get("usageMetadata"); !usageMeta.Exists() { + delete(response, "usage") + } + } + + encoded, err := json.Marshal(response) + if err != nil { + return "" + } + return string(encoded) } diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index 20de6b99..522b36bd 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -13,6 +13,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) var ( @@ -450,6 +451,177 @@ func mapOpenAIFinishReasonToAnthropic(openAIReason string) string { // // Returns: // - string: An Anthropic-compatible JSON response. -func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, _ []byte, _ *any) string { - return "" +func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string { + _ = originalRequestRawJSON + _ = requestRawJSON + + root := gjson.ParseBytes(rawJSON) + + response := map[string]interface{}{ + "id": root.Get("id").String(), + "type": "message", + "role": "assistant", + "model": root.Get("model").String(), + "content": []interface{}{}, + "stop_reason": nil, + "stop_sequence": nil, + "usage": map[string]interface{}{ + "input_tokens": 0, + "output_tokens": 0, + }, + } + + var contentBlocks []interface{} + hasToolCall := false + + if choices := root.Get("choices"); choices.Exists() && choices.IsArray() && len(choices.Array()) > 0 { + choice := choices.Array()[0] + + if finishReason := choice.Get("finish_reason"); finishReason.Exists() { + response["stop_reason"] = mapOpenAIFinishReasonToAnthropic(finishReason.String()) + } + + if message := choice.Get("message"); message.Exists() { + if contentArray := message.Get("content"); contentArray.Exists() && contentArray.IsArray() { + var textBuilder strings.Builder + var thinkingBuilder strings.Builder + + flushText := func() { + if textBuilder.Len() == 0 { + return + } + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "text", + "text": textBuilder.String(), + }) + textBuilder.Reset() + } + + flushThinking := func() { + if thinkingBuilder.Len() == 0 { + return + } + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "thinking", + "thinking": thinkingBuilder.String(), + }) + thinkingBuilder.Reset() + } + + for _, item := range contentArray.Array() { + typeStr := item.Get("type").String() + switch typeStr { + case "text": + flushThinking() + textBuilder.WriteString(item.Get("text").String()) + case "tool_calls": + flushThinking() + flushText() + toolCalls := item.Get("tool_calls") + if toolCalls.IsArray() { + toolCalls.ForEach(func(_, tc gjson.Result) bool { + hasToolCall = true + toolUse := map[string]interface{}{ + "type": "tool_use", + "id": tc.Get("id").String(), + "name": tc.Get("function.name").String(), + } + + argsStr := util.FixJSON(tc.Get("function.arguments").String()) + if argsStr != "" { + var parsed interface{} + if err := json.Unmarshal([]byte(argsStr), &parsed); err == nil { + toolUse["input"] = parsed + } else { + toolUse["input"] = map[string]interface{}{} + } + } else { + toolUse["input"] = map[string]interface{}{} + } + + contentBlocks = append(contentBlocks, toolUse) + return true + }) + } + case "reasoning": + flushText() + if thinking := item.Get("text"); thinking.Exists() { + thinkingBuilder.WriteString(thinking.String()) + } + default: + flushThinking() + flushText() + } + } + + flushThinking() + flushText() + } + + if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() { + toolCalls.ForEach(func(_, toolCall gjson.Result) bool { + hasToolCall = true + toolUseBlock := map[string]interface{}{ + "type": "tool_use", + "id": toolCall.Get("id").String(), + "name": toolCall.Get("function.name").String(), + } + + argsStr := toolCall.Get("function.arguments").String() + argsStr = util.FixJSON(argsStr) + if argsStr != "" { + var args interface{} + if err := json.Unmarshal([]byte(argsStr), &args); err == nil { + toolUseBlock["input"] = args + } else { + toolUseBlock["input"] = map[string]interface{}{} + } + } else { + toolUseBlock["input"] = map[string]interface{}{} + } + + contentBlocks = append(contentBlocks, toolUseBlock) + return true + }) + } + } + } + + response["content"] = contentBlocks + + if respUsage := root.Get("usage"); respUsage.Exists() { + usageJSON := `{}` + usageJSON, _ = sjson.Set(usageJSON, "input_tokens", respUsage.Get("prompt_tokens").Int()) + usageJSON, _ = sjson.Set(usageJSON, "output_tokens", respUsage.Get("completion_tokens").Int()) + parsedUsage := gjson.Parse(usageJSON).Value().(map[string]interface{}) + response["usage"] = parsedUsage + } + + if response["stop_reason"] == nil { + if hasToolCall { + response["stop_reason"] = "tool_use" + } else { + response["stop_reason"] = "end_turn" + } + } + + if !hasToolCall { + if toolBlocks := response["content"].([]interface{}); len(toolBlocks) > 0 { + for _, block := range toolBlocks { + if m, ok := block.(map[string]interface{}); ok && m["type"] == "tool_use" { + hasToolCall = true + break + } + } + } + if hasToolCall { + response["stop_reason"] = "tool_use" + } + } + + responseJSON, err := json.Marshal(response) + if err != nil { + return "" + } + return string(responseJSON) }