openrouter-haystack 1.2.0__tar.gz → 1.3.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 (17) hide show
  1. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/CHANGELOG.md +19 -4
  2. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/LICENSE.txt +1 -1
  3. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/PKG-INFO +3 -3
  4. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/README.md +1 -1
  5. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/pyproject.toml +1 -0
  6. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/src/haystack_integrations/components/generators/openrouter/chat/chat_generator.py +164 -30
  7. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/tests/test_openrouter_chat_generator.py +327 -276
  8. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/tests/test_openrouter_chat_generator_async.py +199 -184
  9. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/.gitignore +0 -0
  10. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/examples/openrouter_with_structured_outputs.py +0 -0
  11. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/examples/openrouter_with_tools_example.py +0 -0
  12. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/pydoc/config_docusaurus.yml +0 -0
  13. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/src/haystack_integrations/components/generators/openrouter/__init__.py +0 -0
  14. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/src/haystack_integrations/components/generators/openrouter/chat/__init__.py +0 -0
  15. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/src/haystack_integrations/components/generators/py.typed +0 -0
  16. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/tests/__init__.py +0 -0
  17. {openrouter_haystack-1.2.0 → openrouter_haystack-1.3.0}/tests/conftest.py +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [integrations/openrouter-v1.2.0] - 2026-07-06
4
+
5
+ ### 🐛 Bug Fixes
6
+
7
+ - Support Haystack 3.0's lazy OpenAI client initialization in OpenAI-inheriting generators (#3536)
8
+
9
+ ### 🧪 Testing
10
+
11
+ - Trust test modules under Haystack 3.0's deserialization allowlist (#3537)
12
+ - Make Tool/Agent serialization assertions version-agnostic for Haystack 2.x/3.x (#3533)
13
+ - Guard ToolInvoker imports so chat-generator tests run under Haystack 3.0 (#3535)
14
+
15
+
3
16
  ## [integrations/openrouter-v1.1.0] - 2026-06-08
4
17
 
5
18
  ### 🚀 Features
@@ -85,10 +98,6 @@
85
98
 
86
99
  ## [integrations/openrouter-v0.2.0] - 2025-07-01
87
100
 
88
- ### 🚀 Features
89
-
90
- - Add OpenRouter integration (#1723)
91
-
92
101
  ### 🐛 Bug Fixes
93
102
 
94
103
  - Fix openrouter types + add py.typed (#2029)
@@ -103,4 +112,10 @@
103
112
 
104
113
  - Test: Remove `test_check_abnormal_completions` - already tested in Haystack (#1842)
105
114
 
115
+ ## [integrations/openrouter-v0.1.0] - 2025-05-16
116
+
117
+ ### 🚀 Features
118
+
119
+ - Add OpenRouter integration (#1723)
120
+
106
121
  <!-- generated by git-cliff -->
@@ -58,7 +58,7 @@ APPENDIX: How to apply the Apache License to your work.
58
58
 
59
59
  To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
60
60
 
61
- Copyright [yyyy] [name of copyright owner]
61
+ Copyright 2023-present deepset GmbH
62
62
 
63
63
  Licensed under the Apache License, Version 2.0 (the "License");
64
64
  you may not use this file except in compliance with the License.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: openrouter-haystack
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/openrouter#readme
5
5
  Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
6
6
  Project-URL: Source, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/openrouter
@@ -24,7 +24,7 @@ Description-Content-Type: text/markdown
24
24
  # openrouter-haystack
25
25
 
26
26
  [![PyPI - Version](https://img.shields.io/pypi/v/openrouter-haystack.svg)](https://pypi.org/project/openrouter-haystack)
27
- [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/openrouter-haystack.svg)](https://pypi.org/project/openrouterhaystack)
27
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/openrouter-haystack.svg)](https://pypi.org/project/openrouter-haystack)
28
28
 
29
29
  - [Integration page](https://haystack.deepset.ai/integrations/openrouter)
30
30
  - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/openrouter/CHANGELOG.md)
@@ -1,7 +1,7 @@
1
1
  # openrouter-haystack
2
2
 
3
3
  [![PyPI - Version](https://img.shields.io/pypi/v/openrouter-haystack.svg)](https://pypi.org/project/openrouter-haystack)
4
- [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/openrouter-haystack.svg)](https://pypi.org/project/openrouterhaystack)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/openrouter-haystack.svg)](https://pypi.org/project/openrouter-haystack)
5
5
 
6
6
  - [Integration page](https://haystack.deepset.ai/integrations/openrouter)
7
7
  - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/openrouter/CHANGELOG.md)
@@ -127,6 +127,7 @@ ignore = [
127
127
  "PLR0912",
128
128
  "PLR0913",
129
129
  "PLR0915",
130
+ "PLR0917",
130
131
  # Misc
131
132
  "B008",
132
133
  "S101",
@@ -2,24 +2,38 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
+ import asyncio
6
+ import inspect
5
7
  import json
8
+ from dataclasses import replace
6
9
  from typing import Any
7
10
 
8
11
  from haystack import component, default_to_dict, logging
9
12
  from haystack.components.generators.chat import OpenAIChatGenerator
10
- from haystack.components.generators.chat.openai import _check_finish_reason
11
- from haystack.components.generators.utils import _normalize_messages, _serialize_object
13
+ from haystack.components.generators.chat.openai import (
14
+ _check_finish_reason,
15
+ _convert_chat_completion_chunk_to_streaming_chunk,
16
+ )
17
+ from haystack.components.generators.utils import (
18
+ _convert_streaming_chunks_to_chat_message,
19
+ _normalize_messages,
20
+ _serialize_object,
21
+ )
12
22
  from haystack.dataclasses import (
13
23
  ChatMessage,
24
+ ComponentInfo,
14
25
  ReasoningContent,
15
26
  StreamingCallbackT,
27
+ StreamingChunk,
28
+ SyncStreamingCallbackT,
16
29
  ToolCall,
17
30
  select_streaming_callback,
18
31
  )
19
32
  from haystack.tools import ToolsType, _check_duplicate_tool_names, flatten_tools_or_toolsets, serialize_tools_or_toolset
20
33
  from haystack.utils import serialize_callable
21
34
  from haystack.utils.auth import Secret
22
- from openai.types.chat import ChatCompletion, ParsedChatCompletion
35
+ from openai import AsyncStream, Stream
36
+ from openai.types.chat import ChatCompletion, ChatCompletionChunk, ParsedChatCompletion
23
37
  from openai.types.chat.chat_completion import Choice
24
38
 
25
39
  logger = logging.getLogger(__name__)
@@ -101,6 +115,109 @@ def _convert_openrouter_completion_to_chat_message(
101
115
  return ChatMessage.from_assistant(text=text, tool_calls=tool_calls, meta=meta, reasoning=reasoning)
102
116
 
103
117
 
118
+ def _convert_openrouter_chunk_to_streaming_chunks(
119
+ chunk: ChatCompletionChunk, previous_chunks: list[StreamingChunk], component_info: ComponentInfo
120
+ ) -> list[StreamingChunk]:
121
+ """
122
+ Convert an OpenRouter streaming chunk to StreamingChunks, including reasoning content.
123
+
124
+ A `StreamingChunk` holds either reasoning or content/tool calls, so a delta carrying both is split into a
125
+ reasoning chunk followed by the content or tool call chunk.
126
+ """
127
+ streaming_chunk = _convert_chat_completion_chunk_to_streaming_chunk(
128
+ chunk=chunk, previous_chunks=previous_chunks, component_info=component_info
129
+ )
130
+ # the parent converter ignores the `reasoning`/`reasoning_details` fields OpenRouter adds to the delta
131
+ reasoning = _extract_reasoning(chunk.choices[0].delta) if chunk.choices else None
132
+ has_payload = bool(streaming_chunk.content or streaming_chunk.tool_calls)
133
+ # the last chunk with reasoning, content, or tool calls tells which block the stream is currently in
134
+ last_block = next((c for c in reversed(previous_chunks) if c.reasoning or c.content or c.tool_calls), None)
135
+
136
+ streaming_chunks = []
137
+ if reasoning is not None:
138
+ details = reasoning.extra.get("reasoning_details", [])
139
+ # adopt the index of the first reasoning detail, as the parent does with the first tool call index
140
+ index = next((d["index"] for d in details if isinstance(d.get("index"), int)), 0)
141
+ reasoning_chunk = StreamingChunk(
142
+ content="",
143
+ reasoning=reasoning,
144
+ index=index,
145
+ start=last_block is None or last_block.reasoning is None or last_block.index != index,
146
+ # a reasoning-only delta replaces the parent's empty chunk, so it carries its finish reason
147
+ finish_reason=None if has_payload else streaming_chunk.finish_reason,
148
+ component_info=component_info,
149
+ meta=streaming_chunk.meta,
150
+ )
151
+ if not has_payload:
152
+ return [reasoning_chunk]
153
+ streaming_chunks.append(reasoning_chunk)
154
+ last_block = reasoning_chunk
155
+
156
+ if streaming_chunk.content:
157
+ # the parent converter marks the second chunk of a stream as start, assuming the first one only carries `role`,
158
+ # and gives text chunks index None when `role` is set. OpenRouter sets `role` on every chunk and can stream text
159
+ # in the first one, so derive start and index from the previous block instead. Text after tool calls keeps the
160
+ # parent's start and index, so the whitespace some models stream between tool calls gets no header.
161
+ if last_block is None:
162
+ streaming_chunk = replace(streaming_chunk, start=True)
163
+ elif last_block.reasoning is not None:
164
+ streaming_chunk = replace(streaming_chunk, start=True, index=(last_block.index or 0) + 1)
165
+ elif last_block.content:
166
+ streaming_chunk = replace(streaming_chunk, start=False, index=last_block.index)
167
+ elif streaming_chunk.tool_calls and last_block is not None:
168
+ # the parent converter uses the provider's tool call index as chunk index, which collides with the reasoning
169
+ # or text block before the tool calls, so shift the chunk index past that block. Later tool calls keep the
170
+ # shift of the first one, even if text is streamed between them. `ToolCallDelta.index` keeps the provider's
171
+ # index, which the tool calls are assembled by.
172
+ previous_tool_chunk = next((c for c in reversed(previous_chunks) if c.tool_calls), None)
173
+ if previous_tool_chunk is not None and previous_tool_chunk.tool_calls:
174
+ offset = (previous_tool_chunk.index or 0) - previous_tool_chunk.tool_calls[0].index
175
+ else:
176
+ offset = (last_block.index or 0) + 1
177
+ streaming_chunk = replace(streaming_chunk, index=streaming_chunk.tool_calls[0].index + offset)
178
+ streaming_chunks.append(streaming_chunk)
179
+ return streaming_chunks
180
+
181
+
182
+ def _merge_reasoning_details(details: list[dict[str, Any]]) -> list[dict[str, Any]]:
183
+ """
184
+ Merge streamed `reasoning_details` fragments into complete entries.
185
+
186
+ OpenRouter streams each reasoning detail as fragments sharing its `index` and `type`. String payloads are
187
+ concatenated in order, other fields keep their latest non-null value, such as a `signature` that only arrives
188
+ with the last fragment.
189
+ """
190
+ merged: dict[tuple[Any, Any], dict[str, Any]] = {}
191
+ for detail in details:
192
+ key = (detail.get("index"), detail.get("type"))
193
+ if key not in merged:
194
+ merged[key] = dict(detail)
195
+ continue
196
+ entry = merged[key]
197
+ for field, value in detail.items():
198
+ if field in ("text", "summary", "data") and isinstance(value, str):
199
+ entry[field] = (entry.get(field) or "") + value
200
+ elif value is not None:
201
+ entry[field] = value
202
+ return list(merged.values())
203
+
204
+
205
+ def _convert_openrouter_streaming_chunks_to_chat_message(chunks: list[StreamingChunk]) -> ChatMessage:
206
+ """Connect OpenRouter streaming chunks into a single ChatMessage, keeping the merged `reasoning_details`."""
207
+ message = _convert_streaming_chunks_to_chat_message(chunks=chunks)
208
+ # the core helper only joins the reasoning text, but OpenRouter expects the details back in multi-turn conversations
209
+ details = [d for c in chunks if c.reasoning for d in c.reasoning.extra.get("reasoning_details", [])]
210
+ if not details:
211
+ return message
212
+ reasoning = ReasoningContent(
213
+ reasoning_text=message.reasoning.reasoning_text if message.reasoning else "",
214
+ extra={"reasoning_details": _merge_reasoning_details(details)},
215
+ )
216
+ return ChatMessage.from_assistant(
217
+ text=message.text, tool_calls=message.tool_calls, meta=message.meta, reasoning=reasoning
218
+ )
219
+
220
+
104
221
  @component
105
222
  class OpenRouterChatGenerator(OpenAIChatGenerator):
106
223
  """
@@ -118,7 +235,8 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
118
235
  - **Customizability**: Supports all parameters supported by the OpenRouter chat completion endpoint.
119
236
  - **Reasoning Support**: Extracts reasoning/thinking content from models that support it
120
237
  (e.g., DeepSeek R1, Claude with extended thinking) and stores it in the `ReasoningContent`
121
- field on `ChatMessage`. Reasoning content is only captured for non-streaming requests.
238
+ field on `ChatMessage`. When streaming, reasoning is also passed to the `streaming_callback`
239
+ in `StreamingChunk.reasoning`.
122
240
 
123
241
  This component uses the ChatMessage format for structuring both input and output,
124
242
  ensuring coherent and contextually relevant responses in chat-based text generation scenarios.
@@ -190,7 +308,6 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
190
308
  - `random_seed`: The seed to use for random sampling.
191
309
  - `reasoning`: A dict to configure reasoning/thinking tokens for models that support it.
192
310
  Example: `{"effort": "high"}` or `{"max_tokens": 2000}`.
193
- Reasoning content is only captured for non-streaming requests.
194
311
  See [OpenRouter reasoning docs](https://openrouter.ai/docs/use-cases/reasoning-tokens).
195
312
  - `response_format`: A JSON schema or a Pydantic model that enforces the structure of the model's response.
196
313
  :param tools:
@@ -321,6 +438,42 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
321
438
  final_args["response_format"] = response_format
322
439
  return final_args
323
440
 
441
+ def _handle_stream_response(
442
+ self, chat_completion: Stream[ChatCompletionChunk], callback: SyncStreamingCallbackT
443
+ ) -> list[ChatMessage]:
444
+ component_info = ComponentInfo.from_component(self)
445
+ chunks: list[StreamingChunk] = []
446
+ for chunk in chat_completion:
447
+ assert len(chunk.choices) <= 1, "Streaming responses should have at most one choice."
448
+ for streaming_chunk in _convert_openrouter_chunk_to_streaming_chunks(
449
+ chunk=chunk, previous_chunks=chunks, component_info=component_info
450
+ ):
451
+ chunks.append(streaming_chunk)
452
+ callback(streaming_chunk)
453
+ return [_convert_openrouter_streaming_chunks_to_chat_message(chunks=chunks)]
454
+
455
+ async def _handle_async_stream_response(
456
+ self, chat_completion: AsyncStream[ChatCompletionChunk], callback: StreamingCallbackT
457
+ ) -> list[ChatMessage]:
458
+ component_info = ComponentInfo.from_component(self)
459
+ chunks: list[StreamingChunk] = []
460
+ try:
461
+ async for chunk in chat_completion:
462
+ assert len(chunk.choices) <= 1, "Streaming responses should have at most one choice."
463
+ for streaming_chunk in _convert_openrouter_chunk_to_streaming_chunks(
464
+ chunk=chunk, previous_chunks=chunks, component_info=component_info
465
+ ):
466
+ chunks.append(streaming_chunk)
467
+ # haystack-ai >= 3.0 also accepts sync callbacks in async runs, so only await async ones
468
+ callback_result = callback(streaming_chunk)
469
+ if inspect.isawaitable(callback_result):
470
+ await callback_result
471
+ except asyncio.CancelledError:
472
+ # shield the close so the stream is released even though the task is being cancelled
473
+ await asyncio.shield(chat_completion.close())
474
+ raise
475
+ return [_convert_openrouter_streaming_chunks_to_chat_message(chunks=chunks)]
476
+
324
477
  @component.output_types(replies=list[ChatMessage])
325
478
  def run(
326
479
  self,
@@ -340,8 +493,9 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
340
493
  :param streaming_callback:
341
494
  A callback function that is called when a new token is received from the stream.
342
495
  :param generation_kwargs:
343
- Additional keyword arguments for text generation. These parameters will
344
- override the parameters passed during component initialization.
496
+ Additional keyword arguments for text generation. These are merged per key with the
497
+ `generation_kwargs` passed at initialization: keys provided here take precedence, keys set only
498
+ at initialization are kept.
345
499
  For details on OpenRouter API parameters, see
346
500
  [OpenRouter docs](https://openrouter.ai/docs/quickstart).
347
501
  :param tools: A list of Tool and/or Toolset objects, or a single Toolset for which the model can prepare calls.
@@ -363,16 +517,6 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
363
517
  init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=False
364
518
  )
365
519
 
366
- # Reasoning content is reconstructed from the full response message, which is not available while
367
- # streaming, so we warn the user that it will not be captured in this mode.
368
- if streaming_callback is not None:
369
- merged_kwargs = {**self.generation_kwargs, **(generation_kwargs or {})}
370
- if merged_kwargs.get("reasoning"):
371
- logger.warning(
372
- "Streaming with reasoning is active. Reasoning content will not be captured during "
373
- "streaming. Use non-streaming mode to extract reasoning content."
374
- )
375
-
376
520
  api_args = self._prepare_api_call(
377
521
  messages=messages,
378
522
  streaming_callback=streaming_callback,
@@ -385,7 +529,6 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
385
529
  chat_completion = getattr(self.client.chat.completions, openai_endpoint)(**api_args) # type: ignore[union-attr]
386
530
 
387
531
  if streaming_callback is not None:
388
- # streaming uses the inherited handler so reasoning extraction is intentionally skipped
389
532
  completions = self._handle_stream_response(chat_completion, streaming_callback)
390
533
  else:
391
534
  assert isinstance(chat_completion, ChatCompletion), "Unexpected response type for non-streaming request."
@@ -419,7 +562,9 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
419
562
  A callback function that is called when a new token is received from the stream.
420
563
  Must be a coroutine.
421
564
  :param generation_kwargs:
422
- Additional keyword arguments for text generation.
565
+ Additional keyword arguments for text generation. These are merged per key with the
566
+ `generation_kwargs` passed at initialization: keys provided here take precedence, keys set only
567
+ at initialization are kept.
423
568
  :param tools: A list of Tool and/or Toolset objects, or a single Toolset.
424
569
  :param tools_strict:
425
570
  Whether to enable strict schema adherence for tool calls.
@@ -442,16 +587,6 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
442
587
  init_callback=self.streaming_callback, runtime_callback=streaming_callback, requires_async=True
443
588
  )
444
589
 
445
- # Reasoning content is reconstructed from the full response message, which is not available while
446
- # streaming, so we warn the user that it will not be captured in this mode.
447
- if streaming_callback is not None:
448
- merged_kwargs = {**self.generation_kwargs, **(generation_kwargs or {})}
449
- if merged_kwargs.get("reasoning"):
450
- logger.warning(
451
- "Streaming with reasoning is active. Reasoning content will not be captured during "
452
- "streaming. Use non-streaming mode to extract reasoning content."
453
- )
454
-
455
590
  api_args = self._prepare_api_call(
456
591
  messages=messages,
457
592
  streaming_callback=streaming_callback,
@@ -464,7 +599,6 @@ class OpenRouterChatGenerator(OpenAIChatGenerator):
464
599
  chat_completion = await getattr(self.async_client.chat.completions, openai_endpoint)(**api_args) # type: ignore[union-attr]
465
600
 
466
601
  if streaming_callback is not None:
467
- # streaming uses the inherited handler so reasoning extraction is intentionally skipped
468
602
  completions = await self._handle_async_stream_response(chat_completion, streaming_callback)
469
603
  else:
470
604
  assert isinstance(chat_completion, ChatCompletion), "Unexpected response type for non-streaming request."