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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions src/protocol/iterator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions src/responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, {});
Expand Down
Loading