Skip to content

fix(openai): surface chat-completions refusal as visible text#102334

Closed
xydt-tanshanshan wants to merge 2 commits into
openclaw:mainfrom
xydt-tanshanshan:fix/chat-completions-refusal-102321
Closed

fix(openai): surface chat-completions refusal as visible text#102334
xydt-tanshanshan wants to merge 2 commits into
openclaw:mainfrom
xydt-tanshanshan:fix/chat-completions-refusal-102321

Conversation

@xydt-tanshanshan

@xydt-tanshanshan xydt-tanshanshan commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

When an OpenAI chat-completions response carries a safety or structured-output refusal, the assistant turn resolves to empty content and the user sees nothing. OpenAI returns refusals in a top-level refusal field with content: null and finish_reason: "stop", but the completions streaming assembler never reads refusal, so the refusal text is silently dropped.

Why This Change Was Made

Two streaming assemblers had the same blind spot:

  • processOpenAICompletionsStream in src/agents/openai-transport-stream.ts — the delta loop reads content, reasoning deltas, and tool_calls from choice.delta but never checks refusal
  • streamOpenAICompletions in packages/ai/src/providers/openai-completions.ts — same pattern, drops refusal

The sibling Responses path (same file, lines 1635, 1697-1700, 1878) already routes refusal correctly. The fix mirrors that pattern: when content is null/absent, check refusal and route it as visible assistant text.

User Impact

For users whose prompts trigger an OpenAI safety refusal on the openai-completions API, the assistant reply will now show the refusal text (e.g. "I can't help with that.") instead of a blank empty reply. Users of the Responses API are unaffected (already handled).

Evidence

Real proof — streaming delta refusal (after fix):

Proof run on disposable Node.js, stubbing only the transport input as an async iterable, keeping the processOpenAICompletionsStream assembly logic real:

$ node --import tsx -e "
import { testing } from './src/agents/openai-transport-stream.js';
const model = { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', api: 'openai-completions',
  provider: 'openai', baseUrl: 'https://api.openai.com/v1', reasoning: false, input: ['text'],
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 8192 };
const output = { role: 'assistant', content: [], api: 'openai-completions', provider: 'openai',
  model: 'gpt-5.4-mini', usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
  stopReason: 'stop', timestamp: Date.now() };

// Test 1: streaming delta with refusal
const o1 = { ...output, content: [] };
await testing.processOpenAICompletionsStream(
  (async function*() {
    yield { id: 'cmpl-1', object: 'chat.completion.chunk', created: 1, model: 'gpt-5.4-mini',
      choices: [{ index: 0, delta: { role: 'assistant', refusal: \"I can't help.\" }, logprobs: null, finish_reason: 'stop' }] };
  })(), o1, model, { push: () => {} });
console.log('Streaming delta:', JSON.stringify(o1.content));

// Test 2: aggregated message with refusal
const o2 = { ...output, content: [] };
await testing.processOpenAICompletionsStream(
  (async function*() {
    yield { id: 'cmpl-2', object: 'chat.completion.chunk', created: 1, model: 'gpt-5.4-mini',
      choices: [{ index: 0, message: { role: 'assistant', content: null, refusal: \"I can't help.\" }, logprobs: null, finish_reason: 'stop' }] };
  })(), o2, model, { push: () => {} });
console.log('Aggregated msg: ', JSON.stringify(o2.content));
"

Streaming delta: [{"type":"text","text":"I can't help."}]
Aggregated msg:  [{"type":"text","text":"I can't help."}]

Both forms now surface the refusal as visible assistant text instead of empty content.

Code change — src/agents/openai-transport-stream.ts (after choiceDelta.content block):

    } else if (
      !choiceDelta.content &&
      typeof (choiceDelta as { refusal?: string }).refusal === "string"
    ) {
      // Chat Completions can put safety/structured-output refusals in a top-level
      // `refusal` field with content: null. Surface refusal as visible text
      // (Responses path already routes refusal deltas).
      const refusalText = (choiceDelta as { refusal?: string }).refusal!;
      const routedDeltas = hasMirroredReasoning
        ? reasoningTagTextPartitioner.push(refusalText)
        : reasoningTagTextPartitioner.pushVisible(refusalText);
      for (const routedDelta of routedDeltas) {
        appendPartitionedVisibleDelta(routedDelta);
      }
    }

Code change — packages/ai/src/providers/openai-completions.ts (after choice.delta.content block):

    } else if (typeof choice.delta.refusal === "string") {
      appendPartitionedContent(choice.delta.refusal, Boolean(foundReasoningField));
    }

Test coverage — 3 new tests across 2 files:

  • src/agents/openai-transport-stream.test.ts: streaming delta refusal + aggregated message refusal
  • packages/ai/src/providers/openai-completions.test.ts: streaming delta refusal in the shared assembler

All tests feed a refusal-only chunk and assert output.content equals [{ type: "text", text: "I can't help with that." }].

Environment: Linux x86_64, Node v24.13.1, commit 615cab6.

Fixes #102321

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: S labels Jul 9, 2026
@clawsweeper

clawsweeper Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Close as superseded: the same linked refusal bug now has a better canonical landing candidate at #102344, while this branch is conflicting and still misses the shared-provider message.refusal case.

Canonical path: Use #102344 as the canonical landing path for the linked refusal bug, then close the duplicate refusal-fix branches.

So I’m closing this here and keeping the remaining discussion on #102344.

Review details

Best possible solution:

Use #102344 as the canonical landing path for the linked refusal bug, then close the duplicate refusal-fix branches.

Do we have a high-confidence way to reproduce the issue?

Yes. Source inspection gives a high-confidence path: feed either completions assembler a refusal-only delta or message chunk and current main never reads the refusal field.

Is this the best way to solve the issue?

No. The narrow parser-parity fix is the right shape, but this branch is not the best landing path because it leaves the shared provider's aggregated message.refusal case uncovered and is superseded by a proof-positive sibling PR.

Security review:

Security review cleared: No concrete security or supply-chain regression was found; the diff touches provider parsing and focused tests without dependency, workflow, secret, package, or install-script changes.

AGENTS.md: found and applied where relevant.

What I checked:

Likely related people:

  • steipete: Commit 062f88e extracted the reusable AI runtime package and touched both the shared provider and agent transport; commit 4bf70be recently touched the shared OpenAI completions provider path. (role: AI runtime package refactor owner and recent adjacent contributor; confidence: high; commits: 062f88e3e3af, 4bf70be01a21; files: packages/ai/src/providers/openai-completions.ts, packages/ai/src/providers/register-builtins.ts, src/agents/openai-transport-stream.ts)
  • vincentkoc: Commit bf8626c touched the shared OpenAI completions provider while adding provider support, making this person a plausible reviewer for provider-shape handling. (role: recent provider contributor; confidence: medium; commits: bf8626c0e950; files: packages/ai/src/providers/openai-completions.ts)
  • balajisiva: Commit efab976 fixed a nearby OpenAI-compatible completions parser issue involving silent output loss in the agent transport path. (role: adjacent parser bug-fix author; confidence: medium; commits: efab9763dc87; files: src/agents/openai-transport-stream.ts)

Codex review notes: model internal, reasoning high; reviewed against e90bf3182927.

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. labels Jul 9, 2026
@xydt-tanshanshan

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@openclaw-barnacle openclaw-barnacle Bot added app: ios App: ios size: M scripts Repository scripts and removed size: S labels Jul 9, 2026
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jul 9, 2026
hailory added 2 commits July 9, 2026 11:21
OpenAI chat-completions safety/structured-output refusals arrive in a top-level
refusal field with content: null. The streaming assembler never read this field,
so the assistant turn resolved to empty content.

- src/agents/openai-transport-stream.ts: add else-if branch for choiceDelta.refusal
  in processOpenAICompletionsStream delta loop, routing refusal text through
  appendTextDelta
- packages/ai/src/providers/openai-completions.ts: add else-if for refusal in
  streamOpenAICompletions, routing through appendPartitionedContent
- Add 2 tests (streaming delta + aggregated message) covering refusal surfacing
- Sibling Responses path already handled refusal at lines 1635, 1697-1700, 1878

Fixes openclaw#102321
@xydt-tanshanshan
xydt-tanshanshan force-pushed the fix/chat-completions-refusal-102321 branch from b1c062b to 1484e4e Compare July 9, 2026 03:25
@openclaw-barnacle openclaw-barnacle Bot added size: S and removed app: ios App: ios scripts Repository scripts size: M labels Jul 9, 2026
@xydt-tanshanshan

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper could not start a re-review for this item.

Reason: re-review requires an open issue or PR.

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

Labels

agents Agent runtime and tooling P2 Normal backlog priority with limited blast radius. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. size: S status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

openai-completions: chat-completions refusal field dropped, assistant turn resolves to empty content

1 participant