From 2b6f408de087e12d89e402f0a56c073c8b1a6c83 Mon Sep 17 00:00:00 2001 From: zijiren233 Date: Sat, 13 Jun 2026 21:58:23 +0800 Subject: [PATCH 1/2] fix: chat responses mode finish_reason --- core/relay/adaptor/openai/chat.go | 128 +++++++--- core/relay/adaptor/openai/chat_test.go | 320 +++++++++++++++++++++++++ 2 files changed, 410 insertions(+), 38 deletions(-) diff --git a/core/relay/adaptor/openai/chat.go b/core/relay/adaptor/openai/chat.go index 874110c8..ccbd9602 100644 --- a/core/relay/adaptor/openai/chat.go +++ b/core/relay/adaptor/openai/chat.go @@ -29,6 +29,7 @@ type chatCompletionStreamState struct { currentToolCall *relaymodel.ToolCall currentToolCallID string toolCallArgs string + hasToolCall bool } func responseModelName(meta *meta.Meta) string { @@ -43,6 +44,29 @@ func responseModelName(meta *meta.Meta) string { return meta.ActualModel } +func responseToChatFinishReason(response *relaymodel.Response) relaymodel.FinishReason { + if response == nil { + return relaymodel.FinishReasonStop + } + + if response.Status != relaymodel.ResponseStatusIncomplete { + return relaymodel.FinishReasonStop + } + + if response.IncompleteDetails == nil { + return relaymodel.FinishReasonStop + } + + switch response.IncompleteDetails.Reason { + case "max_output_tokens": + return relaymodel.FinishReasonLength + case "content_filter": + return relaymodel.FinishReasonContentFilter + default: + return relaymodel.FinishReasonStop + } +} + // handleResponseCreated handles response.created event for ChatCompletion func (s *chatCompletionStreamState) handleResponseCreated( event *relaymodel.ResponseStreamEvent, @@ -103,6 +127,7 @@ func (s *chatCompletionStreamState) handleOutputItemAdded( // Track function calls if event.Item.Type == relaymodel.InputItemTypeFunctionCall { + s.hasToolCall = true s.currentToolCallID = event.Item.ID s.currentToolCall = &relaymodel.ToolCall{ ID: event.Item.CallID, @@ -232,6 +257,11 @@ func (s *chatCompletionStreamState) handleResponseCompleted( chatUsage := event.Response.Usage.ToChatUsage() + finishReason := responseToChatFinishReason(event.Response) + if finishReason == relaymodel.FinishReasonStop && s.hasToolCall { + finishReason = relaymodel.FinishReasonToolCalls + } + return &relaymodel.ChatCompletionsStreamResponse{ ID: s.messageID, Object: relaymodel.ChatCompletionChunkObject, @@ -240,7 +270,7 @@ func (s *chatCompletionStreamState) handleResponseCompleted( Choices: []*relaymodel.ChatCompletionsStreamResponseChoice{ { Index: 0, - FinishReason: relaymodel.FinishReasonStop, + FinishReason: finishReason, }, }, Usage: &chatUsage, @@ -1235,50 +1265,69 @@ func ConvertResponsesToChatCompletionResponse( // Convert output items to choices for _, outputItem := range responsesResp.Output { - if outputItem.Type != "" && outputItem.Type != relaymodel.InputItemTypeMessage { - continue - } - - choice := relaymodel.TextResponseChoice{ - Index: 0, // Responses API doesn't have index, default to 0 - Message: relaymodel.Message{ - Role: outputItem.Role, - Content: "", - }, - } + switch outputItem.Type { + case "", relaymodel.InputItemTypeMessage: + choice := relaymodel.TextResponseChoice{ + Index: len(chatResp.Choices), + Message: relaymodel.Message{ + Role: outputItem.Role, + Content: "", + }, + } - // Convert content - var ( - contentParts []string - toolCalls []relaymodel.ToolCall - ) + var contentParts []string + for _, content := range outputItem.Content { + if (content.Type == "text" || content.Type == "output_text") && content.Text != "" { + contentParts = append(contentParts, content.Text) + } + } - for _, content := range outputItem.Content { - if (content.Type == "text" || content.Type == "output_text") && content.Text != "" { - contentParts = append(contentParts, content.Text) + if len(contentParts) > 0 { + choice.Message.Content = strings.Join(contentParts, "\n") } - // Add tool call conversion if needed in the future - } - if len(contentParts) > 0 { - choice.Message.Content = strings.Join(contentParts, "\n") - } + choice.FinishReason = responseToChatFinishReason(&responsesResp) + chatResp.Choices = append(chatResp.Choices, &choice) - if len(toolCalls) > 0 { - choice.Message.ToolCalls = toolCalls - } + case relaymodel.InputItemTypeFunctionCall: + toolCallID := outputItem.CallID + if toolCallID == "" { + toolCallID = outputItem.ID + } - // Set finish reason based on status - switch responsesResp.Status { - case relaymodel.ResponseStatusCompleted: - choice.FinishReason = relaymodel.FinishReasonStop - case relaymodel.ResponseStatusIncomplete: - choice.FinishReason = relaymodel.FinishReasonLength - case relaymodel.ResponseStatusFailed: - choice.FinishReason = relaymodel.FinishReasonStop + chatResp.Choices = append(chatResp.Choices, &relaymodel.TextResponseChoice{ + Index: len(chatResp.Choices), + Message: relaymodel.Message{ + Role: relaymodel.RoleAssistant, + ToolCalls: []relaymodel.ToolCall{ + { + Index: 0, + ID: toolCallID, + Type: relaymodel.ToolChoiceTypeFunction, + Function: relaymodel.Function{ + Name: outputItem.Name, + Arguments: outputItem.Arguments, + }, + }, + }, + }, + FinishReason: relaymodel.FinishReasonToolCalls, + }) + + default: + continue } + } - chatResp.Choices = append(chatResp.Choices, &choice) + if len(chatResp.Choices) == 0 { + chatResp.Choices = append(chatResp.Choices, &relaymodel.TextResponseChoice{ + Index: 0, + Message: relaymodel.Message{ + Role: relaymodel.RoleAssistant, + Content: "", + }, + FinishReason: responseToChatFinishReason(&responsesResp), + }) } // Convert usage @@ -1446,6 +1495,7 @@ func ConvertResponsesToChatCompletionStreamResponse( for scanner.Scan() && !stopStream { data := scanner.Bytes() + if !render.IsValidSSEData(data) { continue } @@ -1492,7 +1542,9 @@ func ConvertResponsesToChatCompletionStreamResponse( chatStreamResp = state.handleFunctionCallArgumentsDelta(&event) case relaymodel.EventOutputItemDone: state.handleOutputItemDone(&event) - case relaymodel.EventResponseCompleted, relaymodel.EventResponseDone: + case relaymodel.EventResponseCompleted, + relaymodel.EventResponseIncomplete, + relaymodel.EventResponseDone: chatStreamResp = state.handleResponseCompleted(&event) case relaymodel.EventResponseFailed, relaymodel.EventError: if wroteStream { diff --git a/core/relay/adaptor/openai/chat_test.go b/core/relay/adaptor/openai/chat_test.go index 5f5131e7..b7de2e98 100644 --- a/core/relay/adaptor/openai/chat_test.go +++ b/core/relay/adaptor/openai/chat_test.go @@ -771,6 +771,140 @@ func TestConvertResponsesToChatCompletionResponse(t *testing.T) { }, expectedStatus: http.StatusOK, }, + { + name: "incomplete reasoning-only response", + responsesResp: relaymodel.Response{ + ID: "resp_incomplete", + Model: "gpt-5-mini", + Status: relaymodel.ResponseStatusIncomplete, + CreatedAt: 1781355958, + Output: []relaymodel.OutputItem{ + { + Type: "reasoning", + Summary: []relaymodel.SummaryPart{}, + }, + }, + IncompleteDetails: &relaymodel.IncompleteDetails{ + Reason: "max_output_tokens", + }, + Usage: &relaymodel.ResponseUsage{ + InputTokens: 268, + OutputTokens: 192, + OutputTokensDetails: &relaymodel.ResponseUsageDetails{ + ReasoningTokens: 192, + }, + TotalTokens: 460, + }, + }, + checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) { + t.Helper() + require.Len(t, chatResp.Choices, 1) + choice := chatResp.Choices[0] + assert.Equal(t, 0, choice.Index) + assert.Equal(t, relaymodel.RoleAssistant, choice.Message.Role) + assert.Equal(t, "", choice.Message.Content) + assert.Equal(t, relaymodel.FinishReasonLength, choice.FinishReason) + assert.Equal(t, int64(192), chatResp.Usage.CompletionTokens) + require.NotNil(t, chatResp.Usage.CompletionTokensDetails) + assert.Equal( + t, + int64(192), + chatResp.Usage.CompletionTokensDetails.ReasoningTokens, + ) + }, + expectedStatus: http.StatusOK, + }, + { + name: "incomplete content filter response", + responsesResp: relaymodel.Response{ + ID: "resp_content_filter", + Model: "gpt-5-mini", + Status: relaymodel.ResponseStatusIncomplete, + CreatedAt: 1781355958, + IncompleteDetails: &relaymodel.IncompleteDetails{ + Reason: "content_filter", + }, + Usage: &relaymodel.ResponseUsage{ + InputTokens: 12, + OutputTokens: 3, + TotalTokens: 15, + }, + }, + checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) { + t.Helper() + require.Len(t, chatResp.Choices, 1) + assert.Equal( + t, + relaymodel.FinishReasonContentFilter, + chatResp.Choices[0].FinishReason, + ) + }, + expectedStatus: http.StatusOK, + }, + { + name: "function call only response", + responsesResp: relaymodel.Response{ + ID: "resp_tool", + Model: "gpt-5-mini", + Status: relaymodel.ResponseStatusCompleted, + CreatedAt: 1781355958, + Output: []relaymodel.OutputItem{ + { + ID: "fc_123", + Type: relaymodel.InputItemTypeFunctionCall, + CallID: "call_123", + Name: "get_weather", + Arguments: `{"location":"Boston"}`, + }, + }, + Usage: &relaymodel.ResponseUsage{ + InputTokens: 12, + OutputTokens: 3, + TotalTokens: 15, + }, + }, + checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) { + t.Helper() + require.Len(t, chatResp.Choices, 1) + + choice := chatResp.Choices[0] + assert.Equal(t, relaymodel.FinishReasonToolCalls, choice.FinishReason) + assert.Equal(t, relaymodel.RoleAssistant, choice.Message.Role) + assert.Empty(t, choice.Message.Content) + require.Len(t, choice.Message.ToolCalls, 1) + + toolCall := choice.Message.ToolCalls[0] + assert.Equal(t, 0, toolCall.Index) + assert.Equal(t, "call_123", toolCall.ID) + assert.Equal(t, relaymodel.ToolChoiceTypeFunction, toolCall.Type) + assert.Equal(t, "get_weather", toolCall.Function.Name) + assert.Equal(t, `{"location":"Boston"}`, toolCall.Function.Arguments) + }, + expectedStatus: http.StatusOK, + }, + { + name: "incomplete unknown reason response", + responsesResp: relaymodel.Response{ + ID: "resp_unknown_incomplete", + Model: "gpt-5-mini", + Status: relaymodel.ResponseStatusIncomplete, + CreatedAt: 1781355958, + IncompleteDetails: &relaymodel.IncompleteDetails{ + Reason: "unknown_reason", + }, + Usage: &relaymodel.ResponseUsage{ + InputTokens: 12, + OutputTokens: 3, + TotalTokens: 15, + }, + }, + checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) { + t.Helper() + require.Len(t, chatResp.Choices, 1) + assert.Equal(t, relaymodel.FinishReasonStop, chatResp.Choices[0].FinishReason) + }, + expectedStatus: http.StatusOK, + }, } for _, tt := range tests { @@ -1065,6 +1199,167 @@ func TestConvertResponsesToChatCompletionStreamResponseHandlesErrorAfterDownstre assert.NotContains(t, w.Body.String(), "late") } +func TestConvertResponsesToChatCompletionStreamResponseHandlesIncompleteReasoningOnly( + t *testing.T, +) { + gin.SetMode(gin.TestMode) + + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_incomplete","object":"response","created_at":1781355623,"status":"in_progress","model":"gpt-5-mini","output":[],"parallel_tool_calls":true,"store":false}}`, + "", + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"rs_1","type":"reasoning","summary":[]},"output_index":0,"sequence_number":2}`, + "", + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","item":{"id":"rs_1","type":"reasoning","summary":[]},"output_index":0,"sequence_number":3}`, + "", + `event: response.incomplete`, + `data: {"type":"response.incomplete","response":{"id":"resp_incomplete","object":"response","created_at":1781355623,"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"model":"gpt-5-mini","output":[{"id":"rs_1","type":"reasoning","summary":[]}],"parallel_tool_calls":true,"store":false,"usage":{"input_tokens":268,"output_tokens":192,"output_tokens_details":{"reasoning_tokens":192},"total_tokens":460}},"sequence_number":4}`, + "", + }, "\n") + + httpResp := &http.Response{ + StatusCode: http.StatusOK, + Body: &mockReadCloser{Reader: bytes.NewReader([]byte(stream))}, + Header: make(http.Header), + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/chat/completions", + nil, + ) + + m := &meta.Meta{ + ActualModel: "gpt-5-mini", + } + + result, err := openai.ConvertResponsesToChatCompletionStreamResponse(m, c, httpResp) + require.Nil(t, err) + assert.Equal(t, "resp_incomplete", result.UpstreamID) + assert.Equal(t, int64(460), int64(result.Usage.TotalTokens)) + assert.Equal(t, int64(192), int64(result.Usage.ReasoningTokens)) + + chunks := collectChatCompletionStreamChunks(t, w.Body.String()) + require.Len(t, chunks, 2) + + assert.Equal(t, relaymodel.RoleAssistant, chunks[0].Choices[0].Delta.Role) + assert.Equal(t, relaymodel.FinishReasonLength, chunks[1].Choices[0].FinishReason) + require.NotNil(t, chunks[1].Usage) + assert.Equal(t, int64(192), chunks[1].Usage.CompletionTokens) + assert.Equal(t, 1, strings.Count(w.Body.String(), "data: [DONE]")) +} + +func TestConvertResponsesToChatCompletionStreamResponseHandlesIncompleteContentFilter( + t *testing.T, +) { + gin.SetMode(gin.TestMode) + + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_content_filter","object":"response","created_at":1781355623,"status":"in_progress","model":"gpt-5-mini","output":[],"parallel_tool_calls":true,"store":false}}`, + "", + `event: response.incomplete`, + `data: {"type":"response.incomplete","response":{"id":"resp_content_filter","object":"response","created_at":1781355623,"status":"incomplete","incomplete_details":{"reason":"content_filter"},"model":"gpt-5-mini","output":[],"parallel_tool_calls":true,"store":false,"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}},"sequence_number":1}`, + "", + }, "\n") + + httpResp := &http.Response{ + StatusCode: http.StatusOK, + Body: &mockReadCloser{Reader: bytes.NewReader([]byte(stream))}, + Header: make(http.Header), + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/chat/completions", + nil, + ) + + m := &meta.Meta{ + ActualModel: "gpt-5-mini", + } + + _, err := openai.ConvertResponsesToChatCompletionStreamResponse(m, c, httpResp) + require.Nil(t, err) + + chunks := collectChatCompletionStreamChunks(t, w.Body.String()) + require.Len(t, chunks, 2) + assert.Equal( + t, + relaymodel.FinishReasonContentFilter, + chunks[1].Choices[0].FinishReason, + ) + assert.Equal(t, 1, strings.Count(w.Body.String(), "data: [DONE]")) +} + +func TestConvertResponsesToChatCompletionStreamResponseUsesToolCallsFinishReason( + t *testing.T, +) { + gin.SetMode(gin.TestMode) + + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_tool","object":"response","created_at":1781355623,"status":"in_progress","model":"gpt-5-mini","output":[],"parallel_tool_calls":true,"store":false}}`, + "", + `event: response.output_item.added`, + `data: {"type":"response.output_item.added","item":{"id":"fc_123","type":"function_call","call_id":"call_123","name":"get_weather","arguments":"","status":"in_progress"},"output_index":0,"sequence_number":1}`, + "", + `event: response.function_call_arguments.delta`, + `data: {"type":"response.function_call_arguments.delta","item_id":"fc_123","output_index":0,"delta":"{\"location\":\"Boston\"}","sequence_number":2}`, + "", + `event: response.output_item.done`, + `data: {"type":"response.output_item.done","item":{"id":"fc_123","type":"function_call","call_id":"call_123","name":"get_weather","arguments":"{\"location\":\"Boston\"}","status":"completed"},"output_index":0,"sequence_number":3}`, + "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_tool","object":"response","created_at":1781355623,"status":"completed","model":"gpt-5-mini","output":[{"id":"fc_123","type":"function_call","call_id":"call_123","name":"get_weather","arguments":"{\"location\":\"Boston\"}","status":"completed"}],"parallel_tool_calls":true,"store":false,"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}},"sequence_number":4}`, + "", + }, "\n") + + httpResp := &http.Response{ + StatusCode: http.StatusOK, + Body: &mockReadCloser{Reader: bytes.NewReader([]byte(stream))}, + Header: make(http.Header), + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/chat/completions", + nil, + ) + + m := &meta.Meta{ + ActualModel: "gpt-5-mini", + } + + _, err := openai.ConvertResponsesToChatCompletionStreamResponse(m, c, httpResp) + require.Nil(t, err) + + chunks := collectChatCompletionStreamChunks(t, w.Body.String()) + require.Len(t, chunks, 4) + + require.Len(t, chunks[1].Choices[0].Delta.ToolCalls, 1) + assert.Equal(t, "call_123", chunks[1].Choices[0].Delta.ToolCalls[0].ID) + assert.Equal(t, "get_weather", chunks[1].Choices[0].Delta.ToolCalls[0].Function.Name) + assert.Equal( + t, + `{"location":"Boston"}`, + chunks[2].Choices[0].Delta.ToolCalls[0].Function.Arguments, + ) + assert.Equal(t, relaymodel.FinishReasonToolCalls, chunks[3].Choices[0].FinishReason) + assert.Equal(t, 1, strings.Count(w.Body.String(), "data: [DONE]")) +} + func TestConvertResponsesToChatCompletionStreamResponseUsesOriginModelForEveryChunk( t *testing.T, ) { @@ -1146,6 +1441,31 @@ func collectChatCompletionStreamContent(t *testing.T, body string) string { return builder.String() } +func collectChatCompletionStreamChunks( + t *testing.T, + body string, +) []relaymodel.ChatCompletionsStreamResponse { + t.Helper() + + var chunks []relaymodel.ChatCompletionsStreamResponse + + for line := range strings.SplitSeq(body, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" { + continue + } + + var chunk relaymodel.ChatCompletionsStreamResponse + + err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &chunk) + require.NoError(t, err) + + chunks = append(chunks, chunk) + } + + return chunks +} + // mockReadCloser is a helper to create a ReadCloser from a Reader type mockReadCloser struct { *bytes.Reader From e31359e27f446003491e77898f50b9eb14e51b14 Mon Sep 17 00:00:00 2001 From: zijiren233 Date: Sat, 13 Jun 2026 22:08:11 +0800 Subject: [PATCH 2/2] fix: chat responses mode finish_reason --- core/relay/adaptor/openai/chat.go | 14 +++++- core/relay/adaptor/openai/chat_test.go | 62 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/core/relay/adaptor/openai/chat.go b/core/relay/adaptor/openai/chat.go index ccbd9602..d2fef83c 100644 --- a/core/relay/adaptor/openai/chat.go +++ b/core/relay/adaptor/openai/chat.go @@ -1267,10 +1267,15 @@ func ConvertResponsesToChatCompletionResponse( for _, outputItem := range responsesResp.Output { switch outputItem.Type { case "", relaymodel.InputItemTypeMessage: + role := outputItem.Role + if role == "" { + role = relaymodel.RoleAssistant + } + choice := relaymodel.TextResponseChoice{ Index: len(chatResp.Choices), Message: relaymodel.Message{ - Role: outputItem.Role, + Role: role, Content: "", }, } @@ -1295,6 +1300,11 @@ func ConvertResponsesToChatCompletionResponse( toolCallID = outputItem.ID } + finishReason := responseToChatFinishReason(&responsesResp) + if finishReason == relaymodel.FinishReasonStop { + finishReason = relaymodel.FinishReasonToolCalls + } + chatResp.Choices = append(chatResp.Choices, &relaymodel.TextResponseChoice{ Index: len(chatResp.Choices), Message: relaymodel.Message{ @@ -1311,7 +1321,7 @@ func ConvertResponsesToChatCompletionResponse( }, }, }, - FinishReason: relaymodel.FinishReasonToolCalls, + FinishReason: finishReason, }) default: diff --git a/core/relay/adaptor/openai/chat_test.go b/core/relay/adaptor/openai/chat_test.go index b7de2e98..5bc98acb 100644 --- a/core/relay/adaptor/openai/chat_test.go +++ b/core/relay/adaptor/openai/chat_test.go @@ -771,6 +771,35 @@ func TestConvertResponsesToChatCompletionResponse(t *testing.T) { }, expectedStatus: http.StatusOK, }, + { + name: "message response without role defaults to assistant", + responsesResp: relaymodel.Response{ + ID: "resp_missing_role", + Model: "gpt-5-mini", + Status: relaymodel.ResponseStatusCompleted, + CreatedAt: 1781355958, + Output: []relaymodel.OutputItem{ + { + Type: relaymodel.InputItemTypeMessage, + Content: []relaymodel.OutputContent{ + {Type: "output_text", Text: "Hello"}, + }, + }, + }, + Usage: &relaymodel.ResponseUsage{ + InputTokens: 4, + OutputTokens: 2, + TotalTokens: 6, + }, + }, + checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) { + t.Helper() + require.Len(t, chatResp.Choices, 1) + assert.Equal(t, relaymodel.RoleAssistant, chatResp.Choices[0].Message.Role) + assert.Equal(t, "Hello", chatResp.Choices[0].Message.Content) + }, + expectedStatus: http.StatusOK, + }, { name: "incomplete reasoning-only response", responsesResp: relaymodel.Response{ @@ -882,6 +911,39 @@ func TestConvertResponsesToChatCompletionResponse(t *testing.T) { }, expectedStatus: http.StatusOK, }, + { + name: "incomplete function call keeps incomplete finish reason", + responsesResp: relaymodel.Response{ + ID: "resp_tool_incomplete", + Model: "gpt-5-mini", + Status: relaymodel.ResponseStatusIncomplete, + CreatedAt: 1781355958, + Output: []relaymodel.OutputItem{ + { + ID: "fc_123", + Type: relaymodel.InputItemTypeFunctionCall, + CallID: "call_123", + Name: "get_weather", + Arguments: `{"location":"Boston"}`, + }, + }, + IncompleteDetails: &relaymodel.IncompleteDetails{ + Reason: "max_output_tokens", + }, + Usage: &relaymodel.ResponseUsage{ + InputTokens: 12, + OutputTokens: 3, + TotalTokens: 15, + }, + }, + checkFunc: func(t *testing.T, chatResp relaymodel.TextResponse) { + t.Helper() + require.Len(t, chatResp.Choices, 1) + assert.Equal(t, relaymodel.FinishReasonLength, chatResp.Choices[0].FinishReason) + require.Len(t, chatResp.Choices[0].Message.ToolCalls, 1) + }, + expectedStatus: http.StatusOK, + }, { name: "incomplete unknown reason response", responsesResp: relaymodel.Response{