diff --git a/README.md b/README.md index 18f31fa..4bf1937 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,10 @@ Every field is optional. An absent field keeps the protocol default. Unknown key Interchange's neutral `effort` (`low | medium | high | max`) is sent as `reasoning.effort`, even when the source has no `reasoning` quirk: `low`, `medium` and `high` as given, `max` as `reasoning.maxEffort` or `high` when that is unset. A non-empty `providerOptions[reasoning.effortOption]` wins over it. `includeReasoningEffort` filters either. +### Incomplete responses + +An incomplete response (`response.incomplete`, or a JSON body with `status: "incomplete"`) keeps its partial output and usage. When `incomplete_details.reason` is `content_filter`, an `inference.safety_rating` event with `blockReason: "content_filter"` comes before the usage. + ## Using with Interchange Interchange loads custom adapters from an operator-configured `AdapterManifest`. Add one entry per provider id you serve: diff --git a/src/protocol/iterator.ts b/src/protocol/iterator.ts index b6fc8f1..ea0be32 100644 --- a/src/protocol/iterator.ts +++ b/src/protocol/iterator.ts @@ -82,6 +82,21 @@ const IncompleteEvent = type({ "usage?": ResponsesUsage, }, }); + +// A content-filter stop is a safety signal the caller must tell apart from a +// complete or truncated turn, so it surfaces as `inference.safety_rating`. +// Any other reason (e.g. max_output_tokens) reports usage alone. +function incompleteReasonEvent( + reason: string | undefined, + seq: number, +): InferenceEvent | undefined { + if (reason !== "content_filter") return undefined; + return { + type: "inference.safety_rating", + seq, + data: { safetyRating: { type: "safety_rating", blockReason: reason } }, + }; +} const ErrorEvent = type({ "message?": "string" }); // Maps the Responses API's usage object onto the internal TokenUsage, @@ -573,6 +588,11 @@ export function parseResponse( parsed, ); } + const reasonEvent = incompleteReasonEvent( + validated.response.incomplete_details?.reason, + seq, + ); + if (reasonEvent !== undefined) events.push(reasonEvent); if (validated.response.usage !== undefined) { events.push({ type: "inference.usage", @@ -844,6 +864,11 @@ export function parseJSONResponse( // has nothing to do with; ignored, mirroring the SSE default case. } + const reasonEvent = incompleteReasonEvent( + response.incomplete_details?.reason, + seq, + ); + if (reasonEvent !== undefined) events.push(reasonEvent); events.push({ type: "inference.usage", seq, diff --git a/src/responses.test.ts b/src/responses.test.ts index 7f303c1..4d08fc8 100644 --- a/src/responses.test.ts +++ b/src/responses.test.ts @@ -412,6 +412,29 @@ describe("Responses parser — event schema validation", () => { expect(events.map((e) => e.type)).toEqual(["inference.usage"]); }); + test("response.incomplete on content_filter reports a safety rating before usage", () => { + const adapter = createOpenAIResponsesAdapter(source, {}); + adapter.buildRequest(turns, "model", {}); + const incomplete = JSON.stringify({ + type: "response.incomplete", + response: { + status: "incomplete", + incomplete_details: { reason: "content_filter" }, + usage: { input_tokens: 10, output_tokens: 3 }, + }, + }); + const events = adapter.parseResponse(incomplete); + expect(events.map((e) => e.type)).toEqual([ + "inference.safety_rating", + "inference.usage", + ]); + expect(events[0]).toMatchObject({ + data: { + safetyRating: { type: "safety_rating", blockReason: "content_filter" }, + }, + }); + }); + // A function_call_arguments.delta is only routable to the tool_call.start // the harness already saw; one for an item_id that never arrived via // output_item.added is an orphan fragment, not a fresh block to @@ -637,6 +660,27 @@ describe("Responses parser — non-streaming failure states", () => { expect(events.some((e) => e.type === "inference.usage")).toBe(true); }); + test("reports a safety rating before usage on status:incomplete content_filter", () => { + const adapter = createOpenAIResponsesAdapter(source, {}); + const incomplete = JSON.stringify({ + output: [], + status: "incomplete", + error: null, + incomplete_details: { reason: "content_filter" }, + usage: { input_tokens: 10, output_tokens: 0 }, + }); + const events = adapter.parseJSONResponse(incomplete); + expect(events.map((e) => e.type)).toEqual([ + "inference.safety_rating", + "inference.usage", + ]); + expect(events[0]).toMatchObject({ + data: { + safetyRating: { type: "safety_rating", blockReason: "content_filter" }, + }, + }); + }); + // OpenAI sends both fields as null on every completed body. test("decodes a completed body with null error and incomplete_details", () => { const adapter = createOpenAIResponsesAdapter(source, {});