Resolved in dev
#3805 (eccbdc4691) preserves refusal through JSON conversion, stream projection and collection.
Verified against dev 5759d9ea2f1e7281cdc01eb9628f2e0a123fb59c. Original report by @turin-dev. The attribution record was added in #3811.
Client or integration
Direct HTTP/API client
Area
Streaming
Summary
The Responses-to-Chat Completions translation drops structured model refusal text. A valid completed Responses message with a { type: "refusal", refusal: "..." } content part becomes message.content: null with no message.refusal. In the streaming path, response.refusal.delta is ignored and the client receives only an assistant-role chunk, a normal finish_reason: "stop", and [DONE].
This makes a refusal-only response look like an empty successful answer. Clients cannot display the explanation or distinguish a refusal from an empty completion. The refusal decision itself is not bypassed.
Expected: preserve the explanation in Chat Completions delta.refusal / message.refusal, including when a streamed Responses response is collected for a non-streaming Chat client. A completed refusal does not need to be reclassified as an HTTP error.
Scope: the Responses translation fallback used by /v1/chat/completions; this report does not claim that the native Chat passthrough path loses refusals.
Reproduction
Deterministic module-level reproduction using the unmodified source modules at commit c8470eff4c7efd2b440ab3f9ff543cfa119f2064. No live provider account, paid request, or real user prompt is needed.
Save the following as repro-refusal.ts at the repository root and run bun run repro-refusal.ts:
import { responsesJsonToChatCompletion, responsesSseToChatCompletionsSse, collectChatCompletion } from "./src/chat/outbound";
import { createTranslatorBudget } from "./src/lib/translator-budget";
const refusal = "I cannot help with that request.";
const message = { id: "msg_test", type: "message", role: "assistant", status: "completed", content: [{ type: "refusal", refusal }] };
const response = { id: "resp_test", status: "completed", output: [message], usage: { input_tokens: 5, output_tokens: 7 } };
function inputStream() {
const events = [
{type:"response.created",response:{...response,status:"in_progress",output:[]}},
{type:"response.output_item.added",output_index:0,item:{...message,status:"in_progress",content:[]}},
{type:"response.content_part.added",item_id:"msg_test",output_index:0,content_index:0,part:{type:"refusal",refusal:""}},
{type:"response.refusal.delta",item_id:"msg_test",output_index:0,content_index:0,delta:refusal},
{type:"response.refusal.done",item_id:"msg_test",output_index:0,content_index:0,refusal},
{type:"response.content_part.done",item_id:"msg_test",output_index:0,content_index:0,part:{type:"refusal",refusal}},
{type:"response.output_item.done",output_index:0,item:message},
{type:"response.completed",response},
];
return new Response(events.map(e => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join("")).body!;
}
console.log("JSON:", JSON.stringify(responsesJsonToChatCompletion(response, "fixture").choices));
const budget = createTranslatorBudget();
const wire = await new Response(responsesSseToChatCompletionsSse(inputStream(),"fixture",{translatorBudget:budget})).text();
console.log("SSE contains refusal:", wire.includes(refusal));
console.log("SSE:",wire);
budget.dispose();
const b = createTranslatorBudget();
const collected = await collectChatCompletion(responsesSseToChatCompletionsSse(inputStream(),"fixture",{translatorBudget:b}),"fixture",b);
console.log("COLLECTED:",JSON.stringify(collected.choices));
b.dispose();
The fixture includes refusal delta/done events, content-part completion, the completed output item, and the final response snapshot. The refusal text is lost even though all these representations contain it.
Validation scope: executed the actual translation functions and their dependencies under Bun using synthetic upstream data. This is not a claimed live-provider or full HTTP-server reproduction.
Version
2.44.0, upstream dev commit c8470eff4c7efd2b440ab3f9ff543cfa119f2064 (checked 2026-09-06).
Operating system
Ubuntu 24.04.3 LTS, Linux x64; Bun 1.4.2.
Provider and model
Provider-independent translation fixture, model identifier fixture. Relevant when the internal Responses upstream emits structured refusal content/events.
Logs or error output
JSON: [{"index":0,"message":{"role":"assistant","content":null},"finish_reason":"stop","logprobs":null}]
SSE contains refusal: false
COLLECTED: [{"index":0,"message":{"role":"assistant","content":null},"finish_reason":"stop","logprobs":null}]
The SSE output contains an initial delta: { role: "assistant", content: "" }, a final delta: {} with finish_reason: "stop" and usage, followed by [DONE]. It contains neither the refusal text nor a refusal field.
Screenshots and supporting files
Source locations at the tested commit:
Suggested fix: map refusal deltas and JSON refusal parts into their corresponding Chat fields, accumulate refusal in collectChatCompletion, and preserve translator-budget accounting. Add focused regressions for a refusal-only response, split refusal deltas, mixed text/refusal, and non-streaming collection; ensure done snapshots do not duplicate already-emitted deltas.
Official wire references:
Duplicate checks searched issues and PRs for "response.refusal", "refusal" in:title, and "chat" "refusal"; no matching report was found.
Redacted configuration
No provider configuration is required for the reproduction. All IDs, message text, and usage values above are synthetic.
Checks
Resolved in dev
#3805 (
eccbdc4691) preserves refusal through JSON conversion, stream projection and collection.Verified against dev
5759d9ea2f1e7281cdc01eb9628f2e0a123fb59c. Original report by @turin-dev. The attribution record was added in #3811.Client or integration
Direct HTTP/API client
Area
Streaming
Summary
The Responses-to-Chat Completions translation drops structured model refusal text. A valid completed Responses message with a
{ type: "refusal", refusal: "..." }content part becomesmessage.content: nullwith nomessage.refusal. In the streaming path,response.refusal.deltais ignored and the client receives only an assistant-role chunk, a normalfinish_reason: "stop", and[DONE].This makes a refusal-only response look like an empty successful answer. Clients cannot display the explanation or distinguish a refusal from an empty completion. The refusal decision itself is not bypassed.
Expected: preserve the explanation in Chat Completions
delta.refusal/message.refusal, including when a streamed Responses response is collected for a non-streaming Chat client. A completed refusal does not need to be reclassified as an HTTP error.Scope: the Responses translation fallback used by
/v1/chat/completions; this report does not claim that the native Chat passthrough path loses refusals.Reproduction
Deterministic module-level reproduction using the unmodified source modules at commit
c8470eff4c7efd2b440ab3f9ff543cfa119f2064. No live provider account, paid request, or real user prompt is needed.Save the following as
repro-refusal.tsat the repository root and runbun run repro-refusal.ts:The fixture includes refusal delta/done events, content-part completion, the completed output item, and the final response snapshot. The refusal text is lost even though all these representations contain it.
Validation scope: executed the actual translation functions and their dependencies under Bun using synthetic upstream data. This is not a claimed live-provider or full HTTP-server reproduction.
Version
2.44.0, upstreamdevcommitc8470eff4c7efd2b440ab3f9ff543cfa119f2064(checked 2026-09-06).Operating system
Ubuntu 24.04.3 LTS, Linux x64; Bun 1.4.2.
Provider and model
Provider-independent translation fixture, model identifier
fixture. Relevant when the internal Responses upstream emits structured refusal content/events.Logs or error output
The SSE output contains an initial
delta: { role: "assistant", content: "" }, a finaldelta: {}withfinish_reason: "stop"and usage, followed by[DONE]. It contains neither the refusal text nor arefusalfield.Screenshots and supporting files
Source locations at the tested commit:
response.refusal.delta.output_textfrom message content; message construction has no refusal field.delta.refusal. Fixing the streaming emitter alone would still leave non-streaming clients without the explanation.Suggested fix: map refusal deltas and JSON refusal parts into their corresponding Chat fields, accumulate refusal in
collectChatCompletion, and preserve translator-budget accounting. Add focused regressions for a refusal-only response, split refusal deltas, mixed text/refusal, and non-streaming collection; ensure done snapshots do not duplicate already-emitted deltas.Official wire references:
Duplicate checks searched issues and PRs for
"response.refusal","refusal" in:title, and"chat" "refusal"; no matching report was found.Redacted configuration
No provider configuration is required for the reproduction. All IDs, message text, and usage values above are synthetic.
Checks