Skip to content

Restore Kimi-compatible streams when providers send null deltas - #3815

Merged
kwakayama merged 4 commits into
mainfrom
fix/kimi-null-reasoning-delta
Aug 17, 2026
Merged

Restore Kimi-compatible streams when providers send null deltas#3815
kwakayama merged 4 commits into
mainfrom
fix/kimi-null-reasoning-delta

Conversation

@kwakayama

@kwakayama kwakayama commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Problem

Every veryfront-cloud/moonshotai/kimi-k2.6 and kimi-k2.5 run failed on the first successful stream chunk because the gateway encodes absent optional delta fields as null. The OpenAI-compatible parser accepted only strings or omitted fields, so Kimi reasoning failed immediately; captured tool continuations then exposed the same defect for id, type, function.name, and function.arguments.

Fix

Treat null as absence for optional reasoning, role, and tool-call continuation fields. Preserve the existing fail-closed contract for wrong types, non-assistant roles, tool-call identity changes, cross-index ID reuse, argument ordering, incomplete calls, and stream resource bounds.

The regression tests replay the captured Kimi text and tool streams. The tool fixture assembles functions.list_events:0 across null-bearing continuation fragments into {"date":"2026-08-18"} and finishes as tool-calls; adversarial fixtures prove null cannot launder identities or bypass completeness checks.

Release

Bumps Veryfront to 0.1.1241 and synchronizes the generated hydration runtime version.

Fixes veryfront/veryfront-issue-inbox#542

Constraint: Moonshot encodes absent optional fields as null
Rejected: Drop stream validation | would allow wrong types and malformed tool-call continuations through the OpenAI-compatible parser
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Treat null as absence only; wrong types and identity, ordering, and completeness violations must still reject
Tested: Captured text and tool fixtures; ext-llm-openai 10 tests and 156 steps; targeted check, lint, format, and diff verification; full pre-push 3,921 tests and 29,965 steps
Not-tested: Post-release live staging Kimi run
Related: veryfront/veryfront-issue-inbox#542

…ible streams

The Veryfront Cloud Moonshot gateway encodes absent optional delta fields as
`null` rather than omitting them. Every kimi-k2.6 / kimi-k2.5 stream opens with
`{"reasoning_content":null,"role":"assistant","content":""}` and closes with
`{"reasoning_content":null}` alongside the finish reason.

The Chat Completions SSE parser rejected any non-string `reasoning_content`,
so it threw on the first chunk of every request:

    veryfront-cloud request failed: invalid successful stream
    (reasoning delta was malformed)

Kimi has been unusable through veryfront-cloud since #3235 hardened this
validation on 2026-08-02. That change turned a type-narrowing check into a hard
one without carrying over null tolerance, which `content`, `refusal`,
`tool_calls`, and `finish_reason` all already have in the same function.

Treat null as "not present on this chunk" for `reasoning_content` and `role`.
Genuinely wrong types (number, object, a non-assistant role) still reject the
stream.

Tests replay the verbatim chunk shape captured from the live gateway.

Fixes veryfront/veryfront-issue-inbox#542
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@kojiwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Limit details: You’ve used all 1 included review currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 17512890-26ab-43fe-a891-5360b64e3f49

📥 Commits

Reviewing files that changed from the base of the PR and between d84a7d4 and 38d09bf.

⛔ Files ignored due to path filters (1)
  • src/html/hydration-script-builder/hydration-runtime.generated.ts is excluded by !**/*.generated.*
📒 Files selected for processing (4)
  • deno.json
  • extensions/ext-llm-openai/src/openai-chat-stream.test.ts
  • extensions/ext-llm-openai/src/openai-chat-stream.ts
  • src/utils/version-constant.ts

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.

@github-actions

Copy link
Copy Markdown

📦 Client bundle boundary

Entrypoint Modules Source size Server leaks
src/index.client.ts 325 1937 KiB ✅ 0

A server module in a client graph aborts hydration in the browser. New leaks fail CI; known leaks are tracked in scripts/lint/client-bundle-baseline.json to burn down.

The same gateway that nulls `reasoning_content` also repeats tool-call
identity fields as `null` on continuation fragments. Captured from
`kimi-k2.6` with a forced tool call:

    {"id":"functions.list_events:0","index":0,"type":"function",
     "function":{"name":"list_events","arguments":"{\"date\":\""}}
    {"id":null,"index":0,"type":"function",
     "function":{"name":null,"arguments":"2026-08"}}
    {"id":null,"index":0,"type":null,
     "function":{"name":null,"arguments":"-18\"}"}}

Only the opening fragment carries real values. With the reasoning fix alone
the stream still died on the second fragment with "tool call id was
malformed", so any tools-enabled Kimi agent stayed broken -- which is every
agent that does real work.

Treat null as absent for `id`, `type`, `function.name`, and
`function.arguments`, so the fragment merges into the call being assembled.
The downstream merge logic already gates on positive `typeof === "string"`
checks, so null flows through without contributing. Wrong types still reject.

Replaying the captured tool stream now yields the full sequence:
reasoning-start, 13 reasoning-delta, reasoning-end, tool-input-start,
3 tool-input-delta, tool-call({"date":"2026-08-18"}), finish(tool-calls).
@kwakayama

Copy link
Copy Markdown
Contributor Author

Second commit: the tool-call path was still broken

Review flagged that tool-call deltas kept the same null intolerance this PR was fixing elsewhere. That turned out to be real, and it meant the original fix would not have unblocked any agent that actually uses tools — including the calendar agent from the bug report (toolCount: 8).

Captured live from the gateway with tool_choice: "required":

{"id":"functions.list_events:0","index":0,"type":"function",
 "function":{"name":"list_events","arguments":"{\"date\":\""}}
{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"2026-08"}}
{"id":null,"index":0,"type":null,    "function":{"name":null,"arguments":"-18\"}"}}

Only the opening fragment carries id and function.name; every continuation repeats them as null. Same serializer behaviour as reasoning_content.

With the first commit alone, replaying that captured stream still died:

ProviderRequestError: invalid successful stream (tool call id was malformed)

So id, type, function.name, and function.arguments now treat null as absent too. The downstream merge logic already gates on positive typeof === "string" checks, so null flows through without contributing anything. Wrong types still reject, locked in by a new negative test covering id: 42, name: 7, arguments: 5, and type: "not_function".

Both captured streams, replayed through the parser

text stream:  {"reasoning-start":1,"reasoning-delta":23,"reasoning-end":1,"finish":1}
              finish: length, usage complete

tool stream:  {"reasoning-start":1,"reasoning-delta":13,"reasoning-end":1,
               "tool-input-start":1,"tool-input-delta":3,"tool-call":1,"finish":1}
              tool-call input: {"date":"2026-08-18"}
              finish: {"unified":"tool-calls","raw":"tool_calls"}, usage complete

deno test extensions/ext-llm-openai/ — 10 passed (155 steps), 0 failed.

Thanks to both reviewers for catching this; it was the difference between the model parsing and the model actually working.

Null must behave exactly like an omitted field. These assert it cannot be
used to change a call's identity mid-stream, collide at another index, skip
the id/name ordering rule, or emit a tool call that never received an id,
name, or arguments -- all verified to still reject.
Review noted the previous commit message claimed this case was locked while
no test covered it. Add the missing one rather than soften the claim: a null
fragment at a second index must not let a reused id slip past the
index-collision guard.
@kojiwakayama

Copy link
Copy Markdown
Contributor

Exact-head review requested for f0379f51fc96fa04083973ef7676211707609d75. The regression fixture and nullable continuation handling preserve the parser's identity, ordering, type, and completeness guards. Local exact-head focused verification passes: 1 test / 15 steps, and git diff --check is clean.

@codex review
@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

requested='f0379f51fc96fa04083973ef7676211707609d75'
actual="$(git rev-parse HEAD)"
printf 'HEAD=%s\nREQUESTED=%s\n' "$actual" "$requested"
git status --short
git show --no-ext-diff --format=fuller --stat "$requested"
printf '\n--- DIFF ---\n'
git diff --no-ext-diff "${requested}^" "$requested" --check
git diff --no-ext-diff "${requested}^" "$requested" -- \
  'extensions/ext-llm-openai/**' \
  'deno.json' \
  'src/utils/version-constant.ts'

printf '\n--- CHANGED FILE OUTLINES ---\n'
git diff --name-only "${requested}^" "$requested" | while IFS= read -r file; do
  case "$file" in
    *.ts|*.tsx|*.js|*.jsx)
      printf '\n### %s\n' "$file"
      ast-grep outline "$file" --items all --view compact || true
      ;;
  esac
done

Length of output: 4570


🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit='f0379f51fc96fa04083973ef7676211707609d75'

printf '%s\n' '--- COMMIT ANCESTRY AND RANGE ---'
git log --no-ext-diff --oneline --decorate -12 "$commit"
base="$(git merge-base "$commit" HEAD)"
printf 'MERGE_BASE_WITH_CHECKED_OUT_HEAD=%s\n' "$base"
printf '%s\n' '--- FILES CHANGED SINCE THE EARLIEST RELATED COMMIT ---'
git log --no-ext-diff --format='%H %s' --reverse "${commit}~6..${commit}"
git diff --no-ext-diff --name-status "${commit}~6" "$commit"

printf '%s\n' '--- EXACT-COMMIT PARSER OUTLINE ---'
git show "${commit}:extensions/ext-llm-openai/src/openai-chat-stream.ts" > /tmp/openai-chat-stream.ts
ast-grep outline /tmp/openai-chat-stream.ts --items all --view signatures

printf '%s\n' '--- NULLABLE DELTA VALIDATION AND TOOL-CALL MERGE LOGIC ---'
git show "${commit}:extensions/ext-llm-openai/src/openai-chat-stream.ts" |
  nl -ba |
  sed -n '130,410p'

printf '%s\n' '--- EXACT-COMMIT TEST CONTEXT AND HELPERS ---'
git show "${commit}:extensions/ext-llm-openai/src/openai-chat-stream.test.ts" |
  nl -ba |
  sed -n '1,110p;500,730p'

printf '%s\n' '--- NULL-RELATED TEST CASES AT EXACT COMMIT ---'
git grep -n -i -C 3 'null' "$commit" -- \
  'extensions/ext-llm-openai/src/openai-chat-stream.test.ts' \
  'extensions/ext-llm-openai/src/openai-chat-stream.ts' || true

Length of output: 5085


🏁 Script executed:

#!/bin/bash
set -euo pipefail

base='d84a7d4af427e01fddb7e25097c4aecb3173fecc'
commit='f0379f51fc96fa04083973ef7676211707609d75'
parser='extensions/ext-llm-openai/src/openai-chat-stream.ts'
testfile='extensions/ext-llm-openai/src/openai-chat-stream.test.ts'

printf '%s\n' '--- EXACT PR DIFF: PROVIDER PARSER AND TESTS ---'
git diff --no-ext-diff --check "$base" "$commit"
git diff --no-ext-diff --unified=80 "$base" "$commit" -- "$parser" "$testfile"

printf '%s\n' '--- PARSER: VALIDATION, TOOL-CALL STATE, AND FINALIZATION ---'
git show "${commit}:${parser}" |
  sed -n '1,460p'

printf '%s\n' '--- TEST: HELPERS, NULL FIXTURES, AND NEARBY TOOL-CALL CASES ---'
git show "${commit}:${testfile}" |
  sed -n '1,160p;430,760p'

printf '%s\n' '--- EXACT-COMMIT SYMBOL REFERENCES ---'
git grep -n -E \
  'OpenAIStreamToolCallState|toolCallsByIndex|reasoning_content|delta\.role|tool call (id|type|name|arguments)|assertCompleteToolInput' \
  "$commit" -- "$parser" "$testfile" || true

Length of output: 60260


🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit='f0379f51fc96fa04083973ef7676211707609d75'
parser='extensions/ext-llm-openai/src/openai-chat-stream.ts'
testfile='extensions/ext-llm-openai/src/openai-chat-stream.test.ts'

printf '%s\n' '--- PARSER TAIL: STREAM COMPLETION AND TOOL-CALL EMISSION ---'
git show "${commit}:${parser}" | sed -n '440,590p'

printf '%s\n' '--- EXISTING TOOL-CALL IDENTITY, INDEX, AND COMPLETENESS TESTS ---'
git show "${commit}:${testfile}" | sed -n '190,430p'

printf '%s\n' '--- TEST NAMES AT EXACT COMMIT ---'
git show "${commit}:${testfile}" |
  grep -nE '^[[:space:]]*it\('

printf '%s\n' '--- DIFF WHITESPACE CHECK ---'
git diff --no-ext-diff --check 'd84a7d4af427e01fddb7e25097c4aecb3173fecc' "$commit"

Length of output: 12541


@kojiwakayama No findings from the exact-head static review of f0379f51fc96fa04083973ef7676211707609d75.

The nullable-field handling preserves the type, identity, ordering, and completeness guards. The regression tests cover the captured text and tool-call continuation shapes. The existing tests retain cross-index ID-reuse and resource-limit checks. git diff --check is clean.

Runtime test execution was not available in this sandbox.

⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 38d09bf0cf

ℹ️ 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".

@kojiwakayama

Copy link
Copy Markdown
Contributor

The failed Node job is the recurring scheduler-sensitive link observer test, not this PRs streaming change. I reproduced the test defect deterministically and opened the isolated test-only fix in #3816. I will keep this PR unmerged until #3816 lands and this exact head has a fresh fully green CI run.

@kojiwakayama kojiwakayama changed the title fix(provider): accept null reasoning and role deltas in OpenAI-compatible streams Restore Kimi-compatible streams when providers send null deltas Aug 17, 2026
Merged via the queue into main with commit ec7e612 Aug 17, 2026
62 of 63 checks passed
@kwakayama
kwakayama deleted the fix/kimi-null-reasoning-delta branch August 17, 2026 10:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants