sshinmen Claude commited on
Commit
bd749dd
·
1 Parent(s): 394ab74

Add shared state utilities for streaming translators

Browse files

- Create sdk/translator/state.go with:
- StreamState: Base struct for response metadata, timestamps, finish reasons
- ToolCallState/ToolCallAccumulator: Generic tool call accumulation
- ContentBuffer: Text and reasoning content accumulation
- StreamingState: Combined state for streaming translators
- FinishReasonMapper: Bidirectional finish reason mapping
- Helper functions for ID generation

- Create comprehensive unit tests in sdk/translator/state_test.go

- Migrate Claude translator to use shared state:
- Use embedded translator.StreamState
- Replace local ToolCallAccumulator with shared sdk version
- Use DefaultFinishReasonMapper for stop reason conversion
- Simplify both streaming and non-streaming functions

Co-Authored-By: Claude <noreply@anthropic.com>

internal/translator/claude/openai/chat-completions/claude_openai_response.go CHANGED
@@ -12,6 +12,7 @@ import (
12
  "strings"
13
  "time"
14
 
 
15
  "github.com/tidwall/gjson"
16
  "github.com/tidwall/sjson"
17
  )
@@ -20,20 +21,12 @@ var (
20
  dataTag = []byte("data:")
21
  )
22
 
23
- // ConvertAnthropicResponseToOpenAIParams holds parameters for response conversion
 
24
  type ConvertAnthropicResponseToOpenAIParams struct {
25
- CreatedAt int64
26
- ResponseID string
27
- FinishReason string
28
- // Tool calls accumulator for streaming
29
- ToolCallsAccumulator map[int]*ToolCallAccumulator
30
- }
31
-
32
- // ToolCallAccumulator holds the state for accumulating tool call data
33
- type ToolCallAccumulator struct {
34
- ID string
35
- Name string
36
- Arguments strings.Builder
37
  }
38
 
39
  // ConvertClaudeResponseToOpenAI converts Claude Code streaming response format to OpenAI Chat Completions format.
@@ -52,12 +45,15 @@ type ToolCallAccumulator struct {
52
  func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
53
  if *param == nil {
54
  *param = &ConvertAnthropicResponseToOpenAIParams{
55
- CreatedAt: 0,
56
- ResponseID: "",
57
- FinishReason: "",
58
  }
59
  }
60
 
 
 
 
 
 
61
  if !bytes.HasPrefix(rawJSON, dataTag) {
62
  return []string{}
63
  }
@@ -75,31 +71,26 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
75
  }
76
 
77
  // Set response ID and creation time
78
- if (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID != "" {
79
- template, _ = sjson.Set(template, "id", (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID)
80
  }
81
- if (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt > 0 {
82
- template, _ = sjson.Set(template, "created", (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt)
83
  }
84
 
85
  switch eventType {
86
  case "message_start":
87
  // Initialize response with message metadata when a new message begins
88
  if message := root.Get("message"); message.Exists() {
89
- (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID = message.Get("id").String()
90
- (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt = time.Now().Unix()
91
 
92
- template, _ = sjson.Set(template, "id", (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID)
93
  template, _ = sjson.Set(template, "model", modelName)
94
- template, _ = sjson.Set(template, "created", (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt)
95
 
96
  // Set initial role to assistant for the response
97
  template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
98
-
99
- // Initialize tool calls accumulator for tracking tool call progress
100
- if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {
101
- (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
102
- }
103
  }
104
  return []string{template}
105
 
@@ -109,19 +100,11 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
109
  blockType := contentBlock.Get("type").String()
110
 
111
  if blockType == "tool_use" {
112
- // Start of tool call - initialize accumulator to track arguments
113
  toolCallID := contentBlock.Get("id").String()
114
  toolName := contentBlock.Get("name").String()
115
- index := int(root.Get("index").Int())
116
 
117
- if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {
118
- (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
119
- }
120
-
121
- (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator[index] = &ToolCallAccumulator{
122
- ID: toolCallID,
123
- Name: toolName,
124
- }
125
 
126
  // Don't output anything yet - wait for complete tool call
127
  return []string{}
@@ -149,13 +132,11 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
149
  hasContent = true
150
  }
151
  case "input_json_delta":
152
- // Tool use input delta - accumulate arguments for tool calls
153
  if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
154
  index := int(root.Get("index").Int())
155
- if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator != nil {
156
- if accumulator, exists := (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator[index]; exists {
157
- accumulator.Arguments.WriteString(partialJSON.String())
158
- }
159
  }
160
  }
161
  // Don't output anything yet - wait for complete tool call
@@ -171,24 +152,19 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
171
  case "content_block_stop":
172
  // End of content block - output complete tool call if it's a tool_use block
173
  index := int(root.Get("index").Int())
174
- if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator != nil {
175
- if accumulator, exists := (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator[index]; exists {
176
- // Build complete tool call with accumulated arguments
177
- arguments := accumulator.Arguments.String()
178
- if arguments == "" {
179
- arguments = "{}"
180
- }
181
- template, _ = sjson.Set(template, "choices.0.delta.tool_calls.0.index", index)
182
- template, _ = sjson.Set(template, "choices.0.delta.tool_calls.0.id", accumulator.ID)
183
- template, _ = sjson.Set(template, "choices.0.delta.tool_calls.0.type", "function")
184
- template, _ = sjson.Set(template, "choices.0.delta.tool_calls.0.function.name", accumulator.Name)
185
- template, _ = sjson.Set(template, "choices.0.delta.tool_calls.0.function.arguments", arguments)
186
-
187
- // Clean up the accumulator for this index
188
- delete((*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator, index)
189
 
190
- return []string{template}
191
- }
192
  }
193
  return []string{}
194
 
@@ -196,8 +172,8 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
196
  // Handle message-level changes including stop reason and usage
197
  if delta := root.Get("delta"); delta.Exists() {
198
  if stopReason := delta.Get("stop_reason"); stopReason.Exists() {
199
- (*param).(*ConvertAnthropicResponseToOpenAIParams).FinishReason = mapAnthropicStopReasonToOpenAI(stopReason.String())
200
- template, _ = sjson.Set(template, "choices.0.finish_reason", (*param).(*ConvertAnthropicResponseToOpenAIParams).FinishReason)
201
  }
202
  }
203
 
@@ -238,22 +214,6 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
238
  }
239
  }
240
 
241
- // mapAnthropicStopReasonToOpenAI maps Anthropic stop reasons to OpenAI stop reasons
242
- func mapAnthropicStopReasonToOpenAI(anthropicReason string) string {
243
- switch anthropicReason {
244
- case "end_turn":
245
- return "stop"
246
- case "tool_use":
247
- return "tool_calls"
248
- case "max_tokens":
249
- return "length"
250
- case "stop_sequence":
251
- return "stop"
252
- default:
253
- return "stop"
254
- }
255
- }
256
-
257
  // ConvertClaudeResponseToOpenAINonStream converts a non-streaming Claude Code response to a non-streaming OpenAI response.
258
  // This function processes the complete Claude Code response and transforms it into a single OpenAI-compatible
259
  // JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
@@ -287,7 +247,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
287
  var stopReason string
288
  var contentParts []string
289
  var reasoningParts []string
290
- toolCallsAccumulator := make(map[int]*ToolCallAccumulator)
291
 
292
  for _, chunk := range chunks {
293
  root := gjson.ParseBytes(chunk)
@@ -310,12 +270,10 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
310
  // Start of thinking/reasoning content - skip for now as it's handled in delta
311
  continue
312
  } else if blockType == "tool_use" {
313
- // Initialize tool call accumulator for this index
314
- index := int(root.Get("index").Int())
315
- toolCallsAccumulator[index] = &ToolCallAccumulator{
316
- ID: contentBlock.Get("id").String(),
317
- Name: contentBlock.Get("name").String(),
318
- }
319
  }
320
  }
321
 
@@ -335,11 +293,11 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
335
  reasoningParts = append(reasoningParts, thinking.String())
336
  }
337
  case "input_json_delta":
338
- // Accumulate tool call arguments
339
  if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
340
  index := int(root.Get("index").Int())
341
- if accumulator, exists := toolCallsAccumulator[index]; exists {
342
- accumulator.Arguments.WriteString(partialJSON.String())
343
  }
344
  }
345
  }
@@ -348,10 +306,9 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
348
  case "content_block_stop":
349
  // Finalize tool call arguments for this index when content block ends
350
  index := int(root.Get("index").Int())
351
- if accumulator, exists := toolCallsAccumulator[index]; exists {
352
- if accumulator.Arguments.Len() == 0 {
353
- accumulator.Arguments.WriteString("{}")
354
- }
355
  }
356
 
357
  case "message_delta":
@@ -391,41 +348,29 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
391
  }
392
 
393
  // Set tool calls if any were accumulated during processing
394
- if len(toolCallsAccumulator) > 0 {
395
  toolCallsCount := 0
396
- maxIndex := -1
397
- for index := range toolCallsAccumulator {
398
- if index > maxIndex {
399
- maxIndex = index
400
- }
401
- }
402
-
403
- for i := 0; i <= maxIndex; i++ {
404
- accumulator, exists := toolCallsAccumulator[i]
405
- if !exists {
406
- continue
407
- }
408
-
409
- arguments := accumulator.Arguments.String()
410
 
411
  idPath := fmt.Sprintf("choices.0.message.tool_calls.%d.id", toolCallsCount)
412
  typePath := fmt.Sprintf("choices.0.message.tool_calls.%d.type", toolCallsCount)
413
  namePath := fmt.Sprintf("choices.0.message.tool_calls.%d.function.name", toolCallsCount)
414
  argumentsPath := fmt.Sprintf("choices.0.message.tool_calls.%d.function.arguments", toolCallsCount)
415
 
416
- out, _ = sjson.Set(out, idPath, accumulator.ID)
417
  out, _ = sjson.Set(out, typePath, "function")
418
- out, _ = sjson.Set(out, namePath, accumulator.Name)
419
  out, _ = sjson.Set(out, argumentsPath, arguments)
420
  toolCallsCount++
421
  }
422
  if toolCallsCount > 0 {
423
  out, _ = sjson.Set(out, "choices.0.finish_reason", "tool_calls")
424
  } else {
425
- out, _ = sjson.Set(out, "choices.0.finish_reason", mapAnthropicStopReasonToOpenAI(stopReason))
426
  }
427
  } else {
428
- out, _ = sjson.Set(out, "choices.0.finish_reason", mapAnthropicStopReasonToOpenAI(stopReason))
429
  }
430
 
431
  return out
 
12
  "strings"
13
  "time"
14
 
15
+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
16
  "github.com/tidwall/gjson"
17
  "github.com/tidwall/sjson"
18
  )
 
21
  dataTag = []byte("data:")
22
  )
23
 
24
+ // ConvertAnthropicResponseToOpenAIParams holds parameters for response conversion.
25
+ // It embeds the shared StreamState and uses ToolCallAccumulator from the SDK.
26
  type ConvertAnthropicResponseToOpenAIParams struct {
27
+ translator.StreamState
28
+ // ToolCalls manages tool call accumulation using the shared SDK utility.
29
+ ToolCalls *translator.ToolCallAccumulator
 
 
 
 
 
 
 
 
 
30
  }
31
 
32
  // ConvertClaudeResponseToOpenAI converts Claude Code streaming response format to OpenAI Chat Completions format.
 
45
  func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
46
  if *param == nil {
47
  *param = &ConvertAnthropicResponseToOpenAIParams{
48
+ ToolCalls: translator.NewToolCallAccumulator(),
 
 
49
  }
50
  }
51
 
52
+ state := (*param).(*ConvertAnthropicResponseToOpenAIParams)
53
+ if state.ToolCalls == nil {
54
+ state.ToolCalls = translator.NewToolCallAccumulator()
55
+ }
56
+
57
  if !bytes.HasPrefix(rawJSON, dataTag) {
58
  return []string{}
59
  }
 
71
  }
72
 
73
  // Set response ID and creation time
74
+ if state.ResponseID != "" {
75
+ template, _ = sjson.Set(template, "id", state.ResponseID)
76
  }
77
+ if state.CreatedAt > 0 {
78
+ template, _ = sjson.Set(template, "created", state.CreatedAt)
79
  }
80
 
81
  switch eventType {
82
  case "message_start":
83
  // Initialize response with message metadata when a new message begins
84
  if message := root.Get("message"); message.Exists() {
85
+ state.ResponseID = message.Get("id").String()
86
+ state.EnsureTimestamp()
87
 
88
+ template, _ = sjson.Set(template, "id", state.ResponseID)
89
  template, _ = sjson.Set(template, "model", modelName)
90
+ template, _ = sjson.Set(template, "created", state.CreatedAt)
91
 
92
  // Set initial role to assistant for the response
93
  template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
 
 
 
 
 
94
  }
95
  return []string{template}
96
 
 
100
  blockType := contentBlock.Get("type").String()
101
 
102
  if blockType == "tool_use" {
103
+ // Start of tool call - create using shared accumulator
104
  toolCallID := contentBlock.Get("id").String()
105
  toolName := contentBlock.Get("name").String()
 
106
 
107
+ state.ToolCalls.CreateNext(toolCallID, toolName)
 
 
 
 
 
 
 
108
 
109
  // Don't output anything yet - wait for complete tool call
110
  return []string{}
 
132
  hasContent = true
133
  }
134
  case "input_json_delta":
135
+ // Tool use input delta - accumulate arguments using shared utility
136
  if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
137
  index := int(root.Get("index").Int())
138
+ if tc := state.ToolCalls.Get(index); tc != nil {
139
+ tc.WriteArguments(partialJSON.String())
 
 
140
  }
141
  }
142
  // Don't output anything yet - wait for complete tool call
 
152
  case "content_block_stop":
153
  // End of content block - output complete tool call if it's a tool_use block
154
  index := int(root.Get("index").Int())
155
+ if tc := state.ToolCalls.MarkComplete(index); tc != nil {
156
+ // Build complete tool call with accumulated arguments
157
+ arguments := tc.GetArguments()
158
+ template, _ = sjson.Set(template, "choices.0.delta.tool_calls.0.index", tc.Index)
159
+ template, _ = sjson.Set(template, "choices.0.delta.tool_calls.0.id", tc.ID)
160
+ template, _ = sjson.Set(template, "choices.0.delta.tool_calls.0.type", "function")
161
+ template, _ = sjson.Set(template, "choices.0.delta.tool_calls.0.function.name", tc.Name)
162
+ template, _ = sjson.Set(template, "choices.0.delta.tool_calls.0.function.arguments", arguments)
163
+
164
+ // Clean up the accumulator for this index
165
+ state.ToolCalls.Remove(index)
 
 
 
 
166
 
167
+ return []string{template}
 
168
  }
169
  return []string{}
170
 
 
172
  // Handle message-level changes including stop reason and usage
173
  if delta := root.Get("delta"); delta.Exists() {
174
  if stopReason := delta.Get("stop_reason"); stopReason.Exists() {
175
+ state.FinishReason = translator.DefaultFinishReasonMapper.ToOpenAI(stopReason.String())
176
+ template, _ = sjson.Set(template, "choices.0.finish_reason", state.FinishReason)
177
  }
178
  }
179
 
 
214
  }
215
  }
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  // ConvertClaudeResponseToOpenAINonStream converts a non-streaming Claude Code response to a non-streaming OpenAI response.
218
  // This function processes the complete Claude Code response and transforms it into a single OpenAI-compatible
219
  // JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
 
247
  var stopReason string
248
  var contentParts []string
249
  var reasoningParts []string
250
+ toolCallsAccumulator := translator.NewToolCallAccumulator()
251
 
252
  for _, chunk := range chunks {
253
  root := gjson.ParseBytes(chunk)
 
270
  // Start of thinking/reasoning content - skip for now as it's handled in delta
271
  continue
272
  } else if blockType == "tool_use" {
273
+ // Initialize tool call accumulator for this index using shared utility
274
+ toolCallID := contentBlock.Get("id").String()
275
+ toolName := contentBlock.Get("name").String()
276
+ toolCallsAccumulator.CreateNext(toolCallID, toolName)
 
 
277
  }
278
  }
279
 
 
293
  reasoningParts = append(reasoningParts, thinking.String())
294
  }
295
  case "input_json_delta":
296
+ // Accumulate tool call arguments using shared utility
297
  if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
298
  index := int(root.Get("index").Int())
299
+ if tc := toolCallsAccumulator.Get(index); tc != nil {
300
+ tc.WriteArguments(partialJSON.String())
301
  }
302
  }
303
  }
 
306
  case "content_block_stop":
307
  // Finalize tool call arguments for this index when content block ends
308
  index := int(root.Get("index").Int())
309
+ if tc := toolCallsAccumulator.Get(index); tc != nil {
310
+ // Ensure arguments are not empty (GetArguments returns "{}" by default)
311
+ _ = tc.GetArguments()
 
312
  }
313
 
314
  case "message_delta":
 
348
  }
349
 
350
  // Set tool calls if any were accumulated during processing
351
+ if toolCallsAccumulator.HasAny() {
352
  toolCallsCount := 0
353
+ for _, tc := range toolCallsAccumulator.GetOrdered() {
354
+ arguments := tc.GetArguments()
 
 
 
 
 
 
 
 
 
 
 
 
355
 
356
  idPath := fmt.Sprintf("choices.0.message.tool_calls.%d.id", toolCallsCount)
357
  typePath := fmt.Sprintf("choices.0.message.tool_calls.%d.type", toolCallsCount)
358
  namePath := fmt.Sprintf("choices.0.message.tool_calls.%d.function.name", toolCallsCount)
359
  argumentsPath := fmt.Sprintf("choices.0.message.tool_calls.%d.function.arguments", toolCallsCount)
360
 
361
+ out, _ = sjson.Set(out, idPath, tc.ID)
362
  out, _ = sjson.Set(out, typePath, "function")
363
+ out, _ = sjson.Set(out, namePath, tc.Name)
364
  out, _ = sjson.Set(out, argumentsPath, arguments)
365
  toolCallsCount++
366
  }
367
  if toolCallsCount > 0 {
368
  out, _ = sjson.Set(out, "choices.0.finish_reason", "tool_calls")
369
  } else {
370
+ out, _ = sjson.Set(out, "choices.0.finish_reason", translator.DefaultFinishReasonMapper.ToOpenAI(stopReason))
371
  }
372
  } else {
373
+ out, _ = sjson.Set(out, "choices.0.finish_reason", translator.DefaultFinishReasonMapper.ToOpenAI(stopReason))
374
  }
375
 
376
  return out
sdk/translator/state.go ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package translator provides types and functions for converting chat requests and responses between different schemas.
2
+ // This file contains shared state utilities for streaming response translators.
3
+ package translator
4
+
5
+ import (
6
+ "fmt"
7
+ "strings"
8
+ "sync/atomic"
9
+ "time"
10
+ )
11
+
12
+ // StreamState provides common state fields for streaming response translators.
13
+ // Embed this struct into translator-specific state types.
14
+ type StreamState struct {
15
+ // ResponseID is the unique identifier for the response.
16
+ ResponseID string
17
+ // CreatedAt is the Unix timestamp when the response was created.
18
+ CreatedAt int64
19
+ // Model is the name of the model being used.
20
+ Model string
21
+ // SequenceNum is used for ordered events (e.g., OpenAI Responses API).
22
+ SequenceNum int
23
+ // FinishReason is the mapped finish reason in target format.
24
+ FinishReason string
25
+ // NativeFinishReason is the original finish reason from the source.
26
+ NativeFinishReason string
27
+ // InputTokens tracks the number of input tokens used.
28
+ InputTokens int64
29
+ // OutputTokens tracks the number of output tokens used.
30
+ OutputTokens int64
31
+ // UsageSeen indicates whether usage data has been processed.
32
+ UsageSeen bool
33
+ }
34
+
35
+ // Reset clears the state for reuse.
36
+ func (s *StreamState) Reset() {
37
+ s.ResponseID = ""
38
+ s.CreatedAt = 0
39
+ s.Model = ""
40
+ s.SequenceNum = 0
41
+ s.FinishReason = ""
42
+ s.NativeFinishReason = ""
43
+ s.InputTokens = 0
44
+ s.OutputTokens = 0
45
+ s.UsageSeen = false
46
+ }
47
+
48
+ // NextSequence returns the next sequence number for event ordering.
49
+ func (s *StreamState) NextSequence() int {
50
+ s.SequenceNum++
51
+ return s.SequenceNum
52
+ }
53
+
54
+ // EnsureTimestamp sets CreatedAt to current time if not already set.
55
+ func (s *StreamState) EnsureTimestamp() int64 {
56
+ if s.CreatedAt == 0 {
57
+ s.CreatedAt = time.Now().Unix()
58
+ }
59
+ return s.CreatedAt
60
+ }
61
+
62
+ // ToolCallState tracks the state of a single tool/function call being accumulated.
63
+ type ToolCallState struct {
64
+ // ID is the unique identifier for this tool call.
65
+ ID string
66
+ // Name is the name of the tool/function.
67
+ Name string
68
+ // Index is the position of this tool call in the sequence.
69
+ Index int
70
+ // Arguments accumulates the JSON arguments for this tool call.
71
+ Arguments strings.Builder
72
+ // Complete indicates whether this tool call has been fully received.
73
+ Complete bool
74
+ }
75
+
76
+ // GetArguments returns the accumulated arguments, defaulting to "{}" if empty.
77
+ func (t *ToolCallState) GetArguments() string {
78
+ args := t.Arguments.String()
79
+ if args == "" {
80
+ return "{}"
81
+ }
82
+ return args
83
+ }
84
+
85
+ // WriteArguments appends partial argument JSON.
86
+ func (t *ToolCallState) WriteArguments(partial string) {
87
+ t.Arguments.WriteString(partial)
88
+ }
89
+
90
+ // ToolCallAccumulator manages multiple concurrent tool calls indexed by position.
91
+ type ToolCallAccumulator struct {
92
+ // calls maps index -> tool call state.
93
+ calls map[int]*ToolCallState
94
+ // ordered tracks the order of indices for deterministic iteration.
95
+ ordered []int
96
+ // nextIndex tracks the next available index for new calls.
97
+ nextIndex int
98
+ }
99
+
100
+ // NewToolCallAccumulator creates a new accumulator.
101
+ func NewToolCallAccumulator() *ToolCallAccumulator {
102
+ return &ToolCallAccumulator{
103
+ calls: make(map[int]*ToolCallState),
104
+ ordered: make([]int, 0),
105
+ nextIndex: 0,
106
+ }
107
+ }
108
+
109
+ // Get retrieves a tool call by index, returning nil if not found.
110
+ func (t *ToolCallAccumulator) Get(index int) *ToolCallState {
111
+ return t.calls[index]
112
+ }
113
+
114
+ // GetOrCreate retrieves or creates a tool call at the given index.
115
+ func (t *ToolCallAccumulator) GetOrCreate(index int) *ToolCallState {
116
+ if tc, exists := t.calls[index]; exists {
117
+ return tc
118
+ }
119
+ tc := &ToolCallState{Index: index}
120
+ t.calls[index] = tc
121
+ t.ordered = append(t.ordered, index)
122
+ return tc
123
+ }
124
+
125
+ // CreateNext creates a new tool call at the next available index.
126
+ func (t *ToolCallAccumulator) CreateNext(id, name string) *ToolCallState {
127
+ tc := &ToolCallState{
128
+ ID: id,
129
+ Name: name,
130
+ Index: t.nextIndex,
131
+ }
132
+ t.calls[t.nextIndex] = tc
133
+ t.ordered = append(t.ordered, t.nextIndex)
134
+ t.nextIndex++
135
+ return tc
136
+ }
137
+
138
+ // MarkComplete marks a tool call as complete and returns it.
139
+ func (t *ToolCallAccumulator) MarkComplete(index int) *ToolCallState {
140
+ if tc, exists := t.calls[index]; exists {
141
+ tc.Complete = true
142
+ return tc
143
+ }
144
+ return nil
145
+ }
146
+
147
+ // Remove deletes a tool call from the accumulator (cleanup after processing).
148
+ func (t *ToolCallAccumulator) Remove(index int) {
149
+ delete(t.calls, index)
150
+ // Rebuild ordered slice
151
+ newOrdered := make([]int, 0, len(t.calls))
152
+ for _, idx := range t.ordered {
153
+ if idx != index {
154
+ newOrdered = append(newOrdered, idx)
155
+ }
156
+ }
157
+ t.ordered = newOrdered
158
+ }
159
+
160
+ // HasAny returns true if any tool calls are being tracked.
161
+ func (t *ToolCallAccumulator) HasAny() bool {
162
+ return len(t.calls) > 0
163
+ }
164
+
165
+ // Count returns the number of tracked tool calls.
166
+ func (t *ToolCallAccumulator) Count() int {
167
+ return len(t.calls)
168
+ }
169
+
170
+ // GetOrdered returns tool calls in the order they were created.
171
+ func (t *ToolCallAccumulator) GetOrdered() []*ToolCallState {
172
+ result := make([]*ToolCallState, 0, len(t.ordered))
173
+ for _, idx := range t.ordered {
174
+ if tc, exists := t.calls[idx]; exists {
175
+ result = append(result, tc)
176
+ }
177
+ }
178
+ return result
179
+ }
180
+
181
+ // Reset clears all accumulated state.
182
+ func (t *ToolCallAccumulator) Reset() {
183
+ t.calls = make(map[int]*ToolCallState)
184
+ t.ordered = make([]int, 0)
185
+ t.nextIndex = 0
186
+ }
187
+
188
+ // ContentBuffer accumulates text and reasoning content.
189
+ type ContentBuffer struct {
190
+ // Text accumulates the main text content.
191
+ Text strings.Builder
192
+ // Reasoning accumulates reasoning/thinking content.
193
+ Reasoning strings.Builder
194
+ }
195
+
196
+ // Reset clears the buffers.
197
+ func (c *ContentBuffer) Reset() {
198
+ c.Text.Reset()
199
+ c.Reasoning.Reset()
200
+ }
201
+
202
+ // HasContent returns true if any content has been accumulated.
203
+ func (c *ContentBuffer) HasContent() bool {
204
+ return c.Text.Len() > 0 || c.Reasoning.Len() > 0
205
+ }
206
+
207
+ // StreamingState combines all common state for streaming translators.
208
+ type StreamingState struct {
209
+ StreamState
210
+ // ToolCalls tracks accumulated tool calls.
211
+ ToolCalls *ToolCallAccumulator
212
+ // Content accumulates text and reasoning content.
213
+ Content ContentBuffer
214
+ // InTextBlock indicates whether currently processing a text block.
215
+ InTextBlock bool
216
+ // InToolBlock indicates whether currently processing a tool block.
217
+ InToolBlock bool
218
+ // InReasoningBlock indicates whether currently processing a reasoning block.
219
+ InReasoningBlock bool
220
+ // CurrentMessageID is the ID of the current message being processed.
221
+ CurrentMessageID string
222
+ // CurrentToolCallID is the ID of the current tool call being processed.
223
+ CurrentToolCallID string
224
+ // CurrentReasoningID is the ID of the current reasoning block being processed.
225
+ CurrentReasoningID string
226
+ }
227
+
228
+ // NewStreamingState creates a fully initialized streaming state.
229
+ func NewStreamingState() *StreamingState {
230
+ return &StreamingState{
231
+ ToolCalls: NewToolCallAccumulator(),
232
+ }
233
+ }
234
+
235
+ // Reset clears all state for reuse.
236
+ func (s *StreamingState) Reset() {
237
+ s.StreamState.Reset()
238
+ s.ToolCalls.Reset()
239
+ s.Content.Reset()
240
+ s.InTextBlock = false
241
+ s.InToolBlock = false
242
+ s.InReasoningBlock = false
243
+ s.CurrentMessageID = ""
244
+ s.CurrentToolCallID = ""
245
+ s.CurrentReasoningID = ""
246
+ }
247
+
248
+ // FinishReasonMapper provides bidirectional finish reason mapping.
249
+ type FinishReasonMapper struct {
250
+ toOpenAI map[string]string
251
+ fromOpenAI map[string]string
252
+ }
253
+
254
+ // NewFinishReasonMapper creates a mapper with standard OpenAI mappings.
255
+ func NewFinishReasonMapper() *FinishReasonMapper {
256
+ return &FinishReasonMapper{
257
+ toOpenAI: map[string]string{
258
+ // Anthropic/Claude
259
+ "end_turn": "stop",
260
+ "tool_use": "tool_calls",
261
+ "max_tokens": "length",
262
+ "stop_sequence": "stop",
263
+ // Gemini
264
+ "STOP": "stop",
265
+ "MAX_TOKENS": "length",
266
+ "SAFETY": "content_filter",
267
+ "RECITATION": "content_filter",
268
+ "OTHER": "stop",
269
+ // Codex
270
+ "completed": "stop",
271
+ },
272
+ fromOpenAI: map[string]string{
273
+ "stop": "end_turn",
274
+ "tool_calls": "tool_use",
275
+ "length": "max_tokens",
276
+ "content_filter": "SAFETY",
277
+ },
278
+ }
279
+ }
280
+
281
+ // ToOpenAI converts a provider-specific finish reason to OpenAI format.
282
+ func (m *FinishReasonMapper) ToOpenAI(reason string) string {
283
+ if mapped, ok := m.toOpenAI[reason]; ok {
284
+ return mapped
285
+ }
286
+ return "stop" // default
287
+ }
288
+
289
+ // FromOpenAI converts an OpenAI finish reason to provider-specific format.
290
+ func (m *FinishReasonMapper) FromOpenAI(reason string) string {
291
+ if mapped, ok := m.fromOpenAI[reason]; ok {
292
+ return mapped
293
+ }
294
+ return "STOP" // default
295
+ }
296
+
297
+ // RegisterMapping adds or overrides a mapping.
298
+ func (m *FinishReasonMapper) RegisterMapping(providerReason, openAIReason string) {
299
+ m.toOpenAI[providerReason] = openAIReason
300
+ m.fromOpenAI[openAIReason] = providerReason
301
+ }
302
+
303
+ // DefaultFinishReasonMapper is the package-level default mapper.
304
+ var DefaultFinishReasonMapper = NewFinishReasonMapper()
305
+
306
+ // Process-wide unique ID generators.
307
+ var (
308
+ responseIDCounter uint64
309
+ functionCallIDCounter uint64
310
+ )
311
+
312
+ // GenerateResponseID creates a unique response ID with optional prefix.
313
+ func GenerateResponseID(prefix string) string {
314
+ counter := atomic.AddUint64(&responseIDCounter, 1)
315
+ if prefix != "" {
316
+ return fmt.Sprintf("%s_%x_%d", prefix, time.Now().UnixNano(), counter)
317
+ }
318
+ return fmt.Sprintf("resp_%x_%d", time.Now().UnixNano(), counter)
319
+ }
320
+
321
+ // GenerateFunctionCallID creates a unique function call ID.
322
+ func GenerateFunctionCallID(name string) string {
323
+ counter := atomic.AddUint64(&functionCallIDCounter, 1)
324
+ if name != "" {
325
+ return fmt.Sprintf("call_%s_%d_%d", name, time.Now().UnixNano(), counter)
326
+ }
327
+ return fmt.Sprintf("call_%d_%d", time.Now().UnixNano(), counter)
328
+ }
sdk/translator/state_test.go ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package translator
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ func TestStreamState(t *testing.T) {
9
+ t.Run("Reset clears all fields", func(t *testing.T) {
10
+ s := StreamState{
11
+ ResponseID: "test-id",
12
+ CreatedAt: 12345,
13
+ Model: "gpt-4",
14
+ SequenceNum: 10,
15
+ FinishReason: "stop",
16
+ NativeFinishReason: "end_turn",
17
+ InputTokens: 100,
18
+ OutputTokens: 50,
19
+ UsageSeen: true,
20
+ }
21
+
22
+ s.Reset()
23
+
24
+ if s.ResponseID != "" {
25
+ t.Errorf("Expected ResponseID to be empty, got %s", s.ResponseID)
26
+ }
27
+ if s.CreatedAt != 0 {
28
+ t.Errorf("Expected CreatedAt to be 0, got %d", s.CreatedAt)
29
+ }
30
+ if s.Model != "" {
31
+ t.Errorf("Expected Model to be empty, got %s", s.Model)
32
+ }
33
+ if s.SequenceNum != 0 {
34
+ t.Errorf("Expected SequenceNum to be 0, got %d", s.SequenceNum)
35
+ }
36
+ if s.FinishReason != "" {
37
+ t.Errorf("Expected FinishReason to be empty, got %s", s.FinishReason)
38
+ }
39
+ if s.NativeFinishReason != "" {
40
+ t.Errorf("Expected NativeFinishReason to be empty, got %s", s.NativeFinishReason)
41
+ }
42
+ if s.InputTokens != 0 {
43
+ t.Errorf("Expected InputTokens to be 0, got %d", s.InputTokens)
44
+ }
45
+ if s.OutputTokens != 0 {
46
+ t.Errorf("Expected OutputTokens to be 0, got %d", s.OutputTokens)
47
+ }
48
+ if s.UsageSeen {
49
+ t.Error("Expected UsageSeen to be false")
50
+ }
51
+ })
52
+
53
+ t.Run("NextSequence increments correctly", func(t *testing.T) {
54
+ s := StreamState{}
55
+
56
+ if s.NextSequence() != 1 {
57
+ t.Errorf("Expected first sequence to be 1, got %d", s.SequenceNum)
58
+ }
59
+ if s.NextSequence() != 2 {
60
+ t.Errorf("Expected second sequence to be 2, got %d", s.SequenceNum)
61
+ }
62
+ if s.NextSequence() != 3 {
63
+ t.Errorf("Expected third sequence to be 3, got %d", s.SequenceNum)
64
+ }
65
+ })
66
+
67
+ t.Run("EnsureTimestamp sets timestamp when zero", func(t *testing.T) {
68
+ s := StreamState{}
69
+
70
+ ts := s.EnsureTimestamp()
71
+ if ts == 0 {
72
+ t.Error("Expected timestamp to be set")
73
+ }
74
+ if s.CreatedAt != ts {
75
+ t.Errorf("Expected CreatedAt to match returned timestamp, got %d vs %d", s.CreatedAt, ts)
76
+ }
77
+ })
78
+
79
+ t.Run("EnsureTimestamp preserves existing timestamp", func(t *testing.T) {
80
+ s := StreamState{CreatedAt: 12345}
81
+
82
+ ts := s.EnsureTimestamp()
83
+ if ts != 12345 {
84
+ t.Errorf("Expected timestamp to remain 12345, got %d", ts)
85
+ }
86
+ })
87
+ }
88
+
89
+ func TestToolCallState(t *testing.T) {
90
+ t.Run("GetArguments returns empty object when no arguments", func(t *testing.T) {
91
+ tc := &ToolCallState{}
92
+ args := tc.GetArguments()
93
+ if args != "{}" {
94
+ t.Errorf("Expected '{}', got %s", args)
95
+ }
96
+ })
97
+
98
+ t.Run("GetArguments returns accumulated arguments", func(t *testing.T) {
99
+ tc := &ToolCallState{}
100
+ tc.Arguments.WriteString(`{"key": "value"}`)
101
+ args := tc.GetArguments()
102
+ if args != `{"key": "value"}` {
103
+ t.Errorf("Expected '{\"key\": \"value\"}', got %s", args)
104
+ }
105
+ })
106
+
107
+ t.Run("WriteArguments appends content", func(t *testing.T) {
108
+ tc := &ToolCallState{}
109
+ tc.WriteArguments(`{"a":`)
110
+ tc.WriteArguments(`1}`)
111
+
112
+ expected := `{"a":1}`
113
+ if tc.Arguments.String() != expected {
114
+ t.Errorf("Expected '%s', got %s", expected, tc.Arguments.String())
115
+ }
116
+ })
117
+ }
118
+
119
+ func TestToolCallAccumulator(t *testing.T) {
120
+ t.Run("NewToolCallAccumulator creates empty accumulator", func(t *testing.T) {
121
+ a := NewToolCallAccumulator()
122
+ if a == nil {
123
+ t.Fatal("Expected non-nil accumulator")
124
+ }
125
+ if a.HasAny() {
126
+ t.Error("Expected HasAny to be false for new accumulator")
127
+ }
128
+ if a.Count() != 0 {
129
+ t.Errorf("Expected Count to be 0, got %d", a.Count())
130
+ }
131
+ })
132
+
133
+ t.Run("Get returns nil for non-existent index", func(t *testing.T) {
134
+ a := NewToolCallAccumulator()
135
+ if a.Get(0) != nil {
136
+ t.Error("Expected Get to return nil for non-existent index")
137
+ }
138
+ })
139
+
140
+ t.Run("GetOrCreate creates new tool call", func(t *testing.T) {
141
+ a := NewToolCallAccumulator()
142
+ tc := a.GetOrCreate(0)
143
+ if tc == nil {
144
+ t.Fatal("Expected non-nil tool call")
145
+ }
146
+ if tc.Index != 0 {
147
+ t.Errorf("Expected index 0, got %d", tc.Index)
148
+ }
149
+ if !a.HasAny() {
150
+ t.Error("Expected HasAny to be true after creating tool call")
151
+ }
152
+ if a.Count() != 1 {
153
+ t.Errorf("Expected Count to be 1, got %d", a.Count())
154
+ }
155
+ })
156
+
157
+ t.Run("GetOrCreate returns existing tool call", func(t *testing.T) {
158
+ a := NewToolCallAccumulator()
159
+ tc1 := a.GetOrCreate(0)
160
+ tc1.Name = "test-function"
161
+
162
+ tc2 := a.GetOrCreate(0)
163
+ if tc2.Name != "test-function" {
164
+ t.Error("Expected to get existing tool call")
165
+ }
166
+ if a.Count() != 1 {
167
+ t.Errorf("Expected Count to still be 1, got %d", a.Count())
168
+ }
169
+ })
170
+
171
+ t.Run("CreateNext assigns incremental indices", func(t *testing.T) {
172
+ a := NewToolCallAccumulator()
173
+
174
+ tc1 := a.CreateNext("id1", "func1")
175
+ tc2 := a.CreateNext("id2", "func2")
176
+
177
+ if tc1.Index != 0 {
178
+ t.Errorf("Expected first index to be 0, got %d", tc1.Index)
179
+ }
180
+ if tc2.Index != 1 {
181
+ t.Errorf("Expected second index to be 1, got %d", tc2.Index)
182
+ }
183
+ if tc1.ID != "id1" || tc1.Name != "func1" {
184
+ t.Error("Expected first tool call to have correct ID and Name")
185
+ }
186
+ if tc2.ID != "id2" || tc2.Name != "func2" {
187
+ t.Error("Expected second tool call to have correct ID and Name")
188
+ }
189
+ })
190
+
191
+ t.Run("MarkComplete marks tool call as complete", func(t *testing.T) {
192
+ a := NewToolCallAccumulator()
193
+ a.CreateNext("id1", "func1")
194
+
195
+ tc := a.MarkComplete(0)
196
+ if tc == nil {
197
+ t.Fatal("Expected non-nil tool call")
198
+ }
199
+ if !tc.Complete {
200
+ t.Error("Expected tool call to be marked complete")
201
+ }
202
+ })
203
+
204
+ t.Run("MarkComplete returns nil for non-existent index", func(t *testing.T) {
205
+ a := NewToolCallAccumulator()
206
+ if a.MarkComplete(0) != nil {
207
+ t.Error("Expected MarkComplete to return nil for non-existent index")
208
+ }
209
+ })
210
+
211
+ t.Run("Remove deletes tool call", func(t *testing.T) {
212
+ a := NewToolCallAccumulator()
213
+ a.CreateNext("id1", "func1")
214
+ a.CreateNext("id2", "func2")
215
+
216
+ a.Remove(0)
217
+
218
+ if a.Count() != 1 {
219
+ t.Errorf("Expected Count to be 1 after removal, got %d", a.Count())
220
+ }
221
+ if a.Get(0) != nil {
222
+ t.Error("Expected Get(0) to return nil after removal")
223
+ }
224
+ if a.Get(1) == nil {
225
+ t.Error("Expected Get(1) to still exist")
226
+ }
227
+ })
228
+
229
+ t.Run("GetOrdered returns tool calls in creation order", func(t *testing.T) {
230
+ a := NewToolCallAccumulator()
231
+ a.CreateNext("id1", "func1")
232
+ a.CreateNext("id2", "func2")
233
+ a.CreateNext("id3", "func3")
234
+
235
+ ordered := a.GetOrdered()
236
+ if len(ordered) != 3 {
237
+ t.Fatalf("Expected 3 ordered tool calls, got %d", len(ordered))
238
+ }
239
+ if ordered[0].ID != "id1" || ordered[1].ID != "id2" || ordered[2].ID != "id3" {
240
+ t.Error("Expected tool calls in creation order")
241
+ }
242
+ })
243
+
244
+ t.Run("Reset clears all state", func(t *testing.T) {
245
+ a := NewToolCallAccumulator()
246
+ a.CreateNext("id1", "func1")
247
+ a.CreateNext("id2", "func2")
248
+
249
+ a.Reset()
250
+
251
+ if a.HasAny() {
252
+ t.Error("Expected HasAny to be false after reset")
253
+ }
254
+ if a.Count() != 0 {
255
+ t.Errorf("Expected Count to be 0 after reset, got %d", a.Count())
256
+ }
257
+ })
258
+ }
259
+
260
+ func TestContentBuffer(t *testing.T) {
261
+ t.Run("HasContent returns false for empty buffer", func(t *testing.T) {
262
+ cb := ContentBuffer{}
263
+ if cb.HasContent() {
264
+ t.Error("Expected HasContent to be false for empty buffer")
265
+ }
266
+ })
267
+
268
+ t.Run("HasContent returns true with text", func(t *testing.T) {
269
+ cb := ContentBuffer{}
270
+ cb.Text.WriteString("hello")
271
+ if !cb.HasContent() {
272
+ t.Error("Expected HasContent to be true with text")
273
+ }
274
+ })
275
+
276
+ t.Run("HasContent returns true with reasoning", func(t *testing.T) {
277
+ cb := ContentBuffer{}
278
+ cb.Reasoning.WriteString("thinking...")
279
+ if !cb.HasContent() {
280
+ t.Error("Expected HasContent to be true with reasoning")
281
+ }
282
+ })
283
+
284
+ t.Run("Reset clears both buffers", func(t *testing.T) {
285
+ cb := ContentBuffer{}
286
+ cb.Text.WriteString("text")
287
+ cb.Reasoning.WriteString("reasoning")
288
+
289
+ cb.Reset()
290
+
291
+ if cb.Text.Len() != 0 {
292
+ t.Error("Expected Text to be empty after reset")
293
+ }
294
+ if cb.Reasoning.Len() != 0 {
295
+ t.Error("Expected Reasoning to be empty after reset")
296
+ }
297
+ if cb.HasContent() {
298
+ t.Error("Expected HasContent to be false after reset")
299
+ }
300
+ })
301
+ }
302
+
303
+ func TestStreamingState(t *testing.T) {
304
+ t.Run("NewStreamingState creates initialized state", func(t *testing.T) {
305
+ s := NewStreamingState()
306
+ if s == nil {
307
+ t.Fatal("Expected non-nil state")
308
+ }
309
+ if s.ToolCalls == nil {
310
+ t.Error("Expected ToolCalls to be initialized")
311
+ }
312
+ })
313
+
314
+ t.Run("Reset clears all fields", func(t *testing.T) {
315
+ s := NewStreamingState()
316
+ s.ResponseID = "test-id"
317
+ s.InTextBlock = true
318
+ s.InToolBlock = true
319
+ s.InReasoningBlock = true
320
+ s.CurrentMessageID = "msg-id"
321
+ s.CurrentToolCallID = "tool-id"
322
+ s.CurrentReasoningID = "reasoning-id"
323
+ s.ToolCalls.CreateNext("id", "func")
324
+ s.Content.Text.WriteString("text")
325
+
326
+ s.Reset()
327
+
328
+ if s.ResponseID != "" {
329
+ t.Error("Expected ResponseID to be cleared")
330
+ }
331
+ if s.InTextBlock || s.InToolBlock || s.InReasoningBlock {
332
+ t.Error("Expected block flags to be false")
333
+ }
334
+ if s.CurrentMessageID != "" || s.CurrentToolCallID != "" || s.CurrentReasoningID != "" {
335
+ t.Error("Expected current IDs to be cleared")
336
+ }
337
+ if s.ToolCalls.HasAny() {
338
+ t.Error("Expected ToolCalls to be cleared")
339
+ }
340
+ if s.Content.HasContent() {
341
+ t.Error("Expected Content to be cleared")
342
+ }
343
+ })
344
+ }
345
+
346
+ func TestFinishReasonMapper(t *testing.T) {
347
+ t.Run("ToOpenAI maps known reasons", func(t *testing.T) {
348
+ m := NewFinishReasonMapper()
349
+
350
+ testCases := []struct {
351
+ input string
352
+ expected string
353
+ }{
354
+ {"end_turn", "stop"},
355
+ {"tool_use", "tool_calls"},
356
+ {"max_tokens", "length"},
357
+ {"STOP", "stop"},
358
+ {"MAX_TOKENS", "length"},
359
+ {"SAFETY", "content_filter"},
360
+ {"completed", "stop"},
361
+ }
362
+
363
+ for _, tc := range testCases {
364
+ result := m.ToOpenAI(tc.input)
365
+ if result != tc.expected {
366
+ t.Errorf("ToOpenAI(%s) = %s, expected %s", tc.input, result, tc.expected)
367
+ }
368
+ }
369
+ })
370
+
371
+ t.Run("ToOpenAI returns default for unknown reasons", func(t *testing.T) {
372
+ m := NewFinishReasonMapper()
373
+ result := m.ToOpenAI("unknown_reason")
374
+ if result != "stop" {
375
+ t.Errorf("Expected default 'stop', got %s", result)
376
+ }
377
+ })
378
+
379
+ t.Run("FromOpenAI maps known reasons", func(t *testing.T) {
380
+ m := NewFinishReasonMapper()
381
+
382
+ testCases := []struct {
383
+ input string
384
+ expected string
385
+ }{
386
+ {"stop", "end_turn"},
387
+ {"tool_calls", "tool_use"},
388
+ {"length", "max_tokens"},
389
+ {"content_filter", "SAFETY"},
390
+ }
391
+
392
+ for _, tc := range testCases {
393
+ result := m.FromOpenAI(tc.input)
394
+ if result != tc.expected {
395
+ t.Errorf("FromOpenAI(%s) = %s, expected %s", tc.input, result, tc.expected)
396
+ }
397
+ }
398
+ })
399
+
400
+ t.Run("FromOpenAI returns default for unknown reasons", func(t *testing.T) {
401
+ m := NewFinishReasonMapper()
402
+ result := m.FromOpenAI("unknown")
403
+ if result != "STOP" {
404
+ t.Errorf("Expected default 'STOP', got %s", result)
405
+ }
406
+ })
407
+
408
+ t.Run("RegisterMapping adds new mapping", func(t *testing.T) {
409
+ m := NewFinishReasonMapper()
410
+ m.RegisterMapping("custom_reason", "custom_openai")
411
+
412
+ if m.ToOpenAI("custom_reason") != "custom_openai" {
413
+ t.Error("Expected new mapping to work in ToOpenAI direction")
414
+ }
415
+ if m.FromOpenAI("custom_openai") != "custom_reason" {
416
+ t.Error("Expected new mapping to work in FromOpenAI direction")
417
+ }
418
+ })
419
+ }
420
+
421
+ func TestGenerateResponseID(t *testing.T) {
422
+ t.Run("generates unique IDs", func(t *testing.T) {
423
+ id1 := GenerateResponseID("")
424
+ id2 := GenerateResponseID("")
425
+
426
+ if id1 == id2 {
427
+ t.Error("Expected unique IDs")
428
+ }
429
+ })
430
+
431
+ t.Run("includes prefix when provided", func(t *testing.T) {
432
+ id := GenerateResponseID("chatcmpl")
433
+ if !strings.HasPrefix(id, "chatcmpl_") {
434
+ t.Errorf("Expected ID to start with 'chatcmpl_', got %s", id)
435
+ }
436
+ })
437
+
438
+ t.Run("default prefix is resp_", func(t *testing.T) {
439
+ id := GenerateResponseID("")
440
+ if !strings.HasPrefix(id, "resp_") {
441
+ t.Errorf("Expected ID to start with 'resp_', got %s", id)
442
+ }
443
+ })
444
+ }
445
+
446
+ func TestGenerateFunctionCallID(t *testing.T) {
447
+ t.Run("generates unique IDs", func(t *testing.T) {
448
+ id1 := GenerateFunctionCallID("")
449
+ id2 := GenerateFunctionCallID("")
450
+
451
+ if id1 == id2 {
452
+ t.Error("Expected unique IDs")
453
+ }
454
+ })
455
+
456
+ t.Run("includes name when provided", func(t *testing.T) {
457
+ id := GenerateFunctionCallID("get_weather")
458
+ if !strings.Contains(id, "get_weather") {
459
+ t.Errorf("Expected ID to contain 'get_weather', got %s", id)
460
+ }
461
+ })
462
+ }