#1538

Bug: openai-chat-completions fails with InvalidChunk when the finish chunk carries an empty legacy `function_call` ({"name":"","arguments":""})

Openyxd-ymopened 14d ago

Summary

A custom openai-chat-completions connection reports fx: InvalidChunk on every reply. The full answer prints first; the failure happens on the final chunk.

The server's finish chunk includes the deprecated delta.function_call field populated with empty strings:

"delta": {"content": "", "role": "assistant", "function_call": {"name": "", "arguments": ""}},
"finish_reason": "stop"

fx appears to treat any non-null function_call as a legacy function call and rejects it because name is empty, which aborts the stream. The same stream succeeds when function_call is null or omitted.

This is the same symptom as #1173 but a different trigger: there the offending field is model: ""; here model is populated and the offending field is the empty function_call.

Environment

  • fx version: v0.0.16 (latest release), build revision 481104fdbc24, stable channel
  • Platform: macOS 27.0.1, arm64
  • Connection: custom openai-chat-completions provider, auth: none
  • Real-world source: a local OpenAI-compatible gateway (Magpie) forwarding to an upstream (WorkBuddy) serving deepseek-v4.1-flash and glm-5.3. Both models emit this finish chunk on every streamed response. Other upstreams behind the same gateway (Anthropic, Codex, Gemini) omit function_call and work fine.

Minimal reproduction (no external service needed)

server.py is a ~40-line stdlib-only Python server that streams three chunks plus [DONE]. Its mode argument controls only the final delta's function_call.

server.py
#!/usr/bin/env python3
"""Minimal OpenAI-compatible SSE server reproducing fx InvalidChunk on an empty function_call.

Usage: python3 server.py [bad|null|omit]   (default: bad)
"""
import json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer

MODE = sys.argv[1] if len(sys.argv) > 1 else "bad"
MODEL = "repro-model"

def chunk(delta, finish=None, usage=None):
    return {"id": "chatcmpl-repro", "object": "chat.completion.chunk", "created": 1793341426,
            "model": MODEL, "choices": [{"index": 0, "delta": delta, "finish_reason": finish, "logprobs": None}],
            "usage": usage}

class H(BaseHTTPRequestHandler):
    def do_POST(self):
        self.rfile.read(int(self.headers.get("content-length", 0)))
        final_delta = {"content": "", "role": "assistant"}
        if MODE == "bad":
            final_delta["function_call"] = {"name": "", "arguments": ""}
        elif MODE == "null":
            final_delta["function_call"] = None
        events = [
            chunk({"role": "assistant", "content": ""}),
            chunk({"content": "Hi!"}),
            chunk(final_delta, finish="stop",
                  usage={"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}),
        ]
        body = "".join(f"data: {json.dumps(e, separators=(',', ':'))}\n\n" for e in events) + "data: [DONE]\n\n"
        data = body.encode()
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)
    def log_message(self, *a): pass

HTTPServer(("127.0.0.1", 3499), H).serve_forever()

settings.json:

{
  "provider": "repro",
  "providers": {
    "repro": {
      "protocol": "openai-chat-completions",
      "base_url": "http://127.0.0.1:3499/v1",
      "auth": { "type": "none" },
      "model_metadata": {
        "repro-model": { "context_window": 128000, "max_output_tokens": 8192, "supports_tool_use": true }
      }
    }
  },
  "models": { "repro": "repro-model" }
}

Run:

mkdir -p /tmp/fxrepro/home/.fx /tmp/fxrepro/ws
cp settings.json /tmp/fxrepro/home/.fx/settings.json
python3 server.py bad &        # or: null | omit
cd /tmp/fxrepro/ws && HOME=/tmp/fxrepro/home fx ask "say hi"

Results:

server.py modefinal delta.function_callfx result
bad{"name":"","arguments":""}Hi! then fx: InvalidChunk, exit 1
nullnullHi!, exit 0
omitfield absentHi!, exit 0

Exact stream sent in bad mode

data: {"id":"chatcmpl-repro","object":"chat.completion.chunk","created":1793341426,"model":"repro-model","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null,"logprobs":null}],"usage":null}

data: {"id":"chatcmpl-repro","object":"chat.completion.chunk","created":1793341426,"model":"repro-model","choices":[{"index":0,"delta":{"content":"Hi!"},"finish_reason":null,"logprobs":null}],"usage":null}

data: {"id":"chatcmpl-repro","object":"chat.completion.chunk","created":1793341426,"model":"repro-model","choices":[{"index":0,"delta":{"content":"","role":"assistant","function_call":{"name":"","arguments":""}},"finish_reason":"stop","logprobs":null}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}

data: [DONE]

What I ruled out

Against the real gateway, I captured the wire traffic and replayed it to fx from a local server while changing one variable at a time:

  • HTTP framing is not the cause. The chunked transfer encoding validated end to end, and serving the same body with Content-Length and no chunking still fails with InvalidChunk. The error name points at the transport layer, but the rejection is SSE/JSON-level.
  • Removing the leading : heartbeat SSE comment, merging chunks, dropping data: [DONE], and trimming the extra non-standard usage fields (prompt_cache_hit_tokens, credit, etc.) each still failed.
  • Changing only function_call to null or removing it made the identical stream succeed.

Expected behavior

Treat a function_call (and, by the same logic, a tool_calls entry) whose name and arguments are both empty as absent, rather than failing the stream. This shape typically comes from servers that serialize zero-valued structs instead of omitting them. fx never requested legacy function calling: its request contains tools (14 entries) and tool_choice, and no functions key.

Suggested fixes, in order of preference

  1. In the chunk decoder, normalize an all-empty delta.function_call to null before validation.
  2. More generally, ignore the deprecated function_call delta when the request did not use legacy functions, or log and skip a malformed delta instead of terminating the stream after content has already arrived.
  3. Include the offending field or chunk (redacted) in the InvalidChunk error or stream_error event. As with #1173, isolating this required a man-in-the-middle proxy and replay bisection.

Related

  • #1173: same InvalidChunk failure, triggered by model: "" (Bifrost)
  • #1334: a separate strict-parsing failure (IncompleteStream without data: [DONE])

Activity

0

No activity yet.