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>
|
@@ -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 |
-
|
| 26 |
-
|
| 27 |
-
|
| 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 |
-
|
| 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
|
| 79 |
-
template, _ = sjson.Set(template, "id",
|
| 80 |
}
|
| 81 |
-
if
|
| 82 |
-
template, _ = sjson.Set(template, "created",
|
| 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 |
-
|
| 90 |
-
|
| 91 |
|
| 92 |
-
template, _ = sjson.Set(template, "id",
|
| 93 |
template, _ = sjson.Set(template, "model", modelName)
|
| 94 |
-
template, _ = sjson.Set(template, "created",
|
| 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 -
|
| 113 |
toolCallID := contentBlock.Get("id").String()
|
| 114 |
toolName := contentBlock.Get("name").String()
|
| 115 |
-
index := int(root.Get("index").Int())
|
| 116 |
|
| 117 |
-
|
| 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
|
| 153 |
if partialJSON := delta.Get("partial_json"); partialJSON.Exists() {
|
| 154 |
index := int(root.Get("index").Int())
|
| 155 |
-
if
|
| 156 |
-
|
| 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
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 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 |
-
|
| 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 |
-
|
| 200 |
-
template, _ = sjson.Set(template, "choices.0.finish_reason",
|
| 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 :=
|
| 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 |
-
|
| 315 |
-
|
| 316 |
-
|
| 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
|
| 342 |
-
|
| 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
|
| 352 |
-
|
| 353 |
-
|
| 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
|
| 395 |
toolCallsCount := 0
|
| 396 |
-
|
| 397 |
-
|
| 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,
|
| 417 |
out, _ = sjson.Set(out, typePath, "function")
|
| 418 |
-
out, _ = sjson.Set(out, namePath,
|
| 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",
|
| 426 |
}
|
| 427 |
} else {
|
| 428 |
-
out, _ = sjson.Set(out, "choices.0.finish_reason",
|
| 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
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|