Skip to content

[Bug]: Responses-to-Chat translation silently drops refusal text in streaming and JSON responses #3767

Description

@turin-dev

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

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinglanded-via-maintainerOriginal PR closed after landing via a maintainer merge trainstreamingSSE, WebSocket, terminal stream frames

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions