Skip to content

[회귀] OpenAI Chat 스트림의 tool_calls id/name=null 후속 청크를 invalid tool calls로 거부 #1731

Closed

Description

@idguyst

Client or integration

Codex App의 qwen_review 서브에이전트

Provider or upstream service

Modal OpenAI-compatible Chat Completions endpoint, SGLang backend, model family Qwen/Qwen3.8-2.4T-A95B

공통 Modal gateway에서 endpoint hostname을 model ID로 쓰는 경로와 전용 endpoint의 /v1에서 정식 모델명을 쓰는 경로 모두 같은 스트림 형태를 반환했습니다. hostname-as-model 자체는 원인이 아닙니다.

OpenCodex version

  • 2.13.0: 동일한 후속 청크의 null 값을 허용하여 정상 동작
  • 2.14.0 preview/stable: 회귀 시작
  • 2.17.0: 실제 서브에이전트 호출에서 재현
  • 2.19.0: 최신 소스에도 같은 엄격 검사 조건이 남아 있음

Endpoint or capability

OpenAI-compatible /v1/chat/completions, streaming tool calls

stream: false 응답에서는 id와 function.name이 유효한 문자열이며 정상 동작합니다.

Current behaviour

SGLang 스트림의 첫 tool-call delta에는 유효한 id와 function.name이 있습니다. 이후 arguments continuation delta에서는 이미 보낸 필드를 null로 보냅니다.

OpenCodex 2.14.0 이후 parser는 후속 청크의 null을 “문자열이 아닌 값”으로 판단하여 전체 도구 호출을 무효화합니다. Qwen 서브에이전트는 첫 도구 호출을 실행하기 전에 종료되어 “응답 없음”처럼 보입니다.

Expected behaviour

  • continuation delta의 id: null, function.name: null, function.arguments: null은 “이번 delta에는 새 값이 없음”으로 취급해야 합니다.
  • 첫 청크에서 받은 기존 값은 유지해야 합니다.
  • 스트림 종료 시점까지 function name을 한 번도 받지 못한 호출은 기존처럼 거부해야 합니다. fail-closed 최종 검증은 유지해야 합니다.

Minimal redacted request or reproduction

# 첫 tool-call delta
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_redacted","function":{"name":"exec","arguments":""}}]}}]}

# arguments continuation delta
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":null,"function":{"name":null,"arguments":"...chunk..."}}]}}]}

# 비교 절차
1. 위 스트림을 OpenCodex 2.13.0과 2.14.0 이상에 입력
2. 동일한 tool call이 실행되는지 확인
3. stream=false 응답과 비교

Actual response or error

stream disconnected before completion:
Provider stream error: upstream response contained invalid tool calls

OpenCodex 2.13.0: tool call 실행 및 최종 응답 성공
OpenCodex 2.17.0: invalid tool calls로 즉시 종료
reasoning effort=max: 동일 실패
reasoning effort=xhigh: 6/6 동일 실패
stream=false: 유효한 string 필드로 성공

reasoning effort나 Qwen 모델의 도구 선택 문제가 아니라 streaming parser 호환성 문제로 보입니다.

Upstream documentation

Suggested mapping or implementation notes

회귀를 도입한 것으로 보이는 커밋:

2.19.0에도 남아 있는 검사:

rawName !== undefined && typeof rawName !== "string"
rawArguments !== undefined && typeof rawArguments !== "string"
tc.id !== undefined && typeof tc.id !== "string"

중간 delta에서 null을 unset과 동일하게 처리하도록 다음처럼 바꾸고, 스트림 종료 후 name 존재 검증은 유지하는 방법을 제안합니다.

rawName != null && typeof rawName !== "string"
rawArguments != null && typeof rawArguments !== "string"
tc.id != null && typeof tc.id !== "string"

회귀 테스트:

  1. 첫 delta에 유효한 name/id, 후속 delta에 null → 성공
  2. 모든 delta에서 name이 null/누락 → 기존처럼 실패

Additional context and attachments

  • #1483은 표면 오류는 비슷하지만 유효한 첫 청크 뒤 id:null / name:null continuation으로 발생한 버전 회귀를 특정하지 않습니다.
  • 관련 PR: fix(openai-chat): tolerate null tool-call padding #1468, fix(openai-chat): refuse to emit an unnamed streamed tool call #1531. 현재 2.19.0 소스에는 위 nested field 검사가 남아 있어 이 재현은 여전히 실패합니다.
  • Windows에서도 같은 서브에이전트 실패가 관찰됐지만, 결정적 프로토콜 로그는 macOS에서 캡처했습니다. parser 코드는 플랫폼 공통입니다.
  • 원본 JSONL과 전체 터미널 스크린샷에는 endpoint 식별자, 개인 경로, 요청 ID가 포함될 수 있어 올리지 않고, 재현에 필요한 SSE 구조와 오류 문구만 비식별 텍스트로 첨부했습니다.

Checks

  • I searched existing provider and compatibility issues.
  • The request and response were redacted.
  • The expected behaviour is based on an upstream specification or a concrete client requirement.

Activity

  1. coderabbitai commented on Aug 15, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #302 - feat(kiro): harden completion and transport integration [merged]
    #363 - fix(chat-completions): emit tool call arguments once [merged]
    #1361 - fix(responses): restore DeepSeek streaming and routed code-mode tools [merged]
    #1468 - fix(openai-chat): tolerate null tool-call padding [merged]
    #1531 - fix(openai-chat): refuse to emit an unnamed streamed tool call [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. github-actions commented on Aug 15, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  3. Ingwannu commented on Aug 15, 2026

    @Ingwannu
    Owner

    Confirmed on current dev. The streamed tool-call parser still treats continuation padding such as id: null / function.name: null as an invalid non-string value even when an earlier delta already supplied the valid field.

    I will make the compatibility change narrowly: null will be treated the same as an omitted field only while accumulating deltas. Non-null non-string values will still fail closed, and the existing final validation will continue rejecting any completed tool call that never acquired a valid function name.

    I will add regressions for both boundaries:

    • valid first id/name followed by null continuation padding succeeds;
    • a call whose name remains missing or null through completion is still rejected.
  4. Ingwannu commented on Aug 15, 2026

    @Ingwannu
    Owner

    Implemented the narrow fix in #1754.

    The patch treats only null/omitted continuation fields as absent while retaining the existing fail-closed behavior for non-null non-string values and for a completed tool call that never receives a valid function name.

    Local validation on current dev:

    • focused stream assembly tests: 14 passed, including the reported null-padding shape and the unnamed-call negative case
    • typecheck passed
    • privacy scan passed
    • diff check passed

    I will keep the issue linked to the PR so it closes only when the fix actually lands.

  5. added 2 commits that reference this issue on Aug 15, 2026
    1e90645
    906533b
  6. lidge-jun commented on Aug 15, 2026

    @lidge-jun
    Owner

    Fixed on dev as the squash of #1754.

    The stream parser now treats null and undefined alike as absent for a streamed id/name/arguments, so a continuation delta that repeats already-sent fields as null no longer aborts the turn. Any other non-string value still fails closed.

    One addition beyond the original patch: the diagnostics path used its own null predicate, so accepted padding was still being classified as the invalid delta — a payload with real corruption later in the array would have reported the harmless padding instead and aimed compatibility work at the wrong chunk. The accumulator and the diagnostic now share one predicate, with a regression proving the diagnostic points at the genuinely malformed entry.

    Verified on the remote validation host: 97 pass / 0 fail across tests/openai-chat-parallel-stream.test.ts, tests/openai-chat-hardening.test.ts, tests/openai-chat-eof.test.ts.

    Thanks @Ingwannu.

  7. added a commit that references this issue on Sep 17, 2026
    a5269d0
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 workingproviderProvider adapters, OpenAI-compat presets, upstream API quirksprovider-compatibilityProvider compatibility reportstoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions