Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
20
backend/internal/translator/openai/claude/init.go
Normal file
20
backend/internal/translator/openai/claude/init.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
Claude,
|
||||
OpenAI,
|
||||
ConvertClaudeRequestToOpenAI,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertOpenAIResponseToClaude,
|
||||
NonStream: ConvertOpenAIResponseToClaudeNonStream,
|
||||
TokenCount: ClaudeTokenCount,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertClaudeRequestToOpenAIWithCompatPreservesEmptySignatureThinking(t *testing.T) {
|
||||
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`)
|
||||
|
||||
withoutCompat := ConvertClaudeRequestToOpenAI("deepseek-v4", payload, false)
|
||||
if gjson.GetBytes(withoutCompat, "messages.0.reasoning_content").Exists() {
|
||||
t.Fatalf("default translation preserved empty-signature reasoning: %s", withoutCompat)
|
||||
}
|
||||
|
||||
withCompat := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
|
||||
if gjson.GetBytes(withCompat, "messages.0.reasoning_content").String() != "reason" {
|
||||
t.Fatalf("compat translation missing reasoning_content: %s", withCompat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAIWithCompatPreservesThinkingWithToolCalls(t *testing.T) {
|
||||
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""},{"type":"text","text":"Reading files."},{"type":"tool_use","id":"call_1","name":"Read","input":{"path":"main.go"}}]}]}`)
|
||||
|
||||
result := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
|
||||
assistant := gjson.GetBytes(result, "messages.0")
|
||||
if got := assistant.Get("reasoning_content").String(); got != "reason" {
|
||||
t.Fatalf("reasoning_content = %q, want %q; output: %s", got, "reason", result)
|
||||
}
|
||||
if !assistant.Get("tool_calls").Exists() {
|
||||
t.Fatalf("tool_calls missing from compatible translation: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAIWithCompatDoesNotAddReasoningWithoutThinking(t *testing.T) {
|
||||
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`)
|
||||
|
||||
result := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
|
||||
assistant := gjson.GetBytes(result, "messages.0")
|
||||
if assistant.Get("reasoning_content").Exists() {
|
||||
t.Fatalf("compatible translation added reasoning_content without thinking: %s", result)
|
||||
}
|
||||
if !assistant.Get("tool_calls").Exists() {
|
||||
t.Fatalf("tool_calls missing from compatible translation: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAIWithCompatPreservesIncompatibleThinking(t *testing.T) {
|
||||
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"claude#opaque"},{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`)
|
||||
|
||||
result := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
|
||||
assistant := gjson.GetBytes(result, "messages.0")
|
||||
if got := assistant.Get("reasoning_content").String(); got != "reason" {
|
||||
t.Fatalf("reasoning_content = %q, want %q; output: %s", got, "reason", result)
|
||||
}
|
||||
if !assistant.Get("tool_calls").Exists() {
|
||||
t.Fatalf("tool_calls missing from compatible translation: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAIWithoutCompatDoesNotAddReasoningForToolCalls(t *testing.T) {
|
||||
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`)
|
||||
|
||||
result := ConvertClaudeRequestToOpenAI("deepseek-v4", payload, false)
|
||||
if gjson.GetBytes(result, "messages.0.reasoning_content").Exists() {
|
||||
t.Fatalf("default translation added reasoning_content: %s", result)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,505 @@
|
|||
// Package claude provides request translation functionality for Anthropic to OpenAI API.
|
||||
// It handles parsing and transforming Anthropic API requests into OpenAI Chat Completions API format,
|
||||
// extracting model information, system instructions, message contents, and tool declarations.
|
||||
// The package performs JSON data transformation to ensure compatibility
|
||||
// between Anthropic API format and OpenAI API's expected format.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/thinking"
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ConvertClaudeRequestToOpenAI parses and transforms an Anthropic API request into OpenAI Chat Completions API format.
|
||||
// It extracts the model name, system instruction, message contents, and tool declarations
|
||||
// from the raw JSON request and returns them in the format expected by the OpenAI API.
|
||||
func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
return convertClaudeRequestToOpenAI(modelName, inputRawJSON, stream, false)
|
||||
}
|
||||
|
||||
// ConvertClaudeRequestToOpenAIWithCompat preserves assistant thinking text
|
||||
// for configured compatibility endpoints.
|
||||
func ConvertClaudeRequestToOpenAIWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
return convertClaudeRequestToOpenAI(modelName, inputRawJSON, stream, true)
|
||||
}
|
||||
|
||||
func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool, preserveThinkingBlocks bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
// Base OpenAI Chat Completions API template
|
||||
out := []byte(`{"model":"","messages":[]}`)
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
|
||||
// Model mapping
|
||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||
|
||||
// Max tokens
|
||||
if maxTokens := root.Get("max_tokens"); maxTokens.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
|
||||
}
|
||||
|
||||
// Temperature
|
||||
if temp := root.Get("temperature"); temp.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "temperature", temp.Float())
|
||||
} else if topP := root.Get("top_p"); topP.Exists() { // Top P
|
||||
out, _ = sjson.SetBytes(out, "top_p", topP.Float())
|
||||
}
|
||||
|
||||
// Stop sequences -> stop
|
||||
if stopSequences := root.Get("stop_sequences"); stopSequences.Exists() {
|
||||
if stopSequences.IsArray() {
|
||||
var stops []string
|
||||
stopSequences.ForEach(func(_, value gjson.Result) bool {
|
||||
stops = append(stops, value.String())
|
||||
return true
|
||||
})
|
||||
if len(stops) > 0 {
|
||||
out, _ = sjson.SetBytes(out, "stop", stops)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream
|
||||
out, _ = sjson.SetBytes(out, "stream", stream)
|
||||
|
||||
// Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort
|
||||
if thinkingConfig := root.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
|
||||
if thinkingType := thinkingConfig.Get("type"); thinkingType.Exists() {
|
||||
switch thinkingType.String() {
|
||||
case "enabled":
|
||||
if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() {
|
||||
budget := int(budgetTokens.Int())
|
||||
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" {
|
||||
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
|
||||
}
|
||||
} else {
|
||||
// No budget_tokens specified, default to "auto" for enabled thinking
|
||||
if effort, ok := thinking.ConvertBudgetToLevel(-1); ok && effort != "" {
|
||||
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
|
||||
}
|
||||
}
|
||||
case "adaptive", "auto":
|
||||
// Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6).
|
||||
// Pass through directly; ApplyThinking handles clamping to target model's levels.
|
||||
effort := ""
|
||||
if v := root.Get("output_config.effort"); v.Exists() && v.Type == gjson.String {
|
||||
effort = strings.ToLower(strings.TrimSpace(v.String()))
|
||||
}
|
||||
if effort != "" {
|
||||
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, "reasoning_effort", string(thinking.LevelXHigh))
|
||||
}
|
||||
case "disabled":
|
||||
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
|
||||
out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process messages and system.
|
||||
messageCapacity := root.Get("messages.#").Int()
|
||||
if root.Get("system").Exists() {
|
||||
messageCapacity++
|
||||
}
|
||||
messageItems := translatorcommon.NewRawArrayItems(messageCapacity)
|
||||
|
||||
// Handle system message first.
|
||||
systemContentItems := make([][]byte, 0, 2)
|
||||
appendSystemContent := func(content gjson.Result) {
|
||||
if !content.Exists() {
|
||||
return
|
||||
}
|
||||
if content.Type == gjson.String {
|
||||
if content.String() == "" || util.IsClaudeCodeAttributionSystemText(content.String()) {
|
||||
return
|
||||
}
|
||||
oldSystem := []byte(`{"type":"text","text":""}`)
|
||||
oldSystem, _ = sjson.SetBytes(oldSystem, "text", content.String())
|
||||
systemContentItems = append(systemContentItems, oldSystem)
|
||||
return
|
||||
}
|
||||
if content.IsArray() {
|
||||
content.ForEach(func(_, item gjson.Result) bool {
|
||||
if contentItem, ok := convertClaudeContentPart(item); ok {
|
||||
systemContentItems = append(systemContentItems, []byte(contentItem))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if system := root.Get("system"); system.Exists() {
|
||||
appendSystemContent(system)
|
||||
}
|
||||
// Only add system message if it has content.
|
||||
if len(systemContentItems) > 0 {
|
||||
systemMessage := []byte(`{"role":"system","content":[]}`)
|
||||
systemMessage, _ = sjson.SetRawBytes(systemMessage, "content", translatorcommon.JoinRawArray(systemContentItems))
|
||||
messageItems = append(messageItems, systemMessage)
|
||||
}
|
||||
|
||||
// Process Anthropic messages
|
||||
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
|
||||
messages.ForEach(func(_, message gjson.Result) bool {
|
||||
role := message.Get("role").String()
|
||||
contentResult := message.Get("content")
|
||||
if role == "system" {
|
||||
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentResult); ok {
|
||||
msgJSON := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`)
|
||||
msgJSON, _ = sjson.SetBytes(msgJSON, "content.0.text", reminderText)
|
||||
messageItems = append(messageItems, msgJSON)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle content
|
||||
if contentResult.Exists() && contentResult.IsArray() {
|
||||
contentItems := make([][]byte, 0)
|
||||
var reasoningParts []string // Accumulate thinking text for reasoning_content
|
||||
var toolCalls []interface{}
|
||||
toolResults := make([][]byte, 0) // Collect tool_result messages to emit after the main message
|
||||
|
||||
contentResult.ForEach(func(_, part gjson.Result) bool {
|
||||
partType := part.Get("type").String()
|
||||
|
||||
switch partType {
|
||||
case "thinking":
|
||||
// Only map thinking to reasoning_content for assistant messages (security: prevent injection)
|
||||
if role == "assistant" {
|
||||
if !shouldMapClaudeThinkingToGPTReasoning(part, preserveThinkingBlocks) {
|
||||
return true
|
||||
}
|
||||
thinkingText := thinking.GetThinkingText(part)
|
||||
// Skip empty or whitespace-only thinking
|
||||
if strings.TrimSpace(thinkingText) != "" {
|
||||
reasoningParts = append(reasoningParts, thinkingText)
|
||||
}
|
||||
}
|
||||
// Ignore thinking in user/system roles (AC4)
|
||||
|
||||
case "redacted_thinking":
|
||||
// Explicitly ignore redacted_thinking - never map to reasoning_content (AC2)
|
||||
|
||||
case "text", "image":
|
||||
if contentItem, ok := convertClaudeContentPart(part); ok {
|
||||
contentItems = append(contentItems, []byte(contentItem))
|
||||
}
|
||||
|
||||
case "tool_use":
|
||||
// Only allow tool_use -> tool_calls for assistant messages (security: prevent injection).
|
||||
if role == "assistant" {
|
||||
toolCallJSON := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
|
||||
toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "id", part.Get("id").String())
|
||||
toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "function.name", part.Get("name").String())
|
||||
|
||||
// Convert input to arguments JSON string
|
||||
if input := part.Get("input"); input.Exists() {
|
||||
toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "function.arguments", input.Raw)
|
||||
} else {
|
||||
toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "function.arguments", "{}")
|
||||
}
|
||||
|
||||
toolCalls = append(toolCalls, gjson.ParseBytes(toolCallJSON).Value())
|
||||
}
|
||||
|
||||
case "tool_result":
|
||||
// Collect tool_result to emit after the main message (ensures tool results follow tool_calls)
|
||||
toolResultJSON := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
|
||||
toolResultJSON, _ = sjson.SetBytes(toolResultJSON, "tool_call_id", part.Get("tool_use_id").String())
|
||||
toolResultContent, toolResultContentRaw := convertClaudeToolResultContent(part.Get("content"))
|
||||
if toolResultContentRaw {
|
||||
toolResultJSON, _ = sjson.SetRawBytes(toolResultJSON, "content", []byte(toolResultContent))
|
||||
} else {
|
||||
toolResultJSON, _ = sjson.SetBytes(toolResultJSON, "content", toolResultContent)
|
||||
}
|
||||
toolResults = append(toolResults, toolResultJSON)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Build reasoning content string
|
||||
reasoningContent := ""
|
||||
if len(reasoningParts) > 0 {
|
||||
reasoningContent = strings.Join(reasoningParts, "\n\n")
|
||||
}
|
||||
|
||||
hasContent := len(contentItems) > 0
|
||||
hasReasoning := reasoningContent != ""
|
||||
hasToolCalls := len(toolCalls) > 0
|
||||
hasToolResults := len(toolResults) > 0
|
||||
|
||||
// OpenAI requires: tool messages MUST immediately follow the assistant message with tool_calls.
|
||||
// Therefore, we emit tool_result messages FIRST (they respond to the previous assistant's tool_calls),
|
||||
// then emit the current message's content.
|
||||
messageItems = append(messageItems, toolResults...)
|
||||
|
||||
// For assistant messages: emit a single unified message with content, tool_calls, and reasoning_content
|
||||
// This avoids splitting into multiple assistant messages which breaks OpenAI tool-call adjacency
|
||||
if role == "assistant" {
|
||||
if hasContent || hasReasoning || hasToolCalls {
|
||||
msgJSON := []byte(`{"role":"assistant"}`)
|
||||
|
||||
// Add content (as array if we have items, empty string if reasoning-only)
|
||||
if hasContent {
|
||||
msgJSON, _ = sjson.SetRawBytes(msgJSON, "content", translatorcommon.JoinRawArray(contentItems))
|
||||
} else {
|
||||
// Ensure content field exists for OpenAI compatibility
|
||||
msgJSON, _ = sjson.SetBytes(msgJSON, "content", "")
|
||||
}
|
||||
|
||||
// Add reasoning_content if present
|
||||
if hasReasoning {
|
||||
msgJSON, _ = sjson.SetBytes(msgJSON, "reasoning_content", reasoningContent)
|
||||
}
|
||||
|
||||
// Add tool_calls if present (in same message as content)
|
||||
if hasToolCalls {
|
||||
msgJSON, _ = sjson.SetBytes(msgJSON, "tool_calls", toolCalls)
|
||||
}
|
||||
|
||||
messageItems = append(messageItems, msgJSON)
|
||||
}
|
||||
} else {
|
||||
// For non-assistant roles: emit content message if we have content
|
||||
// If the message only contains tool_results (no text/image), we still processed them above
|
||||
if hasContent {
|
||||
msgJSON := []byte(`{"role":""}`)
|
||||
msgJSON, _ = sjson.SetBytes(msgJSON, "role", role)
|
||||
|
||||
msgJSON, _ = sjson.SetRawBytes(msgJSON, "content", translatorcommon.JoinRawArray(contentItems))
|
||||
messageItems = append(messageItems, msgJSON)
|
||||
} else if hasToolResults && !hasContent {
|
||||
// tool_results already emitted above, no additional user message needed
|
||||
}
|
||||
}
|
||||
|
||||
} else if contentResult.Exists() && contentResult.Type == gjson.String {
|
||||
// Simple string content
|
||||
msgJSON := []byte(`{"role":"","content":""}`)
|
||||
msgJSON, _ = sjson.SetBytes(msgJSON, "role", role)
|
||||
msgJSON, _ = sjson.SetBytes(msgJSON, "content", contentResult.String())
|
||||
messageItems = append(messageItems, msgJSON)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Set messages.
|
||||
if len(messageItems) > 0 {
|
||||
out = translatorcommon.SetRawArrayItems(out, "messages", messageItems)
|
||||
}
|
||||
|
||||
// Process tools - convert Anthropic tools to OpenAI functions
|
||||
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
|
||||
var toolItems [][]byte
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
openAIToolJSON := []byte(`{"type":"function","function":{"name":"","description":""}}`)
|
||||
openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.name", tool.Get("name").String())
|
||||
openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.description", tool.Get("description").String())
|
||||
|
||||
// Convert Anthropic input_schema to OpenAI function parameters
|
||||
if inputSchema := tool.Get("input_schema"); inputSchema.Exists() {
|
||||
openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.parameters", normalizeObjectSchemaProperties(inputSchema.Value()))
|
||||
}
|
||||
|
||||
toolItems = append(toolItems, openAIToolJSON)
|
||||
return true
|
||||
})
|
||||
|
||||
if len(toolItems) > 0 {
|
||||
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
|
||||
}
|
||||
}
|
||||
|
||||
// Tool choice mapping - convert Anthropic tool_choice to OpenAI format
|
||||
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
|
||||
switch toolChoice.Get("type").String() {
|
||||
case "auto":
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
|
||||
case "any":
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", "required")
|
||||
case "tool":
|
||||
// Specific tool choice
|
||||
toolName := toolChoice.Get("name").String()
|
||||
toolChoiceJSON := []byte(`{"type":"function","function":{"name":""}}`)
|
||||
toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "function.name", toolName)
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
|
||||
default:
|
||||
// Default to auto if not specified
|
||||
out, _ = sjson.SetBytes(out, "tool_choice", "auto")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle user parameter (for tracking)
|
||||
if user := root.Get("user"); user.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "user", user.String())
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeObjectSchemaProperties(schema any) any {
|
||||
switch value := schema.(type) {
|
||||
case map[string]any:
|
||||
if schemaType, ok := value["type"].(string); ok && schemaType == "object" {
|
||||
if _, ok := value["properties"]; !ok {
|
||||
value["properties"] = map[string]any{}
|
||||
}
|
||||
}
|
||||
for key, child := range value {
|
||||
value[key] = normalizeObjectSchemaProperties(child)
|
||||
}
|
||||
return value
|
||||
case []any:
|
||||
for i, child := range value {
|
||||
value[i] = normalizeObjectSchemaProperties(child)
|
||||
}
|
||||
return value
|
||||
default:
|
||||
return schema
|
||||
}
|
||||
}
|
||||
|
||||
func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result, preserveThinkingBlocks ...bool) bool {
|
||||
preserveThinking := len(preserveThinkingBlocks) > 0 && preserveThinkingBlocks[0]
|
||||
if preserveThinking {
|
||||
return true
|
||||
}
|
||||
|
||||
signature := part.Get("signature")
|
||||
if !signature.Exists() || strings.TrimSpace(signature.String()) == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, signature.String())
|
||||
return ok
|
||||
}
|
||||
|
||||
func convertClaudeContentPart(part gjson.Result) (string, bool) {
|
||||
partType := part.Get("type").String()
|
||||
|
||||
switch partType {
|
||||
case "text":
|
||||
text := part.Get("text").String()
|
||||
if strings.TrimSpace(text) == "" || util.IsClaudeCodeAttributionSystemText(text) {
|
||||
return "", false
|
||||
}
|
||||
textContent := []byte(`{"type":"text","text":""}`)
|
||||
textContent, _ = sjson.SetBytes(textContent, "text", text)
|
||||
return string(textContent), true
|
||||
|
||||
case "image":
|
||||
var imageURL string
|
||||
|
||||
if source := part.Get("source"); source.Exists() {
|
||||
sourceType := source.Get("type").String()
|
||||
switch sourceType {
|
||||
case "base64":
|
||||
mediaType := source.Get("media_type").String()
|
||||
if mediaType == "" {
|
||||
mediaType = "application/octet-stream"
|
||||
}
|
||||
data := source.Get("data").String()
|
||||
if data != "" {
|
||||
imageURL = "data:" + mediaType + ";base64," + data
|
||||
}
|
||||
case "url":
|
||||
imageURL = source.Get("url").String()
|
||||
}
|
||||
}
|
||||
|
||||
if imageURL == "" {
|
||||
imageURL = part.Get("url").String()
|
||||
}
|
||||
|
||||
if imageURL == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
imageContent := []byte(`{"type":"image_url","image_url":{"url":""}}`)
|
||||
imageContent, _ = sjson.SetBytes(imageContent, "image_url.url", imageURL)
|
||||
|
||||
return string(imageContent), true
|
||||
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func convertClaudeToolResultContent(content gjson.Result) (string, bool) {
|
||||
if !content.Exists() {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if content.Type == gjson.String {
|
||||
return content.String(), false
|
||||
}
|
||||
|
||||
if content.IsArray() {
|
||||
var parts []string
|
||||
contentItems := make([][]byte, 0, 4)
|
||||
hasImagePart := false
|
||||
content.ForEach(func(_, item gjson.Result) bool {
|
||||
switch {
|
||||
case item.Type == gjson.String:
|
||||
text := item.String()
|
||||
parts = append(parts, text)
|
||||
textContent := []byte(`{"type":"text","text":""}`)
|
||||
textContent, _ = sjson.SetBytes(textContent, "text", text)
|
||||
contentItems = append(contentItems, textContent)
|
||||
case item.IsObject() && item.Get("type").String() == "text":
|
||||
text := item.Get("text").String()
|
||||
parts = append(parts, text)
|
||||
textContent := []byte(`{"type":"text","text":""}`)
|
||||
textContent, _ = sjson.SetBytes(textContent, "text", text)
|
||||
contentItems = append(contentItems, textContent)
|
||||
case item.IsObject() && item.Get("type").String() == "image":
|
||||
contentItem, ok := convertClaudeContentPart(item)
|
||||
if ok {
|
||||
contentItems = append(contentItems, []byte(contentItem))
|
||||
hasImagePart = true
|
||||
} else {
|
||||
parts = append(parts, item.Raw)
|
||||
}
|
||||
case item.IsObject() && item.Get("text").Exists() && item.Get("text").Type == gjson.String:
|
||||
parts = append(parts, item.Get("text").String())
|
||||
default:
|
||||
parts = append(parts, item.Raw)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if hasImagePart {
|
||||
return string(translatorcommon.JoinRawArray(contentItems)), true
|
||||
}
|
||||
|
||||
joined := strings.Join(parts, "\n\n")
|
||||
if strings.TrimSpace(joined) != "" {
|
||||
return joined, false
|
||||
}
|
||||
return content.Raw, false
|
||||
}
|
||||
|
||||
if content.IsObject() {
|
||||
if content.Get("type").String() == "image" {
|
||||
contentItem, ok := convertClaudeContentPart(content)
|
||||
if ok {
|
||||
return string(translatorcommon.JoinRawArray([][]byte{[]byte(contentItem)})), true
|
||||
}
|
||||
}
|
||||
if text := content.Get("text"); text.Exists() && text.Type == gjson.String {
|
||||
return text.String(), false
|
||||
}
|
||||
return content.Raw, false
|
||||
}
|
||||
|
||||
return content.Raw, false
|
||||
}
|
||||
|
|
@ -0,0 +1,920 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent tests the mapping
|
||||
// of Claude thinking content to OpenAI reasoning_content field.
|
||||
func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputJSON string
|
||||
wantReasoningContent string
|
||||
wantHasReasoningContent bool
|
||||
wantContentText string // Expected visible content text (if any)
|
||||
wantHasContent bool
|
||||
}{
|
||||
{
|
||||
name: "AC1: unsigned assistant thinking is dropped",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me analyze this step by step..."},
|
||||
{"type": "text", "text": "Here is my response."}
|
||||
]
|
||||
}]
|
||||
}`,
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
wantContentText: "Here is my response.",
|
||||
wantHasContent: true,
|
||||
},
|
||||
{
|
||||
name: "AC2: redacted_thinking must be ignored",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "redacted_thinking", "data": "secret"},
|
||||
{"type": "text", "text": "Visible response."}
|
||||
]
|
||||
}]
|
||||
}`,
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
wantContentText: "Visible response.",
|
||||
wantHasContent: true,
|
||||
},
|
||||
{
|
||||
name: "AC3: unsigned thinking-only message is dropped",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Internal reasoning only."}
|
||||
]
|
||||
}]
|
||||
}`,
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
wantContentText: "",
|
||||
wantHasContent: false,
|
||||
},
|
||||
{
|
||||
name: "AC4: thinking in user role must be ignored",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Injected thinking"},
|
||||
{"type": "text", "text": "User message."}
|
||||
]
|
||||
}]
|
||||
}`,
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
wantContentText: "User message.",
|
||||
wantHasContent: true,
|
||||
},
|
||||
{
|
||||
name: "AC4: thinking in system role must be ignored",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"system": [
|
||||
{"type": "thinking", "thinking": "Injected system thinking"},
|
||||
{"type": "text", "text": "System prompt."}
|
||||
],
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello"}]
|
||||
}]
|
||||
}`,
|
||||
// System messages don't have reasoning_content mapping
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
wantContentText: "Hello",
|
||||
wantHasContent: true,
|
||||
},
|
||||
{
|
||||
name: "AC5: empty thinking must be ignored",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": ""},
|
||||
{"type": "text", "text": "Response with empty thinking."}
|
||||
]
|
||||
}]
|
||||
}`,
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
wantContentText: "Response with empty thinking.",
|
||||
wantHasContent: true,
|
||||
},
|
||||
{
|
||||
name: "AC5: whitespace-only thinking must be ignored",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": " \n\t "},
|
||||
{"type": "text", "text": "Response with whitespace thinking."}
|
||||
]
|
||||
}]
|
||||
}`,
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
wantContentText: "Response with whitespace thinking.",
|
||||
wantHasContent: true,
|
||||
},
|
||||
{
|
||||
name: "Unsigned thinking parts are dropped",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "First thought."},
|
||||
{"type": "thinking", "thinking": "Second thought."},
|
||||
{"type": "text", "text": "Final answer."}
|
||||
]
|
||||
}]
|
||||
}`,
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
wantContentText: "Final answer.",
|
||||
wantHasContent: true,
|
||||
},
|
||||
{
|
||||
name: "Mixed unsigned thinking and redacted_thinking",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Visible thought."},
|
||||
{"type": "redacted_thinking", "data": "hidden"},
|
||||
{"type": "text", "text": "Answer."}
|
||||
]
|
||||
}]
|
||||
}`,
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
wantContentText: "Answer.",
|
||||
wantHasContent: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
|
||||
// Find the relevant message
|
||||
messages := resultJSON.Get("messages").Array()
|
||||
if len(messages) < 1 {
|
||||
if tt.wantHasReasoningContent || tt.wantHasContent {
|
||||
t.Fatalf("Expected at least 1 message, got %d", len(messages))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check the last non-system message
|
||||
var targetMsg gjson.Result
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Get("role").String() != "system" {
|
||||
targetMsg = messages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check reasoning_content
|
||||
gotReasoningContent := targetMsg.Get("reasoning_content").String()
|
||||
gotHasReasoningContent := targetMsg.Get("reasoning_content").Exists()
|
||||
|
||||
if gotHasReasoningContent != tt.wantHasReasoningContent {
|
||||
t.Errorf("reasoning_content existence = %v, want %v", gotHasReasoningContent, tt.wantHasReasoningContent)
|
||||
}
|
||||
|
||||
if gotReasoningContent != tt.wantReasoningContent {
|
||||
t.Errorf("reasoning_content = %q, want %q", gotReasoningContent, tt.wantReasoningContent)
|
||||
}
|
||||
|
||||
// Check content
|
||||
content := targetMsg.Get("content")
|
||||
// content has meaningful content if it's a non-empty array, or a non-empty string
|
||||
var gotHasContent bool
|
||||
switch {
|
||||
case content.IsArray():
|
||||
gotHasContent = len(content.Array()) > 0
|
||||
case content.Type == gjson.String:
|
||||
gotHasContent = content.String() != ""
|
||||
default:
|
||||
gotHasContent = false
|
||||
}
|
||||
|
||||
if gotHasContent != tt.wantHasContent {
|
||||
t.Errorf("content existence = %v, want %v", gotHasContent, tt.wantHasContent)
|
||||
}
|
||||
|
||||
if tt.wantHasContent && tt.wantContentText != "" {
|
||||
// Find text content
|
||||
var foundText string
|
||||
content.ForEach(func(_, v gjson.Result) bool {
|
||||
if v.Get("type").String() == "text" {
|
||||
foundText = v.Get("text").String()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if foundText != tt.wantContentText {
|
||||
t.Errorf("content text = %q, want %q", foundText, tt.wantContentText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_SignedThinkingCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
signature string
|
||||
wantReasoningContent string
|
||||
wantHasReasoningContent bool
|
||||
}{
|
||||
{
|
||||
name: "GPT-compatible signature keeps reasoning_content",
|
||||
signature: validGPTChatReasoningSignature(),
|
||||
wantReasoningContent: "provider state",
|
||||
wantHasReasoningContent: true,
|
||||
},
|
||||
{
|
||||
name: "Claude signature drops reasoning_content",
|
||||
signature: "claude#EjQ=",
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
},
|
||||
{
|
||||
name: "Gemini signature drops reasoning_content",
|
||||
signature: "gemini#EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA",
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
},
|
||||
{
|
||||
name: "Unknown signature drops reasoning_content",
|
||||
signature: "not-a-provider-signature",
|
||||
wantReasoningContent: "",
|
||||
wantHasReasoningContent: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "provider state", "signature": "` + tt.signature + `"},
|
||||
{"type": "text", "text": "visible answer"}
|
||||
]
|
||||
}]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToOpenAI("gpt-5", []byte(inputJSON), false)
|
||||
assistantMsg := gjson.GetBytes(result, "messages.0")
|
||||
gotReasoningContent := assistantMsg.Get("reasoning_content").String()
|
||||
gotHasReasoningContent := assistantMsg.Get("reasoning_content").Exists()
|
||||
|
||||
if gotHasReasoningContent != tt.wantHasReasoningContent {
|
||||
t.Fatalf("reasoning_content exists = %v, want %v. Output: %s", gotHasReasoningContent, tt.wantHasReasoningContent, string(result))
|
||||
}
|
||||
if gotReasoningContent != tt.wantReasoningContent {
|
||||
t.Fatalf("reasoning_content = %q, want %q. Output: %s", gotReasoningContent, tt.wantReasoningContent, string(result))
|
||||
}
|
||||
if got := assistantMsg.Get("content.0.text").String(); got != "visible answer" {
|
||||
t.Fatalf("visible content = %q, want visible answer. Output: %s", got, string(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertClaudeRequestToOpenAI_UnsignedThinkingOnlyMessageDropped verifies
|
||||
// that unsigned Claude thinking is not migrated into GPT reasoning state.
|
||||
func TestConvertClaudeRequestToOpenAI_UnsignedThinkingOnlyMessageDropped(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "What is 2+2?"}]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "thinking", "thinking": "Let me calculate: 2+2=4"}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Thanks"}]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
|
||||
messages := resultJSON.Get("messages").Array()
|
||||
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("Expected unsigned thinking-only assistant message to be dropped, got %d. Messages: %v", len(messages), resultJSON.Get("messages").Raw)
|
||||
}
|
||||
for _, message := range messages {
|
||||
if message.Get("reasoning_content").Exists() {
|
||||
t.Fatalf("unsigned thinking should not produce reasoning_content. Messages: %v", resultJSON.Get("messages").Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validGPTChatReasoningSignature() string {
|
||||
raw := make([]byte, 1+8+16+16+32)
|
||||
raw[0] = 0x80
|
||||
raw[8] = 1
|
||||
for i := 9; i < len(raw); i++ {
|
||||
raw[i] = byte(i)
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_MessageSystemRoleWrapsAsUserReminder(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"system": [{"type": "text", "text": "Top-level rules"}],
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
|
||||
{"role": "system", "content": "String mid-conversation rule"},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "Hi there"}]},
|
||||
{"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]},
|
||||
{"role": "user", "content": [{"type": "text", "text": "Follow up"}]}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToOpenAI("gpt-5", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
messages := resultJSON.Get("messages").Array()
|
||||
|
||||
if len(messages) != 6 {
|
||||
t.Fatalf("Expected 6 messages, got %d: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||
}
|
||||
|
||||
roles := make([]string, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
roles = append(roles, message.Get("role").String())
|
||||
}
|
||||
if got, want := roles, []string{"system", "user", "user", "assistant", "user", "user"}; fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
|
||||
t.Fatalf("Unexpected message roles: got %v, want %v", got, want)
|
||||
}
|
||||
|
||||
systemContent := messages[0].Get("content").Array()
|
||||
if len(systemContent) != 1 {
|
||||
t.Fatalf("Expected only top-level system content, got %d items: %s", len(systemContent), messages[0].Get("content").Raw)
|
||||
}
|
||||
if got := systemContent[0].Get("text").String(); got != "Top-level rules" {
|
||||
t.Fatalf("system content = %q, want Top-level rules", got)
|
||||
}
|
||||
if got := messages[2].Get("content.0.text").String(); got != "<system-reminder>\nString mid-conversation rule\n</system-reminder>" {
|
||||
t.Fatalf("unexpected string reminder text: %q", got)
|
||||
}
|
||||
if got := messages[4].Get("content.0.text").String(); got != "<system-reminder>\nArray mid-conversation rule\n</system-reminder>" {
|
||||
t.Fatalf("unexpected array reminder text: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_SystemMessageScenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputJSON string
|
||||
wantHasSys bool
|
||||
wantSysText string
|
||||
}{
|
||||
{
|
||||
name: "No system field",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}`,
|
||||
wantHasSys: false,
|
||||
},
|
||||
{
|
||||
name: "Empty string system field",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"system": "",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}`,
|
||||
wantHasSys: false,
|
||||
},
|
||||
{
|
||||
name: "String system field",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"system": "Be helpful",
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}`,
|
||||
wantHasSys: true,
|
||||
wantSysText: "Be helpful",
|
||||
},
|
||||
{
|
||||
name: "Array system field with text",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"system": [{"type": "text", "text": "Array system"}],
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}`,
|
||||
wantHasSys: true,
|
||||
wantSysText: "Array system",
|
||||
},
|
||||
{
|
||||
name: "Array system field with multiple text blocks",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"system": [
|
||||
{"type": "text", "text": "Block 1"},
|
||||
{"type": "text", "text": "Block 2"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}`,
|
||||
wantHasSys: true,
|
||||
wantSysText: "Block 2", // We will update the test logic to check all blocks or specifically the second one
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
messages := resultJSON.Get("messages").Array()
|
||||
|
||||
hasSys := false
|
||||
var sysMsg gjson.Result
|
||||
if len(messages) > 0 && messages[0].Get("role").String() == "system" {
|
||||
hasSys = true
|
||||
sysMsg = messages[0]
|
||||
}
|
||||
|
||||
if hasSys != tt.wantHasSys {
|
||||
t.Errorf("got hasSystem = %v, want %v", hasSys, tt.wantHasSys)
|
||||
}
|
||||
|
||||
if tt.wantHasSys {
|
||||
// Check content - it could be string or array in OpenAI
|
||||
content := sysMsg.Get("content")
|
||||
var gotText string
|
||||
if content.IsArray() {
|
||||
arr := content.Array()
|
||||
if len(arr) > 0 {
|
||||
// Get the last element's text for validation
|
||||
gotText = arr[len(arr)-1].Get("text").String()
|
||||
}
|
||||
} else {
|
||||
gotText = content.String()
|
||||
}
|
||||
|
||||
if tt.wantSysText != "" && gotText != tt.wantSysText {
|
||||
t.Errorf("got system text = %q, want %q", gotText, tt.wantSysText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_ToolSchemaAddsMissingObjectProperties(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-3-opus",
|
||||
"tools": [
|
||||
{
|
||||
"name": "empty_params",
|
||||
"description": "No args",
|
||||
"input_schema": {"type": "object"}
|
||||
},
|
||||
{
|
||||
"name": "nested_params",
|
||||
"description": "Nested args",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nested": {"type": "object"},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "hello"}]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToOpenAI("test-model", inputJSON, false)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
if got := outputJSON.Get("tools.0.function.parameters.properties"); !got.Exists() || !got.IsObject() {
|
||||
t.Fatalf("root object properties missing or invalid: %s", outputJSON.Get("tools.0.function.parameters").Raw)
|
||||
}
|
||||
if got := outputJSON.Get("tools.1.function.parameters.properties.nested.properties"); !got.Exists() || !got.IsObject() {
|
||||
t.Fatalf("nested object properties missing or invalid: %s", outputJSON.Get("tools.1.function.parameters").Raw)
|
||||
}
|
||||
if got := outputJSON.Get("tools.1.function.parameters.properties.items.items.properties"); !got.Exists() || !got.IsObject() {
|
||||
t.Fatalf("array item object properties missing or invalid: %s", outputJSON.Get("tools.1.function.parameters").Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "before"},
|
||||
{"type": "tool_result", "tool_use_id": "call_1", "content": [{"type":"text","text":"tool ok"}]},
|
||||
{"type": "text", "text": "after"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
messages := resultJSON.Get("messages").Array()
|
||||
|
||||
// OpenAI requires: tool messages MUST immediately follow assistant(tool_calls).
|
||||
// Correct order: assistant(tool_calls) + tool(result) + user(before+after)
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("Expected 3 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||
}
|
||||
|
||||
if messages[0].Get("role").String() != "assistant" || !messages[0].Get("tool_calls").Exists() {
|
||||
t.Fatalf("Expected messages[0] to be assistant tool_calls, got %s: %s", messages[0].Get("role").String(), messages[0].Raw)
|
||||
}
|
||||
|
||||
// tool message MUST immediately follow assistant(tool_calls) per OpenAI spec
|
||||
if messages[1].Get("role").String() != "tool" {
|
||||
t.Fatalf("Expected messages[1] to be tool (must follow tool_calls), got %s", messages[1].Get("role").String())
|
||||
}
|
||||
if got := messages[1].Get("tool_call_id").String(); got != "call_1" {
|
||||
t.Fatalf("Expected tool_call_id %q, got %q", "call_1", got)
|
||||
}
|
||||
if got := messages[1].Get("content").String(); got != "tool ok" {
|
||||
t.Fatalf("Expected tool content %q, got %q", "tool ok", got)
|
||||
}
|
||||
|
||||
// User message comes after tool message
|
||||
if messages[2].Get("role").String() != "user" {
|
||||
t.Fatalf("Expected messages[2] to be user, got %s", messages[2].Get("role").String())
|
||||
}
|
||||
// User message should contain both "before" and "after" text
|
||||
if got := messages[2].Get("content.0.text").String(); got != "before" {
|
||||
t.Fatalf("Expected user text[0] %q, got %q", "before", got)
|
||||
}
|
||||
if got := messages[2].Get("content.1.text").String(); got != "after" {
|
||||
t.Fatalf("Expected user text[1] %q, got %q", "after", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_ToolResultObjectContent(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "call_1", "content": {"foo": "bar"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
messages := resultJSON.Get("messages").Array()
|
||||
|
||||
// assistant(tool_calls) + tool(result)
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||
}
|
||||
|
||||
if messages[1].Get("role").String() != "tool" {
|
||||
t.Fatalf("Expected messages[1] to be tool, got %s", messages[1].Get("role").String())
|
||||
}
|
||||
|
||||
toolContent := messages[1].Get("content").String()
|
||||
parsed := gjson.Parse(toolContent)
|
||||
if parsed.Get("foo").String() != "bar" {
|
||||
t.Fatalf("Expected tool content JSON foo=bar, got %q", toolContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_ToolResultTextAndImageContent(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_1",
|
||||
"content": [
|
||||
{"type": "text", "text": "tool ok"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "iVBORw0KGgoAAAANSUhEUg=="
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
messages := resultJSON.Get("messages").Array()
|
||||
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||
}
|
||||
|
||||
toolContent := messages[1].Get("content")
|
||||
if !toolContent.IsArray() {
|
||||
t.Fatalf("Expected tool content array, got %s", toolContent.Raw)
|
||||
}
|
||||
if got := toolContent.Get("0.type").String(); got != "text" {
|
||||
t.Fatalf("Expected first tool content type %q, got %q", "text", got)
|
||||
}
|
||||
if got := toolContent.Get("0.text").String(); got != "tool ok" {
|
||||
t.Fatalf("Expected first tool content text %q, got %q", "tool ok", got)
|
||||
}
|
||||
if got := toolContent.Get("1.type").String(); got != "image_url" {
|
||||
t.Fatalf("Expected second tool content type %q, got %q", "image_url", got)
|
||||
}
|
||||
if got := toolContent.Get("1.image_url.url").String(); got != "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==" {
|
||||
t.Fatalf("Unexpected image_url: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_ToolResultURLImageOnly(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "call_1",
|
||||
"content": {
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "url",
|
||||
"url": "https://example.com/tool.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
messages := resultJSON.Get("messages").Array()
|
||||
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("Expected 2 messages, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||
}
|
||||
|
||||
toolContent := messages[1].Get("content")
|
||||
if !toolContent.IsArray() {
|
||||
t.Fatalf("Expected tool content array, got %s", toolContent.Raw)
|
||||
}
|
||||
if got := toolContent.Get("0.type").String(); got != "image_url" {
|
||||
t.Fatalf("Expected tool content type %q, got %q", "image_url", got)
|
||||
}
|
||||
if got := toolContent.Get("0.image_url.url").String(); got != "https://example.com/tool.png" {
|
||||
t.Fatalf("Unexpected image_url: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_AssistantTextToolUseTextOrder(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "pre"},
|
||||
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}},
|
||||
{"type": "text", "text": "post"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
messages := resultJSON.Get("messages").Array()
|
||||
|
||||
// New behavior: content + tool_calls unified in single assistant message
|
||||
// Expect: assistant(content[pre,post] + tool_calls)
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("Expected 1 message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||
}
|
||||
|
||||
assistantMsg := messages[0]
|
||||
if assistantMsg.Get("role").String() != "assistant" {
|
||||
t.Fatalf("Expected messages[0] to be assistant, got %s", assistantMsg.Get("role").String())
|
||||
}
|
||||
|
||||
// Should have both content and tool_calls in same message
|
||||
if !assistantMsg.Get("tool_calls").Exists() {
|
||||
t.Fatalf("Expected assistant message to have tool_calls")
|
||||
}
|
||||
if got := assistantMsg.Get("tool_calls.0.id").String(); got != "call_1" {
|
||||
t.Fatalf("Expected tool_call id %q, got %q", "call_1", got)
|
||||
}
|
||||
if got := assistantMsg.Get("tool_calls.0.function.name").String(); got != "do_work" {
|
||||
t.Fatalf("Expected tool_call name %q, got %q", "do_work", got)
|
||||
}
|
||||
|
||||
// Content should have both pre and post text
|
||||
if got := assistantMsg.Get("content.0.text").String(); got != "pre" {
|
||||
t.Fatalf("Expected content[0] text %q, got %q", "pre", got)
|
||||
}
|
||||
if got := assistantMsg.Get("content.1.text").String(); got != "post" {
|
||||
t.Fatalf("Expected content[1] text %q, got %q", "post", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "claude-3-opus",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "t1"},
|
||||
{"type": "text", "text": "pre"},
|
||||
{"type": "tool_use", "id": "call_1", "name": "do_work", "input": {"a": 1}},
|
||||
{"type": "thinking", "thinking": "t2"},
|
||||
{"type": "text", "text": "post"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
messages := resultJSON.Get("messages").Array()
|
||||
|
||||
// Unsigned thinking is dropped, while text and tool_calls remain unified.
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("Expected 1 message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||
}
|
||||
|
||||
assistantMsg := messages[0]
|
||||
if assistantMsg.Get("role").String() != "assistant" {
|
||||
t.Fatalf("Expected messages[0] to be assistant, got %s", assistantMsg.Get("role").String())
|
||||
}
|
||||
|
||||
// Should have content with both pre and post
|
||||
if got := assistantMsg.Get("content.0.text").String(); got != "pre" {
|
||||
t.Fatalf("Expected content[0] text %q, got %q", "pre", got)
|
||||
}
|
||||
if got := assistantMsg.Get("content.1.text").String(); got != "post" {
|
||||
t.Fatalf("Expected content[1] text %q, got %q", "post", got)
|
||||
}
|
||||
|
||||
// Should have tool_calls
|
||||
if !assistantMsg.Get("tool_calls").Exists() {
|
||||
t.Fatalf("Expected assistant message to have tool_calls")
|
||||
}
|
||||
|
||||
if assistantMsg.Get("reasoning_content").Exists() {
|
||||
t.Fatalf("unsigned thinking should not produce reasoning_content: %s", assistantMsg.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_StripsClaudeCodeAttribution(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"system": [
|
||||
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;"},
|
||||
{"type": "text", "text": "User system prompt"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToOpenAI("gpt-5", inputJSON, false)
|
||||
messages := gjson.GetBytes(output, "messages").Array()
|
||||
if len(messages) == 0 || messages[0].Get("role").String() != "system" {
|
||||
t.Fatalf("Expected first message to be system, got: %s", gjson.GetBytes(output, "messages").Raw)
|
||||
}
|
||||
|
||||
content := messages[0].Get("content").Array()
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("Expected 1 system content item after attribution strip, got %d: %s", len(content), messages[0].Get("content").Raw)
|
||||
}
|
||||
if got := content[0].Get("text").String(); got != "User system prompt" {
|
||||
t.Fatalf("Unexpected system content: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToOpenAI_StopSequences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inputJSON string
|
||||
wantStop []string
|
||||
}{
|
||||
{
|
||||
name: "single stop sequence is emitted as array",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"stop_sequences": ["</block>"],
|
||||
"messages": [{"role": "user", "content": "hi"}]
|
||||
}`,
|
||||
wantStop: []string{"</block>"},
|
||||
},
|
||||
{
|
||||
name: "multiple stop sequences are emitted as array",
|
||||
inputJSON: `{
|
||||
"model": "claude-3-opus",
|
||||
"stop_sequences": ["stop1", "stop2"],
|
||||
"messages": [{"role": "user", "content": "hi"}]
|
||||
}`,
|
||||
wantStop: []string{"stop1", "stop2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := ConvertClaudeRequestToOpenAI("gpt-4o", []byte(tt.inputJSON), false)
|
||||
stopRes := gjson.GetBytes(output, "stop")
|
||||
if !stopRes.Exists() {
|
||||
t.Fatalf("expected 'stop' field in output, got: %s", string(output))
|
||||
}
|
||||
if !stopRes.IsArray() {
|
||||
t.Fatalf("expected 'stop' field to be JSON array, got: %s", stopRes.Raw)
|
||||
}
|
||||
items := stopRes.Array()
|
||||
if len(items) != len(tt.wantStop) {
|
||||
t.Fatalf("expected %d stop items, got %d (%v)", len(tt.wantStop), len(items), stopRes.Raw)
|
||||
}
|
||||
for i, want := range tt.wantStop {
|
||||
if items[i].String() != want {
|
||||
t.Errorf("stop[%d] = %q, want %q", i, items[i].String(), want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,816 @@
|
|||
// Package claude provides response translation functionality for OpenAI to Anthropic API.
|
||||
// This package handles the conversion of OpenAI Chat Completions API responses into Anthropic API-compatible
|
||||
// JSON format, transforming streaming events and non-streaming responses into the format
|
||||
// expected by Anthropic API clients. It supports both streaming and non-streaming modes,
|
||||
// handling text content, tool calls, and usage metadata appropriately.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
var (
|
||||
dataTag = []byte("data:")
|
||||
)
|
||||
|
||||
// ConvertOpenAIResponseToAnthropicParams holds parameters for response conversion
|
||||
type ConvertOpenAIResponseToAnthropicParams struct {
|
||||
MessageID string
|
||||
Model string
|
||||
CreatedAt int64
|
||||
ToolNameMap map[string]string
|
||||
// SawToolCall is true once at least one tool_use content_block_start has
|
||||
// been emitted on the wire. Using raw upstream tool_calls presence here
|
||||
// can produce stop_reason=tool_use with zero announced tool blocks.
|
||||
SawToolCall bool
|
||||
// Content accumulator for streaming
|
||||
ContentAccumulator strings.Builder
|
||||
// Tool calls accumulator for streaming
|
||||
ToolCallsAccumulator map[int]*ToolCallAccumulator
|
||||
// Track if text content block has been started
|
||||
TextContentBlockStarted bool
|
||||
// Track if thinking content block has been started
|
||||
ThinkingContentBlockStarted bool
|
||||
// Track finish reason for later use
|
||||
FinishReason string
|
||||
// Track if content blocks have been stopped
|
||||
ContentBlocksStopped bool
|
||||
// Track if message_delta has been sent
|
||||
MessageDeltaSent bool
|
||||
// Track if message_start has been sent
|
||||
MessageStarted bool
|
||||
// Track if message_stop has been sent
|
||||
MessageStopSent bool
|
||||
// Tool call content block index mapping
|
||||
ToolCallBlockIndexes map[int]int
|
||||
// Index assigned to text content block
|
||||
TextContentBlockIndex int
|
||||
// Index assigned to thinking content block
|
||||
ThinkingContentBlockIndex int
|
||||
// Next available content block index
|
||||
NextContentBlockIndex int
|
||||
}
|
||||
|
||||
// ToolCallAccumulator holds the state for accumulating tool call data
|
||||
type ToolCallAccumulator struct {
|
||||
ID string
|
||||
Name string
|
||||
Arguments strings.Builder
|
||||
// StartEmitted tracks whether content_block_start has already been sent
|
||||
// for this tool index.
|
||||
StartEmitted bool
|
||||
}
|
||||
|
||||
// ConvertOpenAIResponseToClaude converts OpenAI streaming response format to Anthropic API format.
|
||||
// This function processes OpenAI streaming chunks and transforms them into Anthropic-compatible JSON responses.
|
||||
// It handles text content, tool calls, and usage metadata, outputting responses that match the Anthropic API format.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request.
|
||||
// - modelName: The name of the model.
|
||||
// - rawJSON: The raw JSON response from the OpenAI API.
|
||||
// - param: A pointer to a parameter object for the conversion.
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: A slice of byte chunks, each containing an Anthropic-compatible JSON response.
|
||||
func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
if *param == nil {
|
||||
*param = &ConvertOpenAIResponseToAnthropicParams{
|
||||
MessageID: "",
|
||||
Model: "",
|
||||
CreatedAt: 0,
|
||||
ToolNameMap: nil,
|
||||
SawToolCall: false,
|
||||
ContentAccumulator: strings.Builder{},
|
||||
ToolCallsAccumulator: nil,
|
||||
TextContentBlockStarted: false,
|
||||
ThinkingContentBlockStarted: false,
|
||||
FinishReason: "",
|
||||
ContentBlocksStopped: false,
|
||||
MessageDeltaSent: false,
|
||||
ToolCallBlockIndexes: make(map[int]int),
|
||||
TextContentBlockIndex: -1,
|
||||
ThinkingContentBlockIndex: -1,
|
||||
NextContentBlockIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if !bytes.HasPrefix(rawJSON, dataTag) {
|
||||
return [][]byte{}
|
||||
}
|
||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||
|
||||
if (*param).(*ConvertOpenAIResponseToAnthropicParams).ToolNameMap == nil {
|
||||
(*param).(*ConvertOpenAIResponseToAnthropicParams).ToolNameMap = util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)
|
||||
}
|
||||
|
||||
// Check if this is the [DONE] marker
|
||||
if bytes.Equal(bytes.TrimSpace(rawJSON), []byte("[DONE]")) {
|
||||
return convertOpenAIDoneToAnthropic((*param).(*ConvertOpenAIResponseToAnthropicParams))
|
||||
}
|
||||
|
||||
streamResult := gjson.GetBytes(originalRequestRawJSON, "stream")
|
||||
if !streamResult.Exists() || (streamResult.Exists() && streamResult.Type == gjson.False) {
|
||||
return convertOpenAINonStreamingToAnthropic(rawJSON)
|
||||
} else {
|
||||
return convertOpenAIStreamingChunkToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams))
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveOpenAIFinishReason(param *ConvertOpenAIResponseToAnthropicParams) string {
|
||||
if param == nil {
|
||||
return ""
|
||||
}
|
||||
if param.SawToolCall {
|
||||
return "tool_calls"
|
||||
}
|
||||
return param.FinishReason
|
||||
}
|
||||
|
||||
// convertOpenAIStreamingChunkToAnthropic converts OpenAI streaming chunk to Anthropic streaming events
|
||||
func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAIResponseToAnthropicParams) [][]byte {
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
var results [][]byte
|
||||
|
||||
// Initialize parameters if needed
|
||||
if param.MessageID == "" {
|
||||
param.MessageID = root.Get("id").String()
|
||||
}
|
||||
if param.Model == "" {
|
||||
param.Model = root.Get("model").String()
|
||||
}
|
||||
if param.CreatedAt == 0 {
|
||||
param.CreatedAt = root.Get("created").Int()
|
||||
}
|
||||
|
||||
// Emit message_start on the very first chunk, regardless of whether it has a role field.
|
||||
// Some providers (like Copilot) may send tool_calls in the first chunk without a role field.
|
||||
if delta := root.Get("choices.0.delta"); delta.Exists() {
|
||||
if !param.MessageStarted {
|
||||
// Send message_start event
|
||||
messageStartJSON := []byte(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
|
||||
messageStartJSON, _ = sjson.SetBytes(messageStartJSON, "message.id", param.MessageID)
|
||||
messageStartJSON, _ = sjson.SetBytes(messageStartJSON, "message.model", param.Model)
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "message_start", messageStartJSON, 2))
|
||||
param.MessageStarted = true
|
||||
|
||||
// Don't send content_block_start for text here - wait for actual content
|
||||
}
|
||||
|
||||
// Handle reasoning content delta
|
||||
if reasoning := delta.Get("reasoning_content"); reasoning.Exists() {
|
||||
for _, reasoningText := range collectOpenAIReasoningTexts(reasoning) {
|
||||
if reasoningText == "" {
|
||||
continue
|
||||
}
|
||||
stopTextContentBlock(param, &results)
|
||||
if !param.ThinkingContentBlockStarted {
|
||||
if param.ThinkingContentBlockIndex == -1 {
|
||||
param.ThinkingContentBlockIndex = param.NextContentBlockIndex
|
||||
param.NextContentBlockIndex++
|
||||
}
|
||||
contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`
|
||||
contentBlockStartJSONBytes := []byte(contentBlockStartJSON)
|
||||
contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.ThinkingContentBlockIndex)
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2))
|
||||
param.ThinkingContentBlockStarted = true
|
||||
}
|
||||
|
||||
thinkingDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`
|
||||
thinkingDeltaJSONBytes := []byte(thinkingDeltaJSON)
|
||||
thinkingDeltaJSONBytes, _ = sjson.SetBytes(thinkingDeltaJSONBytes, "index", param.ThinkingContentBlockIndex)
|
||||
thinkingDeltaJSONBytes, _ = sjson.SetBytes(thinkingDeltaJSONBytes, "delta.thinking", reasoningText)
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", thinkingDeltaJSONBytes, 2))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle content delta
|
||||
if content := delta.Get("content"); content.Exists() && content.String() != "" {
|
||||
// Send content_block_start for text if not already sent
|
||||
if !param.TextContentBlockStarted {
|
||||
stopThinkingContentBlock(param, &results)
|
||||
if param.TextContentBlockIndex == -1 {
|
||||
param.TextContentBlockIndex = param.NextContentBlockIndex
|
||||
param.NextContentBlockIndex++
|
||||
}
|
||||
contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`
|
||||
contentBlockStartJSONBytes := []byte(contentBlockStartJSON)
|
||||
contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.TextContentBlockIndex)
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2))
|
||||
param.TextContentBlockStarted = true
|
||||
}
|
||||
|
||||
contentDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`
|
||||
contentDeltaJSONBytes := []byte(contentDeltaJSON)
|
||||
contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "index", param.TextContentBlockIndex)
|
||||
contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "delta.text", content.String())
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", contentDeltaJSONBytes, 2))
|
||||
|
||||
// Accumulate content
|
||||
param.ContentAccumulator.WriteString(content.String())
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
|
||||
if param.ToolCallsAccumulator == nil {
|
||||
param.ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
|
||||
}
|
||||
|
||||
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
|
||||
index := int(toolCall.Get("index").Int())
|
||||
|
||||
// Initialize accumulator if needed
|
||||
if _, exists := param.ToolCallsAccumulator[index]; !exists {
|
||||
param.ToolCallsAccumulator[index] = &ToolCallAccumulator{}
|
||||
}
|
||||
|
||||
accumulator := param.ToolCallsAccumulator[index]
|
||||
|
||||
// Handle tool call ID. Only accept JSON-string, non-empty
|
||||
// values so malformed upstream fields do not overwrite a
|
||||
// valid ID or coerce into a content_block.id.
|
||||
if id := toolCall.Get("id"); id.Exists() && id.Type == gjson.String {
|
||||
if idStr := id.String(); idStr != "" {
|
||||
accumulator.ID = idStr
|
||||
}
|
||||
}
|
||||
|
||||
// Handle function name and arguments
|
||||
if function := toolCall.Get("function"); function.Exists() {
|
||||
// Only record the name until content_block_start has been
|
||||
// emitted. Some upstreams send "name": "" or repeat the
|
||||
// field across chunks; reassigning after start could drift
|
||||
// from what was already announced.
|
||||
if !accumulator.StartEmitted {
|
||||
if name := function.Get("name"); name.Exists() && name.Type == gjson.String && name.String() != "" {
|
||||
accumulator.Name = util.MapToolName(param.ToolNameMap, name.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Handle function arguments
|
||||
if args := function.Get("arguments"); args.Exists() {
|
||||
argsText := args.String()
|
||||
if argsText != "" {
|
||||
accumulator.Arguments.WriteString(argsText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check on every chunk, not only chunks with a function
|
||||
// object. Some upstreams split function.name and id across
|
||||
// separate deltas.
|
||||
if !accumulator.StartEmitted && accumulator.Name != "" && accumulator.ID != "" && !param.ContentBlocksStopped {
|
||||
emitToolUseStart(param, index, accumulator, &results)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finish_reason (but don't send message_delta/message_stop yet)
|
||||
if finishReason := root.Get("choices.0.finish_reason"); finishReason.Exists() && finishReason.String() != "" {
|
||||
reason := finishReason.String()
|
||||
switch {
|
||||
case param.SawToolCall:
|
||||
param.FinishReason = "tool_calls"
|
||||
case reason == "tool_calls":
|
||||
param.FinishReason = "stop"
|
||||
default:
|
||||
param.FinishReason = reason
|
||||
}
|
||||
|
||||
// Send content_block_stop for thinking content if needed
|
||||
if param.ThinkingContentBlockStarted {
|
||||
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
|
||||
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", param.ThinkingContentBlockIndex)
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
|
||||
param.ThinkingContentBlockStarted = false
|
||||
param.ThinkingContentBlockIndex = -1
|
||||
}
|
||||
|
||||
// Send content_block_stop for text if text content block was started
|
||||
stopTextContentBlock(param, &results)
|
||||
|
||||
// Send content_block_stop for any tool calls
|
||||
if !param.ContentBlocksStopped {
|
||||
for _, index := range toolCallAccumulatorIndexes(param.ToolCallsAccumulator) {
|
||||
accumulator := param.ToolCallsAccumulator[index]
|
||||
if !emitBelatedToolUseStart(param, index, accumulator, &results) {
|
||||
continue
|
||||
}
|
||||
blockIndex := param.toolContentBlockIndex(index)
|
||||
|
||||
// Send complete input_json_delta with all accumulated arguments
|
||||
if accumulator.Arguments.Len() > 0 {
|
||||
inputDeltaJSON := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
|
||||
inputDeltaJSON, _ = sjson.SetBytes(inputDeltaJSON, "index", blockIndex)
|
||||
inputDeltaJSON, _ = sjson.SetBytes(inputDeltaJSON, "delta.partial_json", util.FixJSON(accumulator.Arguments.String()))
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", inputDeltaJSON, 2))
|
||||
}
|
||||
|
||||
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
|
||||
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", blockIndex)
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
|
||||
delete(param.ToolCallBlockIndexes, index)
|
||||
}
|
||||
param.ContentBlocksStopped = true
|
||||
}
|
||||
|
||||
// Don't send message_delta here - wait for usage info or [DONE]
|
||||
}
|
||||
|
||||
// Handle usage information separately (this comes in a later chunk)
|
||||
// Only process if usage has actual values (not null)
|
||||
if param.FinishReason != "" && !param.MessageDeltaSent {
|
||||
usage := root.Get("usage")
|
||||
var inputTokens, outputTokens, cachedTokens int64
|
||||
if usage.Exists() && usage.Type != gjson.Null {
|
||||
inputTokens, outputTokens, cachedTokens = extractOpenAIUsage(usage)
|
||||
// Send message_delta with usage
|
||||
messageDeltaJSON := []byte(`{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "delta.stop_reason", mapOpenAIFinishReasonToAnthropic(effectiveOpenAIFinishReason(param)))
|
||||
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.input_tokens", inputTokens)
|
||||
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.output_tokens", outputTokens)
|
||||
if cachedTokens > 0 {
|
||||
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.cache_read_input_tokens", cachedTokens)
|
||||
}
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "message_delta", messageDeltaJSON, 2))
|
||||
param.MessageDeltaSent = true
|
||||
|
||||
emitMessageStopIfNeeded(param, &results)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// convertOpenAIDoneToAnthropic handles the [DONE] marker and sends final events
|
||||
func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams) [][]byte {
|
||||
var results [][]byte
|
||||
|
||||
// Ensure all content blocks are stopped before final events
|
||||
if param.ThinkingContentBlockStarted {
|
||||
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
|
||||
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", param.ThinkingContentBlockIndex)
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
|
||||
param.ThinkingContentBlockStarted = false
|
||||
param.ThinkingContentBlockIndex = -1
|
||||
}
|
||||
|
||||
stopTextContentBlock(param, &results)
|
||||
|
||||
if !param.ContentBlocksStopped {
|
||||
for _, index := range toolCallAccumulatorIndexes(param.ToolCallsAccumulator) {
|
||||
accumulator := param.ToolCallsAccumulator[index]
|
||||
if !emitBelatedToolUseStart(param, index, accumulator, &results) {
|
||||
continue
|
||||
}
|
||||
blockIndex := param.toolContentBlockIndex(index)
|
||||
|
||||
if accumulator.Arguments.Len() > 0 {
|
||||
inputDeltaJSON := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
|
||||
inputDeltaJSON, _ = sjson.SetBytes(inputDeltaJSON, "index", blockIndex)
|
||||
inputDeltaJSON, _ = sjson.SetBytes(inputDeltaJSON, "delta.partial_json", util.FixJSON(accumulator.Arguments.String()))
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", inputDeltaJSON, 2))
|
||||
}
|
||||
|
||||
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
|
||||
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", blockIndex)
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
|
||||
delete(param.ToolCallBlockIndexes, index)
|
||||
}
|
||||
param.ContentBlocksStopped = true
|
||||
}
|
||||
|
||||
// If we haven't sent message_delta yet (no usage info was received), send it now
|
||||
if param.FinishReason != "" && !param.MessageDeltaSent {
|
||||
messageDeltaJSON := []byte(`{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "delta.stop_reason", mapOpenAIFinishReasonToAnthropic(effectiveOpenAIFinishReason(param)))
|
||||
results = append(results, translatorcommon.AppendSSEEventBytes(nil, "message_delta", messageDeltaJSON, 2))
|
||||
param.MessageDeltaSent = true
|
||||
}
|
||||
|
||||
emitMessageStopIfNeeded(param, &results)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// convertOpenAINonStreamingToAnthropic converts OpenAI non-streaming response to Anthropic format
|
||||
func convertOpenAINonStreamingToAnthropic(rawJSON []byte) [][]byte {
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
|
||||
out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
out, _ = sjson.SetBytes(out, "id", root.Get("id").String())
|
||||
out, _ = sjson.SetBytes(out, "model", root.Get("model").String())
|
||||
|
||||
// Process message content and tool calls
|
||||
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() && len(choices.Array()) > 0 {
|
||||
choice := choices.Array()[0] // Take first choice
|
||||
var contentBlocks [][]byte
|
||||
|
||||
reasoningNode := choice.Get("message.reasoning_content")
|
||||
for _, reasoningText := range collectOpenAIReasoningTexts(reasoningNode) {
|
||||
if reasoningText == "" {
|
||||
continue
|
||||
}
|
||||
block := []byte(`{"type":"thinking","thinking":""}`)
|
||||
block, _ = sjson.SetBytes(block, "thinking", reasoningText)
|
||||
contentBlocks = append(contentBlocks, block)
|
||||
}
|
||||
|
||||
// Handle text content
|
||||
if content := choice.Get("message.content"); content.Exists() && content.String() != "" {
|
||||
block := []byte(`{"type":"text","text":""}`)
|
||||
block, _ = sjson.SetBytes(block, "text", content.String())
|
||||
contentBlocks = append(contentBlocks, block)
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
if toolCalls := choice.Get("message.tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
|
||||
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
|
||||
toolUseBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
|
||||
toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "id", util.SanitizeClaudeToolID(toolCall.Get("id").String()))
|
||||
toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "name", toolCall.Get("function.name").String())
|
||||
|
||||
argsStr := util.FixJSON(toolCall.Get("function.arguments").String())
|
||||
if argsStr != "" && gjson.Valid(argsStr) {
|
||||
argsJSON := gjson.Parse(argsStr)
|
||||
if argsJSON.IsObject() {
|
||||
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(argsJSON.Raw))
|
||||
} else {
|
||||
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
|
||||
}
|
||||
} else {
|
||||
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
|
||||
}
|
||||
|
||||
contentBlocks = append(contentBlocks, toolUseBlock)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
if len(contentBlocks) > 0 {
|
||||
out = translatorcommon.SetRawArrayItems(out, "content", contentBlocks)
|
||||
}
|
||||
|
||||
// Set stop reason
|
||||
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
|
||||
out, _ = sjson.SetBytes(out, "stop_reason", mapOpenAIFinishReasonToAnthropic(finishReason.String()))
|
||||
}
|
||||
}
|
||||
|
||||
// Set usage information
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(usage)
|
||||
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
|
||||
if cachedTokens > 0 {
|
||||
out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
|
||||
}
|
||||
}
|
||||
|
||||
return [][]byte{out}
|
||||
}
|
||||
|
||||
// mapOpenAIFinishReasonToAnthropic maps OpenAI finish reasons to Anthropic equivalents
|
||||
func mapOpenAIFinishReasonToAnthropic(openAIReason string) string {
|
||||
switch openAIReason {
|
||||
case "stop":
|
||||
return "end_turn"
|
||||
case "length":
|
||||
return "max_tokens"
|
||||
case "tool_calls":
|
||||
return "tool_use"
|
||||
case "content_filter":
|
||||
return "end_turn" // Anthropic doesn't have direct equivalent
|
||||
case "function_call": // Legacy OpenAI
|
||||
return "tool_use"
|
||||
default:
|
||||
return "end_turn"
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ConvertOpenAIResponseToAnthropicParams) toolContentBlockIndex(openAIToolIndex int) int {
|
||||
if idx, ok := p.ToolCallBlockIndexes[openAIToolIndex]; ok {
|
||||
return idx
|
||||
}
|
||||
idx := p.NextContentBlockIndex
|
||||
p.NextContentBlockIndex++
|
||||
p.ToolCallBlockIndexes[openAIToolIndex] = idx
|
||||
return idx
|
||||
}
|
||||
|
||||
func collectOpenAIReasoningTexts(node gjson.Result) []string {
|
||||
var texts []string
|
||||
if !node.Exists() {
|
||||
return texts
|
||||
}
|
||||
|
||||
if node.IsArray() {
|
||||
node.ForEach(func(_, value gjson.Result) bool {
|
||||
texts = append(texts, collectOpenAIReasoningTexts(value)...)
|
||||
return true
|
||||
})
|
||||
return texts
|
||||
}
|
||||
|
||||
switch node.Type {
|
||||
case gjson.String:
|
||||
if text := node.String(); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
case gjson.JSON:
|
||||
if text := node.Get("text"); text.Exists() {
|
||||
if textStr := text.String(); textStr != "" {
|
||||
texts = append(texts, textStr)
|
||||
}
|
||||
} else if raw := node.Raw; raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
|
||||
texts = append(texts, raw)
|
||||
}
|
||||
}
|
||||
|
||||
return texts
|
||||
}
|
||||
|
||||
func stopThinkingContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results *[][]byte) {
|
||||
if !param.ThinkingContentBlockStarted {
|
||||
return
|
||||
}
|
||||
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
|
||||
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", param.ThinkingContentBlockIndex)
|
||||
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
|
||||
param.ThinkingContentBlockStarted = false
|
||||
param.ThinkingContentBlockIndex = -1
|
||||
}
|
||||
|
||||
func emitMessageStopIfNeeded(param *ConvertOpenAIResponseToAnthropicParams, results *[][]byte) {
|
||||
if param.MessageStopSent {
|
||||
return
|
||||
}
|
||||
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "message_stop", []byte(`{"type":"message_stop"}`), 2))
|
||||
param.MessageStopSent = true
|
||||
}
|
||||
|
||||
func stopTextContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results *[][]byte) {
|
||||
if !param.TextContentBlockStarted {
|
||||
return
|
||||
}
|
||||
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
|
||||
contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", param.TextContentBlockIndex)
|
||||
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
|
||||
param.TextContentBlockStarted = false
|
||||
param.TextContentBlockIndex = -1
|
||||
}
|
||||
|
||||
func emitToolUseStart(param *ConvertOpenAIResponseToAnthropicParams, openAIToolIndex int, accumulator *ToolCallAccumulator, results *[][]byte) {
|
||||
stopThinkingContentBlock(param, results)
|
||||
stopTextContentBlock(param, results)
|
||||
|
||||
blockIndex := param.toolContentBlockIndex(openAIToolIndex)
|
||||
contentBlockStartJSON := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
|
||||
contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "index", blockIndex)
|
||||
contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "content_block.id", util.SanitizeClaudeToolID(accumulator.ID))
|
||||
contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "content_block.name", accumulator.Name)
|
||||
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSON, 2))
|
||||
accumulator.StartEmitted = true
|
||||
param.SawToolCall = true
|
||||
}
|
||||
|
||||
// emitBelatedToolUseStart finalizes a tool_use block that never received a
|
||||
// mid-stream start. Some OpenAI-compatible providers leave function.name empty
|
||||
// for the whole stream; dropping those calls loses tool_use for Claude Code and
|
||||
// can trigger retry loops. When name is still empty but the call has an id
|
||||
// and/or arguments, synthesize tool_<index> instead of silently discarding it.
|
||||
// Returns false when the accumulator has no usable tool-call signal.
|
||||
func emitBelatedToolUseStart(param *ConvertOpenAIResponseToAnthropicParams, openAIToolIndex int, accumulator *ToolCallAccumulator, results *[][]byte) bool {
|
||||
if accumulator == nil {
|
||||
return false
|
||||
}
|
||||
if accumulator.StartEmitted {
|
||||
return true
|
||||
}
|
||||
if accumulator.Name == "" && accumulator.ID == "" && accumulator.Arguments.Len() == 0 {
|
||||
return false
|
||||
}
|
||||
if accumulator.Name == "" {
|
||||
accumulator.Name = fmt.Sprintf("tool_%d", openAIToolIndex)
|
||||
}
|
||||
emitToolUseStart(param, openAIToolIndex, accumulator, results)
|
||||
return true
|
||||
}
|
||||
|
||||
func toolCallAccumulatorIndexes(accumulators map[int]*ToolCallAccumulator) []int {
|
||||
indexes := make([]int, 0, len(accumulators))
|
||||
for index := range accumulators {
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
sort.Ints(indexes)
|
||||
return indexes
|
||||
}
|
||||
|
||||
// ConvertOpenAIResponseToClaudeNonStream converts a non-streaming OpenAI response to a non-streaming Anthropic response.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request.
|
||||
// - modelName: The name of the model.
|
||||
// - rawJSON: The raw JSON response from the OpenAI API.
|
||||
// - param: A pointer to a parameter object for the conversion.
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: An Anthropic-compatible JSON response.
|
||||
func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
_ = requestRawJSON
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
toolNameMap := util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)
|
||||
out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
out, _ = sjson.SetBytes(out, "id", root.Get("id").String())
|
||||
out, _ = sjson.SetBytes(out, "model", root.Get("model").String())
|
||||
|
||||
hasToolCall := false
|
||||
stopReasonSet := false
|
||||
var blocks [][]byte
|
||||
|
||||
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() {
|
||||
out, _ = sjson.SetBytes(out, "stop_reason", mapOpenAIFinishReasonToAnthropic(finishReason.String()))
|
||||
stopReasonSet = true
|
||||
}
|
||||
|
||||
if message := choice.Get("message"); message.Exists() {
|
||||
if contentResult := message.Get("content"); contentResult.Exists() {
|
||||
if contentResult.IsArray() {
|
||||
var textBuilder strings.Builder
|
||||
var thinkingBuilder strings.Builder
|
||||
|
||||
flushText := func() {
|
||||
if textBuilder.Len() == 0 {
|
||||
return
|
||||
}
|
||||
block := []byte(`{"type":"text","text":""}`)
|
||||
block, _ = sjson.SetBytes(block, "text", textBuilder.String())
|
||||
blocks = append(blocks, block)
|
||||
textBuilder.Reset()
|
||||
}
|
||||
|
||||
flushThinking := func() {
|
||||
if thinkingBuilder.Len() == 0 {
|
||||
return
|
||||
}
|
||||
block := []byte(`{"type":"thinking","thinking":""}`)
|
||||
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
|
||||
blocks = append(blocks, block)
|
||||
thinkingBuilder.Reset()
|
||||
}
|
||||
|
||||
for _, item := range contentResult.Array() {
|
||||
switch item.Get("type").String() {
|
||||
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 := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
|
||||
toolUse, _ = sjson.SetBytes(toolUse, "id", util.SanitizeClaudeToolID(tc.Get("id").String()))
|
||||
toolUse, _ = sjson.SetBytes(toolUse, "name", util.MapToolName(toolNameMap, tc.Get("function.name").String()))
|
||||
|
||||
argsStr := util.FixJSON(tc.Get("function.arguments").String())
|
||||
if argsStr != "" && gjson.Valid(argsStr) {
|
||||
argsJSON := gjson.Parse(argsStr)
|
||||
if argsJSON.IsObject() {
|
||||
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw))
|
||||
} else {
|
||||
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(`{}`))
|
||||
}
|
||||
} else {
|
||||
toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(`{}`))
|
||||
}
|
||||
|
||||
blocks = append(blocks, toolUse)
|
||||
return true
|
||||
})
|
||||
}
|
||||
case "reasoning":
|
||||
flushText()
|
||||
if thinking := item.Get("text"); thinking.Exists() {
|
||||
thinkingBuilder.WriteString(thinking.String())
|
||||
}
|
||||
default:
|
||||
flushThinking()
|
||||
flushText()
|
||||
}
|
||||
}
|
||||
|
||||
flushThinking()
|
||||
flushText()
|
||||
} else if contentResult.Type == gjson.String {
|
||||
textContent := contentResult.String()
|
||||
if textContent != "" {
|
||||
block := []byte(`{"type":"text","text":""}`)
|
||||
block, _ = sjson.SetBytes(block, "text", textContent)
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
|
||||
for _, reasoningText := range collectOpenAIReasoningTexts(reasoning) {
|
||||
if reasoningText == "" {
|
||||
continue
|
||||
}
|
||||
block := []byte(`{"type":"thinking","thinking":""}`)
|
||||
block, _ = sjson.SetBytes(block, "thinking", reasoningText)
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
}
|
||||
|
||||
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
|
||||
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
|
||||
hasToolCall = true
|
||||
toolUseBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
|
||||
toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "id", util.SanitizeClaudeToolID(toolCall.Get("id").String()))
|
||||
toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "name", util.MapToolName(toolNameMap, toolCall.Get("function.name").String()))
|
||||
|
||||
argsStr := util.FixJSON(toolCall.Get("function.arguments").String())
|
||||
if argsStr != "" && gjson.Valid(argsStr) {
|
||||
argsJSON := gjson.Parse(argsStr)
|
||||
if argsJSON.IsObject() {
|
||||
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(argsJSON.Raw))
|
||||
} else {
|
||||
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
|
||||
}
|
||||
} else {
|
||||
toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
|
||||
}
|
||||
|
||||
blocks = append(blocks, toolUseBlock)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(blocks) > 0 {
|
||||
out, _ = sjson.SetRawBytes(out, "content", translatorcommon.JoinRawArray(blocks))
|
||||
}
|
||||
|
||||
if respUsage := root.Get("usage"); respUsage.Exists() {
|
||||
inputTokens, outputTokens, cachedTokens := extractOpenAIUsage(respUsage)
|
||||
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
|
||||
if cachedTokens > 0 {
|
||||
out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
|
||||
}
|
||||
}
|
||||
|
||||
if !stopReasonSet {
|
||||
if hasToolCall {
|
||||
out, _ = sjson.SetBytes(out, "stop_reason", "tool_use")
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, "stop_reason", "end_turn")
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func ClaudeTokenCount(ctx context.Context, count int64) []byte {
|
||||
return translatorcommon.ClaudeInputTokensJSON(count)
|
||||
}
|
||||
|
||||
func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64) {
|
||||
if !usage.Exists() || usage.Type == gjson.Null {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
inputTokens := usage.Get("prompt_tokens").Int()
|
||||
outputTokens := usage.Get("completion_tokens").Int()
|
||||
cachedTokens := usage.Get("prompt_tokens_details.cached_tokens").Int()
|
||||
|
||||
if cachedTokens > 0 {
|
||||
if inputTokens >= cachedTokens {
|
||||
inputTokens -= cachedTokens
|
||||
} else {
|
||||
inputTokens = 0
|
||||
}
|
||||
}
|
||||
|
||||
return inputTokens, outputTokens, cachedTokens
|
||||
}
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type sseEvent struct {
|
||||
Type string
|
||||
Payload string
|
||||
}
|
||||
|
||||
func runStream(t *testing.T, originalReq string, chunks ...string) []sseEvent {
|
||||
t.Helper()
|
||||
|
||||
var paramAny any
|
||||
var emitted [][]byte
|
||||
for _, chunk := range chunks {
|
||||
emitted = append(emitted, ConvertOpenAIResponseToClaude(
|
||||
context.Background(),
|
||||
"",
|
||||
[]byte(originalReq),
|
||||
nil,
|
||||
[]byte("data: "+chunk),
|
||||
¶mAny,
|
||||
)...)
|
||||
}
|
||||
emitted = append(emitted, ConvertOpenAIResponseToClaude(
|
||||
context.Background(),
|
||||
"",
|
||||
[]byte(originalReq),
|
||||
nil,
|
||||
[]byte("data: [DONE]"),
|
||||
¶mAny,
|
||||
)...)
|
||||
|
||||
var events []sseEvent
|
||||
for _, raw := range emitted {
|
||||
s := string(raw)
|
||||
if !strings.HasPrefix(s, "event: ") {
|
||||
continue
|
||||
}
|
||||
nl := strings.Index(s, "\n")
|
||||
if nl < 0 {
|
||||
continue
|
||||
}
|
||||
typ := strings.TrimPrefix(s[:nl], "event: ")
|
||||
rest := s[nl+1:]
|
||||
if !strings.HasPrefix(rest, "data: ") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimRight(strings.TrimPrefix(rest, "data: "), "\n")
|
||||
events = append(events, sseEvent{Type: typ, Payload: payload})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func countByType(events []sseEvent, typ string) int {
|
||||
n := 0
|
||||
for _, e := range events {
|
||||
if e.Type == typ {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func toolUseStarts(events []sseEvent) []sseEvent {
|
||||
var out []sseEvent
|
||||
for _, e := range events {
|
||||
if e.Type != "content_block_start" {
|
||||
continue
|
||||
}
|
||||
if gjson.Get(e.Payload, "content_block.type").String() == "tool_use" {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func blockIndices(events []sseEvent) []int64 {
|
||||
var idx []int64
|
||||
for _, e := range events {
|
||||
if e.Type == "content_block_start" {
|
||||
idx = append(idx, gjson.Get(e.Payload, "index").Int())
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func lastStopReason(events []sseEvent) string {
|
||||
for i := len(events) - 1; i >= 0; i-- {
|
||||
if events[i].Type == "message_delta" {
|
||||
return gjson.Get(events[i].Payload, "delta.stop_reason").String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const streamReq = `{"stream":true}`
|
||||
|
||||
func TestStreaming_LateUsageOnlyDoesNotEmitAfterMessageStop(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
|
||||
`{"id":"c1","model":"m","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
|
||||
)
|
||||
|
||||
if got := countByType(events, "message_delta"); got != 1 {
|
||||
t.Fatalf("expected exactly one message_delta, got %d (events=%+v)", got, events)
|
||||
}
|
||||
if got := countByType(events, "message_stop"); got != 1 {
|
||||
t.Fatalf("expected exactly one message_stop, got %d (events=%+v)", got, events)
|
||||
}
|
||||
if len(events) == 0 || events[len(events)-1].Type != "message_stop" {
|
||||
t.Fatalf("message_stop must be the last semantic event (events=%+v)", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) {
|
||||
originalRequest := []byte(streamReq)
|
||||
var param any
|
||||
|
||||
firstChunks := ConvertOpenAIResponseToClaude(
|
||||
context.Background(),
|
||||
"test-model",
|
||||
originalRequest,
|
||||
nil,
|
||||
[]byte(`data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"read_file","arguments":""}}]},"finish_reason":null}]}`),
|
||||
¶m,
|
||||
)
|
||||
firstOutput := bytes.Join(firstChunks, nil)
|
||||
if !bytes.Contains(firstOutput, []byte(`"name":"read_file"`)) {
|
||||
t.Fatalf("expected first chunk to start read_file tool block, got %s", string(firstOutput))
|
||||
}
|
||||
|
||||
secondChunks := ConvertOpenAIResponseToClaude(
|
||||
context.Background(),
|
||||
"test-model",
|
||||
originalRequest,
|
||||
nil,
|
||||
[]byte(`data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":null,"arguments":"{\"path\":\"/tmp/a\"}"}}]},"finish_reason":null}]}`),
|
||||
¶m,
|
||||
)
|
||||
secondOutput := bytes.Join(secondChunks, nil)
|
||||
if bytes.Contains(secondOutput, []byte(`content_block_start`)) {
|
||||
t.Fatalf("did not expect null tool name delta to start a new content block, got %s", string(secondOutput))
|
||||
}
|
||||
if bytes.Contains(secondOutput, []byte(`"name":""`)) {
|
||||
t.Fatalf("did not expect null tool name delta to emit an empty tool name, got %s", string(secondOutput))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_EmptyNameThroughout(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":"","arguments":""}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"","arguments":"{\"x\":1}"}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 1 {
|
||||
t.Fatalf("expected one tool_use content_block_start with synthetic name, got %d (events=%+v)", len(starts), events)
|
||||
}
|
||||
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
|
||||
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
|
||||
}
|
||||
if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_a" {
|
||||
t.Fatalf("announced tool id = %q, want %q", id, "call_a")
|
||||
}
|
||||
if got := countByType(events, "content_block_delta"); got != 1 {
|
||||
t.Fatalf("expected one content_block_delta for accumulated args, got %d", got)
|
||||
}
|
||||
if got := countByType(events, "content_block_stop"); got != 1 {
|
||||
t.Fatalf("expected one content_block_stop, got %d", got)
|
||||
}
|
||||
if got := lastStopReason(events); got != "tool_use" {
|
||||
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_NullName(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":null,"arguments":""}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 1 {
|
||||
t.Fatalf("null name with id should belated-emit synthetic tool name; got %d", len(starts))
|
||||
}
|
||||
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
|
||||
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
|
||||
}
|
||||
if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_a" {
|
||||
t.Fatalf("announced tool id = %q, want %q", id, "call_a")
|
||||
}
|
||||
if got := countByType(events, "content_block_stop"); got != 1 {
|
||||
t.Fatalf("expected one content_block_stop, got %d", got)
|
||||
}
|
||||
if got := lastStopReason(events); got != "tool_use" {
|
||||
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_NonStringName(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":123,"arguments":""}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 1 {
|
||||
t.Fatalf("non-string name with id should belated-emit synthetic tool name; got %d", len(starts))
|
||||
}
|
||||
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
|
||||
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_RepeatedName(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":"do_it","arguments":""}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"do_it","arguments":"{\"x\""}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"do_it","arguments":":1}"}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 1 {
|
||||
t.Fatalf("expected exactly one tool_use start, got %d", len(starts))
|
||||
}
|
||||
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
|
||||
t.Fatalf("announced tool name = %q, want %q", name, "do_it")
|
||||
}
|
||||
if got := countByType(events, "content_block_stop"); got != 1 {
|
||||
t.Fatalf("expected exactly one content_block_stop, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_MixedEmptyNameAndValid(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
|
||||
{"index":0,"id":"call_empty","function":{"name":"","arguments":""}},
|
||||
{"index":1,"id":"call_real","function":{"name":"do_it","arguments":""}}
|
||||
]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[
|
||||
{"index":1,"function":{"arguments":"{}"}}
|
||||
]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 2 {
|
||||
t.Fatalf("expected two tool_use starts (valid mid-stream + synthetic empty-name), got %d", len(starts))
|
||||
}
|
||||
// Valid name+id is emitted mid-stream first; empty-name is belated at finish.
|
||||
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
|
||||
t.Fatalf("first tool name = %q, want %q", name, "do_it")
|
||||
}
|
||||
if name := gjson.Get(starts[1].Payload, "content_block.name").String(); name != "tool_0" {
|
||||
t.Fatalf("second tool name = %q, want %q", name, "tool_0")
|
||||
}
|
||||
if got := countByType(events, "content_block_stop"); got != 2 {
|
||||
t.Fatalf("expected two content_block_stop events, got %d", got)
|
||||
}
|
||||
|
||||
indices := blockIndices(events)
|
||||
if len(indices) < 2 || indices[0] != 0 || indices[1] != 1 {
|
||||
t.Fatalf("content_block_start indices must be [0,1], got %v", indices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_EmptyNameWithoutSignalIsSuppressed(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"","arguments":""}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
if got := len(toolUseStarts(events)); got != 0 {
|
||||
t.Fatalf("empty name without id/args must stay suppressed; got %d", got)
|
||||
}
|
||||
if got := lastStopReason(events); got == "tool_use" {
|
||||
t.Fatalf("stop_reason must not be tool_use when zero tool_use blocks were emitted; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_EmptyIDDeferStart(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"","function":{"name":"do_it","arguments":""}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_real","function":{"arguments":"{}"}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 1 {
|
||||
t.Fatalf("expected exactly one tool_use start once id arrived, got %d", len(starts))
|
||||
}
|
||||
if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_real" {
|
||||
t.Fatalf("announced tool id = %q, want %q", id, "call_real")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_IDInDeltaWithoutFunction(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"do_it"}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_real"}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 1 {
|
||||
t.Fatalf("expected exactly one tool_use start when id arrives in a function-less delta, got %d", len(starts))
|
||||
}
|
||||
if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_real" {
|
||||
t.Fatalf("announced tool id = %q, want %q", id, "call_real")
|
||||
}
|
||||
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
|
||||
t.Fatalf("announced tool name = %q, want %q", name, "do_it")
|
||||
}
|
||||
if got := countByType(events, "content_block_stop"); got != 1 {
|
||||
t.Fatalf("expected exactly one content_block_stop, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_StopReasonWithEmittedTool(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","function":{"name":"do_it","arguments":"{}"}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
|
||||
)
|
||||
if got := lastStopReason(events); got != "tool_use" {
|
||||
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_StopReasonWhenIDNeverArrives(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"do_it","arguments":""}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 1 {
|
||||
t.Fatalf("expected one belated tool_use start with synthetic id, got %d", len(starts))
|
||||
}
|
||||
id := gjson.Get(starts[0].Payload, "content_block.id").String()
|
||||
if !strings.HasPrefix(id, "toolu_") {
|
||||
t.Fatalf("synthetic id should match toolu_<nanos>_<n>, got %q", id)
|
||||
}
|
||||
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
|
||||
t.Fatalf("announced tool name = %q, want %q", name, "do_it")
|
||||
}
|
||||
if got := lastStopReason(events); got != "tool_use" {
|
||||
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_BelatedStartsUseOpenAIToolIndexOrder(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
|
||||
{"index":2,"function":{"name":"third_tool","arguments":"{}"}},
|
||||
{"index":0,"function":{"name":"first_tool","arguments":"{}"}},
|
||||
{"index":1,"function":{"name":"second_tool","arguments":"{}"}}
|
||||
]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 3 {
|
||||
t.Fatalf("expected three belated tool_use starts, got %d", len(starts))
|
||||
}
|
||||
|
||||
wantNames := []string{"first_tool", "second_tool", "third_tool"}
|
||||
for i, wantName := range wantNames {
|
||||
if name := gjson.Get(starts[i].Payload, "content_block.name").String(); name != wantName {
|
||||
t.Fatalf("tool_use start %d name = %q, want %q (starts=%+v)", i, name, wantName, starts)
|
||||
}
|
||||
if blockIndex := gjson.Get(starts[i].Payload, "index").Int(); blockIndex != int64(i) {
|
||||
t.Fatalf("tool_use start %d block index = %d, want %d", i, blockIndex, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_LateIDAfterFinalization(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"do_it"}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late"}]}}]}`,
|
||||
)
|
||||
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 1 {
|
||||
t.Fatalf("expected one belated tool_use start, got %d", len(starts))
|
||||
}
|
||||
|
||||
var sawMessageStop bool
|
||||
for _, e := range events {
|
||||
if e.Type == "message_stop" {
|
||||
sawMessageStop = true
|
||||
continue
|
||||
}
|
||||
if sawMessageStop {
|
||||
switch e.Type {
|
||||
case "content_block_start", "content_block_delta", "content_block_stop":
|
||||
t.Fatalf("event %q emitted after message_stop (events=%+v)", e.Type, events)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_StopReasonMixedEmptyNameAndValid(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[
|
||||
{"index":0,"id":"call_empty","function":{"name":"","arguments":""}},
|
||||
{"index":1,"id":"call_real","function":{"name":"do_it","arguments":"{}"}}
|
||||
]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
if got := lastStopReason(events); got != "tool_use" {
|
||||
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
|
||||
}
|
||||
if got := len(toolUseStarts(events)); got != 2 {
|
||||
t.Fatalf("expected two tool_use starts, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingTool_EmptyNameArgsOnlyNoID(t *testing.T) {
|
||||
events := runStream(t, streamReq,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"function":{"name":"","arguments":"{\"q\":\"x\"}"}}]}}]}`,
|
||||
`{"id":"c1","model":"m","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
|
||||
)
|
||||
starts := toolUseStarts(events)
|
||||
if len(starts) != 1 {
|
||||
t.Fatalf("expected one belated tool_use start for empty-name args-only call, got %d", len(starts))
|
||||
}
|
||||
if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
|
||||
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
|
||||
}
|
||||
id := gjson.Get(starts[0].Payload, "content_block.id").String()
|
||||
if !strings.HasPrefix(id, "toolu_") {
|
||||
t.Fatalf("synthetic id should match toolu_<nanos>_<n>, got %q", id)
|
||||
}
|
||||
if got := lastStopReason(events); got != "tool_use" {
|
||||
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue