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
22 changes: 20 additions & 2 deletions core/schemas/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -3724,8 +3724,11 @@ type BifrostResponsesStreamResponse struct {

Response *BifrostResponsesResponse `json:"response,omitempty"`

OutputIndex *int `json:"output_index,omitempty"`
Item *ResponsesMessage `json:"item"`
OutputIndex *int `json:"output_index,omitempty"`
// Item is only emitted on output_item.added / output_item.done. omitempty is
// required: other event types must not serialize "item": null — strict
// Responses clients (opencode open-responses protocol) reject null there.
Item *ResponsesMessage `json:"item,omitempty"`
Comment thread
ReStranger marked this conversation as resolved.
// SummaryIndex identifies which summary block within an item a delta belongs to.
// Emitted on response.reasoning_summary_text.{delta,done} and
// response.reasoning_summary_part.{added,done}.
Expand Down Expand Up @@ -3769,6 +3772,21 @@ type BifrostResponsesStreamResponse struct {
Citations []string `json:"citations,omitempty"`
}

// MarshalJSON omits event-scoped fields that are nil so strict Responses
// clients do not see explicit nulls on unrelated event types. Some event types
// still intentionally emit empty arrays after WithDefaults populates them.
func (resp BifrostResponsesStreamResponse) MarshalJSON() ([]byte, error) {
type alias BifrostResponsesStreamResponse
encoded, err := Marshal(alias(resp))
if err != nil {
return nil, err
}
if resp.LogProbs == nil && gjson.GetBytes(encoded, "logprobs").Exists() {
return sjson.DeleteBytes(encoded, "logprobs")
}
return encoded, nil
}

func (resp *BifrostResponsesStreamResponse) WithDefaults() *BifrostResponsesStreamResponse {
if resp == nil {
return nil
Expand Down
94 changes: 94 additions & 0 deletions core/schemas/responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,100 @@ import (
"testing"
)

// TestBifrostResponsesStreamResponseOmitsEmptyItem verifies that events without
// an item object (response.created, output_text.delta, response.completed, ...)
// do not serialize "item": null. Strict Responses API clients (e.g. opencode's
// open-responses protocol) reject events where "item" is present but null —
// the field only belongs on output_item.added / output_item.done.
func TestBifrostResponsesStreamResponseOmitsEmptyItem(t *testing.T) {
for _, typ := range []ResponsesStreamResponseType{
ResponsesStreamResponseTypeCreated,
ResponsesStreamResponseTypeInProgress,
ResponsesStreamResponseTypeOutputTextDelta,
ResponsesStreamResponseTypeContentPartAdded,
ResponsesStreamResponseTypeCompleted,
} {
ev := &BifrostResponsesStreamResponse{Type: typ, SequenceNumber: 0}
encoded, err := MarshalSorted(ev)
if err != nil {
t.Fatalf("%s: marshal: %v", typ, err)
}
var decoded map[string]json.RawMessage
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatalf("%s: unmarshal encoded event: %v", typ, err)
}
if _, ok := decoded["item"]; ok {
t.Errorf("%s: event without item serializes an item field:\n%s", typ, encoded)
}
}

for _, typ := range []ResponsesStreamResponseType{
ResponsesStreamResponseTypeOutputItemAdded,
ResponsesStreamResponseTypeOutputItemDone,
} {
withItem := &BifrostResponsesStreamResponse{
Type: typ,
Item: &ResponsesMessage{Type: Ptr(ResponsesMessageTypeMessage), ID: Ptr("msg_1")},
}
encoded, err := MarshalSorted(withItem)
if err != nil {
t.Fatalf("%s: marshal: %v", typ, err)
}
var decoded map[string]json.RawMessage
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatalf("%s: unmarshal encoded event: %v", typ, err)
}
itemJSON, ok := decoded["item"]
if !ok {
t.Errorf("%s: lost item object:\n%s", typ, encoded)
continue
}
var item ResponsesMessage
if err := json.Unmarshal(itemJSON, &item); err != nil {
t.Fatalf("%s: unmarshal item: %v", typ, err)
}
if item.ID == nil || *item.ID != "msg_1" {
t.Errorf("%s: unexpected item payload: %#v", typ, item)
}
}
}

func TestBifrostResponsesStreamResponseLogProbsScopedToApplicableEvents(t *testing.T) {
created := &BifrostResponsesStreamResponse{Type: ResponsesStreamResponseTypeCreated, SequenceNumber: 0}
encoded, err := MarshalSorted(created)
if err != nil {
t.Fatalf("created: marshal: %v", err)
}
var createdDecoded map[string]json.RawMessage
if err := json.Unmarshal(encoded, &createdDecoded); err != nil {
t.Fatalf("created: unmarshal encoded event: %v", err)
}
if _, ok := createdDecoded["logprobs"]; ok {
t.Fatalf("created: unexpected logprobs field: %s", encoded)
}

delta := (&BifrostResponsesStreamResponse{Type: ResponsesStreamResponseTypeOutputTextDelta}).WithDefaults()
encoded, err = MarshalSorted(delta)
if err != nil {
t.Fatalf("output_text.delta: marshal: %v", err)
}
var deltaDecoded map[string]json.RawMessage
if err := json.Unmarshal(encoded, &deltaDecoded); err != nil {
t.Fatalf("output_text.delta: unmarshal encoded event: %v", err)
}
logprobsJSON, ok := deltaDecoded["logprobs"]
if !ok {
t.Fatalf("output_text.delta: missing logprobs field: %s", encoded)
}
var logprobs []ResponsesOutputMessageContentTextLogProb
if err := json.Unmarshal(logprobsJSON, &logprobs); err != nil {
t.Fatalf("output_text.delta: unmarshal logprobs: %v", err)
}
if logprobs == nil || len(logprobs) != 0 {
t.Fatalf("output_text.delta: expected empty logprobs array, got %#v", logprobs)
}
}

func TestBifrostResponsesStreamResponsePreservesOpenAIStreamMetadata(t *testing.T) {
raw := []byte(`{"type":"response.reasoning_summary_text.delta","delta":"thinking","item_id":"rs_123","obfuscation":"opaque","output_index":0,"sequence_number":4,"summary_index":0}`)

Expand Down
43 changes: 43 additions & 0 deletions tests/e2e/api/collections/provider-harness.json
Original file line number Diff line number Diff line change
Expand Up @@ -5027,6 +5027,49 @@
{
"name": "8.2.B Native /v1/responses streaming × all providers",
"description": "Streaming Responses-API via native endpoint.",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"// Regression (fix/responses-stream-item-null): Responses SSE must omit item on",
"// non-output events while preserving a non-null item object on output_item events.",
"if (pm.response.code >= 400) { return; }",
"var types = {};",
"var nonOutputItemFields = 0;",
"var outputItemEvents = 0;",
"var outputItemObjects = 0;",
"(pm.response.text() || '').split('\\n').forEach(function (l) {",
" if (l.indexOf('data: ') !== 0) { return; }",
" var p = l.slice(6).trim();",
" if (p === '[DONE]') { return; }",
" try {",
" var j = JSON.parse(p);",
" if (j.type) { types[j.type] = (types[j.type] || 0) + 1; }",
" if (j.type === 'response.output_item.added' || j.type === 'response.output_item.done') {",
" outputItemEvents++;",
" if (j.item && typeof j.item === 'object' && !Array.isArray(j.item)) { outputItemObjects++; }",
" return;",
" }",
" if ('item' in j) { nonOutputItemFields++; }",
" } catch (e) {}",
"});",
"pm.test('Responses stream emits response.created and response.completed', function () {",
" pm.expect(types['response.created'], 'expected response.created').to.not.be.undefined;",
" pm.expect(types['response.completed'], 'expected response.completed').to.not.be.undefined;",
"});",
"pm.test('Regression: non-output events omit item entirely', function () {",
" pm.expect(nonOutputItemFields, 'found ' + nonOutputItemFields + ' non-output events carrying item').to.equal(0);",
"});",
"pm.test('Regression: output_item events keep non-null item objects', function () {",
" pm.expect(outputItemEvents, 'expected at least one output_item event').to.be.above(0);",
" pm.expect(outputItemObjects, 'every output_item event must carry a non-null item object').to.equal(outputItemEvents);",
"});"
]
}
}
],
"item": [
{
"name": "openai/gpt-4o-mini",
Expand Down