Skip to content

fix(auditlog): reconstruct streamed tool calls#317

Merged
SantiagoDePolonia merged 4 commits intomainfrom
fix/multimodal-2
May 10, 2026
Merged

fix(auditlog): reconstruct streamed tool calls#317
SantiagoDePolonia merged 4 commits intomainfrom
fix/multimodal-2

Conversation

@SantiagoDePolonia
Copy link
Copy Markdown
Contributor

@SantiagoDePolonia SantiagoDePolonia commented May 10, 2026

Summary

  • reconstruct streamed chat audit responses per choice instead of content-only
  • accumulate streamed delta.tool_calls id/type/name and concatenated arguments
  • preserve provider metadata and trailing usage in captured streaming response bodies

Verification

  • go test ./internal/auditlog ./internal/streaming ./internal/responsecache
  • go test ./...
  • commit hook: make test-race, go mod tidy, hot-path performance guard, make lint
  • live Bedrock repro with amazon.nova-micro-v1:0: audit row stored tool_calls[0] as get_weather with {"city":"Paris"}, finish_reason=tool_calls, and usage total tokens

Summary by CodeRabbit

  • Tests

    • Added comprehensive unit tests for streamed chat responses: text-only, tool-call-only, interleaved text/tool streams, parallel tool-call aggregation, sparse-index fallbacks, skipping incomplete tool calls, trailing usage capture, and truncation behavior when budget is exhausted.
  • Improvements

    • Streaming response builder now emits richer multi-choice chat outputs with preserved metadata, deterministic tool-call ordering, robust handling of streamed tool-call arguments, and improved output-text aggregation.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

📝 Walkthrough

Walkthrough

Builds richer ChatCompletion responses from SSE events: captures metadata, supports multiple choices, accumulates per-choice content and tool calls with truncation-aware appends, sorts choices/tool_calls deterministically, and adds unit tests for streamed reconstructions.

Changes

Streaming Chat Completion Response Enhancement

Layer / File(s) Summary
Response Builder Types
internal/auditlog/stream_wrapper.go
New per-index state types (streamChatChoiceState, streamChatToolCallState); streamResponseBuilder adds metadata fields (ID, Model, Provider, SystemFingerprint, Created, Usage), Choices map and OutputText; import sort added.
Utility Helpers
internal/auditlog/stream_wrapper.go
Helpers copyAnyMap, jsonNumberToInt/jsonNumberToInt64, nonEmptyString, and lazy initializers (chatChoice, toolCall) added for cloning, numeric coercion, trimming, and per-index init.
Response Assembly
internal/auditlog/stream_wrapper.go
buildChatCompletionResponse emits top-level metadata and delegates to buildChatChoices; buildChatChoices and buildStreamChatToolCalls deterministically sort and render choices/tool_calls, skip incomplete calls, and apply message.content rules (content string, nil when tool_calls exist without content, otherwise empty string).
Stream Event Parsing
internal/auditlog/stream_observer.go
parseChatCompletionEvent rewritten to set metadata, iterate all choices, update per-choice role/content/finish_reason, parse tool_calls deltas into per-choice tool-call state with index-fallback helpers (defaultChatChoiceIndex, defaultToolCallIndex), and accumulate function.arguments with truncation awareness.
Text Append Refactor
internal/auditlog/stream_observer.go
appendStreamContent now routes through appendLimitedStreamText(dst, ...); appendLimitedStreamText accepts a destination *strings.Builder so choice content, tool-call arguments, and Responses API output_text share truncation logic and update builder.contentLen/builder.truncated.
Test Coverage
internal/auditlog/auditlog_test.go
Adds tests validating text-only, tool-call-only, interleaved text/tool-calls, parallel tool calls, sparse-index fallbacks, skipped incomplete tool calls, trailing usage capture, and truncation behavior; includes helpers to build stream responses and extract choices/messages/tool_calls/functions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • ENTERPILOT/GoModel#241: Modifies the same auditlog stream parsing and response-building code; likely directly related.

Poem

I nibble bytes from SSE threads so neat,
Choices bloom, tool calls find their seat,
Metadata snug in tidy rows,
Arguments stitch where the delta flows,
Hooray — the stream assembles complete! 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(auditlog): reconstruct streamed tool calls' directly describes the main change across all modified files—implementing reconstruction of streamed tool calls from delta events into complete message structures with per-choice state and tool-call aggregation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/multimodal-2

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 and usage tips.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 10, 2026

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 78.60963% with 40 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/auditlog/stream_wrapper.go 78.37% 19 Missing and 5 partials ⚠️
internal/auditlog/stream_observer.go 78.94% 9 Missing and 7 partials ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@internal/auditlog/stream_observer.go`:
- Around line 211-218: The current defaultChatChoiceIndex uses len(states) which
can collide with sparse provider indexes; change the logic to return the single
existing key when len(states)==1, and otherwise compute the maximum key present
in the states map and return maxKey+1 (or 0 if the map is empty) so fallback
synthetic indexes never reuse or assume contiguous provider indexes; apply the
same fix to the analogous function used around lines 265-272 (the other default
index helper) referencing the same map parameter name (states) and function name
patterns to locate and update both implementations.
🪄 Autofix (Beta)

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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: de167519-151c-4578-8eee-7a6c44e9a38d

📥 Commits

Reviewing files that changed from the base of the PR and between d47c61c and e03fc4e.

📒 Files selected for processing (3)
  • internal/auditlog/auditlog_test.go
  • internal/auditlog/stream_observer.go
  • internal/auditlog/stream_wrapper.go

Comment thread internal/auditlog/stream_observer.go
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 10, 2026

Greptile Summary

This PR rewrites the streaming audit-log builder to reconstruct a proper multi-choice, multi-tool-call chat.completion response from accumulated SSE deltas, replacing the previous single-choice text-only capture. Provider metadata (provider, system_fingerprint) and trailing usage chunks are now preserved.

  • Per-choice state: streamChatChoiceState tracks role, content, finish reason, and a keyed map of streamChatToolCallState entries; buildChatChoices sorts choices deterministically and emits message.content = nil for tool-only outputs per the OpenAI spec.
  • Tool call reconstruction: appendChatToolCalls accumulates id/type/name and concatenates argument fragments per index; buildStreamChatToolCalls filters out states that never received a function delta and strips the streaming index field from the final output.
  • Budget tracking: appendLimitedStreamText is now shared between content and tool-call argument writers, with a single contentLen/truncated counter on the builder (the budget design trade-off is already captured in a previous review thread).

Confidence Score: 5/5

Safe to merge — the change is well-tested, audit-path only, and carries no impact on request routing or response pass-through.

All new code paths are in the audit-log builder, which only affects stored audit rows and never touches the proxied response seen by callers. Eight new unit tests exercise the key scenarios (text-only, tool-call-only, interleaved, parallel, sparse-index, orphan-tool-call, trailing usage, budget truncation). The design trade-offs around shared budget and sparse-index fallback are already documented in prior review threads and are deliberate choices rather than defects.

No files require special attention; the three changed files are self-contained within the auditlog package.

Important Files Changed

Filename Overview
internal/auditlog/stream_wrapper.go Core data structures and build logic for reconstructing multi-choice, multi-tool-call streamed responses; new helper functions are correct and well-guarded
internal/auditlog/stream_observer.go Refactored chat-completion event parser iterates all choices, accumulates per-choice tool-call deltas, and captures trailing usage/metadata on every chunk
internal/auditlog/auditlog_test.go Adds eight targeted unit tests covering text-only, tool-call-only, interleaved, parallel, sparse-index, orphan-tool-call, trailing-usage, and budget-truncation scenarios

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[SSE chunk arrives] --> B[parseChatCompletionEvent]
    B --> C[Update builder metadata\nid, model, created, provider,\nsystem_fingerprint, usage]
    B --> D{choices present?}
    D -- yes --> E[For each choice]
    D -- no --> Z[Done]
    E --> F{index field present?}
    F -- yes --> G[use explicit index]
    F -- no --> H[defaultChatChoiceIndex\nlen==1: reuse existing\nlen!=1: max+1]
    G --> I[builder.chatChoice index]
    H --> I
    I --> J[Update FinishReason if set]
    I --> K[Append delta.content\nappendLimitedStreamText]
    I --> L[appendChatToolCalls]
    L --> M[For each tool_call delta]
    M --> N{index field present?}
    N -- yes --> O[use explicit tool index]
    N -- no --> P[defaultToolCallIndex\nlen==1: reuse existing\nlen!=1: max+1]
    O --> Q[state.toolCall index]
    P --> Q
    Q --> R[Set id / type if not yet set]
    Q --> S{function field?}
    S -- no --> T[continue - no hasFunction flag]
    S -- yes --> U[Set hasFunction=true\nSet name if not yet set\nAppend arguments text]
    V[buildChatCompletionResponse] --> W[buildChatChoices\nsort by index]
    W --> X[For each choice state\nbuild message map]
    X --> Y[buildStreamChatToolCalls\nskip !hasFunction\nsort by index]
    Y --> Z2[Emit final choices array]
Loading

Reviews (2): Last reviewed commit: "fix(auditlog): mark exact stream capture..." | Re-trigger Greptile

Comment thread internal/auditlog/stream_observer.go
Comment thread internal/auditlog/stream_observer.go
@SantiagoDePolonia
Copy link
Copy Markdown
Contributor Author

Addressed the review follow-ups in e8a0a65:

  • Sparse fallback indexes now use max(existing)+1 for both choices and tool calls, so index-less chunks do not collide with sparse provider indexes.
  • Tool-call states that never receive a function delta are no longer serialized, while id/type from earlier deltas is still preserved when function data arrives later.
  • Added regression coverage for sparse choice indexes, sparse tool-call indexes, and id/type-only orphan tool-call deltas.

I left the shared MaxContentCapture budget as-is intentionally: audit streaming capture has a single global body safety cap and already marks ResponseBodyTooBigToHandle when it truncates. Splitting the cap per field would allow unbounded total growth across many choices/tool calls and change the existing memory-safety behavior.

Validation rerun:

  • go test ./internal/auditlog
  • go test ./internal/streaming ./internal/responsecache
  • go test ./...
  • commit hook: make test-race, go mod tidy, hot-path performance guard, make lint

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@internal/auditlog/stream_wrapper.go`:
- Around line 99-111: Add a brief clarifying comment above the content selection
logic in stream_wrapper.go explaining why message["content"] is set to nil when
toolCalls exist but content is empty, and why it uses an empty string ("") for
truly empty messages; reference the variables/state.Content.String(),
state.ToolCalls, buildStreamChatToolCalls(...) and the message["content"]
assignment so maintainers know this mirrors the OpenAI spec distinction between
"no text because tools provided output" (nil) and "explicitly empty message"
("").
🪄 Autofix (Beta)

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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 81eef941-8bb0-41e5-8169-dc1e5d030a8b

📥 Commits

Reviewing files that changed from the base of the PR and between e03fc4e and e8a0a65.

📒 Files selected for processing (3)
  • internal/auditlog/auditlog_test.go
  • internal/auditlog/stream_observer.go
  • internal/auditlog/stream_wrapper.go

Comment thread internal/auditlog/stream_wrapper.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/auditlog/stream_observer.go (1)

316-327: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mark exact-budget overflow as truncated.

Line 317 drops later chunks once contentLen reaches MaxContentCapture, but builder.truncated is only flipped when the current chunk crosses the boundary. If the buffer fills exactly and one more chunk arrives, we silently lose content while ResponseBodyTooBigToHandle stays false.

Suggested fix
 func appendLimitedStreamText(builder *streamResponseBuilder, dst *strings.Builder, content string) {
-	if builder == nil || dst == nil || content == "" || builder.truncated || builder.contentLen >= MaxContentCapture {
+	if builder == nil || dst == nil || content == "" || builder.truncated {
 		return
 	}
+	if builder.contentLen >= MaxContentCapture {
+		builder.truncated = true
+		return
+	}
 
 	remaining := MaxContentCapture - builder.contentLen
 	if len(content) > remaining {
 		content = content[:remaining]
 		builder.truncated = true
🤖 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 `@internal/auditlog/stream_observer.go` around lines 316 - 327,
appendLimitedStreamText currently only sets builder.truncated when the incoming
chunk itself exceeds the remaining budget, which misses the case where
builder.contentLen == MaxContentCapture and a later chunk is dropped; update the
function (appendLimitedStreamText) to mark builder.truncated = true whenever
there is no remaining budget (remaining == 0) or when any incoming chunk is
truncated, i.e., compute remaining := MaxContentCapture - builder.contentLen and
if remaining <= 0 set builder.truncated = true and return, otherwise truncate
the content to remaining and set builder.truncated = true if truncation
occurred, then write and update builder.contentLen accordingly so
ResponseBodyTooBigToHandle state reflects exact-budget overflows.
🤖 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 `@internal/auditlog/stream_wrapper.go`:
- Around line 137-146: The code currently appends a tool call as soon as
state.hasFunction is true even if the function name is empty, producing
incomplete tool_calls with an empty function.name; update the append logic in
the loop that builds toolCalls to require both state.hasFunction and a non-empty
state.Name (i.e., check state.Name != "" or use nonEmptyString(state.Name, "")
to ensure it's present) before appending the map (the block that sets
"id","type" and "function": {"name": state.Name,"arguments":
state.Arguments.String()}); this will skip incomplete
function-only/arguments-only deltas until the function name is known so
reconstructed audit payloads are complete and safe for consumers.

---

Outside diff comments:
In `@internal/auditlog/stream_observer.go`:
- Around line 316-327: appendLimitedStreamText currently only sets
builder.truncated when the incoming chunk itself exceeds the remaining budget,
which misses the case where builder.contentLen == MaxContentCapture and a later
chunk is dropped; update the function (appendLimitedStreamText) to mark
builder.truncated = true whenever there is no remaining budget (remaining == 0)
or when any incoming chunk is truncated, i.e., compute remaining :=
MaxContentCapture - builder.contentLen and if remaining <= 0 set
builder.truncated = true and return, otherwise truncate the content to remaining
and set builder.truncated = true if truncation occurred, then write and update
builder.contentLen accordingly so ResponseBodyTooBigToHandle state reflects
exact-budget overflows.
🪄 Autofix (Beta)

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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dd16e9de-28ca-45cc-a5b5-78210f4dfbbd

📥 Commits

Reviewing files that changed from the base of the PR and between e8a0a65 and 2434ead.

📒 Files selected for processing (2)
  • internal/auditlog/stream_observer.go
  • internal/auditlog/stream_wrapper.go

Comment on lines +137 to +146
if !state.hasFunction {
continue
}
toolCalls = append(toolCalls, map[string]any{
"id": state.ID,
"type": nonEmptyString(state.Type, "function"),
"function": map[string]any{
"name": state.Name,
"arguments": state.Arguments.String(),
},
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Skip incomplete tool calls until the function name is known.

Line 137 only checks hasFunction. If a stream ends after an arguments-only delta, this code emits tool_calls[].function.name = "", which makes the reconstructed audit payload incomplete and harder for consumers to process safely.

Suggested fix
 	for _, index := range indexes {
 		state := states[index]
-		if !state.hasFunction {
+		if !state.hasFunction || strings.TrimSpace(state.Name) == "" {
 			continue
 		}
 		toolCalls = append(toolCalls, map[string]any{
 			"id":   state.ID,
 			"type": nonEmptyString(state.Type, "function"),

As per coding guidelines, "Accept provider responses liberally and return them to the user in a conservative OpenAI-compatible format."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if !state.hasFunction {
continue
}
toolCalls = append(toolCalls, map[string]any{
"id": state.ID,
"type": nonEmptyString(state.Type, "function"),
"function": map[string]any{
"name": state.Name,
"arguments": state.Arguments.String(),
},
if !state.hasFunction || strings.TrimSpace(state.Name) == "" {
continue
}
toolCalls = append(toolCalls, map[string]any{
"id": state.ID,
"type": nonEmptyString(state.Type, "function"),
"function": map[string]any{
"name": state.Name,
"arguments": state.Arguments.String(),
},
🤖 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 `@internal/auditlog/stream_wrapper.go` around lines 137 - 146, The code
currently appends a tool call as soon as state.hasFunction is true even if the
function name is empty, producing incomplete tool_calls with an empty
function.name; update the append logic in the loop that builds toolCalls to
require both state.hasFunction and a non-empty state.Name (i.e., check
state.Name != "" or use nonEmptyString(state.Name, "") to ensure it's present)
before appending the map (the block that sets "id","type" and "function":
{"name": state.Name,"arguments": state.Arguments.String()}); this will skip
incomplete function-only/arguments-only deltas until the function name is known
so reconstructed audit payloads are complete and safe for consumers.

@SantiagoDePolonia
Copy link
Copy Markdown
Contributor Author

Addressed the latest review findings in 7bd1f9a:

  • appendLimitedStreamText now marks ResponseBodyTooBigToHandle when a non-empty chunk arrives after the stream capture budget is already exactly full, and still marks truncation when a chunk is partially captured.
  • Added a short comment documenting the message["content"] = nil vs "" distinction for tool-only vs truly empty assistant messages.
  • Added regression coverage for the exact-budget overflow case.

Validation rerun:

  • go test ./internal/auditlog
  • go test ./internal/streaming ./internal/responsecache
  • go test ./...
  • commit hook: make test-race, go mod tidy, hot-path performance guard, make lint

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (1)
internal/auditlog/stream_wrapper.go (1)

127-154: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tool-call serialization gating on hasFunction only.

Same gating concern as previously raised: state.Name can still be "" when emitted if a provider streams a function delta containing only arguments (or only an empty name) and never sends name afterward. With all currently known providers sending function.name in the first function-bearing delta, this is acceptable, but it leaves the door open for malformed tool_calls[].function.name = "" in audit payloads for non-standard providers. Worth at least an inline note documenting the assumption.

As per coding guidelines, "Accept provider responses liberally and return them to the user in a conservative OpenAI-compatible format."

🤖 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 `@internal/auditlog/stream_wrapper.go` around lines 127 - 154, The
buildStreamChatToolCalls function currently emits tool calls whenever
state.hasFunction is true, which can produce an empty function.name; change the
gating so a tool call is only appended if state.hasFunction is true AND
state.Name is non-empty (or use nonEmptyString(state.Name, "") to validate), and
update the emitted "function"."name" to use that validated non-empty value (or
skip emitting the tool call if name is empty); alternatively add an explicit
inline comment in buildStreamChatToolCalls documenting the assumption that
providers must stream function.name in their first function delta and that empty
names are rejected by the gating logic (referencing state.hasFunction,
state.Name, buildStreamChatToolCalls).
🤖 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 `@internal/auditlog/stream_observer.go`:
- Around line 184-208: Add a short clarifying comment to the
defaultStreamStateIndex function documenting the single-stream merge assumption:
when len(states) == 1 the function returns the existing key which means multiple
index-less entries in the same event will be collapsed into that single
synthetic index; reference this behavior and note callers like the loop in
stream_observer.go that uses builder.chatChoice, appendChatToolCalls, and
appendChatContent may rely on provider-sent indices to avoid collisions, so
future changes should preserve or explicitly handle the "single-stream merge"
edge-case instead of silently altering it.

In `@internal/auditlog/stream_wrapper.go`:
- Around line 73-84: The current code fabricates a synthetic assistant choice
when b.Choices is empty; instead, change the logic in the audit-serialization
path that uses b.Choices so that if len(b.Choices) == 0 you return an empty
slice (or omit the "choices" field) rather than injecting the artificial
{"index":0,"message":{...},"finish_reason":""}; update the branch handling
b.Choices to produce an actual empty []map[string]any (or skip adding the
choices key) so downstream consumers can distinguish "no deltas received" from
an empty assistant output, ensuring you only change the empty-case behavior and
leave non-empty b.Choices handling unchanged.
- Around line 270-309: Add short doc comments: above copyAnyMap(document that
maps.Clone performs a shallow copy, so nested map values remain shared and the
function assumes the caller/producer retains ownership and will not mutate
nested structures), and above jsonNumberToInt and jsonNumberToInt64(note they
currently only handle encoding/json's default float64/int/int64 and that if the
upstream SSE decoder switches to Decoder.UseNumber() you must add a json.Number
case to parse via .Int64()/String()). Optionally proactively add json.Number
handling in jsonNumberToInt/jsonNumberToInt64 if you want to be defensive.

---

Duplicate comments:
In `@internal/auditlog/stream_wrapper.go`:
- Around line 127-154: The buildStreamChatToolCalls function currently emits
tool calls whenever state.hasFunction is true, which can produce an empty
function.name; change the gating so a tool call is only appended if
state.hasFunction is true AND state.Name is non-empty (or use
nonEmptyString(state.Name, "") to validate), and update the emitted
"function"."name" to use that validated non-empty value (or skip emitting the
tool call if name is empty); alternatively add an explicit inline comment in
buildStreamChatToolCalls documenting the assumption that providers must stream
function.name in their first function delta and that empty names are rejected by
the gating logic (referencing state.hasFunction, state.Name,
buildStreamChatToolCalls).
🪄 Autofix (Beta)

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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b738b708-77c1-4345-99f6-675235d1380a

📥 Commits

Reviewing files that changed from the base of the PR and between 2434ead and 7bd1f9a.

📒 Files selected for processing (3)
  • internal/auditlog/auditlog_test.go
  • internal/auditlog/stream_observer.go
  • internal/auditlog/stream_wrapper.go

Comment on lines +184 to 208
for _, choiceAny := range choices {
choice, ok := choiceAny.(map[string]any)
if !ok {
continue
}
index, ok := jsonNumberToInt(choice["index"])
if !ok {
index = defaultChatChoiceIndex(builder.Choices)
}
state := builder.chatChoice(index)
if fr, ok := choice["finish_reason"].(string); ok && fr != "" {
builder.FinishReason = fr
state.FinishReason = fr
}

if delta, ok := choice["delta"].(map[string]any); ok {
if role, ok := delta["role"].(string); ok {
builder.Role = role
state.Role = role
}
if content, ok := delta["content"].(string); ok && content != "" {
appendStreamContent(builder, content)
appendChatContent(builder, state, content)
}
appendChatToolCalls(builder, state, delta)
}
}
}
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.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Edge case: two index-less entries in the same event collapse into one.

defaultStreamStateIndex returns the existing single key when len(states) == 1. Inside this loop, if a provider event happens to include two choices (or two tool_calls via appendChatToolCalls) without an index, the first iteration creates state at synthetic index N, and the second iteration sees len==1 and returns N — both index-less entries merge into the same state. In practice every observed provider sends index on parallel choices/tool_calls, so the risk is low, but worth a brief comment on the function noting the "single-stream merge" assumption so future maintainers don't reinforce the behavior accidentally.

🤖 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 `@internal/auditlog/stream_observer.go` around lines 184 - 208, Add a short
clarifying comment to the defaultStreamStateIndex function documenting the
single-stream merge assumption: when len(states) == 1 the function returns the
existing key which means multiple index-less entries in the same event will be
collapsed into that single synthetic index; reference this behavior and note
callers like the loop in stream_observer.go that uses builder.chatChoice,
appendChatToolCalls, and appendChatContent may rely on provider-sent indices to
avoid collisions, so future changes should preserve or explicitly handle the
"single-stream merge" edge-case instead of silently altering it.

Comment on lines +73 to +84
if len(b.Choices) == 0 {
return []map[string]any{
{
"index": 0,
"message": map[string]any{
"role": role,
"content": b.Content.String(),
"role": "assistant",
"content": "",
},
"finish_reason": b.FinishReason,
"finish_reason": "",
},
},
}
}
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.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Synthetic empty choice when no deltas were ever observed.

If a stream closes without any choices events (e.g. error mid-stream after only metadata/usage), this still emits a fabricated assistant/""/finish_reason: "" entry in the audit row. That can be confusing for downstream consumers, who can't distinguish "model produced empty assistant content" from "no choice deltas ever arrived". Consider returning an empty slice (or omitting choices) in that case, but only if it doesn't break existing audit consumers that assume choices is always populated.

🤖 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 `@internal/auditlog/stream_wrapper.go` around lines 73 - 84, The current code
fabricates a synthetic assistant choice when b.Choices is empty; instead, change
the logic in the audit-serialization path that uses b.Choices so that if
len(b.Choices) == 0 you return an empty slice (or omit the "choices" field)
rather than injecting the artificial
{"index":0,"message":{...},"finish_reason":""}; update the branch handling
b.Choices to produce an actual empty []map[string]any (or skip adding the
choices key) so downstream consumers can distinguish "no deltas received" from
an empty assistant output, ensuring you only change the empty-case behavior and
leave non-empty b.Choices handling unchanged.

Comment on lines +270 to +309
func copyAnyMap(m map[string]any) map[string]any {
if m == nil {
return nil
}
return maps.Clone(m)
}

func jsonNumberToInt(value any) (int, bool) {
switch v := value.(type) {
case float64:
return int(v), true
case int:
return v, true
case int64:
return int(v), true
default:
return 0, false
}
}

func jsonNumberToInt64(value any) (int64, bool) {
switch v := value.(type) {
case float64:
return int64(v), true
case int:
return int64(v), true
case int64:
return v, true
default:
return 0, false
}
}

func nonEmptyString(value, fallback string) string {
value = strings.TrimSpace(value)
if value != "" {
return value
}
return strings.TrimSpace(fallback)
}
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.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Helpers are idiomatic, but copyAnyMap is shallow.

maps.Clone performs a shallow copy, so nested maps inside Usage (e.g. OpenAI's completion_tokens_details, prompt_tokens_details) would share references with the originating JSON event map. Today this is safe because the SSE parser hands ownership of the decoded map to the observer and doesn't mutate it, but if that contract ever changes the builder's Usage could be silently mutated. Consider a brief doc comment to record the shallow-copy assumption.

jsonNumberToInt/jsonNumberToInt64 cover the default encoding/json decoding (float64). If the upstream SSE decoder ever switches to Decoder.UseNumber(), you'll also want a json.Number case here — worth a short comment noting the float64-only assumption.

🤖 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 `@internal/auditlog/stream_wrapper.go` around lines 270 - 309, Add short doc
comments: above copyAnyMap(document that maps.Clone performs a shallow copy, so
nested map values remain shared and the function assumes the caller/producer
retains ownership and will not mutate nested structures), and above
jsonNumberToInt and jsonNumberToInt64(note they currently only handle
encoding/json's default float64/int/int64 and that if the upstream SSE decoder
switches to Decoder.UseNumber() you must add a json.Number case to parse via
.Int64()/String()). Optionally proactively add json.Number handling in
jsonNumberToInt/jsonNumberToInt64 if you want to be defensive.

@SantiagoDePolonia SantiagoDePolonia merged commit b894c46 into main May 10, 2026
19 checks passed
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