From ab4bfe33fb170a98d607983694e7acf6f03f8b3c Mon Sep 17 00:00:00 2001 From: ReStranger Date: Fri, 21 Aug 2026 07:38:57 +0300 Subject: [PATCH 1/5] fixes responses sse item null regression --- core/schemas/responses.go | 7 +- core/schemas/responses_test.go | 41 +++++++++++ .../e2e/api/collections/provider-harness.json | 69 +++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/core/schemas/responses.go b/core/schemas/responses.go index c316df9561c..2bbc66c3797 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -3719,8 +3719,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"` // 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}. diff --git a/core/schemas/responses_test.go b/core/schemas/responses_test.go index 01a048f8050..ef6ece29cc5 100644 --- a/core/schemas/responses_test.go +++ b/core/schemas/responses_test.go @@ -6,6 +6,47 @@ 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) + } + if strings.Contains(string(encoded), `"item"`) { + 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) + } + if !strings.Contains(string(encoded), `"item":{"id":"msg_1"`) { + t.Errorf("%s: lost item object:\n%s", typ, encoded) + } + } +} + 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}`) diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json index 599e9d047ac..a7640f2564e 100644 --- a/tests/e2e/api/collections/provider-harness.json +++ b/tests/e2e/api/collections/provider-harness.json @@ -365,6 +365,75 @@ ] } } + }, + { + "name": "POST /v1/responses — streaming (no item:null outside output_item.added/done)", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// Regression (fix/responses-stream-item-null): Responses SSE must not emit \"item\": null", + "// on events that carry no item object. The field belongs only on output_item.added /", + "// output_item.done. Strict clients such as opencode's open-responses protocol reject a", + "// null item and drop the whole stream with 'Invalid ... stream event'.", + "pm.test('Responses stream request succeeds', function () {", + " pm.expect(pm.response.code, 'expected < 400').to.be.below(400);", + "});", + "if (pm.response.code >= 400) { return; }", + "pm.test('Responses SSE content-type is event-stream', function () {", + " var ct = pm.response.headers.get('content-type') || '';", + " pm.expect(ct, 'expected text/event-stream, got ' + ct).to.include('text/event-stream');", + "});", + "var types = {};", + "var itemNull = 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') {", + " if ('item' in j && j.item === null) { itemNull++; }", + " }", + " } catch (e) {}", + "});", + "pm.test('Responses stream emits response.created and response.completed', function () {", + " pm.expect(types['response.created'], 'expected response.created').to.be.defined;", + " pm.expect(types['response.completed'], 'expected response.completed').to.be.defined;", + "});", + "pm.test('Regression: no item: null outside output_item.added/done', function () {", + " pm.expect(itemNull, 'found ' + itemNull + ' events with item: null').to.equal(0);", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"openai/gpt-4o-mini\",\n \"input\": \"Say hello in one short sentence.\",\n \"stream\": true\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + } + } } ] }, From 5eecea39c4ae19b14be894443ef56dd8aa8204dd Mon Sep 17 00:00:00 2001 From: ReStranger Date: Fri, 21 Aug 2026 07:50:12 +0300 Subject: [PATCH 2/5] tests: assert item payload on responses output_item events --- tests/e2e/api/collections/provider-harness.json | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json index a7640f2564e..add4727e636 100644 --- a/tests/e2e/api/collections/provider-harness.json +++ b/tests/e2e/api/collections/provider-harness.json @@ -388,6 +388,8 @@ "});", "var types = {};", "var itemNull = 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();", @@ -395,9 +397,12 @@ " 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') {", - " if ('item' in j && j.item === null) { itemNull++; }", + " 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 && j.item === null) { itemNull++; }", " } catch (e) {}", "});", "pm.test('Responses stream emits response.created and response.completed', function () {", @@ -406,6 +411,10 @@ "});", "pm.test('Regression: no item: null outside output_item.added/done', function () {", " pm.expect(itemNull, 'found ' + itemNull + ' events with item: null').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);", "});" ] } From 2194df6a10a625685c0cf0e2c197043940656868 Mon Sep 17 00:00:00 2001 From: ReStranger Date: Fri, 21 Aug 2026 08:45:41 +0300 Subject: [PATCH 3/5] tests: require responses non-output events to omit item --- tests/e2e/api/collections/provider-harness.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json index add4727e636..1f318331ad4 100644 --- a/tests/e2e/api/collections/provider-harness.json +++ b/tests/e2e/api/collections/provider-harness.json @@ -387,7 +387,7 @@ " pm.expect(ct, 'expected text/event-stream, got ' + ct).to.include('text/event-stream');", "});", "var types = {};", - "var itemNull = 0;", + "var nonOutputItemFields = 0;", "var outputItemEvents = 0;", "var outputItemObjects = 0;", "(pm.response.text() || '').split('\\n').forEach(function (l) {", @@ -402,15 +402,15 @@ " if (j.item && typeof j.item === 'object' && !Array.isArray(j.item)) { outputItemObjects++; }", " return;", " }", - " if ('item' in j && j.item === null) { itemNull++; }", + " 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.be.defined;", " pm.expect(types['response.completed'], 'expected response.completed').to.be.defined;", "});", - "pm.test('Regression: no item: null outside output_item.added/done', function () {", - " pm.expect(itemNull, 'found ' + itemNull + ' events with item: null').to.equal(0);", + "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);", From 9114d0cb147d2800119ac75d7075ad14bdec2cfc Mon Sep 17 00:00:00 2001 From: ReStranger Date: Sun, 23 Aug 2026 15:56:18 +0300 Subject: [PATCH 4/5] responses: tighten stream event regression coverage --- core/schemas/responses.go | 15 +++ core/schemas/responses_test.go | 57 ++++++++- .../e2e/api/collections/provider-harness.json | 121 +++++++----------- 3 files changed, 113 insertions(+), 80 deletions(-) diff --git a/core/schemas/responses.go b/core/schemas/responses.go index 2bbc66c3797..c270667827b 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -3767,6 +3767,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 := json.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 diff --git a/core/schemas/responses_test.go b/core/schemas/responses_test.go index ef6ece29cc5..6bc31fed3ee 100644 --- a/core/schemas/responses_test.go +++ b/core/schemas/responses_test.go @@ -24,7 +24,11 @@ func TestBifrostResponsesStreamResponseOmitsEmptyItem(t *testing.T) { if err != nil { t.Fatalf("%s: marshal: %v", typ, err) } - if strings.Contains(string(encoded), `"item"`) { + 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) } } @@ -41,9 +45,58 @@ func TestBifrostResponsesStreamResponseOmitsEmptyItem(t *testing.T) { if err != nil { t.Fatalf("%s: marshal: %v", typ, err) } - if !strings.Contains(string(encoded), `"item":{"id":"msg_1"`) { + 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) } } diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json index 1f318331ad4..1c4f91de056 100644 --- a/tests/e2e/api/collections/provider-harness.json +++ b/tests/e2e/api/collections/provider-harness.json @@ -365,84 +365,6 @@ ] } } - }, - { - "name": "POST /v1/responses — streaming (no item:null outside output_item.added/done)", - "event": [ - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "// Regression (fix/responses-stream-item-null): Responses SSE must not emit \"item\": null", - "// on events that carry no item object. The field belongs only on output_item.added /", - "// output_item.done. Strict clients such as opencode's open-responses protocol reject a", - "// null item and drop the whole stream with 'Invalid ... stream event'.", - "pm.test('Responses stream request succeeds', function () {", - " pm.expect(pm.response.code, 'expected < 400').to.be.below(400);", - "});", - "if (pm.response.code >= 400) { return; }", - "pm.test('Responses SSE content-type is event-stream', function () {", - " var ct = pm.response.headers.get('content-type') || '';", - " pm.expect(ct, 'expected text/event-stream, got ' + ct).to.include('text/event-stream');", - "});", - "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.be.defined;", - " pm.expect(types['response.completed'], 'expected response.completed').to.be.defined;", - "});", - "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);", - "});" - ] - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"model\": \"openai/gpt-4o-mini\",\n \"input\": \"Say hello in one short sentence.\",\n \"stream\": true\n}" - }, - "url": { - "raw": "{{baseUrl}}/v1/responses", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "v1", - "responses" - ] - } - } } ] }, @@ -5064,6 +4986,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", From dc47f4b1a334bbeb5746d0fc45657eace9fe7f30 Mon Sep 17 00:00:00 2001 From: ReStranger Date: Thu, 10 Sep 2026 18:00:59 +0300 Subject: [PATCH 5/5] fix: use sonic marshal for responses stream response --- core/schemas/responses.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/schemas/responses.go b/core/schemas/responses.go index 3c08a3de2a0..b3142636ba3 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -3777,7 +3777,7 @@ type BifrostResponsesStreamResponse struct { // still intentionally emit empty arrays after WithDefaults populates them. func (resp BifrostResponsesStreamResponse) MarshalJSON() ([]byte, error) { type alias BifrostResponsesStreamResponse - encoded, err := json.Marshal(alias(resp)) + encoded, err := Marshal(alias(resp)) if err != nil { return nil, err }