Repository navigation
[Bug]: Responses-to-Chat translation silently drops refusal text in streaming and JSON responses #3767
Description
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:
- Streaming event dispatch: handles text/reasoning deltas but no
response.refusal.delta. - JSON message projection: reads only
output_textfrom message content; message construction has no refusal field. - SSE collector: accumulates content and reasoning but no
delta.refusal. Fixing the streaming emitter alone would still leave non-streaming clients without the explanation. - HTTP translation path: uses this SSE translator and collector for the Responses fallback.
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
lidge-jun commentedon Sep 6, 2026
리뷰 · 우선순위 71 / 80
이 이슈는
/v1/chat/completions가 내부적으로 Responses 업스트림을 쓸 때, 모델이 보낸 거절(refusal) 설명을 Chat Completions 형식으로 옮기지 못하는 버그입니다. 거절 결정 자체를 우회하는 보안 구멍은 아니고, 이미 완성된 Responses 메시지에{type: "refusal", refusal: "..."}파트가 있는데도 Chat 쪽message.content가 null이고message.refusal도 없어서, 클라이언트가 “빈 성공 답변”처럼 보이게 됩니다. 스트리밍에서는response.refusal.delta를 무시하고 assistant 역할 청크와finish_reason: "stop"만 나갑니다.지금
devHEAD는a349b521b(package2.44.0,#3766대시보드 시각 검증 문서까지 들어간 release-244). 리포터는c8470eff4에서 모듈 단위로 재현했고, 현재 HEAD의src/chat/outbound.ts도 같은 구멍입니다.responsesSseToChatCompletionsSse의handleFrame(대략 L359–L373)은response.output_text.delta와 reasoning delta만 처리하고response.refusal.delta/response.refusal.done분기가 없습니다.responsesJsonToChatCompletion(L570–L577)은 message content에서output_text만 이어 붙이고, 메시지 조립(L609–L614)에refusal필드가 없습니다.collectChatCompletion(L714–L715)도delta.content/delta.reasoning_content만 모아, 스트리밍 emitter만 고쳐도 비스트리밍 Chat 클라이언트에는 설명이 안 남습니다. HTTP 진입은src/server/chat-completions.ts가 Responses 폴백에서 위 SSE 번역기와 collector를 쓰는 경로입니다. 네이티브 Chat 패스스루가 refusal을 잃는다는 주장은 이슈 범위 밖입니다.재현이 좋습니다. 라이브 계정 없이
responsesJsonToChatCompletion/responsesSseToChatCompletionsSse/collectChatCompletion에 합성 refusal 이벤트를 넣으면 JSON·SSE·수집 결과 모두 refusal 문자열이 사라집니다. OpenAI 공식 와이어도 Responses 쪽에 refusal 스트림 이벤트가 있고, Chat Completions 쪽에message.refusal/delta.refusal이 있습니다. 고치면 거절 설명을 보존하고, translator-budget 계산도 맞추며, refusal-only / 분할 delta / text+refusal 혼재 / 비스트리밍 수집 회귀를 넣으면 됩니다. done 스냅샷이 이미 보낸 delta를 중복하지 않게만 주의하면 됩니다.release-244 기차는 대시보드·문서 쪽이 막 닫힌 상태지만, 이 이슈는 번역기 한 파일 중심의 명확한 호환 버그라 기차와 독립으로 받아도 됩니다. types/config 분할에 무효화될 성격도 아닙니다.
라인 359-373 -
src/chat/outbound.tsSSE 디스패치가 text/reasoning delta만 처리하고response.refusal.delta/done을 무시함라인 570-577 - JSON 투영이 message content의
output_text만 읽어 refusal 파트를 버림라인 609-614 - Chat
message에refusal필드가 없어 content null + finish stop만 남음라인 714-715 -
collectChatCompletion이delta.refusal을 누적하지 않음(스트리밍만 고쳐도 비스트리밍 폴백은 빈 답으로 남음)src/server/chat-completions.ts- Responses→Chat 폴백이 위 번역기/collector에 의존(리포트 L385 근처 경로와 동일 계열)메인테이너의 판단이 필요한 지점
finish_reason을stop으로 둘지, Chat 관례에 맞춰 다른 값/별도 신호를 쓸지(이슈는 HTTP 오류로 재분류하지 말라고 함)너의 추천
열린 채로 두고 작은 번역기 PR을 받으세요. 우선순위는 (1)
src/chat/outbound.ts에 refusal delta/JSON 파트 →delta.refusal/message.refusal매핑, (2)collectChatCompletion누적, (3) refusal-only·분할 delta·혼재·비스트리밍 수집 테스트입니다. HTTP 500으로 바꾸지 마세요. release-244 대시보드 기차와 독립이니 바로 랜딩해도 됩니다.이 댓글은 grok-bot이 작성했습니다
lidge-jun commentedon Sep 6, 2026
Landed via #3805 at
eccbdc46913b5a9d27810aa1f10b5943ea3b0bd8.lidge-jun commentedon Sep 6, 2026
Resolved on dev by #3805. Refusal content is retained across native/translated JSON and SSE paths, with indexed terminal validation, bounded buffering and cancellation cleanup. Final combined cross-platform verification: https://github.com/lidge-jun/opencodex/actions/runs/34058496989 .