Merge branch 'router-for-me:main' into my-fix
This commit is contained in:
@@ -25,10 +25,19 @@ type ConvertAnthropicResponseToOpenAIParams struct {
|
||||
CreatedAt int64
|
||||
ResponseID string
|
||||
FinishReason string
|
||||
Usage claudeUsageTokens
|
||||
// Tool calls accumulator for streaming
|
||||
ToolCallsAccumulator map[int]*ToolCallAccumulator
|
||||
}
|
||||
|
||||
type claudeUsageTokens struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
CacheCreationInputTokens int64
|
||||
CacheReadInputTokens int64
|
||||
HasUsage bool
|
||||
}
|
||||
|
||||
// ToolCallAccumulator holds the state for accumulating tool call data
|
||||
type ToolCallAccumulator struct {
|
||||
ID string
|
||||
@@ -36,15 +45,30 @@ type ToolCallAccumulator struct {
|
||||
Arguments strings.Builder
|
||||
}
|
||||
|
||||
func calculateClaudeUsageTokens(usage gjson.Result) (promptTokens, completionTokens, totalTokens, cachedTokens int64) {
|
||||
inputTokens := usage.Get("input_tokens").Int()
|
||||
completionTokens = usage.Get("output_tokens").Int()
|
||||
cachedTokens = usage.Get("cache_read_input_tokens").Int()
|
||||
cacheCreationInputTokens := usage.Get("cache_creation_input_tokens").Int()
|
||||
func (u *claudeUsageTokens) Merge(usage gjson.Result) {
|
||||
if !usage.Exists() {
|
||||
return
|
||||
}
|
||||
u.HasUsage = true
|
||||
if inputTokens := usage.Get("input_tokens"); inputTokens.Exists() {
|
||||
u.InputTokens = inputTokens.Int()
|
||||
}
|
||||
if outputTokens := usage.Get("output_tokens"); outputTokens.Exists() {
|
||||
u.OutputTokens = outputTokens.Int()
|
||||
}
|
||||
if cacheCreationInputTokens := usage.Get("cache_creation_input_tokens"); cacheCreationInputTokens.Exists() {
|
||||
u.CacheCreationInputTokens = cacheCreationInputTokens.Int()
|
||||
}
|
||||
if cacheReadInputTokens := usage.Get("cache_read_input_tokens"); cacheReadInputTokens.Exists() {
|
||||
u.CacheReadInputTokens = cacheReadInputTokens.Int()
|
||||
}
|
||||
}
|
||||
|
||||
promptTokens = inputTokens + cacheCreationInputTokens + cachedTokens
|
||||
func (u claudeUsageTokens) OpenAIUsage() (promptTokens, completionTokens, totalTokens, cachedTokens int64) {
|
||||
cachedTokens = u.CacheReadInputTokens
|
||||
promptTokens = u.InputTokens + u.CacheCreationInputTokens + cachedTokens
|
||||
completionTokens = u.OutputTokens
|
||||
totalTokens = promptTokens + completionTokens
|
||||
|
||||
return promptTokens, completionTokens, totalTokens, cachedTokens
|
||||
}
|
||||
|
||||
@@ -112,6 +136,7 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
|
||||
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {
|
||||
(*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
|
||||
}
|
||||
(*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(message.Get("usage"))
|
||||
}
|
||||
return [][]byte{template}
|
||||
|
||||
@@ -215,7 +240,8 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
|
||||
|
||||
// Handle usage information for token counts
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
promptTokens, completionTokens, totalTokens, cachedTokens := calculateClaudeUsageTokens(usage)
|
||||
(*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(usage)
|
||||
promptTokens, completionTokens, totalTokens, cachedTokens := (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.OpenAIUsage()
|
||||
template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokens)
|
||||
template, _ = sjson.SetBytes(template, "usage.completion_tokens", completionTokens)
|
||||
template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokens)
|
||||
@@ -296,6 +322,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
|
||||
var stopReason string
|
||||
var contentParts []string
|
||||
var reasoningParts []string
|
||||
usageTokens := claudeUsageTokens{}
|
||||
toolCallsAccumulator := make(map[int]*ToolCallAccumulator)
|
||||
|
||||
for _, chunk := range chunks {
|
||||
@@ -309,6 +336,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
|
||||
messageID = message.Get("id").String()
|
||||
model = message.Get("model").String()
|
||||
createdAt = time.Now().Unix()
|
||||
usageTokens.Merge(message.Get("usage"))
|
||||
}
|
||||
|
||||
case "content_block_start":
|
||||
@@ -371,15 +399,19 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
|
||||
}
|
||||
}
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
promptTokens, completionTokens, totalTokens, cachedTokens := calculateClaudeUsageTokens(usage)
|
||||
out, _ = sjson.SetBytes(out, "usage.prompt_tokens", promptTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.completion_tokens", completionTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
|
||||
usageTokens.Merge(usage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if usageTokens.HasUsage {
|
||||
promptTokens, completionTokens, totalTokens, cachedTokens := usageTokens.OpenAIUsage()
|
||||
out, _ = sjson.SetBytes(out, "usage.prompt_tokens", promptTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.completion_tokens", completionTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
|
||||
}
|
||||
|
||||
// Set basic response fields including message ID, creation time, and model
|
||||
out, _ = sjson.SetBytes(out, "id", messageID)
|
||||
out, _ = sjson.SetBytes(out, "created", createdAt)
|
||||
|
||||
@@ -37,6 +37,44 @@ func TestConvertClaudeResponseToOpenAI_StreamUsageIncludesCachedTokens(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeResponseToOpenAI_StreamUsageMergesMessageStartUsage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
ConvertClaudeResponseToOpenAI(
|
||||
ctx,
|
||||
"claude-opus-4-6",
|
||||
nil,
|
||||
nil,
|
||||
[]byte(`data: {"type":"message_start","message":{"id":"msg_123","model":"claude-opus-4-6","usage":{"input_tokens":13,"output_tokens":1,"cache_read_input_tokens":22000,"cache_creation_input_tokens":31}}}`),
|
||||
¶m,
|
||||
)
|
||||
out := ConvertClaudeResponseToOpenAI(
|
||||
ctx,
|
||||
"claude-opus-4-6",
|
||||
nil,
|
||||
nil,
|
||||
[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":4}}`),
|
||||
¶m,
|
||||
)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 chunk, got %d", len(out))
|
||||
}
|
||||
|
||||
if gotPromptTokens := gjson.GetBytes(out[0], "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
|
||||
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
|
||||
}
|
||||
if gotCompletionTokens := gjson.GetBytes(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
|
||||
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
|
||||
}
|
||||
if gotTotalTokens := gjson.GetBytes(out[0], "usage.total_tokens").Int(); gotTotalTokens != 22048 {
|
||||
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
|
||||
}
|
||||
if gotCachedTokens := gjson.GetBytes(out[0], "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
|
||||
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *testing.T) {
|
||||
rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\"}}\n" +
|
||||
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":13,\"output_tokens\":4,\"cache_read_input_tokens\":22000,\"cache_creation_input_tokens\":31}}\n")
|
||||
@@ -56,3 +94,23 @@ func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *tes
|
||||
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeResponseToOpenAINonStream_UsageMergesMessageStartUsage(t *testing.T) {
|
||||
rawJSON := []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-opus-4-6\",\"usage\":{\"input_tokens\":13,\"output_tokens\":1,\"cache_read_input_tokens\":22000,\"cache_creation_input_tokens\":31}}}\n" +
|
||||
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":4}}\n")
|
||||
|
||||
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
|
||||
|
||||
if gotPromptTokens := gjson.GetBytes(out, "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
|
||||
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
|
||||
}
|
||||
if gotCompletionTokens := gjson.GetBytes(out, "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
|
||||
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
|
||||
}
|
||||
if gotTotalTokens := gjson.GetBytes(out, "usage.total_tokens").Int(); gotTotalTokens != 22048 {
|
||||
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
|
||||
}
|
||||
if gotCachedTokens := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
|
||||
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,25 +339,21 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
||||
})
|
||||
}
|
||||
|
||||
includedToolNames := map[string]struct{}{}
|
||||
toolNameMap := map[string]string{}
|
||||
|
||||
// tools mapping: parameters -> input_schema
|
||||
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
|
||||
toolsJSON := []byte("[]")
|
||||
tools.ForEach(func(_, tool gjson.Result) bool {
|
||||
tJSON := []byte(`{"name":"","description":"","input_schema":{}}`)
|
||||
if n := tool.Get("name"); n.Exists() {
|
||||
tJSON, _ = sjson.SetBytes(tJSON, "name", n.String())
|
||||
convertedTools := convertResponsesToolToClaudeTools(tool, toolNameMap)
|
||||
for _, tJSON := range convertedTools {
|
||||
toolName := gjson.GetBytes(tJSON, "name").String()
|
||||
if toolName != "" {
|
||||
includedToolNames[toolName] = struct{}{}
|
||||
}
|
||||
toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON)
|
||||
}
|
||||
if d := tool.Get("description"); d.Exists() {
|
||||
tJSON, _ = sjson.SetBytes(tJSON, "description", d.String())
|
||||
}
|
||||
|
||||
if params := tool.Get("parameters"); params.Exists() {
|
||||
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", []byte(params.Raw))
|
||||
} else if params = tool.Get("parametersJsonSchema"); params.Exists() {
|
||||
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", []byte(params.Raw))
|
||||
}
|
||||
|
||||
toolsJSON, _ = sjson.SetRawBytes(toolsJSON, "-1", tJSON)
|
||||
return true
|
||||
})
|
||||
if parsedTools := gjson.ParseBytes(toolsJSON); parsedTools.IsArray() && len(parsedTools.Array()) > 0 {
|
||||
@@ -375,14 +371,24 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
||||
case "none":
|
||||
// Leave unset; implies no tools
|
||||
case "required":
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
|
||||
if len(includedToolNames) > 0 {
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
|
||||
}
|
||||
}
|
||||
case gjson.JSON:
|
||||
if toolChoice.Get("type").String() == "function" {
|
||||
fn := toolChoice.Get("function.name").String()
|
||||
toolChoiceJSON := []byte(`{"name":"","type":"tool"}`)
|
||||
toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn)
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
|
||||
if fn == "" {
|
||||
fn = toolChoice.Get("name").String()
|
||||
}
|
||||
if mappedName := toolNameMap[fn]; mappedName != "" {
|
||||
fn = mappedName
|
||||
}
|
||||
if _, ok := includedToolNames[fn]; ok {
|
||||
toolChoiceJSON := []byte(`{"name":"","type":"tool"}`)
|
||||
toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", fn)
|
||||
out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
|
||||
}
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -391,3 +397,167 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func convertResponsesToolToClaudeTools(tool gjson.Result, toolNameMap map[string]string) [][]byte {
|
||||
toolType := strings.TrimSpace(tool.Get("type").String())
|
||||
switch toolType {
|
||||
case "", "function":
|
||||
if tJSON, ok := convertResponsesFunctionToolToClaude(tool, ""); ok {
|
||||
return [][]byte{tJSON}
|
||||
}
|
||||
case "namespace":
|
||||
return convertResponsesNamespaceToolToClaude(tool, toolNameMap)
|
||||
case "web_search":
|
||||
if tJSON, ok := convertResponsesWebSearchToolToClaude(tool); ok {
|
||||
if name := gjson.GetBytes(tJSON, "name").String(); name != "" {
|
||||
toolNameMap[name] = name
|
||||
}
|
||||
return [][]byte{tJSON}
|
||||
}
|
||||
default:
|
||||
if isUnsupportedOpenAIBuiltinToolType(toolType) {
|
||||
return nil
|
||||
}
|
||||
if tool.Get("name").String() != "" {
|
||||
return [][]byte{[]byte(tool.Raw)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertResponsesNamespaceToolToClaude(tool gjson.Result, toolNameMap map[string]string) [][]byte {
|
||||
namespaceName := strings.TrimSpace(tool.Get("name").String())
|
||||
children := tool.Get("tools")
|
||||
if !children.Exists() || !children.IsArray() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out [][]byte
|
||||
children.ForEach(func(_, child gjson.Result) bool {
|
||||
childName := responsesToolName(child)
|
||||
qualifiedName := qualifyResponsesNamespaceToolName(namespaceName, childName)
|
||||
if tJSON, ok := convertResponsesFunctionToolToClaude(child, qualifiedName); ok {
|
||||
out = append(out, tJSON)
|
||||
toolNameMap[qualifiedName] = qualifiedName
|
||||
if childName != "" {
|
||||
toolNameMap[childName] = qualifiedName
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {
|
||||
name := strings.TrimSpace(overrideName)
|
||||
if name == "" {
|
||||
name = responsesToolName(tool)
|
||||
}
|
||||
if name == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
tJSON := []byte(`{"name":"","description":"","input_schema":{}}`)
|
||||
tJSON, _ = sjson.SetBytes(tJSON, "name", name)
|
||||
if d := responsesToolDescription(tool); d != "" {
|
||||
tJSON, _ = sjson.SetBytes(tJSON, "description", d)
|
||||
}
|
||||
tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", normalizeClaudeToolInputSchema(responsesToolParameters(tool)))
|
||||
return tJSON, true
|
||||
}
|
||||
|
||||
func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) {
|
||||
if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(tool.Get("name").String())
|
||||
if name == "" {
|
||||
name = "web_search"
|
||||
}
|
||||
tJSON := []byte(`{"type":"web_search_20250305","name":""}`)
|
||||
tJSON, _ = sjson.SetBytes(tJSON, "name", name)
|
||||
if maxUses := tool.Get("max_uses"); maxUses.Exists() {
|
||||
tJSON, _ = sjson.SetBytes(tJSON, "max_uses", maxUses.Int())
|
||||
}
|
||||
if allowedDomains := tool.Get("filters.allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
|
||||
tJSON, _ = sjson.SetRawBytes(tJSON, "allowed_domains", []byte(allowedDomains.Raw))
|
||||
}
|
||||
if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
|
||||
tJSON, _ = sjson.SetRawBytes(tJSON, "user_location", []byte(userLocation.Raw))
|
||||
}
|
||||
return tJSON, true
|
||||
}
|
||||
|
||||
func responsesToolName(tool gjson.Result) string {
|
||||
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(tool.Get("function.name").String())
|
||||
}
|
||||
|
||||
func responsesToolDescription(tool gjson.Result) string {
|
||||
if description := tool.Get("description").String(); description != "" {
|
||||
return description
|
||||
}
|
||||
return tool.Get("function.description").String()
|
||||
}
|
||||
|
||||
func responsesToolParameters(tool gjson.Result) gjson.Result {
|
||||
for _, path := range []string{
|
||||
"parameters",
|
||||
"parametersJsonSchema",
|
||||
"input_schema",
|
||||
"function.parameters",
|
||||
"function.parametersJsonSchema",
|
||||
} {
|
||||
if parameters := tool.Get(path); parameters.Exists() {
|
||||
return parameters
|
||||
}
|
||||
}
|
||||
return gjson.Result{}
|
||||
}
|
||||
|
||||
func normalizeClaudeToolInputSchema(parameters gjson.Result) []byte {
|
||||
raw := strings.TrimSpace(parameters.Raw)
|
||||
if raw == "" || raw == "null" || !gjson.Valid(raw) {
|
||||
return []byte(`{"type":"object","properties":{}}`)
|
||||
}
|
||||
result := gjson.Parse(raw)
|
||||
if !result.IsObject() {
|
||||
return []byte(`{"type":"object","properties":{}}`)
|
||||
}
|
||||
schema := []byte(raw)
|
||||
schemaType := result.Get("type").String()
|
||||
if schemaType == "" {
|
||||
schema, _ = sjson.SetBytes(schema, "type", "object")
|
||||
schemaType = "object"
|
||||
}
|
||||
if schemaType == "object" && !result.Get("properties").Exists() {
|
||||
schema, _ = sjson.SetRawBytes(schema, "properties", []byte(`{}`))
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
|
||||
childName = strings.TrimSpace(childName)
|
||||
if childName == "" || namespaceName == "" || strings.HasPrefix(childName, "mcp__") {
|
||||
return childName
|
||||
}
|
||||
if strings.HasPrefix(childName, namespaceName) {
|
||||
return childName
|
||||
}
|
||||
if strings.HasSuffix(namespaceName, "__") {
|
||||
return namespaceName + childName
|
||||
}
|
||||
return namespaceName + "__" + childName
|
||||
}
|
||||
|
||||
func isUnsupportedOpenAIBuiltinToolType(toolType string) bool {
|
||||
switch toolType {
|
||||
case "image_generation", "file_search", "code_interpreter", "computer_use_preview":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ type claudeToResponsesState struct {
|
||||
FuncNames map[int]string // index -> function name
|
||||
FuncCallIDs map[int]string // index -> call id
|
||||
// message text aggregation
|
||||
TextBuf strings.Builder
|
||||
TextBuf strings.Builder
|
||||
CurrentTextBuf strings.Builder
|
||||
// reasoning state
|
||||
ReasoningActive bool
|
||||
ReasoningItemID string
|
||||
@@ -80,6 +81,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
st.CreatedAt = time.Now().Unix()
|
||||
// Reset per-message aggregation state
|
||||
st.TextBuf.Reset()
|
||||
st.CurrentTextBuf.Reset()
|
||||
st.ReasoningBuf.Reset()
|
||||
st.ReasoningActive = false
|
||||
st.InTextBlock = false
|
||||
@@ -128,6 +130,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
if typ == "text" {
|
||||
// open message item + content part
|
||||
st.InTextBlock = true
|
||||
st.CurrentTextBuf.Reset()
|
||||
st.CurrentMsgID = fmt.Sprintf("msg_%s_0", st.ResponseID)
|
||||
item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
|
||||
item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
|
||||
@@ -189,6 +192,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
out = append(out, emitEvent("response.output_text.delta", msg))
|
||||
// aggregate text for response.output
|
||||
st.TextBuf.WriteString(t.String())
|
||||
st.CurrentTextBuf.WriteString(t.String())
|
||||
}
|
||||
} else if dt == "input_json_delta" {
|
||||
idx := int(root.Get("index").Int())
|
||||
@@ -220,17 +224,21 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
case "content_block_stop":
|
||||
idx := int(root.Get("index").Int())
|
||||
if st.InTextBlock {
|
||||
fullText := st.CurrentTextBuf.String()
|
||||
done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
|
||||
done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
|
||||
done, _ = sjson.SetBytes(done, "item_id", st.CurrentMsgID)
|
||||
done, _ = sjson.SetBytes(done, "text", fullText)
|
||||
out = append(out, emitEvent("response.output_text.done", done))
|
||||
partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
|
||||
partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
|
||||
partDone, _ = sjson.SetBytes(partDone, "item_id", st.CurrentMsgID)
|
||||
partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
|
||||
out = append(out, emitEvent("response.content_part.done", partDone))
|
||||
final := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","text":""}],"role":"assistant"}}`)
|
||||
final, _ = sjson.SetBytes(final, "sequence_number", nextSeq())
|
||||
final, _ = sjson.SetBytes(final, "item.id", st.CurrentMsgID)
|
||||
final, _ = sjson.SetBytes(final, "item.content.0.text", fullText)
|
||||
out = append(out, emitEvent("response.output_item.done", final))
|
||||
st.InTextBlock = false
|
||||
} else if st.InFuncBlock {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package geminiCLI
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiCLIRequestToCodex_PreservesSchemaPropertyNamedType(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"request": {
|
||||
"tools": [
|
||||
{
|
||||
"functionDeclarations": [
|
||||
{
|
||||
"name": "ask_user",
|
||||
"description": "Ask the user one or more questions.",
|
||||
"parametersJsonSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"header": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"default": "choice",
|
||||
"description": "Question type.",
|
||||
"enum": [
|
||||
"choice",
|
||||
"text",
|
||||
"yesno"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"question",
|
||||
"header",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"questions"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiCLIRequestToCodex("gpt-5.2", input, true)
|
||||
tool := gjson.GetBytes(out, "tools.0")
|
||||
if got := tool.Get("type").String(); got != "function" {
|
||||
t.Fatalf("expected tool type %q, got %q; output=%s", "function", got, string(out))
|
||||
}
|
||||
|
||||
typeProperty := tool.Get("parameters.properties.questions.items.properties.type")
|
||||
if !typeProperty.IsObject() {
|
||||
t.Fatalf("expected schema property named type to stay an object; output=%s", string(out))
|
||||
}
|
||||
if got := typeProperty.Get("type").String(); got != "string" {
|
||||
t.Fatalf("expected schema property type %q, got %q; output=%s", "string", got, string(out))
|
||||
}
|
||||
if got := typeProperty.Get("default").String(); got != "choice" {
|
||||
t.Fatalf("expected default %q, got %q; output=%s", "choice", got, string(out))
|
||||
}
|
||||
if got := typeProperty.Get("enum.2").String(); got != "yesno" {
|
||||
t.Fatalf("expected enum value %q, got %q; output=%s", "yesno", got, string(out))
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,11 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
util.Walk(toolsResult, "", "type", &pathsToLower)
|
||||
for _, p := range pathsToLower {
|
||||
fullPath := fmt.Sprintf("tools.%s", p)
|
||||
out, _ = sjson.SetBytes(out, fullPath, strings.ToLower(gjson.GetBytes(out, fullPath).String()))
|
||||
typeValue := gjson.GetBytes(out, fullPath)
|
||||
if typeValue.Type != gjson.String {
|
||||
continue
|
||||
}
|
||||
out, _ = sjson.SetBytes(out, fullPath, strings.ToLower(typeValue.String()))
|
||||
}
|
||||
|
||||
return out
|
||||
|
||||
@@ -121,13 +121,13 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
|
||||
case "tool":
|
||||
// Handle tool response messages as top-level function_call_output objects
|
||||
toolCallID := m.Get("tool_call_id").String()
|
||||
content := m.Get("content").String()
|
||||
content := m.Get("content")
|
||||
|
||||
// Create function_call_output object
|
||||
funcOutput := []byte(`{}`)
|
||||
funcOutput, _ = sjson.SetBytes(funcOutput, "type", "function_call_output")
|
||||
funcOutput, _ = sjson.SetBytes(funcOutput, "call_id", toolCallID)
|
||||
funcOutput, _ = sjson.SetBytes(funcOutput, "output", content)
|
||||
funcOutput = setToolCallOutputContent(funcOutput, content)
|
||||
out, _ = sjson.SetRawBytes(out, "input.-1", funcOutput)
|
||||
|
||||
default:
|
||||
@@ -359,6 +359,91 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
|
||||
return out
|
||||
}
|
||||
|
||||
func setToolCallOutputContent(funcOutput []byte, content gjson.Result) []byte {
|
||||
switch {
|
||||
case content.Type == gjson.String:
|
||||
funcOutput, _ = sjson.SetBytes(funcOutput, "output", content.String())
|
||||
case content.IsArray():
|
||||
output := []byte(`[]`)
|
||||
for _, item := range content.Array() {
|
||||
output = appendToolOutputContentPart(output, item)
|
||||
}
|
||||
funcOutput, _ = sjson.SetRawBytes(funcOutput, "output", output)
|
||||
default:
|
||||
fallbackOutput := content.Raw
|
||||
if fallbackOutput == "" {
|
||||
fallbackOutput = content.String()
|
||||
}
|
||||
funcOutput, _ = sjson.SetBytes(funcOutput, "output", fallbackOutput)
|
||||
}
|
||||
return funcOutput
|
||||
}
|
||||
|
||||
func appendToolOutputContentPart(output []byte, item gjson.Result) []byte {
|
||||
switch item.Get("type").String() {
|
||||
case "text":
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_text")
|
||||
part, _ = sjson.SetBytes(part, "text", item.Get("text").String())
|
||||
output, _ = sjson.SetRawBytes(output, "-1", part)
|
||||
case "image_url":
|
||||
imageURL := item.Get("image_url.url").String()
|
||||
fileID := item.Get("image_url.file_id").String()
|
||||
if imageURL == "" && fileID == "" {
|
||||
return appendToolOutputFallbackPart(output, item)
|
||||
}
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_image")
|
||||
if imageURL != "" {
|
||||
part, _ = sjson.SetBytes(part, "image_url", imageURL)
|
||||
}
|
||||
if fileID != "" {
|
||||
part, _ = sjson.SetBytes(part, "file_id", fileID)
|
||||
}
|
||||
if detail := item.Get("image_url.detail").String(); detail != "" {
|
||||
part, _ = sjson.SetBytes(part, "detail", detail)
|
||||
}
|
||||
output, _ = sjson.SetRawBytes(output, "-1", part)
|
||||
case "file":
|
||||
fileID := item.Get("file.file_id").String()
|
||||
fileData := item.Get("file.file_data").String()
|
||||
fileURL := item.Get("file.file_url").String()
|
||||
if fileID == "" && fileData == "" && fileURL == "" {
|
||||
return appendToolOutputFallbackPart(output, item)
|
||||
}
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_file")
|
||||
if fileID != "" {
|
||||
part, _ = sjson.SetBytes(part, "file_id", fileID)
|
||||
}
|
||||
if fileData != "" {
|
||||
part, _ = sjson.SetBytes(part, "file_data", fileData)
|
||||
}
|
||||
if fileURL != "" {
|
||||
part, _ = sjson.SetBytes(part, "file_url", fileURL)
|
||||
}
|
||||
if filename := item.Get("file.filename").String(); filename != "" {
|
||||
part, _ = sjson.SetBytes(part, "filename", filename)
|
||||
}
|
||||
output, _ = sjson.SetRawBytes(output, "-1", part)
|
||||
default:
|
||||
output = appendToolOutputFallbackPart(output, item)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func appendToolOutputFallbackPart(output []byte, item gjson.Result) []byte {
|
||||
text := item.Raw
|
||||
if text == "" {
|
||||
text = item.String()
|
||||
}
|
||||
part := []byte(`{}`)
|
||||
part, _ = sjson.SetBytes(part, "type", "input_text")
|
||||
part, _ = sjson.SetBytes(part, "text", text)
|
||||
output, _ = sjson.SetRawBytes(output, "-1", part)
|
||||
return output
|
||||
}
|
||||
|
||||
// shortenNameIfNeeded applies the simple shortening rule for a single name.
|
||||
// If the name length exceeds 64, it will try to preserve the "mcp__" prefix and last segment.
|
||||
// Otherwise it truncates to 64 characters.
|
||||
|
||||
@@ -176,6 +176,182 @@ func TestToolCallWithContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallOutputWithMultimodalContent(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "gpt-4o",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Show me the generated result."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_output_1",
|
||||
"type": "function",
|
||||
"function": {"name": "render_output", "arguments": "{}"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_output_1",
|
||||
"content": [
|
||||
{"type":"text","text":"Rendered result attached."},
|
||||
{"type":"image_url","image_url":{"url":"https://example.com/generated.png","detail":"high"}},
|
||||
{"type":"image_url","image_url":{"file_id":"file-img-123"}},
|
||||
{"type":"file","file":{"file_id":"file-doc-123","filename":"doc.pdf"}},
|
||||
{"type":"file","file":{"file_data":"SGVsbG8=","filename":"inline.txt"}},
|
||||
{"type":"file","file":{"file_url":"https://example.com/report.pdf","filename":"report.pdf"}}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "render_output", "description": "Render output", "parameters": {"type": "object", "properties": {}}}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
|
||||
result := string(out)
|
||||
|
||||
output := gjson.Get(result, "input.2.output")
|
||||
if !output.IsArray() {
|
||||
t.Fatalf("expected tool output to be an array, got: %s", output.Raw)
|
||||
}
|
||||
|
||||
parts := output.Array()
|
||||
if len(parts) != 6 {
|
||||
t.Fatalf("expected 6 output parts, got %d: %s", len(parts), output.Raw)
|
||||
}
|
||||
if parts[0].Get("type").String() != "input_text" || parts[0].Get("text").String() != "Rendered result attached." {
|
||||
t.Fatalf("part 0: expected input_text with rendered text, got %s", parts[0].Raw)
|
||||
}
|
||||
if parts[1].Get("type").String() != "input_image" {
|
||||
t.Fatalf("part 1: expected input_image, got %s", parts[1].Raw)
|
||||
}
|
||||
if parts[1].Get("image_url").String() != "https://example.com/generated.png" {
|
||||
t.Errorf("part 1: unexpected image_url %s", parts[1].Get("image_url").String())
|
||||
}
|
||||
if parts[1].Get("detail").String() != "high" {
|
||||
t.Errorf("part 1: unexpected detail %s", parts[1].Get("detail").String())
|
||||
}
|
||||
if parts[2].Get("type").String() != "input_image" || parts[2].Get("file_id").String() != "file-img-123" {
|
||||
t.Fatalf("part 2: expected file_id-backed input_image, got %s", parts[2].Raw)
|
||||
}
|
||||
if parts[3].Get("type").String() != "input_file" || parts[3].Get("file_id").String() != "file-doc-123" {
|
||||
t.Fatalf("part 3: expected file_id-backed input_file, got %s", parts[3].Raw)
|
||||
}
|
||||
if parts[3].Get("filename").String() != "doc.pdf" {
|
||||
t.Errorf("part 3: unexpected filename %s", parts[3].Get("filename").String())
|
||||
}
|
||||
if parts[4].Get("type").String() != "input_file" || parts[4].Get("file_data").String() != "SGVsbG8=" {
|
||||
t.Fatalf("part 4: expected file_data-backed input_file, got %s", parts[4].Raw)
|
||||
}
|
||||
if parts[5].Get("type").String() != "input_file" || parts[5].Get("file_url").String() != "https://example.com/report.pdf" {
|
||||
t.Fatalf("part 5: expected file_url-backed input_file, got %s", parts[5].Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallOutputFallsBackForInvalidStructuredParts(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "gpt-4o",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Check tool output."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [
|
||||
{"id": "call_invalid_parts", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_invalid_parts",
|
||||
"content": [
|
||||
{"type":"image_url","image_url":{"detail":"low"}},
|
||||
{"type":"file","file":{"filename":"orphan.txt"}},
|
||||
{"type":"unknown_type","foo":"bar","nested":{"a":1}}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{"type": "function", "function": {"name": "inspect", "description": "Inspect", "parameters": {"type": "object", "properties": {}}}}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
|
||||
result := string(out)
|
||||
|
||||
parts := gjson.Get(result, "input.2.output").Array()
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("expected 3 output parts, got %d: %s", len(parts), gjson.Get(result, "input.2.output").Raw)
|
||||
}
|
||||
|
||||
expectedFallbacks := []string{
|
||||
`{"type":"image_url","image_url":{"detail":"low"}}`,
|
||||
`{"type":"file","file":{"filename":"orphan.txt"}}`,
|
||||
`{"type":"unknown_type","foo":"bar","nested":{"a":1}}`,
|
||||
}
|
||||
for i, expectedFallback := range expectedFallbacks {
|
||||
if parts[i].Get("type").String() != "input_text" {
|
||||
t.Fatalf("part %d: expected input_text fallback, got %s", i, parts[i].Raw)
|
||||
}
|
||||
if parts[i].Get("text").String() != expectedFallback {
|
||||
t.Fatalf("part %d: expected fallback %s, got %s", i, expectedFallback, parts[i].Get("text").String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallOutputWithNonStringJSONContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expectedOutput string
|
||||
}{
|
||||
{name: "null", content: `null`, expectedOutput: `null`},
|
||||
{name: "object", content: `{"status":"ok","count":2}`, expectedOutput: `{"status":"ok","count":2}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "gpt-4o",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Check tool output."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [
|
||||
{"id": "call_json", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_json",
|
||||
"content": ` + tt.content + `
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{"type": "function", "function": {"name": "inspect", "description": "Inspect", "parameters": {"type": "object", "properties": {}}}}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
|
||||
result := string(out)
|
||||
|
||||
output := gjson.Get(result, "input.2.output")
|
||||
if !output.Exists() {
|
||||
t.Fatalf("expected output field to exist: %s", gjson.Get(result, "input.2").Raw)
|
||||
}
|
||||
if output.String() != tt.expectedOutput {
|
||||
t.Fatalf("expected output %s, got %s", tt.expectedOutput, output.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Parallel tool calls: assistant invokes 3 tools at once, all call_ids
|
||||
// and outputs must be translated and paired correctly.
|
||||
func TestMultipleToolCalls(t *testing.T) {
|
||||
|
||||
@@ -236,7 +236,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
|
||||
// Handle function name
|
||||
if function := toolCall.Get("function"); function.Exists() {
|
||||
if name := function.Get("name"); name.Exists() {
|
||||
if name := function.Get("name"); name.Exists() && name.String() != "" {
|
||||
accumulator.Name = util.MapToolName(param.ToolNameMap, name.String())
|
||||
|
||||
stopThinkingContentBlock(param, &results)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIResponseToClaude_StreamIgnoresNullToolNameDelta(t *testing.T) {
|
||||
originalRequest := []byte(`{"stream":true}`)
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user