Skip to content

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

Closed

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

  1. added
    bugSomething isn't working
    streamingSSE, WebSocket, terminal stream frames
    on Sep 6, 2026
  2. lidge-jun commented on Sep 6, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 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"만 나갑니다.

    지금 dev HEAD는 a349b521b(package 2.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.ts SSE 디스패치가 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 근처 경로와 동일 계열)

    메인테이너의 판단이 필요한 지점

    • refusal-only 응답의 finish_reason을 stop으로 둘지, Chat 관례에 맞춰 다른 값/별도 신호를 쓸지(이슈는 HTTP 오류로 재분류하지 말라고 함)
    • text와 refusal이 한 메시지에 섞일 때 content/refusal을 둘 다 채울지, refusal 우선으로 둘지
    • translator-budget에 refusal 바이트를 content와 같은 retained 버킷으로 칠지
    • 네이티브 Chat 패스스루 경로도 같은 회귀를 한 번에 스모크할지(이슈 범위 밖이지만 안전망)

    너의 추천
    열린 채로 두고 작은 번역기 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이 작성했습니다

  3. lidge-jun commented on Sep 6, 2026

    @lidge-jun
    Owner

    Landed via #3805 at eccbdc46913b5a9d27810aa1f10b5943ea3b0bd8.

  4. lidge-jun commented on Sep 6, 2026

    @lidge-jun
    Owner

    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 .

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