anthropic-haystack 6.2.0__tar.gz → 7.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/CHANGELOG.md +14 -0
  2. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/PKG-INFO +1 -1
  3. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/src/haystack_integrations/components/generators/anthropic/chat/chat_generator.py +22 -29
  4. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/src/haystack_integrations/components/generators/anthropic/chat/foundry_chat_generator.py +3 -4
  5. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/src/haystack_integrations/components/generators/anthropic/chat/utils.py +16 -0
  6. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/src/haystack_integrations/components/generators/anthropic/chat/vertex_chat_generator.py +3 -2
  7. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/src/haystack_integrations/token_counters/anthropic/token_counter.py +1 -2
  8. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/tests/test_chat_generator.py +27 -18
  9. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/tests/test_chat_generator_async.py +4 -2
  10. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/tests/test_utils.py +47 -4
  11. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/.gitignore +0 -0
  12. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/LICENSE.txt +0 -0
  13. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/README.md +0 -0
  14. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/example/documentation_rag_with_claude.py +0 -0
  15. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/example/prompt_caching.py +0 -0
  16. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/pydoc/config_docusaurus.yml +0 -0
  17. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/pyproject.toml +0 -0
  18. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/src/haystack_integrations/components/generators/anthropic/__init__.py +0 -0
  19. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/src/haystack_integrations/components/generators/anthropic/chat/__init__.py +0 -0
  20. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/src/haystack_integrations/components/generators/py.typed +0 -0
  21. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/src/haystack_integrations/token_counters/anthropic/__init__.py +0 -0
  22. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/src/haystack_integrations/token_counters/py.typed +0 -0
  23. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/tests/__init__.py +0 -0
  24. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/tests/conftest.py +0 -0
  25. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/tests/test_files/apple.jpg +0 -0
  26. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/tests/test_files/sample_pdf_3.pdf +0 -0
  27. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/tests/test_foundry_chat_generator.py +0 -0
  28. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/tests/test_token_counter.py +0 -0
  29. {anthropic_haystack-6.2.0 → anthropic_haystack-7.0.0}/tests/test_vertex_chat_generator.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/anthropic-v6.3.0] - 2026-10-01
4
+
5
+ ### 🚜 Refactor
6
+
7
+ - Generalize flattened generation_kwargs for tool_choice/thinking/output_config (#4028)
8
+
9
+
10
+ ## [integrations/anthropic-v6.2.0] - 2026-09-24
11
+
12
+ ### 🚀 Features
13
+
14
+ - Add thinking_display flattened generation_kwarg to Anthropic and Bedrock chat generators (#3993)
15
+
16
+
3
17
  ## [integrations/anthropic-v6.1.0] - 2026-09-22
4
18
 
5
19
  ### 🐛 Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: anthropic-haystack
3
- Version: 6.2.0
3
+ Version: 7.0.0
4
4
  Summary: An integration of Anthropic Claude models into the Haystack framework.
5
5
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/anthropic#readme
6
6
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
@@ -19,7 +19,7 @@ from haystack.tools import (
19
19
  flatten_tools_or_toolsets,
20
20
  serialize_tools_or_toolset,
21
21
  )
22
- from haystack.utils.auth import Secret, deserialize_secrets_inplace
22
+ from haystack.utils.auth import Secret
23
23
  from haystack.utils.callable_serialization import deserialize_callable, serialize_callable
24
24
 
25
25
  from anthropic import Anthropic, AsyncAnthropic
@@ -37,6 +37,7 @@ from .utils import (
37
37
  _convert_messages_to_anthropic_format,
38
38
  _extract_citations,
39
39
  _has_server_tool_blocks,
40
+ _merge_streaming_usage,
40
41
  _process_reasoning_contents,
41
42
  )
42
43
 
@@ -278,7 +279,6 @@ class AnthropicChatGenerator:
278
279
  :returns:
279
280
  The deserialized component instance.
280
281
  """
281
- deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
282
282
  deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools")
283
283
  init_params = data.get("init_parameters", {})
284
284
  serialized_callback_handler = init_params.get("streaming_callback")
@@ -369,11 +369,6 @@ class AnthropicChatGenerator:
369
369
  tool_choice["disable_parallel_tool_use"] = disable_parallel_tool_use
370
370
  tool_choice.setdefault("type", "auto") # default value
371
371
 
372
- tool_choice_type = generation_kwargs.pop("tool_choice_type", None)
373
- if tool_choice_type is not None:
374
- tool_choice = generation_kwargs.setdefault("tool_choice", {})
375
- tool_choice["type"] = tool_choice_type
376
-
377
372
  thinking_budget_tokens = generation_kwargs.pop("thinking_budget_tokens", None)
378
373
  if thinking_budget_tokens is not None:
379
374
  thinking = generation_kwargs.setdefault("thinking", {})
@@ -390,12 +385,14 @@ class AnthropicChatGenerator:
390
385
  output_config = generation_kwargs.setdefault("output_config", {})
391
386
  output_config["effort"] = adaptive_thinking_effort
392
387
 
393
- thinking_display = generation_kwargs.pop("thinking_display", None)
394
- if thinking_display is not None:
395
- thinking = generation_kwargs.setdefault("thinking", {})
396
- # `display` is only accepted with enabled or adaptive thinking, so it's dropped when thinking is disabled
397
- if thinking.get("type") != "disabled":
398
- thinking["display"] = thinking_display
388
+ for nested_name in ("tool_choice", "thinking", "output_config"):
389
+ prefix = f"{nested_name}_"
390
+ for key in [k for k in generation_kwargs if k.startswith(prefix)]:
391
+ value = generation_kwargs.pop(key)
392
+ if value is not None:
393
+ nested_key = key[len(prefix) :]
394
+ nested_dict = generation_kwargs.setdefault(nested_name, {})
395
+ nested_dict[nested_key] = value
399
396
 
400
397
  return generation_kwargs
401
398
 
@@ -418,15 +415,14 @@ class AnthropicChatGenerator:
418
415
  model: str | None = None
419
416
  tool_call_index = -1
420
417
  in_server_tool_block = False
421
- input_tokens = None
418
+ start_usage: dict[str, Any] = {}
422
419
  component_info = ComponentInfo.from_component(self)
423
420
  for chunk in response:
424
421
  if chunk.type in ["message_start", "content_block_start", "content_block_delta", "message_delta"]:
425
- # Extract model from message_start chunks
422
+ # Extract model and full usage from message_start chunks
426
423
  if chunk.type == "message_start":
427
424
  model = chunk.message.model
428
- if chunk.message.usage.input_tokens is not None:
429
- input_tokens = chunk.message.usage.input_tokens
425
+ start_usage = chunk.message.usage.model_dump()
430
426
 
431
427
  if chunk.type == "content_block_start":
432
428
  if chunk.content_block.type == "tool_use":
@@ -448,10 +444,9 @@ class AnthropicChatGenerator:
448
444
  {"received_at": datetime.now(timezone.utc).isoformat(), "model": model},
449
445
  )
450
446
 
451
- if input_tokens is not None:
452
- if "usage" not in completion.meta:
453
- completion.meta["usage"] = {}
454
- completion.meta["usage"]["input_tokens"] = input_tokens
447
+ # `completion.meta["usage"]` is the usage of the last message_delta chunk
448
+ if usage := _merge_streaming_usage(start_usage=start_usage, delta_usage=completion.meta.get("usage")):
449
+ completion.meta["usage"] = usage
455
450
 
456
451
  # keep the raw content so server-tool blocks can be replayed on later turns
457
452
  if self.anthropic_server_tools:
@@ -498,7 +493,7 @@ class AnthropicChatGenerator:
498
493
  model: str | None = None
499
494
  tool_call_index = -1
500
495
  in_server_tool_block = False
501
- input_tokens = None
496
+ start_usage: dict[str, Any] = {}
502
497
  component_info = ComponentInfo.from_component(self)
503
498
  async for chunk in response:
504
499
  if chunk.type in [
@@ -507,11 +502,10 @@ class AnthropicChatGenerator:
507
502
  "content_block_delta",
508
503
  "message_delta",
509
504
  ]:
510
- # Extract model from message_start chunks
505
+ # Extract model and full usage from message_start chunks
511
506
  if chunk.type == "message_start":
512
507
  model = chunk.message.model
513
- if chunk.message.usage.input_tokens is not None:
514
- input_tokens = chunk.message.usage.input_tokens
508
+ start_usage = chunk.message.usage.model_dump()
515
509
 
516
510
  if chunk.type == "content_block_start":
517
511
  if chunk.content_block.type == "tool_use":
@@ -536,10 +530,9 @@ class AnthropicChatGenerator:
536
530
  {"received_at": datetime.now(timezone.utc).isoformat(), "model": model},
537
531
  )
538
532
 
539
- if input_tokens is not None:
540
- if "usage" not in completion.meta:
541
- completion.meta["usage"] = {}
542
- completion.meta["usage"]["input_tokens"] = input_tokens
533
+ # `completion.meta["usage"]` is the usage of the last message_delta chunk
534
+ if usage := _merge_streaming_usage(start_usage=start_usage, delta_usage=completion.meta.get("usage")):
535
+ completion.meta["usage"] = usage
543
536
 
544
537
  # keep the raw content so server-tool blocks can be replayed on later turns
545
538
  if self.anthropic_server_tools:
@@ -18,7 +18,6 @@ from haystack.tools import (
18
18
  from haystack.utils import (
19
19
  Secret,
20
20
  deserialize_callable,
21
- deserialize_secrets_inplace,
22
21
  serialize_callable,
23
22
  )
24
23
 
@@ -68,8 +67,9 @@ class AnthropicFoundryChatGenerator(AnthropicChatGenerator):
68
67
  >> focuses on enabling computers to understand, interpret, and generate human language. It involves developing
69
68
  >> techniques and algorithms to analyze and process text or speech data, allowing machines to comprehend and
70
69
  >> communicate in natural languages like English, Spanish, or Chinese.")],
71
- >> _name=None, _meta={'model': 'claude-sonnet-4-5', 'index': 0, 'finish_reason': 'end_turn',
72
- >> 'usage': {'input_tokens': 15, 'output_tokens': 64}})]}
70
+ >> _name=None, _meta={'model': 'claude-sonnet-4-5', 'index': 0, 'finish_reason': 'stop',
71
+ >> 'usage': {'prompt_tokens': 15, 'completion_tokens': 64, 'cache_read_input_tokens': 0,
72
+ >> 'cache_creation_input_tokens': 0, ...}})]}
73
73
  ```
74
74
 
75
75
  For more details on supported models and their capabilities, refer to the Anthropic
@@ -258,7 +258,6 @@ class AnthropicFoundryChatGenerator(AnthropicChatGenerator):
258
258
  The deserialized component instance.
259
259
  """
260
260
  deserialize_tools_or_toolset_inplace(data["init_parameters"], key="tools")
261
- deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
262
261
 
263
262
  init_params = data.get("init_parameters", {})
264
263
  if serialized_callback := init_params.get("streaming_callback"):
@@ -387,6 +387,22 @@ def _get_openai_compatible_usage(response_dict: dict) -> dict:
387
387
  return usage
388
388
 
389
389
 
390
+ def _merge_streaming_usage(start_usage: dict[str, Any], delta_usage: dict[str, Any] | None) -> dict[str, Any]:
391
+ """
392
+ Combines the usage of a streamed response into the format a non-streamed response reports.
393
+
394
+ The `message_start` event carries the full `Usage`, including the `cache_creation` split by TTL and the
395
+ `service_tier`. The `message_delta` event carries the final cumulative counts, but only a subset of the fields.
396
+
397
+ :param start_usage: The usage of the `message_start` event.
398
+ :param delta_usage: The usage of the last `message_delta` event, or `None` if the stream had none.
399
+ :returns: The `message_start` usage with every non-null `message_delta` count written over it, converted with
400
+ `_get_openai_compatible_usage`.
401
+ """
402
+ usage = {**start_usage, **{key: value for key, value in (delta_usage or {}).items() if value is not None}}
403
+ return _get_openai_compatible_usage({"usage": usage})
404
+
405
+
390
406
  def _convert_chat_completion_to_chat_message(
391
407
  anthropic_response: Any, ignore_tools_thinking_messages: bool
392
408
  ) -> ChatMessage:
@@ -57,8 +57,9 @@ class AnthropicVertexChatGenerator(AnthropicChatGenerator):
57
57
  >> focuses on enabling computers to understand, interpret, and generate human language. It involves developing
58
58
  >> techniques and algorithms to analyze and process text or speech data, allowing machines to comprehend and
59
59
  >> communicate in natural languages like English, Spanish, or Chinese.")],
60
- >> _name=None, _meta={'model': 'claude-sonnet-4-5@20250929', 'index': 0, 'finish_reason': 'end_turn',
61
- >> 'usage': {'input_tokens': 15, 'output_tokens': 64}})]}
60
+ >> _name=None, _meta={'model': 'claude-sonnet-4-5@20250929', 'index': 0, 'finish_reason': 'stop',
61
+ >> 'usage': {'prompt_tokens': 15, 'completion_tokens': 64, 'cache_read_input_tokens': 0,
62
+ >> 'cache_creation_input_tokens': 0, ...}})]}
62
63
  ```
63
64
 
64
65
  For more details on supported models and their capabilities, refer to the Anthropic
@@ -7,7 +7,7 @@ from typing import Any
7
7
  from haystack import default_from_dict, default_to_dict
8
8
  from haystack.dataclasses import ChatMessage
9
9
  from haystack.tools import ToolsType, flatten_tools_or_toolsets
10
- from haystack.utils.auth import Secret, deserialize_secrets_inplace
10
+ from haystack.utils.auth import Secret
11
11
 
12
12
  from anthropic import Anthropic
13
13
  from anthropic.types import ToolParam
@@ -132,5 +132,4 @@ class AnthropicTokenCounter:
132
132
  :param data: The dictionary to deserialize from.
133
133
  :returns: The deserialized token counter.
134
134
  """
135
- deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
136
135
  return default_from_dict(cls, data)
@@ -539,23 +539,27 @@ class TestRun:
539
539
  },
540
540
  ),
541
541
  (
542
- # thinking_display is dropped when thinking is disabled through a flattened kwarg
542
+ # thinking type in_between with effort
543
543
  {
544
- "adaptive_thinking_effort": "none",
544
+ "thinking_type": "in_between",
545
545
  "thinking_display": "summarized",
546
+ "output_config_effort": "high",
546
547
  },
547
548
  {
548
- "thinking": {"type": "disabled"},
549
+ "thinking": {"type": "in_between", "display": "summarized"},
550
+ "output_config": {"effort": "high"},
549
551
  },
550
552
  ),
551
553
  (
552
- # thinking_display is dropped when thinking is disabled through an explicit thinking dict
554
+ # thinking type adaptive with effort
553
555
  {
554
- "thinking": {"type": "disabled"},
556
+ "thinking_type": "adaptive",
555
557
  "thinking_display": "summarized",
558
+ "output_config_effort": "high",
556
559
  },
557
560
  {
558
- "thinking": {"type": "disabled"},
561
+ "thinking": {"type": "adaptive", "display": "summarized"},
562
+ "output_config": {"effort": "high"},
559
563
  },
560
564
  ),
561
565
  ],
@@ -897,12 +901,14 @@ class TestIntegration:
897
901
  assert "Paris" in message.text
898
902
  assert "claude-sonnet-4-5" in message.meta["model"]
899
903
  assert message.meta["finish_reason"] == "stop"
904
+ assert "prompt_tokens" in message.meta["usage"]
905
+ assert "completion_tokens" in message.meta["usage"]
906
+ # only the message_start event carries `service_tier` when streaming
907
+ assert message.meta["usage"]["service_tier"] is not None
900
908
 
901
909
  if streaming:
902
910
  assert callback.counter > 1
903
911
  assert "Paris" in callback.responses
904
- assert "input_tokens" in message.meta["usage"]
905
- assert "output_tokens" in message.meta["usage"]
906
912
 
907
913
  def test_live_run_wrong_model(self, chat_messages):
908
914
  component = AnthropicChatGenerator(model="something-obviously-wrong")
@@ -934,11 +940,8 @@ class TestIntegration:
934
940
  assert tool_call.tool_name == "weather"
935
941
  assert tool_call.arguments == {"city": "Paris"}
936
942
  assert message.meta["finish_reason"] == "tool_calls"
937
- if streaming:
938
- assert "output_tokens" in message.meta["usage"]
939
- assert "input_tokens" in message.meta["usage"]
940
- else:
941
- assert "completion_tokens" in message.meta["usage"]
943
+ assert "prompt_tokens" in message.meta["usage"]
944
+ assert "completion_tokens" in message.meta["usage"]
942
945
 
943
946
  new_messages = [
944
947
  *initial_messages,
@@ -1191,8 +1194,8 @@ class TestIntegration:
1191
1194
  assert tool_call.tool_name == "weather"
1192
1195
  assert tool_call.arguments == {"city": "Paris"}
1193
1196
  assert message.meta["finish_reason"] == "tool_calls"
1194
- assert "output_tokens" in message.meta["usage"]
1195
- assert "input_tokens" in message.meta["usage"]
1197
+ assert "completion_tokens" in message.meta["usage"]
1198
+ assert "prompt_tokens" in message.meta["usage"]
1196
1199
 
1197
1200
  new_messages = [
1198
1201
  *initial_messages,
@@ -1206,16 +1209,20 @@ class TestIntegration:
1206
1209
  assert len(final_message.text) > 0
1207
1210
  assert "paris" in final_message.text.lower()
1208
1211
 
1212
+ @pytest.mark.parametrize("streaming", [False, True])
1209
1213
  @pytest.mark.parametrize("cache_enabled", [True, False])
1210
1214
  @pytest.mark.parametrize("cache_location", ["system", "user"])
1211
- def test_prompt_caching_live_run(self, cache_enabled, cache_location):
1215
+ def test_prompt_caching_live_run(self, cache_enabled, cache_location, streaming):
1212
1216
  """
1213
- Prompt caching works whether the cache_control marker sits on the system or the user message.
1217
+ Prompt caching works whether the cache_control marker sits on the system or the user message, with and
1218
+ without streaming.
1214
1219
  """
1215
1220
  generation_kwargs = {"extra_headers": {"anthropic-beta": "prompt-caching-2024-07-31"}} if cache_enabled else {}
1216
1221
 
1217
1222
  claude_llm = AnthropicChatGenerator(
1218
- api_key=Secret.from_env_var("ANTHROPIC_API_KEY"), generation_kwargs=generation_kwargs
1223
+ api_key=Secret.from_env_var("ANTHROPIC_API_KEY"),
1224
+ generation_kwargs=generation_kwargs,
1225
+ streaming_callback=print_streaming_chunk if streaming else None,
1219
1226
  )
1220
1227
 
1221
1228
  # see https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#cache-limitations
@@ -1243,6 +1250,8 @@ class TestIntegration:
1243
1250
  token_usage.get("cache_creation_input_tokens", 0) > 1024
1244
1251
  or token_usage.get("cache_read_input_tokens", 0) > 1024
1245
1252
  ), f"Unexpected usage stats: {token_usage}"
1253
+ # the split of cache writes by TTL, which only the message_start event carries when streaming
1254
+ assert token_usage["cache_creation"] is not None
1246
1255
  else:
1247
1256
  assert token_usage.get("cache_creation_input_tokens", 0) == 0
1248
1257
  assert token_usage.get("cache_read_input_tokens", 0) == 0
@@ -132,8 +132,10 @@ class TestIntegration:
132
132
  assert "paris" in message.text.lower()
133
133
  assert "claude-sonnet-4-5" in message.meta["model"]
134
134
  assert message.meta["finish_reason"] == "stop"
135
- assert "input_tokens" in message.meta["usage"]
136
- assert "output_tokens" in message.meta["usage"]
135
+ assert "prompt_tokens" in message.meta["usage"]
136
+ assert "completion_tokens" in message.meta["usage"]
137
+ # only the message_start event carries `service_tier` when streaming
138
+ assert message.meta["usage"]["service_tier"] is not None
137
139
 
138
140
  # Verify streaming behavior
139
141
  assert counter > 1 # Should have received multiple chunks
@@ -4,6 +4,7 @@ from dataclasses import replace
4
4
 
5
5
  import pytest
6
6
  from anthropic.types import (
7
+ CacheCreation,
7
8
  InputJSONDelta,
8
9
  Message,
9
10
  MessageDeltaUsage,
@@ -52,6 +53,7 @@ from haystack_integrations.components.generators.anthropic.chat.utils import (
52
53
  _finalize_reasoning_group,
53
54
  _get_openai_compatible_usage,
54
55
  _has_server_tool_blocks,
56
+ _merge_streaming_usage,
55
57
  )
56
58
 
57
59
  CITATION = {
@@ -627,11 +629,14 @@ class TestConvertAnthropicChunkToStreamingChunk:
627
629
  message = generator._process_response(raw_chunks)
628
630
  usage = message["replies"][0].meta["usage"]
629
631
  assert isinstance(usage, dict)
630
- assert usage["cache_creation_input_tokens"] is None
631
- assert usage["cache_read_input_tokens"] is None
632
- assert usage["input_tokens"] == 393
633
- assert usage["output_tokens"] == 77
632
+ assert usage["cache_creation_input_tokens"] == 0
633
+ assert usage["cache_read_input_tokens"] == 0
634
+ assert usage["prompt_tokens"] == 393
635
+ assert usage["completion_tokens"] == 77
634
636
  assert usage["server_tool_use"] is None
637
+ assert usage["service_tier"] == "standard"
638
+ assert "input_tokens" not in usage
639
+ assert "output_tokens" not in usage
635
640
 
636
641
  def test_accumulate_raw_content_blocks_from_stream(self):
637
642
  component_info = ComponentInfo(name="test", type="test")
@@ -1568,3 +1573,41 @@ class TestOpenAICompatibleUsage:
1568
1573
  @pytest.mark.parametrize("response_dict", [{}, {"usage": {}}], ids=["no_usage_key", "empty_usage"])
1569
1574
  def test_empty_usage_is_unchanged(self, response_dict):
1570
1575
  assert _get_openai_compatible_usage(response_dict) == {}
1576
+
1577
+
1578
+ class TestMergeStreamingUsage:
1579
+ def test_cache_creation_split_is_kept(self):
1580
+ """`message_delta` usage has no `cache_creation`, so the split must come from `message_start`."""
1581
+ start_usage = Usage(
1582
+ input_tokens=10,
1583
+ output_tokens=1,
1584
+ cache_read_input_tokens=2000,
1585
+ cache_creation_input_tokens=1500,
1586
+ cache_creation=CacheCreation(ephemeral_5m_input_tokens=500, ephemeral_1h_input_tokens=1000),
1587
+ service_tier="standard",
1588
+ ).model_dump()
1589
+ delta_usage = MessageDeltaUsage(
1590
+ input_tokens=10, output_tokens=77, cache_read_input_tokens=2000, cache_creation_input_tokens=1500
1591
+ ).model_dump()
1592
+
1593
+ usage = _merge_streaming_usage(start_usage=start_usage, delta_usage=delta_usage)
1594
+
1595
+ assert usage["prompt_tokens"] == 3510
1596
+ assert usage["completion_tokens"] == 77
1597
+ assert usage["cache_read_input_tokens"] == 2000
1598
+ assert usage["cache_creation_input_tokens"] == 1500
1599
+ assert usage["cache_creation"] == {"ephemeral_5m_input_tokens": 500, "ephemeral_1h_input_tokens": 1000}
1600
+ assert usage["service_tier"] == "standard"
1601
+ assert "input_tokens" not in usage
1602
+ assert "output_tokens" not in usage
1603
+
1604
+ def test_null_delta_counts_keep_start_counts(self):
1605
+ start_usage = {"input_tokens": 393, "output_tokens": 3, "cache_read_input_tokens": 0}
1606
+ delta_usage = {"input_tokens": None, "output_tokens": 77, "cache_read_input_tokens": None}
1607
+
1608
+ usage = _merge_streaming_usage(start_usage=start_usage, delta_usage=delta_usage)
1609
+
1610
+ assert usage == {"prompt_tokens": 393, "completion_tokens": 77, "cache_read_input_tokens": 0}
1611
+
1612
+ def test_no_usage_returns_empty_dict(self):
1613
+ assert _merge_streaming_usage(start_usage={}, delta_usage=None) == {}