Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions core/providers/openai/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,7 @@ func HandleOpenAIChatCompletionStreaming(

var finishReason *string
var messageID string
forwardedTerminalFinishReason := false

for {
// If context was cancelled/timed out, let defer handle it
Expand Down Expand Up @@ -1212,7 +1213,7 @@ func HandleOpenAIChatCompletionStreaming(
response.ExtraFields.RawResponse = jsonData
}

if response.Type == schemas.ResponsesStreamResponseTypeCompleted {
if response.Type == schemas.ResponsesStreamResponseTypeCompleted || response.Type == schemas.ResponsesStreamResponseTypeIncomplete {
// Set raw request if enabled
if sendBackRawRequest {
providerUtils.ParseAndSetRawRequest(&response.ExtraFields, jsonBody)
Expand Down Expand Up @@ -1278,7 +1279,6 @@ func HandleOpenAIChatCompletionStreaming(
if choice.FinishReason != nil && *choice.FinishReason != "" {
// Collect finish reason and send at the end of the stream
finishReason = choice.FinishReason
response.Choices[0].FinishReason = nil
}

if response.ID != "" && messageID == "" {
Expand All @@ -1293,6 +1293,9 @@ func HandleOpenAIChatCompletionStreaming(
len(choice.ChatStreamResponseChoice.Delta.ReasoningDetails) > 0 ||
choice.ChatStreamResponseChoice.Delta.Audio != nil ||
len(choice.ChatStreamResponseChoice.Delta.ToolCalls) > 0) {
if choice.FinishReason != nil && *choice.FinishReason != "" {
forwardedTerminalFinishReason = true
}
chunkIndex++

response.ExtraFields.RequestType = schemas.ChatCompletionStreamRequest
Expand All @@ -1317,7 +1320,11 @@ func HandleOpenAIChatCompletionStreaming(
}

if !isResponsesToChatCompletionsFallback {
response := providerUtils.CreateBifrostChatCompletionChunkResponse(messageID, usage, finishReason, chunkIndex, streamRequestType, providerName, request.Model)
finalFinishReason := finishReason
if forwardedTerminalFinishReason {
finalFinishReason = nil
}
response := providerUtils.CreateBifrostChatCompletionChunkResponse(messageID, usage, finalFinishReason, chunkIndex, streamRequestType, providerName, request.Model)
if postResponseConverter != nil {
response = postResponseConverter(response)
}
Expand Down
82 changes: 71 additions & 11 deletions core/schemas/mux.go
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,38 @@ func sanitizeChatToolChoiceForFallback(toolChoice *ChatToolChoice, tools []ChatT
// RESPONSE CONVERSION METHODS
// =============================================================================

func responsesStatusFromChatFinishReason(finishReason string) (status string, incompleteDetails *ResponsesResponseIncompleteDetails, mapped bool) {
switch finishReason {
case string(BifrostFinishReasonLength):
return "incomplete", &ResponsesResponseIncompleteDetails{Reason: "max_output_tokens"}, true
case string(BifrostFinishReasonStop), string(BifrostFinishReasonToolCalls):
return "completed", nil, true
default:
return "", nil, false
}
}

func responsesTerminalFromChatFinishReason(finishReason *string) (eventType ResponsesStreamResponseType, status string, incompleteDetails *ResponsesResponseIncompleteDetails) {
// Unknown/empty finish reasons preserve prior behavior: treat as completed.
eventType = ResponsesStreamResponseTypeCompleted
status = "completed"

if finishReason == nil || *finishReason == "" {
return eventType, status, nil
}

mappedStatus, mappedIncompleteDetails, mapped := responsesStatusFromChatFinishReason(*finishReason)
if !mapped {
return eventType, status, nil
}

if mappedStatus == "incomplete" {
eventType = ResponsesStreamResponseTypeIncomplete
}

return eventType, mappedStatus, mappedIncompleteDetails
}

// ToBifrostResponsesResponse converts the BifrostChatResponse to BifrostResponsesResponse format
// This converts Chat-style fields (Choices) to Responses API format
func (cr *BifrostChatResponse) ToBifrostResponsesResponse() *BifrostResponsesResponse {
Expand Down Expand Up @@ -1179,6 +1211,28 @@ func (cr *BifrostChatResponse) ToBifrostResponsesResponse() *BifrostResponsesRes
responsesResp.Usage = cr.Usage.ToResponsesResponseUsage()
}

// Map finish reason to Responses status.
hasCompletedFinishReason := false
for _, choice := range cr.Choices {
if choice.FinishReason == nil || *choice.FinishReason == "" {
continue
}
status, incompleteDetails, mapped := responsesStatusFromChatFinishReason(*choice.FinishReason)
if !mapped {
continue
}
if status == "incomplete" {
responsesResp.Status = Ptr(status)
responsesResp.IncompleteDetails = incompleteDetails
hasCompletedFinishReason = false
break
}
hasCompletedFinishReason = true
}
if responsesResp.Status == nil && hasCompletedFinishReason {
responsesResp.Status = Ptr("completed")
}

// Copy other relevant fields
responsesResp.ExtraFields = cr.ExtraFields
responsesResp.ExtraFields.RequestType = ResponsesRequest
Expand Down Expand Up @@ -1709,6 +1763,8 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes

// Check if this is a completion chunk with finish_reason
if choice.FinishReason != nil {
terminalEventType, terminalStatus, terminalIncompleteDetails := responsesTerminalFromChatFinishReason(choice.FinishReason)

// Close text item if still open (regardless of whether it has content, to support reasoning-only responses)
if state.TextItemAdded && !state.TextItemClosed {
outputIndex := 0
Expand Down Expand Up @@ -1748,14 +1804,14 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes
state.SequenceNumber++

// Emit output_item.done
statusCompleted := "completed"
statusFinal := terminalStatus
messageType := ResponsesMessageTypeMessage
role := ResponsesInputMessageRoleAssistant
textType := ResponsesOutputMessageContentTypeText
doneItem := &ResponsesMessage{
Type: &messageType,
Role: &role,
Status: &statusCompleted,
Status: &statusFinal,
Content: &ResponsesMessageContent{
ContentBlocks: []ResponsesMessageContentBlock{
{
Expand Down Expand Up @@ -1807,7 +1863,7 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes
state.SequenceNumber++

// Emit output_item.done for function call
statusCompleted := "completed"
statusFinal := terminalStatus
messageType := ResponsesMessageTypeFunctionCall
callName, hasName := state.ToolCallNames[toolCallID]
var callNamePtr *string
Expand All @@ -1817,7 +1873,7 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes
argsValue := args
outputItemDone := &ResponsesMessage{
Type: &messageType,
Status: &statusCompleted,
Status: &statusFinal,
ResponsesToolMessage: &ResponsesToolMessage{
CallID: &toolCallID,
Name: callNamePtr,
Expand All @@ -1839,23 +1895,27 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes
}
}

// Emit response.completed
// Emit terminal response event.
var usage *ResponsesResponseUsage
if cr.Usage != nil {
usage = cr.Usage.ToResponsesResponseUsage()
}

responseStatus := terminalStatus

response := &BifrostResponsesResponse{
ID: state.MessageID,
CreatedAt: state.CreatedAt,
Usage: usage,
ID: state.MessageID,
CreatedAt: state.CreatedAt,
Usage: usage,
Status: &responseStatus,
IncompleteDetails: terminalIncompleteDetails,
}

if state.Model != nil {
response.Model = *state.Model
}
if state.TextItemAdded {
statusCompleted := "completed"
statusFinal := terminalStatus
messageType := ResponsesMessageTypeMessage
role := ResponsesInputMessageRoleAssistant
textType := ResponsesOutputMessageContentTypeText
Expand All @@ -1865,7 +1925,7 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes
msg := ResponsesMessage{
Type: &messageType,
Role: &role,
Status: &statusCompleted,
Status: &statusFinal,
Content: &ResponsesMessageContent{
ContentBlocks: []ResponsesMessageContentBlock{
{
Expand All @@ -1886,7 +1946,7 @@ func (cr *BifrostChatResponse) ToBifrostResponsesStreamResponse(state *ChatToRes
}

responses = append(responses, &BifrostResponsesStreamResponse{
Type: ResponsesStreamResponseTypeCompleted,
Type: terminalEventType,
SequenceNumber: state.SequenceNumber,
Response: response,
ExtraFields: cr.ExtraFields,
Expand Down
140 changes: 140 additions & 0 deletions core/schemas/mux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,3 +319,143 @@ func TestToBifrostResponsesStreamResponse_PopulatesFinalDoneTextAndCompletedOutp
t.Fatalf("expected completed output text %q, got %q", "Hello world", *msg.Content.ContentBlocks[0].Text)
}
}

func TestToBifrostResponsesResponse_MapsLengthToIncomplete(t *testing.T) {
length := string(BifrostFinishReasonLength)
resp := (&BifrostChatResponse{
Choices: []BifrostResponseChoice{
{FinishReason: &length},
},
}).ToBifrostResponsesResponse()

if resp == nil || resp.Status == nil {
t.Fatal("expected status to be set")
}
if *resp.Status != "incomplete" {
t.Fatalf("expected status %q, got %q", "incomplete", *resp.Status)
}
if resp.IncompleteDetails == nil {
t.Fatal("expected incomplete_details to be set")
}
if resp.IncompleteDetails.Reason != "max_output_tokens" {
t.Fatalf("expected incomplete_details.reason %q, got %q", "max_output_tokens", resp.IncompleteDetails.Reason)
}
}

func TestToBifrostResponsesResponse_MapsToolCallsToCompleted(t *testing.T) {
toolCalls := string(BifrostFinishReasonToolCalls)
resp := (&BifrostChatResponse{
Choices: []BifrostResponseChoice{
{FinishReason: &toolCalls},
},
}).ToBifrostResponsesResponse()

if resp == nil || resp.Status == nil {
t.Fatal("expected status to be set")
}
if *resp.Status != "completed" {
t.Fatalf("expected status %q, got %q", "completed", *resp.Status)
}
if resp.IncompleteDetails != nil {
t.Fatal("expected incomplete_details to be nil")
}
}

func TestToBifrostResponsesResponse_PrioritizesLengthAcrossChoices(t *testing.T) {
stop := string(BifrostFinishReasonStop)
length := string(BifrostFinishReasonLength)
resp := (&BifrostChatResponse{
Choices: []BifrostResponseChoice{
{FinishReason: &stop},
{FinishReason: &length},
},
}).ToBifrostResponsesResponse()

if resp == nil || resp.Status == nil {
t.Fatal("expected status to be set")
}
if *resp.Status != "incomplete" {
t.Fatalf("expected status %q, got %q", "incomplete", *resp.Status)
}
if resp.IncompleteDetails == nil || resp.IncompleteDetails.Reason != "max_output_tokens" {
t.Fatal("expected max_output_tokens incomplete_details")
}
}

func TestToBifrostResponsesResponse_UnknownFinishReasonLeavesStatusUnset(t *testing.T) {
unknown := "content_filter"
resp := (&BifrostChatResponse{
Choices: []BifrostResponseChoice{
{FinishReason: &unknown},
},
}).ToBifrostResponsesResponse()

if resp == nil {
t.Fatal("expected non-nil response")
}
if resp.Status != nil {
t.Fatalf("expected status to be nil, got %q", *resp.Status)
}
if resp.IncompleteDetails != nil {
t.Fatal("expected incomplete_details to be nil")
}
}

func TestToBifrostResponsesStreamResponse_MapsLengthToIncompleteEvent(t *testing.T) {
state := AcquireChatToResponsesStreamState()
defer ReleaseChatToResponsesStreamState(state)

makeChunk := func(role *string, content *string, finishReason *string) *BifrostChatResponse {
return &BifrostChatResponse{
ID: "chatcmpl-test",
Model: "test-model",
Choices: []BifrostResponseChoice{
{
FinishReason: finishReason,
ChatStreamResponseChoice: &ChatStreamResponseChoice{
Delta: &ChatStreamResponseChoiceDelta{
Role: role,
Content: content,
},
},
},
},
}
}

role := string(ChatMessageRoleAssistant)
part := "Hello"
length := string(BifrostFinishReasonLength)

var all []*BifrostResponsesStreamResponse
all = append(all, makeChunk(&role, nil, nil).ToBifrostResponsesStreamResponse(state)...)
all = append(all, makeChunk(nil, &part, nil).ToBifrostResponsesStreamResponse(state)...)
all = append(all, makeChunk(nil, nil, &length).ToBifrostResponsesStreamResponse(state)...)

var completed *BifrostResponsesStreamResponse
var incomplete *BifrostResponsesStreamResponse
for _, evt := range all {
if evt == nil {
continue
}
if evt.Type == ResponsesStreamResponseTypeCompleted {
completed = evt
}
if evt.Type == ResponsesStreamResponseTypeIncomplete {
incomplete = evt
}
}

if completed != nil {
t.Fatal("did not expect response.completed for finish_reason=length")
}
if incomplete == nil || incomplete.Response == nil {
t.Fatal("expected response.incomplete with response payload")
}
if incomplete.Response.Status == nil || *incomplete.Response.Status != "incomplete" {
t.Fatal("expected terminal response status to be incomplete")
}
if incomplete.Response.IncompleteDetails == nil || incomplete.Response.IncompleteDetails.Reason != "max_output_tokens" {
t.Fatal("expected incomplete_details.reason to be max_output_tokens")
}
}
Loading