Repository navigation
fix(ollama): one stream id, 400 on non-text tool content, separated tool results, no empty message item - #45177
Conversation
…ool results, no empty message item The ollama/ text stream minted a fresh chatcmpl- id per chunk; every chunk now carries the one id of its response. A tool message with int content raised a TypeError inside ollama_pt and answered 500; it now answers 400 naming the message index and the type it carried. Merged user, tool, and function turns and the text parts of one message were joined with no separator; they are joined with a newline. A streamed tool call with no text rebuilds to content null instead of an empty string, and the Responses bridge no longer opens a message item on a leading empty delta, so a /v1/responses stream on a tool call completes with the function_call alone, as the non-stream call does
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
…on Python 3.10 to 3.13
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…00 on a non-string text part
… reject non-object ollama content parts The chat-completions bridge reserved output_index 0 for the message item, so a tool-only stream announced its function_call at index 1 with no item at 0 and the OpenAI SDK's responses.stream() accumulator raised IndexError. Items now take the next index in emission order, reasoning included, and response.completed lists the output in that streamed order. ollama_pt answers 400 for a content part that is not an object and for an image_url object without a url string, instead of a 500 or a silent drop.
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Finish reason opens empty message
- Removed the finish_reason exception in _ensure_output_item_for_chunk so a payload-less terminal chunk no longer opens an empty message beside assembled function_call items.
Or push these changes by commenting:
@cursor push a27fc1e91a
Preview (a27fc1e91a)
diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py
--- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py
+++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py
@@ -976,7 +976,7 @@
if not chunk.choices:
return
delta: Final = chunk.choices[0].delta
- if chunk.choices[0].finish_reason is None and not _delta_carries_output(delta):
+ if not _delta_carries_output(delta):
return
self._sequence_number += 1
diff --git a/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py b/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py
--- a/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py
+++ b/tests/unit/responses/litellm_completion_transformation/test_streaming_iterator_transformation.py
@@ -1082,6 +1082,55 @@
@pytest.mark.parametrize("sync_mode", [True, False])
@pytest.mark.asyncio
+async def test_finish_reason_without_payload_does_not_open_a_message_beside_assembled_tool_calls(
+ sync_mode: bool,
+):
+ iterator: Final = _build_iterator([_chunk(""), _chunk("", finish_reason="tool_calls")])
+ iterator.litellm_model_response = ModelResponse(
+ id="resp-assembled",
+ created=1748575031,
+ model="claude-haiku-4-5",
+ object="chat.completion",
+ choices=[
+ {
+ "index": 0,
+ "finish_reason": "tool_calls",
+ "message": {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": "call_assembled",
+ "type": "function",
+ "function": {"name": "run_command", "arguments": '{"command":"pwd"}'},
+ "index": 0,
+ }
+ ],
+ },
+ }
+ ],
+ )
+
+ events: Final = await _collect_events(iterator, sync_mode)
+
+ item_events: Final = [
+ (event.type, event.item.type)
+ for event in events
+ if getattr(event, "type", None)
+ in (ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE)
+ ]
+ assert item_events == [
+ (ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED, "function_call"),
+ (ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE, "function_call"),
+ ]
+ completed: Final = [
+ event for event in events if getattr(event, "type", None) == ResponsesAPIStreamEvents.RESPONSE_COMPLETED
+ ]
+ assert [item.type for item in completed[0].response.output] == ["function_call"]
+
+
+@pytest.mark.parametrize("sync_mode", [True, False])
+@pytest.mark.asyncio
async def test_empty_leading_delta_still_opens_the_message_item_for_the_first_text_delta(sync_mode: bool):
iterator: Final = _build_iterator([_chunk(""), _chunk("Hi"), _chunk("", finish_reason="stop")])You can send follow-ups to the cloud agent here.
…apshot The Responses bridge drops a message item from response.completed when the stream never announced one and its text is empty, so a tool-only stream and a reasoning-only stream end with the items the client saw. The chunk builder's rebuilt content goes back to what main does; aligning it with the non-stream shape is its own change for every provider's consumers
|
bugbot run |
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit c7ac238. Configure here.

TLDR
Problem this solves:
ollama/streams gave every chunk its ownchatcmpl-idcontentanswered 500 instead of 400/v1/responsesstreams on a tool call ended with an emptymessageitem beside thefunction_call, and the OpenAI SDK'sresponses.stream()crashed on the item's indexHow it solves it:
ollama/stream mints one id and stamps it on every chunkresponse.completedlists the items the stream announced in that orderDevin opened this PR under my own account (the org opens PRs as the session's user now), so it needs a reviewer's approval the usual way
User Flow
Before: a developer whose agent app calls tools on an
ollama/model through the proxy gets a tool round-trip that no OpenAI client handles cleanly"model": "ollama/qwen3:0.6b",tools, and"stream": true; every SSE chunk carries a differentchatcmpl-...idParis: 22 degreesclear skies"content": 22on the tool message; the proxy answers 500can only concatenate str (not "int") to str"stream": trueends inresponse.completedlisting an emptymessageitem next to thefunction_call, while the non-stream call lists thefunction_callalone/v1/responsesstream with the OpenAI SDK'sclient.responses.stream()against an Anthropic deployment on the same proxy, whose stream carries no reasoning item; the SDK raisesIndexError: list index out of range, because the only item is announced atoutput_index1 and nothing was announced at 0After: the same round-trip behaves like one against an OpenAI model
chatcmpl-...id of that responseParis: 22 degreesandclear skieson separate lines"content": 22tool message; the proxy answers 400the tool message at index 2 has int content; content must be a string or a list of content partsresponse.completedlisting thefunction_callalone, as the non-stream call doesclient.responses.stream()call completes; thefunction_callis announced atoutput_index0 andget_final_response().outputlists it alonePre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/unit/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Screenshots / Proof of Fix
Last updated: c7ac238, a tests-only commit adding the audit's integration cells. QA and /live-pr-risk ran on 169c0c1, the last commit with a product diff; /audit ran on c7ac238, whose product code is identical
/audit round 1 of 3: head c7ac238, merge base 127278f
Three rigs on one host, each a two-worker proxy from its commit with its own Postgres, Redis and scripted upstream, booted and run through the integration harness (
tests/integration/run.py providers <node ids>, serial as CircleCI runs the group,INTEGRATION_PROXY_READY_SECONDS=240). The 40 cells below ran on the merge base, then twice on the head with--seed 20261007 --order-seed 20261007(40 collected and 40 passed both times, no skips, no retries); the 30 controls ran once on a third rig from the head. Head product code is unchanged since 169c0c1: c7ac238 adds the audit's cells. No litellm component is mocked; the doubles are the scripted Ollama (/api/generate) and a scripted OpenAI-compatible SSE wire for the bridge cellstest_async_openai_sdk_stream_answers_the_tool_result_in_plain_textintest_ollama_prompt_tools_wire.pytest_openai_sdk_completions_stream_keeps_one_idintest_ollama_prompt_tools_wire.pytest_raw_chat_stream_frames_share_one_id_and_end_with_doneintest_ollama_prompt_tools_wire.pytest_cached_stream_replay_keeps_one_id_and_reaches_ollama_onceintest_ollama_prompt_tools_wire.pytest_int_tool_result_content_is_a_400_naming_the_field_and_leaves_the_deployment_servingintest_ollama_prompt_tools_wire.pytest_malformed_content_is_a_400_naming_the_message_and_field[tool-int-content]intest_ollama_prompt_tools_wire.pytest_non_list_user_content_is_rejected_before_it_reaches_ollama[user-int-content]intest_ollama_prompt_tools_wire.pytest_non_list_user_content_is_rejected_before_it_reaches_ollama[user-dict-content]intest_ollama_prompt_tools_wire.pytest_malformed_content_is_a_400_naming_the_message_and_field[tool-int-text-part]intest_ollama_prompt_tools_wire.pytest_malformed_content_is_a_400_naming_the_message_and_field[tool-text-part-without-text]intest_ollama_prompt_tools_wire.pytest_malformed_content_is_a_400_naming_the_message_and_field[user-int-image-url]intest_ollama_prompt_tools_wire.pytest_malformed_content_is_a_400_naming_the_message_and_field[user-image-url-object-without-url]intest_ollama_prompt_tools_wire.pytest_malformed_content_is_a_400_naming_the_message_and_field[user-image-url-part-without-image-url]intest_ollama_prompt_tools_wire.pytest_non_list_user_content_is_rejected_before_it_reaches_ollama[user-string-part]intest_ollama_prompt_tools_wire.pytest_malformed_content_400_writes_a_failure_spend_row_and_leaves_the_deployment_servingintest_ollama_prompt_tools_wire.pytest_malformed_content_on_an_unauthenticated_request_is_a_401_before_translationintest_ollama_prompt_tools_wire.pytest_anthropic_sdk_int_tool_result_content_is_dropped_before_the_promptintest_ollama_prompt_tools_wire.pytest_openai_sdk_responses_int_function_output_reaches_the_prompt_as_textintest_ollama_prompt_tools_wire.pytest_concurrent_valid_and_malformed_turns_are_each_answered_in_their_own_shapeintest_ollama_prompt_tools_wire.pytest_malformed_content_is_a_400_naming_the_message_and_field[tool-dict-content]intest_ollama_prompt_tools_wire.pytest_malformed_content_is_a_400_naming_the_message_and_field[tool-string-part]intest_ollama_prompt_tools_wire.pytest_tool_result_content_shapes_reach_the_prompt[text-parts-joined-by-newline]intest_ollama_prompt_tools_wire.pytest_the_same_tool_result_twice_is_forwarded_twice_under_one_instructionintest_ollama_prompt_tools_wire.pytest_consecutive_user_messages_are_joined_by_a_newlineintest_ollama_prompt_tools_wire.pytest_a_tool_result_followed_by_user_text_is_joined_by_a_newlineintest_ollama_prompt_tools_wire.pytest_anthropic_sdk_two_tool_results_in_one_turn_are_separatedintest_ollama_prompt_tools_wire.pytest_async_anthropic_sdk_stream_separates_a_tool_result_from_user_text_in_one_turnintest_ollama_prompt_tools_wire.pytest_openai_sdk_responses_two_function_outputs_are_separatedintest_ollama_prompt_tools_wire.pytest_empty_and_null_user_content_are_dropped_from_the_user_section[empty-string-skipped]intest_ollama_prompt_tools_wire.pytest_empty_and_null_user_content_are_dropped_from_the_user_section[empty-text-parts-dropped]intest_ollama_prompt_tools_wire.pytest_empty_and_null_user_content_are_dropped_from_the_user_section[null-content-skipped]intest_ollama_prompt_tools_wire.pytest_a_content_part_of_an_unknown_type_is_dropped_from_the_promptintest_ollama_prompt_tools_wire.pytest_async_openai_sdk_responses_stream_emits_the_function_call_itemintest_ollama_prompt_tools_wire.pytest_openai_sdk_responses_stream_context_manager_gets_the_function_call_without_an_empty_messageintest_ollama_prompt_tools_wire.pytest_async_openai_sdk_responses_stream_of_thinking_only_emits_one_reasoning_itemintest_ollama_prompt_tools_wire.pytest_async_openai_sdk_responses_stream_with_an_empty_answer_keeps_one_message_itemintest_ollama_prompt_tools_wire.pytest_openai_sdk_responses_stream_with_a_tool_call_only_reply_announces_no_message_itemintest_responses_bridge_stream_output_items_wire.pytest_async_openai_sdk_responses_stream_text_reply_has_one_message_item_at_index_zerointest_responses_bridge_stream_output_items_wire.pytest_async_openai_sdk_responses_stream_reasoning_then_text_gets_contiguous_output_indexesintest_responses_bridge_stream_output_items_wire.pytest_raw_responses_stream_text_then_tool_call_keeps_the_message_firstintest_responses_bridge_stream_output_items_wire.pyControls, once on the head rig (29 pass):
test_async_openai_sdk_stream_flushes_the_held_tool_call_once,test_async_anthropic_sdk_stream_answers_the_tool_result_in_text,test_async_openai_sdk_responses_stream_answers_the_function_output_in_text,test_tool_result_content_shapes_reach_the_prompt[5kb-string-forwarded-intact],test_tool_result_content_shapes_reach_the_prompt[empty-string-drops-the-section],test_responses_stream_completes_through_empty_choices_metadata_and_usage_chunks,test_codex_namespace_tool_is_flattened_for_chat_upstream_and_restored_in_responses_output,test_legacy_functions_param_is_instructed_the_same_way,test_bedrock_passthrough_converse_stream_response_carries_event_stream_content_type,test_concurrent_tool_result_turns_across_endpoints_each_get_their_own_instructed_prompt,test_dropped_ollama_connections_fail_their_callers_and_the_rest_keep_their_prompts,test_a_non_function_json_answer_is_returned_as_text,test_a_second_function_call_after_the_result_is_surfaced_as_tool_calls,test_anthropic_sdk_tool_request_comes_back_as_tool_use,test_anthropic_sdk_tool_result_turn_ends_with_text,test_async_anthropic_sdk_stream_emits_the_tool_use_block,test_identical_uncached_tool_result_turns_are_each_forwarded_and_billed_once,test_list_system_message_keeps_one_system_section_with_the_instruction,test_ollama_chat_keeps_native_tools_and_gets_no_instruction,test_ollama_model_not_found_reaches_the_caller_after_one_attempt,test_ollama_server_error_on_the_tool_result_turn_does_not_take_the_deployment_down,test_openai_sdk_responses_function_output_turn_gets_a_message,test_openai_sdk_responses_tool_request_comes_back_as_a_function_call,test_openai_sdk_tool_request_reaches_ollama_as_an_instructed_prompt,test_openai_sdk_tool_result_turn_gets_a_plain_text_answer,test_string_system_message_keeps_one_system_section_with_the_instruction,test_the_cell_deployment_is_gone_after_its_scenario,test_two_tools_are_both_listed_under_one_instruction,test_unauthenticated_tool_request_never_reaches_ollamaOne control went red once:
test_worker_sigkill_mid_burst_leaves_the_sibling_instructing_ollama(its own two-worker proxy, 20 held calls) had one call answered 400 with'CredentialLiteLLMParams' object has no attribute 'use_litellm_proxy'on the just-booted second worker, a pre-existing intermittent pydantic-field error on the provider-lookup path that this PR does not touch (the same cell was green on the same product code in the pre-validation lane, and the siblingGenericLiteLLMParams.provider_affinity_headershape is already tracked internally). Recorded as a Low caveat, not rerunQA against a real Ollama (169c0c1)
Cases 1 to 4 run against the same real Ollama (
ollama version is 0.40.0, modelqwen3:0.6b). Each leg is one proxy process booted with--num_workers 2, no database, one deploymentollama-qwen->ollama/qwen3:0.6b,drop_params: true. The before proxy runs the merge base127278f951on port 44170 and the after proxy runs169c0c181bon port 22768. Both point at Ollama through a byte relay (127.0.0.1:21174->127.0.0.1:11434) that appends every request body Ollama receives to a log, which is where the "prompt Ollama received" lines come from. Case 5 runs against the /live-pr-risk rig instead: the same two trees as two-worker proxies with Postgres (base on port 16754, after on 16755), with a second deploymenthaiku->anthropic/claude-haiku-5-5behind a request recorder in front of the real Anthropic API. Every call sendscache: {"no-cache": true}. Clients: the OpenAI Python SDK for/v1/chat/completionsstreams and/v1/responses, the Anthropic Python SDK for/v1/messages, andhttpx.postfor the raw chat posts. The 0.6b model sometimes answers a tool prompt with plain text instead of a tool call, so the/v1/responsesfirst-turn calls were repeated until the model chose the tool on both legs; every other block is the leg's single run. A merge-ref check ran the rig cells once more ond6762d8c46, this branch merged intomainatc38505d265, on port 31296: every cell matched the after side, and the OpenAI SDKresponses.stream()call completed there tooShared payloads:
Before (127278f)
One id per stream (
/v1/chat/completions)Run
Observed
Int tool content (
/v1/chat/completions)Run
Observed
Responses stream on a tool call (
/v1/responses)Run
Observed
Control, the same call without
stream=TrueObserved
Separated tool results, all three endpoints
Two tool messages over
/v1/chat/completionsObserved
One tool message with two text parts over
/v1/chat/completionsObserved
Two
tool_resultblocks over/v1/messagesObserved
Two
function_call_outputitems over/v1/responsesObserved
OpenAI SDK
responses.stream()on a bridged tool call (/v1/responses)Run, against the rig proxy with the
haikudeploymentObserved
After (169c0c1)
One id per stream (
/v1/chat/completions)Same call
Observed
Int tool content (
/v1/chat/completions)Same post
Observed
Responses stream on a tool call (
/v1/responses)Same stream
Observed
Same control without
stream=TrueObserved
Separated tool results, all three endpoints
Same two tool messages over
/v1/chat/completionsObserved
Same two text parts over
/v1/chat/completionsObserved
Same two
tool_resultblocks over/v1/messagesObserved
Same two
function_call_outputitems over/v1/responsesObserved
OpenAI SDK
responses.stream()on a bridged tool call (/v1/responses)Same call
Observed
Type
🐛 Bug Fix
Caveats (if any)
Low
contentis neither a string nor a list (false,0, an object), a content part that is not an object, atextorimage_urlpart with no value, and animage_urlobject without a url string answer 400 now, naming the message index and what the message carried. Before, the int, missing-value, and non-object shapes answered 500, andfalse,0,{}content and a null or intimage_urlanswered 200 from a prompt that silently left that message out, so that 200 was a request that wrongly succeeded and now fails as designed. The index counts litellm's message list, so a system message litellm injects shifts it by one. On a proxy with a database, the enterprise managed-files pre-call hook reads every user message'scontentas a list before translation, so a non-list usercontentis rejected there first on the merge base and on this branch alike (500 for an int, 400 with a traceback for an object or a bare-string part); that hook is pre-existing and tracked separately, and this PR's user-content wording is reached only on a proxy without a database, while the tool-message and content-part shapes reach it on both. The audit's cells pin what both sides share (rejected, never forwarded to Ollama, the deployment keeps serving) and tighten to this PR's wording once the hook is fixed/v1/responsesstream through the bridge now announces itsfunction_callatoutput_index0 where it used to sit at 1 with nothing at 0; a client that hardcoded 1 reads the wrong slotmax_output_tokensspent inside the reasoning) lists only thereasoningitem inresponse.completed, since nomessageitem was ever announced; the non-stream call still lists an emptymessageitem after the reasoningtextpart adds no blank line to the prompt, matching the old concatenationollama/prompt path cannot render (input_audio,file, a custom type) still drops silently, as it did on main; rejecting it is a wider contract change than this ticket's malformed-value 400s, so it stays out of this PRfunctionsrequest still answerstool_calls, as every non-OpenAI provider does; left as is on purposeoutput_item.addedfor its reasoning item, on main and on this branch alike; filed as its own ticket, not fixed here/v1/completionsstream's finish chunk carriestext: nullwhere OpenAI sends an empty string, on main and on this branch alike, in the shared text-completion stream wrapper; tracked separately, not fixed here/v1/responsesfunction_call_outputwhoseoutputis a list ofinput_textparts is joined with no separator by the bridge one layer above the Ollama prompt builder, on main and on this branch alike; this PR separates string outputs and tracked the list shape separately'CredentialLiteLLMParams' object has no attribute 'use_litellm_proxy'on the just-booted second worker, a pre-existing intermittent error on the provider-lookup path this PR does not touch (the same cell was green at the same product code in the pre-validation lane); tracked separatelymiscandproxy-infra-rooton the new/moyai/connect/*routes andmoyai_endpoints.pythat main's feat(proxy,ui): add Moyai to the view switcher with quick connect #45196 added without their allowlist and key-family entries, red on main's own run at 0e07600 and green at this PR's previous tip, andVertex AIontest_path_source_peak_stays_below_list_pipeline, atracemallocratio assertion whose own docstring says the ratio drifts with GC timing and the worker's live set, unrelated to the diff, green at the previous tip, green on main's last eight runs, and green when that one job was rerun at the same merge commit; none is caused by this PR, and the required checks are green at the tip/live-pr-risk
Verdict: no dependent path that worked at the merge base fails at the tip. The contract changes below are the fixes themselves plus three collateral shifts, each decided here. The one rig cell that differed outside those, the deleted-key probe, answered 200 on one merge-base run and 401 on the next with no code change on that side (two workers, one in-memory key cache each), so it is rig noise, not a finding
Breaking
false,0,{}content; null or intimage_url) was served at the merge base from a prompt missing that message, and the ticket asks for a 400 naming the field for non-text content; the falsy and placeholder shapes ride the same rule, decided in this PR by its author (Caveats, first row)Backward incompatible
ollama/streams on/v1/chat/completionsand/v1/completionscarry one id per stream instead of one per chunk; the spend row'srequest_idand the logs page row key off that one id now (the fix itself)ollama/prompts answer 400 instead of 500, so the router no longer retries or falls back on them (the fix itself)/v1/responsesstreams number output items from 0 in emission order andresponse.completedlists the announced items in that order; a tool-only stream moves itsfunction_callfrom index 1 to 0 and loses the emptymessageitem (the fix itself, Caveats rows two and three)ollama/prompts is newline-separated (the fix itself)Regression risk
content: ""role chunk other than Ollama and Anthropic (Bedrock Converse, Gemini, Groq) go through the same deferred message item; not driven here, covered by the unit tests' synthetic chunks only/v1/responsesstreams keys on(output_index, content_index); it was read, not driven, and the indexes it sees now start at 0 for every stream/v1/responseswithprevious_response_idreplaying a tool-only turn from the spend log; not driven/v1/messageswithuse_chat_completions_api: trueon a tool call stream; not drivenDependency graph
Format: dependent path, entrypoint, how it reaches the change, status
Ollama prompt builder, reached by every
ollama/deployment on/v1/chat/completions,/v1/completionsthrough the chat adapter,/v1/messages, and/v1/responseswhen the tool request falls back to the prompt path:tool_resultblocks, twofunction_call_outputitems), tested intest_litellm_core_utils_prompt_templates_factory.pyandtests/integration/providers/test_ollama_prompt_tools_wire.pyimage_urlobject without a url): verified live on/v1/chat/completionsand/v1/responsesthrough the hostile cells on the no-database QA proxies; on the audit's database-backed rigs the enterprise managed-files pre-call hook rejected non-list user content first on both legs (Caveats, first row) while the tool-content and content-part shapes reached this PR's 400; tested in the factory unit tests and the wire test's malformed-content cellscustom_prompt_dictpath forollama/deployments with a registered template: untouched by the diff, not drivenStreamed chat chunk id, minted once per
ollama/stream by the text completion response iterator and adopted by the stream wrapper:/v1/chat/completionsand/v1/completionsstreams: verified live (one id per stream on both)request_idand the logs page row keyed by that id: verified live through the spend row of every streamed cellResponses bridge streaming iterator (every
/v1/responsesstream on a model without a native Responses API, and/v1/messageswithuse_chat_completions_api: true):messageitem until a delta carries output: verified live on Ollama and Anthropic tool streams; tested intest_streaming_iterator_transformation.pyoutput_indexin emission order andresponse.completedlisting the announced items in streamed order: verified live with the OpenAI Python SDK'sclient.responses.stream()on an Anthropictool_choice: requiredstream (base raisesIndexErrorin the SDK accumulator, tip completes) and on Ollama text and tool streams; tested intest_streaming_iterator_transformation.pymessageitem the stream never announced: verified live on the Ollama tool stream and on an Ollama reasoning-only stream; tested intest_streaming_iterator_transformation.pyoutput_index: the OpenAI SDK accumulator (verified live), the guardrail text rewrite keyed by(output_index, content_index)on/v1/responsesstreams, background response polling, the Anthropic adapter'scontent_block_startordering: read, not drivenoutput_item.addedfor the reasoning item, on base and tip alike; pre-existing, filed as its own ticket, not fixed hereSpend logging, logging integrations, caching, and guardrail post-call hooks: the rebuilt chat message is unchanged by this PR (an earlier revision changed it and was reverted at 40571a5), so none of them see a difference; spend rows compared equal on every non-hostile cell
Not verified
content: ""role chunk, not on those providers' frames/v1/responseswithprevious_response_idreplaying a tool-only turn from the spend logmessageitem opens on the finish chunk/v1/messageswithuse_chat_completions_api: trueon a tool call streamoutput_indexon a/v1/responsesstreamFinal Attestation