Repository navigation
fix(ai): decode OpenAI streaming responses with a stateful UTF-8 decoder - #360
Merged
marandaneto merged 1 commit intoOct 29, 2026
Merged
Conversation
TrackingStream decoded each read with Encoding.UTF8.GetString, so a multibyte character split across two reads became U+FFFD in the captured output choices. Keep a Decoder across reads instead. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
marandaneto
approved these changes
Oct 29, 2026
marandaneto
left a comment
Member
There was a problem hiding this comment.
The code looks good; no qualifying findings. Verdict: correct.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
💡 Motivation and Context
TrackingStreaminPostHogOpenAIHandlerdecodes the streamed SSE response chunk by chunk withEncoding.UTF8.GetString(buffer, offset, count). When a multibyte UTF-8 character (accented letters, emoji, CJK) straddles two reads, each half is decoded on its own and becomesU+FFFD(�), so$ai_output_choicescontains corrupted text even though the stream passed to the caller is intact.The tracker now keeps a
Decoder(UTF8Encoding.GetDecoder()) for the lifetime of the stream, so a partial character is buffered until its remaining bytes arrive.💚 How did you test it?
Added
SendAsyncDecodesMultibyteCharactersSplitAcrossStreamReadsinPostHogOpenAIHandlerTests: the SSE body (Café,😀 ñ) is served one byte per read, so every multibyte character is split, and the captured$ai_output_choicescontent must beCafé 😀 ñ. It usesOneByteAtATimeStream, a smallMemoryStreamsubclass in the test file. Edge cases covered by the one-byte reads: a 2-byte accented letter, a 4-byte emoji, and a character split with the rest of a line still buffered. A changeset forPostHog.AI(patch) is included.The test was run locally in a .NET SDK container (
dotnet test tests/PostHog.AI.Tests/PostHog.AI.Tests.csproj --filter FullyQualifiedName~SendAsyncDecodesMultibyteCharactersSplitAcrossStreamReads): it fails onmain, where the captured content no longer matches, and passes with the change (Failed: 0, Passed: 1). The rest of the suite was left to CI. The handler was not exercised against the live OpenAI API, since that needs credentials; the test drives the real handler pipeline end to end throughHttpClientwith a byte-at-a-time response stream.📝 Checklist
If releasing new changes
.changeset/utf8-openai-stream-decoder.md)🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Prepared with an AI coding assistant (Claude Code); the work was directed by the PR author, who owns the change. PostHog/sdk-specs was checked as AGENTS.md asks: the
capture-aispec says nothing about SSE decoding or character encoding, so there is no cross-SDK contract this change diverges from. The decoder choice is a plainDecoderrather than buffering raw bytes until a line boundary: it keeps the existing char-based line/SSE buffering unchanged and is the standard way to decode a UTF-8 stream incrementally.