Skip to content

Commit cbc0711

Browse files
authored
fix: support <think> tag compatibility (#100)
* fix: support think tags in responses conversion * refactor: streamline think tag streaming * refactor: stream think tags incrementally * refactor: reduce think emit duplication
1 parent 7d406aa commit cbc0711

6 files changed

Lines changed: 828 additions & 51 deletions

File tree

internal/transformer/convert/claude_openai.go

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func ClaudeReqToOpenAI(claudeReq []byte, model string) ([]byte, error) {
3737
var textParts []string
3838
var toolCalls []transformer.OpenAIToolCall
3939
var toolResults []transformer.OpenAIMessage
40+
hasThinking := false
4041

4142
for _, block := range content {
4243
m, ok := block.(map[string]interface{})
@@ -51,22 +52,35 @@ func ClaudeReqToOpenAI(claudeReq []byte, model string) ([]byte, error) {
5152
case "thinking":
5253
// Skip thinking blocks - they are Claude's internal reasoning
5354
// and should not be forwarded to other APIs
55+
hasThinking = true
5456
continue
5557
case "tool_use":
5658
args, _ := json.Marshal(m["input"])
59+
id, ok := m["id"].(string)
60+
if !ok || id == "" {
61+
continue
62+
}
63+
name, ok := m["name"].(string)
64+
if !ok || name == "" {
65+
continue
66+
}
5767
toolCalls = append(toolCalls, transformer.OpenAIToolCall{
58-
ID: m["id"].(string),
68+
ID: id,
5969
Type: "function",
6070
Function: struct {
6171
Name string `json:"name"`
6272
Arguments string `json:"arguments"`
63-
}{Name: m["name"].(string), Arguments: string(args)},
73+
}{Name: name, Arguments: string(args)},
6474
})
6575
case "tool_result":
76+
callID, ok := m["tool_use_id"].(string)
77+
if !ok || callID == "" {
78+
continue
79+
}
6680
toolResults = append(toolResults, transformer.OpenAIMessage{
6781
Role: "tool",
6882
Content: extractToolResultContent(m["content"]),
69-
ToolCallID: m["tool_use_id"].(string),
83+
ToolCallID: callID,
7084
})
7185
}
7286
}
@@ -81,6 +95,11 @@ func ClaudeReqToOpenAI(claudeReq []byte, model string) ([]byte, error) {
8195
openaiMsg.ToolCalls = toolCalls
8296
}
8397
messages = append(messages, openaiMsg)
98+
} else if hasThinking && msg.Role == "assistant" {
99+
messages = append(messages, transformer.OpenAIMessage{
100+
Role: "assistant",
101+
Content: "(thinking...)",
102+
})
84103
}
85104

86105
// Add tool result messages
@@ -317,7 +336,7 @@ func OpenAIRespToClaude(openaiResp []byte) ([]byte, error) {
317336
if len(resp.Choices) > 0 {
318337
choice := resp.Choices[0]
319338
if choice.Message.Content != "" {
320-
content = append(content, map[string]interface{}{"type": "text", "text": choice.Message.Content})
339+
content = append(content, splitThinkTaggedText(choice.Message.Content)...)
321340
}
322341
for _, tc := range choice.Message.ToolCalls {
323342
var args map[string]interface{}
@@ -428,6 +447,8 @@ func OpenAIStreamToClaude(event []byte, ctx *transformer.StreamContext) ([]byte,
428447
if jsonData == "" || jsonData == "[DONE]" {
429448
if jsonData == "[DONE]" {
430449
var result []byte
450+
emitText, emitThinking := makeThinkEmitters(ctx, &result)
451+
flushThinkTaggedStream(ctx, emitText, emitThinking)
431452
// Close any open content blocks before message_stop
432453
if ctx.ThinkingBlockStarted {
433454
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ThinkingIndex})...)
@@ -476,11 +497,34 @@ func OpenAIStreamToClaude(event []byte, ctx *transformer.StreamContext) ([]byte,
476497
}
477498

478499
if len(chunk.Choices) == 0 {
500+
if chunk.Usage != nil {
501+
usageObj := map[string]interface{}{
502+
"input_tokens": chunk.Usage.PromptTokens,
503+
"output_tokens": chunk.Usage.CompletionTokens,
504+
}
505+
msgDelta := map[string]interface{}{
506+
"delta": map[string]interface{}{},
507+
"usage": usageObj,
508+
}
509+
result = append(result, buildClaudeEvent("message_delta", msgDelta)...)
510+
}
479511
return result, nil
480512
}
481513

482514
choice := chunk.Choices[0]
483515
delta := choice.Delta
516+
if chunk.Usage != nil && delta.Role == "" && delta.Content == "" && delta.ReasoningContent == "" && len(delta.ToolCalls) == 0 && choice.FinishReason == nil {
517+
usageObj := map[string]interface{}{
518+
"input_tokens": chunk.Usage.PromptTokens,
519+
"output_tokens": chunk.Usage.CompletionTokens,
520+
}
521+
msgDelta := map[string]interface{}{
522+
"delta": map[string]interface{}{},
523+
"usage": usageObj,
524+
}
525+
result = append(result, buildClaudeEvent("message_delta", msgDelta)...)
526+
return result, nil
527+
}
484528

485529
// Reasoning/Thinking content (before text content)
486530
if delta.ReasoningContent != "" {
@@ -499,20 +543,32 @@ func OpenAIStreamToClaude(event []byte, ctx *transformer.StreamContext) ([]byte,
499543

500544
// Text content
501545
if delta.Content != "" {
502-
// Close thinking block if transitioning to text
503-
if ctx.ThinkingBlockStarted && !ctx.ContentBlockStarted {
504-
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ThinkingIndex})...)
505-
ctx.ThinkingBlockStarted = false
546+
content := ctx.ThinkingBuffer + delta.Content
547+
ctx.ThinkingBuffer = ""
548+
549+
emitText, emitThinking := makeThinkEmitters(ctx, &result)
550+
emitTextWithClose := func(text string) {
551+
if text == "" {
552+
return
553+
}
554+
if ctx.ThinkingBlockStarted && !ctx.ContentBlockStarted && !ctx.InThinkingTag {
555+
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ThinkingIndex})...)
556+
ctx.ThinkingBlockStarted = false
557+
}
558+
emitText(text)
506559
}
507-
if !ctx.ContentBlockStarted {
508-
ctx.ContentBlockStarted = true
509-
result = append(result, buildClaudeEvent("content_block_start", map[string]interface{}{
510-
"index": ctx.ContentIndex, "content_block": map[string]interface{}{"type": "text", "text": ""},
511-
})...)
560+
emitThinkingWithClose := func(text string) {
561+
if text == "" {
562+
return
563+
}
564+
emitThinking(text)
565+
if ctx.ThinkingBlockStarted {
566+
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ThinkingIndex})...)
567+
ctx.ThinkingBlockStarted = false
568+
}
512569
}
513-
result = append(result, buildClaudeEvent("content_block_delta", map[string]interface{}{
514-
"index": ctx.ContentIndex, "delta": map[string]interface{}{"type": "text_delta", "text": delta.Content},
515-
})...)
570+
571+
consumeThinkTaggedStream(content, ctx, emitTextWithClose, emitThinkingWithClose)
516572
}
517573

518574
// Tool calls
@@ -667,4 +723,3 @@ func extractToolResultContent(content interface{}) string {
667723
}
668724
return ""
669725
}
670-

internal/transformer/convert/claude_openai2.go

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ func OpenAI2RespToClaude(openai2Resp []byte) ([]byte, error) {
204204
case "message":
205205
for _, part := range item.Content {
206206
if part.Type == "output_text" {
207-
content = append(content, map[string]interface{}{"type": "text", "text": part.Text})
207+
content = append(content, splitThinkTaggedText(part.Text)...)
208208
}
209209
}
210210
case "function_call":
@@ -335,7 +335,7 @@ func ClaudeStreamToOpenAI2(event []byte, ctx *transformer.StreamContext) ([]byte
335335
partial := delta["partial_json"].(string)
336336
ctx.ToolArguments += partial
337337
writeEvent(map[string]interface{}{
338-
"type": "response.function_call_arguments.delta",
338+
"type": "response.function_call_arguments.delta",
339339
"output_index": ctx.ToolIndex, "delta": partial,
340340
})
341341
}
@@ -347,7 +347,7 @@ func ClaudeStreamToOpenAI2(event []byte, ctx *transformer.StreamContext) ([]byte
347347
if ctx.ToolBlockStarted && blockIdx == ctx.ToolIndex {
348348
// function_call_arguments.done
349349
writeEvent(map[string]interface{}{
350-
"type": "response.function_call_arguments.done",
350+
"type": "response.function_call_arguments.done",
351351
"output_index": blockIdx, "arguments": ctx.ToolArguments,
352352
})
353353
// output_item.done for function_call
@@ -414,7 +414,23 @@ func OpenAI2StreamToClaude(event []byte, ctx *transformer.StreamContext) ([]byte
414414
_, jsonData := parseSSE(event)
415415
if jsonData == "" || jsonData == "[DONE]" {
416416
if jsonData == "[DONE]" {
417-
return buildClaudeEvent("message_stop", map[string]interface{}{}), nil
417+
var result []byte
418+
emitText, emitThinking := makeThinkEmitters(ctx, &result)
419+
flushThinkTaggedStream(ctx, emitText, emitThinking)
420+
if ctx.ThinkingBlockStarted {
421+
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ThinkingIndex})...)
422+
ctx.ThinkingBlockStarted = false
423+
}
424+
if ctx.ContentBlockStarted {
425+
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ContentIndex})...)
426+
ctx.ContentBlockStarted = false
427+
}
428+
if ctx.ToolBlockStarted {
429+
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ToolIndex})...)
430+
ctx.ToolBlockStarted = false
431+
}
432+
result = append(result, buildClaudeEvent("message_stop", map[string]interface{}{})...)
433+
return result, nil
418434
}
419435
return nil, nil
420436
}
@@ -440,18 +456,39 @@ func OpenAI2StreamToClaude(event []byte, ctx *transformer.StreamContext) ([]byte
440456
})...)
441457

442458
case "response.output_text.delta":
443-
if !ctx.ContentBlockStarted {
444-
ctx.ContentBlockStarted = true
445-
result = append(result, buildClaudeEvent("content_block_start", map[string]interface{}{
446-
"index": ctx.ContentIndex, "content_block": map[string]interface{}{"type": "text", "text": ""},
447-
})...)
459+
content := ctx.ThinkingBuffer + evt.Delta
460+
ctx.ThinkingBuffer = ""
461+
462+
emitText, emitThinking := makeThinkEmitters(ctx, &result)
463+
emitTextWithClose := func(text string) {
464+
if text == "" {
465+
return
466+
}
467+
if ctx.ThinkingBlockStarted && !ctx.ContentBlockStarted && !ctx.InThinkingTag {
468+
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ThinkingIndex})...)
469+
ctx.ThinkingBlockStarted = false
470+
}
471+
emitText(text)
448472
}
449-
result = append(result, buildClaudeEvent("content_block_delta", map[string]interface{}{
450-
"index": ctx.ContentIndex, "delta": map[string]interface{}{"type": "text_delta", "text": evt.Delta},
451-
})...)
473+
emitThinkingWithClose := func(text string) {
474+
if text == "" {
475+
return
476+
}
477+
emitThinking(text)
478+
if ctx.ThinkingBlockStarted {
479+
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ThinkingIndex})...)
480+
ctx.ThinkingBlockStarted = false
481+
}
482+
}
483+
484+
consumeThinkTaggedStream(content, ctx, emitTextWithClose, emitThinkingWithClose)
452485

453486
case "response.output_item.added":
454487
if evt.Item != nil && evt.Item.Type == "function_call" {
488+
if ctx.ThinkingBlockStarted {
489+
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ThinkingIndex})...)
490+
ctx.ThinkingBlockStarted = false
491+
}
455492
// Close text block if open
456493
if ctx.ContentBlockStarted {
457494
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ContentIndex})...)
@@ -486,8 +523,15 @@ func OpenAI2StreamToClaude(event []byte, ctx *transformer.StreamContext) ([]byte
486523
}
487524

488525
case "response.completed":
526+
emitText, emitThinking := makeThinkEmitters(ctx, &result)
527+
flushThinkTaggedStream(ctx, emitText, emitThinking)
528+
if ctx.ThinkingBlockStarted {
529+
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ThinkingIndex})...)
530+
ctx.ThinkingBlockStarted = false
531+
}
489532
if ctx.ContentBlockStarted {
490533
result = append(result, buildClaudeEvent("content_block_stop", map[string]interface{}{"index": ctx.ContentIndex})...)
534+
ctx.ContentBlockStarted = false
491535
}
492536
stopReason := "end_turn"
493537
if ctx.ToolIndex > 0 || ctx.CurrentToolID != "" {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package convert
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
8+
"github.com/lich0821/ccNexus/internal/transformer"
9+
)
10+
11+
func TestOpenAI2RespToClaudeWithThinking(t *testing.T) {
12+
openai2Resp := `{
13+
"id": "resp_1",
14+
"object": "response",
15+
"status": "completed",
16+
"output": [{
17+
"type": "message",
18+
"role": "assistant",
19+
"content": [{
20+
"type": "output_text",
21+
"text": "<think>Reason</think>Answer"
22+
}]
23+
}],
24+
"usage": {
25+
"input_tokens": 3,
26+
"output_tokens": 5,
27+
"total_tokens": 8
28+
}
29+
}`
30+
31+
claudeRespBytes, err := OpenAI2RespToClaude([]byte(openai2Resp))
32+
if err != nil {
33+
t.Fatalf("OpenAI2RespToClaude failed: %v", err)
34+
}
35+
36+
var claudeResp map[string]interface{}
37+
if err := json.Unmarshal(claudeRespBytes, &claudeResp); err != nil {
38+
t.Fatalf("Failed to unmarshal Claude response: %v", err)
39+
}
40+
41+
content, ok := claudeResp["content"].([]interface{})
42+
if !ok {
43+
t.Fatalf("Expected content to be an array, got %T", claudeResp["content"])
44+
}
45+
if len(content) != 2 {
46+
t.Fatalf("Expected 2 content blocks, got %d", len(content))
47+
}
48+
if content[0].(map[string]interface{})["type"] != "thinking" {
49+
t.Fatalf("Expected first block thinking, got %v", content[0])
50+
}
51+
if content[1].(map[string]interface{})["type"] != "text" {
52+
t.Fatalf("Expected second block text, got %v", content[1])
53+
}
54+
}
55+
56+
func TestOpenAI2StreamToClaudeWithThinking(t *testing.T) {
57+
ctx := transformer.NewStreamContext()
58+
ctx.ModelName = "claude-3-sonnet-20240229"
59+
60+
chunks := []string{
61+
`data: {"type":"response.created","response":{"id":"resp_1","object":"response","status":"in_progress"}}`,
62+
`data: {"type":"response.output_text.delta","delta":"<think>Reason</think>Hello"}`,
63+
`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed"}}`,
64+
`data: [DONE]`,
65+
}
66+
67+
var allEvents []string
68+
for _, chunk := range chunks {
69+
events, err := OpenAI2StreamToClaude([]byte(chunk), ctx)
70+
if err != nil {
71+
t.Fatalf("OpenAI2StreamToClaude failed: %v", err)
72+
}
73+
if events != nil {
74+
allEvents = append(allEvents, string(events))
75+
}
76+
}
77+
78+
fullEvents := strings.Join(allEvents, "")
79+
if !strings.Contains(fullEvents, "\"type\":\"thinking\"") {
80+
t.Fatalf("Expected thinking block start, but not found")
81+
}
82+
if !strings.Contains(fullEvents, "\"thinking\":\"Reason\"") {
83+
t.Fatalf("Expected thinking delta 'Reason', but not found")
84+
}
85+
if !strings.Contains(fullEvents, "\"text\":\"Hello\"") {
86+
t.Fatalf("Expected text delta 'Hello', but not found")
87+
}
88+
if strings.Contains(fullEvents, "<think>") || strings.Contains(fullEvents, "</think>") {
89+
t.Fatalf("Unexpected think tags leaked into output")
90+
}
91+
}

0 commit comments

Comments
 (0)