Skip to content

fix(openai-chat): refuse to emit an unnamed streamed tool call - #1531

Merged
lidge-jun merged 2 commits into
devfrom
codex/1514-unnamed-tool-call
Aug 12, 2026
Merged

fix(openai-chat): refuse to emit an unnamed streamed tool call#1531
lidge-jun merged 2 commits into
devfrom
codex/1514-unnamed-tool-call

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

An OpenCode Zen / DeepSeek route can stream function.arguments deltas while never sending function.name. The pending call kept its initial empty name and every flush site emitted it anyway, so the Codex tool-call contract received a call it cannot dispatch and the streamed turn failed downstream (#1514).

This makes the streamed path fail closed. An unnamed call now terminates the turn through the adapter error channel instead of being emitted.

Three details worth calling out for review:

  • Why not synthesize a name. The id is already synthesized when absent, and that is safe because an id is an opaque correlation handle. A function name is a guess at intent, so it is refused instead. This matches the reference implementations: openai-python and openai-node accumulate such a call with an empty name and let the caller fail; LiteLLM forwards it unchanged. None invent a name.
  • Why not drop it. [Bug] Nested field shapes bypass the #1219 frame guard and crash both adapter paths #1325 established that a claimed tool call which silently disappears can leave the matching function_call_output orphaned on the next turn. A loud terminal error preserves that invariant rather than weakening it.
  • Ingest-time validation. The delta.tool_calls cast is a TypeScript convenience over upstream JSON, so a truthy non-string name reached the accumulator unvalidated and then threw from string handling. Review caught this in the first draft of the patch (reproduced as TypeError: call.name.trim is not a function). Present function, name, arguments, and id fields are now runtime-checked at ingest and route to the existing invalidToolCallsEvent.

The buffered parseResponse validator checked the type of name but not its emptiness, so it is tightened the same way. A completed Chat Completions tool call requires a name in the official OpenAPI schema, and a blank or whitespace-only name cannot select a dispatch target.

Preserved invariants, each covered by a test: raw-EOF truncation still reports truncation rather than the new error; non-array and non-record tool_calls still terminate per #1325; a name arriving in a later chunk is still accepted; done is never emitted after the new error.

T7 in tests/openai-chat-parallel-stream.test.ts previously asserted the empty-name call was flushed, on a "no silent drop" rationale. That invariant still holds and is strengthened — the call does not vanish, the turn fails loudly — so the test was updated to the new contract rather than deleted. T6 (late-arriving name) is untouched.

Verification

Run on a dedicated Linux runner (Bun 1.3.14, clean checkout of this branch):

  • bun x tsc --noEmit — exit 0
  • bun test tests/openai-chat-eof.test.ts tests/openai-chat-parallel-stream.test.ts — 48 pass, 0 fail
  • 11-file adapter sweep (tests/openai-chat*, tests/cl01-openai-chat*, tests/adapter*) — 161 pass, 0 fail
  • bun run privacy:scan — pass

Red-before evidence: with only the test diff applied to unmodified dev, 5 tests failed. With the full diff, 48/48 pass.

The crash path found in review was reproduced directly before and after the fix: a numeric function.name produced THREW: call.name.trim is not a function before, and EVENTS: error after.

Budget safety is asserted directly, not inferred: a stream with a valid call, an unnamed call, and a second valid call after it ends with activeCalls: 0, currentBytes: 0, and highWaterBytes > 0, proving closeToolCalls() released every pending reservation including the call after the offender.

Full-suite run via bun run test was still in progress at the time of opening; it will be reported here when it completes.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Closes #1514

Summary by CodeRabbit

  • Bug Fixes

    • Invalid or unnamed tool calls are now rejected safely in streaming and non-streaming responses.
    • Malformed tool-call data no longer produces unusable events or falsely completes a stream.
    • Pending processing resources are released when tool-call validation fails.
  • Tests

    • Added coverage for missing, blank, malformed, and delayed tool-call names.
    • Verified preservation of existing truncation and invalid-payload diagnostics.

Roadmap unit for issues #1514, #1503, #1497, #1409, #1419, grounded in the
live tree and closed through three independent audit rounds.

Two phases were superseded by the audit:
- 030 -> 031: the proposed rangeFullyCovered field was unsound. usage.jsonl
  appends on request completion while entries carry the request start time,
  so the oldest retained row does not bound the dropped prefix.
- 050 -> 051: installCrashGuards() and cancelBodyOnAbort already ship at 8
  sites, so the proposed hardening was a re-proposal of existing code.

#1497 and #1409 are downgraded from Closes to evidence-backed dispositions:
neither fix meets the reporter's acceptance bar on its own.
An upstream that streams function.arguments deltas without ever sending
function.name left the pending call with an empty name, and every flush
site emitted it anyway. The Codex tool-call contract then received a call
it cannot dispatch and the turn failed downstream (#1514).

Fail closed instead. The id stays synthesizable because it is an opaque
correlation handle; a function name is a guess at intent, so an unnamed
call terminates the turn through the adapter error channel. Dropping it
silently was not an option either: #1325 established that a claimed call
which disappears can orphan the matching result on the next turn.

Ingest now validates the streamed shape at runtime. The tool_calls cast
is a TypeScript convenience over upstream JSON, so a truthy non-string
name reached the accumulator and later threw from string handling; that
path now terminates through the existing invalid-tool-calls error.

The buffered parseResponse validator checked the type of name but not
its emptiness, so it is tightened the same way.

Closes #1514
@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR documents a five-bug investigation and audit process. It adds plans for five remediation phases. It also hardens OpenAI streamed and buffered tool-call handling against blank names and malformed fields.

Changes

Campaign evidence and audit scope

Layer / File(s) Summary
Research and audit findings
devlog/_plan/260812_five_bug_fix_campaign/000_research.md, devlog/_plan/260812_five_bug_fix_campaign/001_external_contract_evidence.md, devlog/_plan/260812_five_bug_fix_campaign/002_audit_round1_synthesis.md, devlog/_plan/260812_five_bug_fix_campaign/003_audit_round2_synthesis.md
The documents record five confirmed defects, external contract evidence, existing protections, revised scope, and audit blockers.
Phase remediation plans
devlog/_plan/260812_five_bug_fix_campaign/010_phase1_issue1514_empty_tool_name.md, devlog/_plan/260812_five_bug_fix_campaign/020_phase2_issue1503_google_thought_text.md, devlog/_plan/260812_five_bug_fix_campaign/030_phase3_issue1497_usage_range_truncation.md, devlog/_plan/260812_five_bug_fix_campaign/031_phase3_revised_usage_coverage.md, devlog/_plan/260812_five_bug_fix_campaign/040_phase4_issue1409_context_window_overrides.md, devlog/_plan/260812_five_bug_fix_campaign/050_phase5_issue1419_tls_crash_survivability.md, devlog/_plan/260812_five_bug_fix_campaign/051_phase5_revised_issue1419_disposition.md
The plans define validation and delivery requirements for OpenAI tool names, Gemini thought events, usage snapshot windows, provider context-window ownership, and TLS-failure disposition. The revised plans remove unsupported completeness and native-runtime claims.
OpenAI adapter validation
src/adapters/openai-chat.ts
Streamed tool-call fields are runtime-validated before accumulation. Blank names produce an adapter error and terminate processing at all flush boundaries. Buffered responses reject blank or whitespace-only names.
Regression coverage
tests/openai-chat-eof.test.ts, tests/openai-chat-parallel-stream.test.ts
Tests cover missing, blank, non-string, and malformed fields across streamed and buffered responses. They also verify truncation diagnostics, late names, budget cleanup, and the absence of completion events after terminal errors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OpenAIStreamParser
  participant flushToolCalls
  participant AdapterConsumer
  OpenAIStreamParser->>flushToolCalls: Flush pending tool-call fragments
  flushToolCalls->>flushToolCalls: Validate name, arguments, ID, and function object
  flushToolCalls-->>OpenAIStreamParser: Return termination status for invalid calls
  OpenAIStreamParser-->>AdapterConsumer: Emit adapter error without tool_call_start or done
Loading

Possibly related PRs

Suggested labels: review-ready

Suggested reviewers: ingwannu, wibias

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes planning documents for unrelated issues #1503, #1497, #1409, and #1419, which are outside the #1514 implementation scope. Remove unrelated campaign documents or move them to a separate PR; retain only #1514 planning, implementation, and regression tests.
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: refusing to emit streamed OpenAI tool calls without usable names.
Linked Issues check ✅ Passed The implementation and tests address issue #1514 by rejecting unnamed calls, preserving fail-closed behavior, and covering malformed and terminal stream cases.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/1514-unnamed-tool-call

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 15c6b53ca5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 927 to +930
for (const call of closeToolCalls()) {
// Ingest already proved `name` is a string; the typeof guard keeps this branch
// total so a future ingest change cannot turn a malformed name into a throw.
if (typeof call.name !== "string" || call.name.trim().length === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevalidate the pending batch before emitting calls

When a parallel batch contains a valid named call before an unnamed call, this loop emits the first call's complete tool_call_start/delta/end sequence before discovering the invalid entry. The bridge immediately converts that sequence into a completed output item, so the subsequent error cannot retract it and the client sees an issued tool call on a failed turn. Scan the snapshot for unnamed calls before yielding any events, then either fail the entire batch or emit all calls.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

Full-suite result, as promised in the description.

bun run test on the Linux runner (Bun 1.3.14, clean checkout of this branch at 15c6b53ca):

11310 pass
0 fail
Ran 11321 tests across 698 files. [410.08s]
EXIT=0

All four Linux CI shards are green here as well.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@devlog/_plan/260812_five_bug_fix_campaign/010_phase1_issue1514_empty_tool_name.md`:
- Around line 78-82: Add a regression test for the unnamed-tool-call error path
around closeToolCalls(): create at least two pending tool calls, trigger the
empty tool-name validation failure, and assert that every call’s budget
reservation is released, including calls after the offending one. Preserve the
existing early-return behavior while verifying the final closeToolCalls() state.

In
`@devlog/_plan/260812_five_bug_fix_campaign/030_phase3_issue1497_usage_range_truncation.md`:
- Around line 1-5: Mark this plan as superseded by adding a prominent
“SUPERSEDED — see 031” banner near the document heading. Update the acceptance
and delivery sections to clearly identify them as historical only, and remove or
qualify any current target language, including the `rangeFullyCovered` contract
and “Closes `#1497`” conclusion, so implementers follow
`031_phase3_revised_usage_coverage.md` instead.

In
`@devlog/_plan/260812_five_bug_fix_campaign/031_phase3_revised_usage_coverage.md`:
- Around line 54-58: Define a null-safe fallback for the dashboard’s
truncated-history disclosure when historyTruncated is true and snapshot.entries
is empty, so null snapshotWindowStart and snapshotWindowEnd never produce
invalid dates or blank text. Update the range control and Available history
label logic in the affected UI flow to use the fallback, and add coverage for
the empty-snapshot case while preserving the retained-window label when dates
exist.

In
`@devlog/_plan/260812_five_bug_fix_campaign/040_phase4_issue1409_context_window_overrides.md`:
- Around line 81-95: Rewrite the field-ownership rule in the “Field-ownership
matrix” section so carry-over is based on fields that are user-editable and
absent from ProviderPayload, without requiring registry seeding. State
separately that registry-only fields must be excluded, while preserving the
existing carry-over entries and matrix decisions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3d575723-d5f1-4e36-a42a-632b75889e54

📥 Commits

Reviewing files that changed from the base of the PR and between 59da369 and 15c6b53.

📒 Files selected for processing (14)
  • devlog/_plan/260812_five_bug_fix_campaign/000_research.md
  • devlog/_plan/260812_five_bug_fix_campaign/001_external_contract_evidence.md
  • devlog/_plan/260812_five_bug_fix_campaign/002_audit_round1_synthesis.md
  • devlog/_plan/260812_five_bug_fix_campaign/003_audit_round2_synthesis.md
  • devlog/_plan/260812_five_bug_fix_campaign/010_phase1_issue1514_empty_tool_name.md
  • devlog/_plan/260812_five_bug_fix_campaign/020_phase2_issue1503_google_thought_text.md
  • devlog/_plan/260812_five_bug_fix_campaign/030_phase3_issue1497_usage_range_truncation.md
  • devlog/_plan/260812_five_bug_fix_campaign/031_phase3_revised_usage_coverage.md
  • devlog/_plan/260812_five_bug_fix_campaign/040_phase4_issue1409_context_window_overrides.md
  • devlog/_plan/260812_five_bug_fix_campaign/050_phase5_issue1419_tls_crash_survivability.md
  • devlog/_plan/260812_five_bug_fix_campaign/051_phase5_revised_issue1419_disposition.md
  • src/adapters/openai-chat.ts
  • tests/openai-chat-eof.test.ts
  • tests/openai-chat-parallel-stream.test.ts

Comment on lines +78 to +82
`closeToolCalls()` is called before the check, so budget reservations for every
pending call are released on the error path exactly as they are on the success
path — including the calls after the offending one, which the early `return`
skips emitting but which `closeToolCalls()` has already closed. The `finally`
block's second `closeToolCalls()` remains a no-op safety net.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add a regression for reservation release.

The plan states that closeToolCalls() releases every pending reservation before the early return. The acceptance criteria do not verify this behavior.

Create at least two pending tool calls, trigger the unnamed-call error, and assert that all reservations are released. Otherwise, a failed turn can retain budget and reject later calls.

Also applies to: 241-260

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@devlog/_plan/260812_five_bug_fix_campaign/010_phase1_issue1514_empty_tool_name.md`
around lines 78 - 82, Add a regression test for the unnamed-tool-call error path
around closeToolCalls(): create at least two pending tool calls, trigger the
empty tool-name validation failure, and assert that every call’s budget
reservation is released, including calls after the offending one. Preserve the
existing early-return behavior while verifying the final closeToolCalls() state.

Comment on lines +1 to +5
# 030 — Phase 3 (#1497): `30d` and `all` must not silently share one byte tail

Depends on: nothing in 010/020 (disjoint files). Ordered here because it is the
first phase that changes a **response contract** rather than an adapter's
internal event stream, so it needs the adapter phases settled first to keep each

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Mark this plan as superseded.

031_phase3_revised_usage_coverage.md rejects rangeFullyCovered because the oldest retained request-start timestamp cannot prove coverage of the dropped prefix. This file still presents that field as the target and ends with Closes #1497``.

Add a prominent SUPERSEDED — see 031 banner and mark the old acceptance and delivery sections as historical only. Otherwise, an implementer can reintroduce the unsound completeness claim.

Also applies to: 96-105, 177-180

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@devlog/_plan/260812_five_bug_fix_campaign/030_phase3_issue1497_usage_range_truncation.md`
around lines 1 - 5, Mark this plan as superseded by adding a prominent
“SUPERSEDED — see 031” banner near the document heading. Update the acceptance
and delivery sections to clearly identify them as historical only, and remove or
qualify any current target language, including the `rangeFullyCovered` contract
and “Closes `#1497`” conclusion, so implementers follow
`031_phase3_revised_usage_coverage.md` instead.

Comment on lines +54 to +58
3. **Make the dashboard stop presenting a truncated view as the requested
range.** When `historyTruncated` is true, the range control shows that the
summary is computed over a bounded tail and names the retained window. The
`Available history` label specifically must stop implying completeness — it
is the more misleading of the two, because `30d` at least states a bound.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Define the UI fallback for a null snapshot window.

If historyTruncated is true while snapshot.entries is empty, snapshotWindowStart and snapshotWindowEnd are null, but the GUI must still disclose the bounded history.

Specify a null-safe fallback label and test it. Otherwise, the UI can render an invalid date or an empty disclosure.

Also applies to: 86-94, 96-107

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 54-54: Ordered list item prefix
Expected: 1; Actual: 3; Style: 1/1/1

(MD029, ol-prefix)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@devlog/_plan/260812_five_bug_fix_campaign/031_phase3_revised_usage_coverage.md`
around lines 54 - 58, Define a null-safe fallback for the dashboard’s
truncated-history disclosure when historyTruncated is true and snapshot.entries
is empty, so null snapshotWindowStart and snapshotWindowEnd never produce
invalid dates or blank text. Update the range control and Available history
label logic in the affected UI flow to use the fallback, and add coverage for
the empty-snapshot case while preserving the retained-window label when dates
exist.

Comment on lines +81 to +95
## Field-ownership matrix (locked at plan time, audit blocker B10)

A field needs carry-over when all three hold: (a) user-editable somewhere in the
product, (b) absent from `ProviderPayload`, (c) registry-seeded by
`enrichProviderFromRegistry`. Carrying over a registry-only field would freeze
stale registry metadata into user config — the opposite failure — so (a) must be
confirmed by reading the editing UI, never assumed.

| Field | User-editable | In POST payload | Registry-seeded | Carry over? |
|---|---|---|---|---|
| `apiKeyPool` | yes | no | no | already carried |
| `modelCosts` | yes | no | no | already carried |
| `modelContextWindows` | yes (`Models.tsx:476`) | **no** | yes (`derive.ts:405`) | **add** |
| `contextWindow` | yes (`Models.tsx:475`) | **no** | yes (`derive.ts:404`) | **add** |
| `modelInputModalities`, `modelMaxOutputTokens`, `modelReasoningEfforts`, `modelDefaultReasoningEfforts`, `modelReasoningEffortMap`, `reasoningEffortMap`, `noVisionModels`, `noReasoningModels`, `noTemperatureModels`, `defaultMaxOutputTokens` | confirm against the real editing surfaces in B | no | yes | only where (a) is confirmed |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Clarify the field-ownership rule.

Lines 83-87 state that carry-over requires registry seeding. Lines 91-92 list apiKeyPool and modelCosts as existing carry-overs even though they are not registry-seeded.

Rewrite the rule as: carry over user-editable fields absent from ProviderPayload; separately exclude fields that are registry-only. This keeps the rule consistent with the matrix and prevents future audits from omitting user-owned fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@devlog/_plan/260812_five_bug_fix_campaign/040_phase4_issue1409_context_window_overrides.md`
around lines 81 - 95, Rewrite the field-ownership rule in the “Field-ownership
matrix” section so carry-over is based on fields that are user-editable and
absent from ProviderPayload, without requiring registry seeding. State
separately that registry-only fields must be excluded, while preserving the
existing carry-over entries and matrix decisions.

@lidge-jun
lidge-jun merged commit 4e0ffb2 into dev Aug 12, 2026
27 checks passed
@lidge-jun
lidge-jun deleted the codex/1514-unnamed-tool-call branch August 12, 2026 11:09
lidge-jun added a commit that referenced this pull request Aug 12, 2026
…idation

Rebase follow-up on top of @Ingwannu's diagnostic commit. #1531 landed
between this PR being written and being rebased, and it moved the
validation the diagnostic describes.

The streamed path now validates function/name/arguments/id at ingest
rather than only checking container and member shapes, so the diagnostic
no longer skips per-member inspection in stream mode, and the three new
ingest rejections log through the same helper. Blank and whitespace-only
names are now rejected on the buffered path; reporting that as
name_invalid would claim a type problem for a correctly-typed value, so
it gets its own reason code.

A diagnostic that describes a boundary the code no longer has is worse
than none: it would send provider-compatibility work after the wrong
shape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant