langchain-dev-utils 1.3.7__py3-none-any.whl
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.
- langchain_dev_utils/__init__.py +1 -0
- langchain_dev_utils/_utils.py +131 -0
- langchain_dev_utils/agents/__init__.py +4 -0
- langchain_dev_utils/agents/factory.py +99 -0
- langchain_dev_utils/agents/file_system.py +252 -0
- langchain_dev_utils/agents/middleware/__init__.py +21 -0
- langchain_dev_utils/agents/middleware/format_prompt.py +66 -0
- langchain_dev_utils/agents/middleware/handoffs.py +214 -0
- langchain_dev_utils/agents/middleware/model_fallback.py +49 -0
- langchain_dev_utils/agents/middleware/model_router.py +200 -0
- langchain_dev_utils/agents/middleware/plan.py +367 -0
- langchain_dev_utils/agents/middleware/summarization.py +85 -0
- langchain_dev_utils/agents/middleware/tool_call_repair.py +96 -0
- langchain_dev_utils/agents/middleware/tool_emulator.py +60 -0
- langchain_dev_utils/agents/middleware/tool_selection.py +82 -0
- langchain_dev_utils/agents/plan.py +188 -0
- langchain_dev_utils/agents/wrap.py +324 -0
- langchain_dev_utils/chat_models/__init__.py +11 -0
- langchain_dev_utils/chat_models/adapters/__init__.py +3 -0
- langchain_dev_utils/chat_models/adapters/create_utils.py +53 -0
- langchain_dev_utils/chat_models/adapters/openai_compatible.py +715 -0
- langchain_dev_utils/chat_models/adapters/register_profiles.py +15 -0
- langchain_dev_utils/chat_models/base.py +282 -0
- langchain_dev_utils/chat_models/types.py +27 -0
- langchain_dev_utils/embeddings/__init__.py +11 -0
- langchain_dev_utils/embeddings/adapters/__init__.py +3 -0
- langchain_dev_utils/embeddings/adapters/create_utils.py +45 -0
- langchain_dev_utils/embeddings/adapters/openai_compatible.py +91 -0
- langchain_dev_utils/embeddings/base.py +234 -0
- langchain_dev_utils/message_convert/__init__.py +15 -0
- langchain_dev_utils/message_convert/content.py +201 -0
- langchain_dev_utils/message_convert/format.py +69 -0
- langchain_dev_utils/pipeline/__init__.py +7 -0
- langchain_dev_utils/pipeline/parallel.py +135 -0
- langchain_dev_utils/pipeline/sequential.py +101 -0
- langchain_dev_utils/pipeline/types.py +3 -0
- langchain_dev_utils/py.typed +0 -0
- langchain_dev_utils/tool_calling/__init__.py +14 -0
- langchain_dev_utils/tool_calling/human_in_the_loop.py +284 -0
- langchain_dev_utils/tool_calling/utils.py +81 -0
- langchain_dev_utils-1.3.7.dist-info/METADATA +103 -0
- langchain_dev_utils-1.3.7.dist-info/RECORD +44 -0
- langchain_dev_utils-1.3.7.dist-info/WHEEL +4 -0
- langchain_dev_utils-1.3.7.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Iterator
|
|
4
|
+
from json import JSONDecodeError
|
|
5
|
+
from typing import (
|
|
6
|
+
Any,
|
|
7
|
+
Callable,
|
|
8
|
+
List,
|
|
9
|
+
Literal,
|
|
10
|
+
Optional,
|
|
11
|
+
Sequence,
|
|
12
|
+
Type,
|
|
13
|
+
TypeVar,
|
|
14
|
+
Union,
|
|
15
|
+
cast,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import openai
|
|
19
|
+
from langchain_core.callbacks import (
|
|
20
|
+
AsyncCallbackManagerForLLMRun,
|
|
21
|
+
CallbackManagerForLLMRun,
|
|
22
|
+
)
|
|
23
|
+
from langchain_core.language_models import (
|
|
24
|
+
LangSmithParams,
|
|
25
|
+
LanguageModelInput,
|
|
26
|
+
ModelProfile,
|
|
27
|
+
)
|
|
28
|
+
from langchain_core.messages import (
|
|
29
|
+
AIMessage,
|
|
30
|
+
AIMessageChunk,
|
|
31
|
+
BaseMessage,
|
|
32
|
+
HumanMessage,
|
|
33
|
+
ToolMessage,
|
|
34
|
+
)
|
|
35
|
+
from langchain_core.outputs import ChatGenerationChunk, ChatResult
|
|
36
|
+
from langchain_core.runnables import Runnable
|
|
37
|
+
from langchain_core.tools import BaseTool
|
|
38
|
+
from langchain_core.utils import from_env, secret_from_env
|
|
39
|
+
from langchain_core.utils.function_calling import convert_to_openai_tool
|
|
40
|
+
from langchain_openai.chat_models._compat import _convert_from_v1_to_chat_completions
|
|
41
|
+
from langchain_openai.chat_models.base import BaseChatOpenAI, _convert_message_to_dict
|
|
42
|
+
from pydantic import (
|
|
43
|
+
BaseModel,
|
|
44
|
+
ConfigDict,
|
|
45
|
+
Field,
|
|
46
|
+
PrivateAttr,
|
|
47
|
+
SecretStr,
|
|
48
|
+
create_model,
|
|
49
|
+
model_validator,
|
|
50
|
+
)
|
|
51
|
+
from typing_extensions import Self
|
|
52
|
+
|
|
53
|
+
from ..._utils import (
|
|
54
|
+
_validate_base_url,
|
|
55
|
+
_validate_model_cls_name,
|
|
56
|
+
_validate_provider_name,
|
|
57
|
+
)
|
|
58
|
+
from ..types import (
|
|
59
|
+
CompatibilityOptions,
|
|
60
|
+
ReasoningKeepPolicy,
|
|
61
|
+
ResponseFormatType,
|
|
62
|
+
ToolChoiceType,
|
|
63
|
+
)
|
|
64
|
+
from .register_profiles import (
|
|
65
|
+
_get_profile_by_provider_and_model,
|
|
66
|
+
_register_profile_with_provider,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
_BM = TypeVar("_BM", bound=BaseModel)
|
|
70
|
+
_DictOrPydanticClass = Union[dict[str, Any], type[_BM], type]
|
|
71
|
+
_DictOrPydantic = Union[dict, _BM]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_last_human_message_index(messages: list[BaseMessage]) -> int:
|
|
75
|
+
"""find the index of the last HumanMessage in the messages list, return -1 if not found."""
|
|
76
|
+
return next(
|
|
77
|
+
(
|
|
78
|
+
i
|
|
79
|
+
for i in range(len(messages) - 1, -1, -1)
|
|
80
|
+
if isinstance(messages[i], HumanMessage)
|
|
81
|
+
),
|
|
82
|
+
-1,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _transform_video_block(block: dict[str, Any]) -> dict:
|
|
87
|
+
"""Transform video block to video_url block."""
|
|
88
|
+
if "url" in block:
|
|
89
|
+
return {
|
|
90
|
+
"type": "video_url",
|
|
91
|
+
"video_url": {
|
|
92
|
+
"url": block["url"],
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
if "base64" in block or block.get("source_type") == "base64":
|
|
96
|
+
if "mime_type" not in block:
|
|
97
|
+
error_message = "mime_type key is required for base64 data."
|
|
98
|
+
raise ValueError(error_message)
|
|
99
|
+
mime_type = block["mime_type"]
|
|
100
|
+
base64_data = block["data"] if "data" in block else block["base64"]
|
|
101
|
+
return {
|
|
102
|
+
"type": "video_url",
|
|
103
|
+
"video_url": {
|
|
104
|
+
"url": f"data:{mime_type};base64,{base64_data}",
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
error_message = "Unsupported source type. Only 'url' and 'base64' are supported."
|
|
108
|
+
raise ValueError(error_message)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _process_video_input(message: BaseMessage):
|
|
112
|
+
"""
|
|
113
|
+
Process BaseMessage with video input.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
message (BaseMessage): The HumanMessage instance to process.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
None: The method modifies the message in-place.
|
|
120
|
+
"""
|
|
121
|
+
if not message.content:
|
|
122
|
+
return message
|
|
123
|
+
content = message.content
|
|
124
|
+
|
|
125
|
+
if not isinstance(content, list):
|
|
126
|
+
return message
|
|
127
|
+
|
|
128
|
+
formatted_content = []
|
|
129
|
+
for block in content:
|
|
130
|
+
if isinstance(block, dict) and block.get("type") == "video":
|
|
131
|
+
formatted_content.append(_transform_video_block(block))
|
|
132
|
+
else:
|
|
133
|
+
formatted_content.append(block)
|
|
134
|
+
message = message.model_copy(update={"content": formatted_content})
|
|
135
|
+
return message
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _BaseChatOpenAICompatible(BaseChatOpenAI):
|
|
139
|
+
"""
|
|
140
|
+
Base template class for OpenAI-compatible chat model implementations.
|
|
141
|
+
|
|
142
|
+
This class provides a foundation for integrating various LLM providers that
|
|
143
|
+
offer OpenAI-compatible APIs. It enhances the base OpenAI functionality by:
|
|
144
|
+
|
|
145
|
+
**1. Supports output of more types of reasoning content (reasoning_content)**
|
|
146
|
+
ChatOpenAI can only output reasoning content natively supported by official
|
|
147
|
+
OpenAI models, while OpenAICompatibleChatModel can output reasoning content
|
|
148
|
+
from other model providers.
|
|
149
|
+
|
|
150
|
+
**2. Dynamically adapts to choose the most suitable structured-output method**
|
|
151
|
+
OpenAICompatibleChatModel selects the best structured-output method (function_calling or json_schema)
|
|
152
|
+
based on the actual capabilities of the model provider.
|
|
153
|
+
|
|
154
|
+
**3. Supports configuration of related parameters**
|
|
155
|
+
For cases where parameters differ from the official OpenAI API, this library
|
|
156
|
+
provides the compatibility_options parameter to address this issue. For
|
|
157
|
+
example, when different model providers have inconsistent support for
|
|
158
|
+
tool_choice, you can adapt by setting supported_tool_choice in
|
|
159
|
+
compatibility_options.
|
|
160
|
+
|
|
161
|
+
Built on top of `langchain-openai`'s `BaseChatOpenAI`, this template class
|
|
162
|
+
extends capabilities to better support diverse OpenAI-compatible model
|
|
163
|
+
providers while maintaining full compatibility with LangChain's chat model
|
|
164
|
+
interface.
|
|
165
|
+
|
|
166
|
+
Note: This is a template class and should not be exported or instantiated
|
|
167
|
+
directly. Instead, use it as a base class and provide the specific provider
|
|
168
|
+
name through inheritance or the factory function
|
|
169
|
+
`create_openai_compatible_model()`.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
model_name: str = Field(alias="model", default="openai compatible model")
|
|
173
|
+
"""The name of the model"""
|
|
174
|
+
api_key: Optional[SecretStr] = Field(
|
|
175
|
+
default_factory=secret_from_env("OPENAI_COMPATIBLE_API_KEY", default=None),
|
|
176
|
+
)
|
|
177
|
+
"""OpenAI Compatible API key"""
|
|
178
|
+
api_base: str = Field(
|
|
179
|
+
default_factory=from_env("OPENAI_COMPATIBLE_API_BASE", default=""),
|
|
180
|
+
)
|
|
181
|
+
"""OpenAI Compatible API base URL"""
|
|
182
|
+
|
|
183
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
184
|
+
|
|
185
|
+
_provider: str = PrivateAttr(default="openai-compatible")
|
|
186
|
+
|
|
187
|
+
"""Provider Compatibility Options"""
|
|
188
|
+
supported_tool_choice: ToolChoiceType = Field(default_factory=list)
|
|
189
|
+
"""Supported tool choice"""
|
|
190
|
+
supported_response_format: ResponseFormatType = Field(default_factory=list)
|
|
191
|
+
"""Supported response format"""
|
|
192
|
+
reasoning_keep_policy: ReasoningKeepPolicy = Field(default="never")
|
|
193
|
+
"""How to keep reasoning content in the messages"""
|
|
194
|
+
include_usage: bool = Field(default=True)
|
|
195
|
+
"""Whether to include usage information in the output"""
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def _llm_type(self) -> str:
|
|
199
|
+
return f"chat-{self._provider}"
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def lc_secrets(self) -> dict[str, str]:
|
|
203
|
+
return {"api_key": f"{self._provider.upper()}_API_KEY"}
|
|
204
|
+
|
|
205
|
+
def _get_ls_params(
|
|
206
|
+
self,
|
|
207
|
+
stop: Optional[list[str]] = None,
|
|
208
|
+
**kwargs: Any,
|
|
209
|
+
) -> LangSmithParams:
|
|
210
|
+
ls_params = super()._get_ls_params(stop=stop, **kwargs)
|
|
211
|
+
ls_params["ls_provider"] = self._provider
|
|
212
|
+
return ls_params
|
|
213
|
+
|
|
214
|
+
def _get_request_payload(
|
|
215
|
+
self,
|
|
216
|
+
input_: LanguageModelInput,
|
|
217
|
+
*,
|
|
218
|
+
stop: list[str] | None = None,
|
|
219
|
+
**kwargs: Any,
|
|
220
|
+
) -> dict:
|
|
221
|
+
payload = {**self._default_params, **kwargs}
|
|
222
|
+
|
|
223
|
+
if self._use_responses_api(payload):
|
|
224
|
+
return super()._get_request_payload(input_, stop=stop, **kwargs)
|
|
225
|
+
|
|
226
|
+
messages = self._convert_input(input_).to_messages()
|
|
227
|
+
if stop is not None:
|
|
228
|
+
kwargs["stop"] = stop
|
|
229
|
+
|
|
230
|
+
payload_messages = []
|
|
231
|
+
last_human_index = -1
|
|
232
|
+
if self.reasoning_keep_policy == "current":
|
|
233
|
+
last_human_index = _get_last_human_message_index(messages)
|
|
234
|
+
|
|
235
|
+
for index, m in enumerate(messages):
|
|
236
|
+
if isinstance(m, AIMessage):
|
|
237
|
+
msg_dict = _convert_message_to_dict(
|
|
238
|
+
_convert_from_v1_to_chat_completions(m)
|
|
239
|
+
)
|
|
240
|
+
if self.reasoning_keep_policy == "all" and m.additional_kwargs.get(
|
|
241
|
+
"reasoning_content"
|
|
242
|
+
):
|
|
243
|
+
msg_dict["reasoning_content"] = m.additional_kwargs.get(
|
|
244
|
+
"reasoning_content"
|
|
245
|
+
)
|
|
246
|
+
elif (
|
|
247
|
+
self.reasoning_keep_policy == "current"
|
|
248
|
+
and index > last_human_index
|
|
249
|
+
and m.additional_kwargs.get("reasoning_content")
|
|
250
|
+
):
|
|
251
|
+
msg_dict["reasoning_content"] = m.additional_kwargs.get(
|
|
252
|
+
"reasoning_content"
|
|
253
|
+
)
|
|
254
|
+
payload_messages.append(msg_dict)
|
|
255
|
+
else:
|
|
256
|
+
if (
|
|
257
|
+
isinstance(m, HumanMessage) or isinstance(m, ToolMessage)
|
|
258
|
+
) and isinstance(m.content, list):
|
|
259
|
+
m = _process_video_input(m)
|
|
260
|
+
payload_messages.append(_convert_message_to_dict(m))
|
|
261
|
+
|
|
262
|
+
payload["messages"] = payload_messages
|
|
263
|
+
return payload
|
|
264
|
+
|
|
265
|
+
@model_validator(mode="after")
|
|
266
|
+
def validate_environment(self) -> Self:
|
|
267
|
+
if not (self.api_key and self.api_key.get_secret_value()):
|
|
268
|
+
msg = f"{self._provider.upper()}_API_KEY must be set."
|
|
269
|
+
raise ValueError(msg)
|
|
270
|
+
client_params: dict = {
|
|
271
|
+
k: v
|
|
272
|
+
for k, v in {
|
|
273
|
+
"api_key": self.api_key.get_secret_value() if self.api_key else None,
|
|
274
|
+
"base_url": self.api_base,
|
|
275
|
+
"timeout": self.request_timeout,
|
|
276
|
+
"max_retries": self.max_retries,
|
|
277
|
+
"default_headers": self.default_headers,
|
|
278
|
+
"default_query": self.default_query,
|
|
279
|
+
}.items()
|
|
280
|
+
if v is not None
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if not (self.client or None):
|
|
284
|
+
sync_specific: dict = {"http_client": self.http_client}
|
|
285
|
+
self.root_client = openai.OpenAI(**client_params, **sync_specific)
|
|
286
|
+
self.client = self.root_client.chat.completions
|
|
287
|
+
if not (self.async_client or None):
|
|
288
|
+
async_specific: dict = {"http_client": self.http_async_client}
|
|
289
|
+
self.root_async_client = openai.AsyncOpenAI(
|
|
290
|
+
**client_params,
|
|
291
|
+
**async_specific,
|
|
292
|
+
)
|
|
293
|
+
self.async_client = self.root_async_client.chat.completions
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
@model_validator(mode="after")
|
|
297
|
+
def _set_model_profile(self) -> Self:
|
|
298
|
+
"""Set model profile if not overridden."""
|
|
299
|
+
if self.profile is None:
|
|
300
|
+
self.profile = cast(
|
|
301
|
+
ModelProfile,
|
|
302
|
+
_get_profile_by_provider_and_model(self._provider, self.model_name),
|
|
303
|
+
)
|
|
304
|
+
return self
|
|
305
|
+
|
|
306
|
+
def _create_chat_result(
|
|
307
|
+
self,
|
|
308
|
+
response: Union[dict, openai.BaseModel],
|
|
309
|
+
generation_info: Optional[dict] = None,
|
|
310
|
+
) -> ChatResult:
|
|
311
|
+
"""Convert API response to LangChain ChatResult with enhanced content processing.
|
|
312
|
+
|
|
313
|
+
Extends base implementation to capture and preserve reasoning content from
|
|
314
|
+
model responses, supporting advanced models that provide reasoning chains
|
|
315
|
+
or thought processes alongside regular responses.
|
|
316
|
+
|
|
317
|
+
Handles multiple response formats:
|
|
318
|
+
- Standard OpenAI response objects with `reasoning_content` attribute
|
|
319
|
+
- Responses with `model_extra` containing reasoning data
|
|
320
|
+
- Dictionary responses (pass-through to base implementation)
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
response: Raw API response (OpenAI object or dict)
|
|
324
|
+
generation_info: Additional generation metadata
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
ChatResult with enhanced message containing reasoning content when available
|
|
328
|
+
"""
|
|
329
|
+
rtn = super()._create_chat_result(response, generation_info)
|
|
330
|
+
|
|
331
|
+
if not isinstance(response, openai.BaseModel):
|
|
332
|
+
return rtn
|
|
333
|
+
|
|
334
|
+
for generation in rtn.generations:
|
|
335
|
+
if generation.message.response_metadata is None:
|
|
336
|
+
generation.message.response_metadata = {}
|
|
337
|
+
generation.message.response_metadata["model_provider"] = "openai-compatible"
|
|
338
|
+
|
|
339
|
+
choices = getattr(response, "choices", None)
|
|
340
|
+
if choices and hasattr(choices[0].message, "reasoning_content"):
|
|
341
|
+
rtn.generations[0].message.additional_kwargs["reasoning_content"] = choices[
|
|
342
|
+
0
|
|
343
|
+
].message.reasoning_content
|
|
344
|
+
elif choices and hasattr(choices[0].message, "model_extra"):
|
|
345
|
+
model_extra = choices[0].message.model_extra
|
|
346
|
+
if isinstance(model_extra, dict) and (
|
|
347
|
+
reasoning := model_extra.get("reasoning")
|
|
348
|
+
):
|
|
349
|
+
rtn.generations[0].message.additional_kwargs["reasoning_content"] = (
|
|
350
|
+
reasoning
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return rtn
|
|
354
|
+
|
|
355
|
+
def _convert_chunk_to_generation_chunk(
|
|
356
|
+
self,
|
|
357
|
+
chunk: dict,
|
|
358
|
+
default_chunk_class: type,
|
|
359
|
+
base_generation_info: Optional[dict],
|
|
360
|
+
) -> Optional[ChatGenerationChunk]:
|
|
361
|
+
"""Convert streaming chunk to generation chunk with reasoning content support.
|
|
362
|
+
|
|
363
|
+
Processes streaming response chunks to extract reasoning content alongside
|
|
364
|
+
regular message content, enabling real-time streaming of both response
|
|
365
|
+
text and reasoning chains from compatible models.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
chunk: Raw streaming chunk from API
|
|
369
|
+
default_chunk_class: Expected chunk type for validation
|
|
370
|
+
base_generation_info: Base metadata for the generation
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
ChatGenerationChunk with reasoning content when present in chunk data
|
|
374
|
+
"""
|
|
375
|
+
generation_chunk = super()._convert_chunk_to_generation_chunk(
|
|
376
|
+
chunk,
|
|
377
|
+
default_chunk_class,
|
|
378
|
+
base_generation_info,
|
|
379
|
+
)
|
|
380
|
+
if (choices := chunk.get("choices")) and generation_chunk:
|
|
381
|
+
top = choices[0]
|
|
382
|
+
if isinstance(generation_chunk.message, AIMessageChunk):
|
|
383
|
+
generation_chunk.message.response_metadata = {
|
|
384
|
+
**generation_chunk.message.response_metadata,
|
|
385
|
+
"model_provider": "openai-compatible",
|
|
386
|
+
}
|
|
387
|
+
if (
|
|
388
|
+
reasoning_content := top.get("delta", {}).get("reasoning_content")
|
|
389
|
+
) is not None:
|
|
390
|
+
generation_chunk.message.additional_kwargs["reasoning_content"] = (
|
|
391
|
+
reasoning_content
|
|
392
|
+
)
|
|
393
|
+
elif (reasoning := top.get("delta", {}).get("reasoning")) is not None:
|
|
394
|
+
generation_chunk.message.additional_kwargs["reasoning_content"] = (
|
|
395
|
+
reasoning
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return generation_chunk
|
|
399
|
+
|
|
400
|
+
def _stream(
|
|
401
|
+
self,
|
|
402
|
+
messages: List[BaseMessage],
|
|
403
|
+
stop: Optional[List[str]] = None,
|
|
404
|
+
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
|
405
|
+
**kwargs: Any,
|
|
406
|
+
) -> Iterator[ChatGenerationChunk]:
|
|
407
|
+
if self._use_responses_api({**kwargs, **self.model_kwargs}):
|
|
408
|
+
for chunk in super()._stream_responses(
|
|
409
|
+
messages, stop=stop, run_manager=run_manager, **kwargs
|
|
410
|
+
):
|
|
411
|
+
yield chunk
|
|
412
|
+
else:
|
|
413
|
+
if self.include_usage:
|
|
414
|
+
kwargs["stream_options"] = {"include_usage": True}
|
|
415
|
+
try:
|
|
416
|
+
for chunk in super()._stream(
|
|
417
|
+
messages, stop=stop, run_manager=run_manager, **kwargs
|
|
418
|
+
):
|
|
419
|
+
yield chunk
|
|
420
|
+
except JSONDecodeError as e:
|
|
421
|
+
raise JSONDecodeError(
|
|
422
|
+
f"{self._provider.title()} API returned an invalid response. "
|
|
423
|
+
"Please check the API status and try again.",
|
|
424
|
+
e.doc,
|
|
425
|
+
e.pos,
|
|
426
|
+
) from e
|
|
427
|
+
|
|
428
|
+
async def _astream(
|
|
429
|
+
self,
|
|
430
|
+
messages: List[BaseMessage],
|
|
431
|
+
stop: Optional[List[str]] = None,
|
|
432
|
+
run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
|
|
433
|
+
**kwargs: Any,
|
|
434
|
+
) -> AsyncIterator[ChatGenerationChunk]:
|
|
435
|
+
if self._use_responses_api({**kwargs, **self.model_kwargs}):
|
|
436
|
+
async for chunk in super()._astream_responses(
|
|
437
|
+
messages, stop=stop, run_manager=run_manager, **kwargs
|
|
438
|
+
):
|
|
439
|
+
yield chunk
|
|
440
|
+
else:
|
|
441
|
+
if self.include_usage:
|
|
442
|
+
kwargs["stream_options"] = {"include_usage": True}
|
|
443
|
+
try:
|
|
444
|
+
async for chunk in super()._astream(
|
|
445
|
+
messages, stop=stop, run_manager=run_manager, **kwargs
|
|
446
|
+
):
|
|
447
|
+
yield chunk
|
|
448
|
+
except JSONDecodeError as e:
|
|
449
|
+
raise JSONDecodeError(
|
|
450
|
+
f"{self._provider.title()} API returned an invalid response. "
|
|
451
|
+
"Please check the API status and try again.",
|
|
452
|
+
e.doc,
|
|
453
|
+
e.pos,
|
|
454
|
+
) from e
|
|
455
|
+
|
|
456
|
+
def _generate(
|
|
457
|
+
self,
|
|
458
|
+
messages: List[BaseMessage],
|
|
459
|
+
stop: Optional[List[str]] = None,
|
|
460
|
+
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
|
461
|
+
**kwargs: Any,
|
|
462
|
+
) -> ChatResult:
|
|
463
|
+
try:
|
|
464
|
+
return super()._generate(
|
|
465
|
+
messages, stop=stop, run_manager=run_manager, **kwargs
|
|
466
|
+
)
|
|
467
|
+
except JSONDecodeError as e:
|
|
468
|
+
raise JSONDecodeError(
|
|
469
|
+
f"{self._provider.title()} API returned an invalid response. "
|
|
470
|
+
"Please check the API status and try again.",
|
|
471
|
+
e.doc,
|
|
472
|
+
e.pos,
|
|
473
|
+
) from e
|
|
474
|
+
|
|
475
|
+
async def _agenerate(
|
|
476
|
+
self,
|
|
477
|
+
messages: List[BaseMessage],
|
|
478
|
+
stop: Optional[List[str]] = None,
|
|
479
|
+
run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
|
|
480
|
+
**kwargs: Any,
|
|
481
|
+
) -> ChatResult:
|
|
482
|
+
try:
|
|
483
|
+
return await super()._agenerate(
|
|
484
|
+
messages, stop=stop, run_manager=run_manager, **kwargs
|
|
485
|
+
)
|
|
486
|
+
except JSONDecodeError as e:
|
|
487
|
+
raise JSONDecodeError(
|
|
488
|
+
f"{self._provider.title()} API returned an invalid response. "
|
|
489
|
+
"Please check the API status and try again.",
|
|
490
|
+
e.doc,
|
|
491
|
+
e.pos,
|
|
492
|
+
) from e
|
|
493
|
+
|
|
494
|
+
def bind_tools(
|
|
495
|
+
self,
|
|
496
|
+
tools: Sequence[dict[str, Any] | type | Callable | BaseTool],
|
|
497
|
+
*,
|
|
498
|
+
tool_choice: dict | str | bool | None = None,
|
|
499
|
+
strict: bool | None = None,
|
|
500
|
+
parallel_tool_calls: bool | None = None,
|
|
501
|
+
**kwargs: Any,
|
|
502
|
+
) -> Runnable[LanguageModelInput, AIMessage]:
|
|
503
|
+
if parallel_tool_calls is not None:
|
|
504
|
+
kwargs["parallel_tool_calls"] = parallel_tool_calls
|
|
505
|
+
formatted_tools = [
|
|
506
|
+
convert_to_openai_tool(tool, strict=strict) for tool in tools
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
tool_names = []
|
|
510
|
+
for tool in formatted_tools:
|
|
511
|
+
if "function" in tool:
|
|
512
|
+
tool_names.append(tool["function"]["name"])
|
|
513
|
+
elif "name" in tool:
|
|
514
|
+
tool_names.append(tool["name"])
|
|
515
|
+
else:
|
|
516
|
+
pass
|
|
517
|
+
|
|
518
|
+
support_tool_choice = False
|
|
519
|
+
if tool_choice is not None:
|
|
520
|
+
if isinstance(tool_choice, bool):
|
|
521
|
+
tool_choice = "required"
|
|
522
|
+
if isinstance(tool_choice, str):
|
|
523
|
+
if (
|
|
524
|
+
tool_choice in ["auto", "none", "required"]
|
|
525
|
+
and tool_choice in self.supported_tool_choice
|
|
526
|
+
):
|
|
527
|
+
support_tool_choice = True
|
|
528
|
+
|
|
529
|
+
elif "specific" in self.supported_tool_choice:
|
|
530
|
+
if tool_choice in tool_names:
|
|
531
|
+
support_tool_choice = True
|
|
532
|
+
tool_choice = {
|
|
533
|
+
"type": "function",
|
|
534
|
+
"function": {"name": tool_choice},
|
|
535
|
+
}
|
|
536
|
+
tool_choice = tool_choice if support_tool_choice else None
|
|
537
|
+
if tool_choice:
|
|
538
|
+
kwargs["tool_choice"] = tool_choice
|
|
539
|
+
return super().bind(tools=formatted_tools, **kwargs)
|
|
540
|
+
|
|
541
|
+
def with_structured_output(
|
|
542
|
+
self,
|
|
543
|
+
schema: Optional[_DictOrPydanticClass] = None,
|
|
544
|
+
*,
|
|
545
|
+
method: Literal[
|
|
546
|
+
"function_calling",
|
|
547
|
+
"json_mode",
|
|
548
|
+
"json_schema",
|
|
549
|
+
] = "json_schema",
|
|
550
|
+
include_raw: bool = False,
|
|
551
|
+
strict: Optional[bool] = None,
|
|
552
|
+
**kwargs: Any,
|
|
553
|
+
) -> Runnable[LanguageModelInput, _DictOrPydantic]:
|
|
554
|
+
"""Configure structured output extraction with provider compatibility handling.
|
|
555
|
+
|
|
556
|
+
Enables parsing of model outputs into structured formats (Pydantic models
|
|
557
|
+
or dictionaries) while handling provider-specific method compatibility.
|
|
558
|
+
Falls back from json_schema to function_calling for providers that don't
|
|
559
|
+
support the json_schema method.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
schema: Output schema (Pydantic model class or dictionary definition)
|
|
563
|
+
method: Extraction method - defaults to json_schema, it the provider doesn't support json_schema, it will fallback to function_calling
|
|
564
|
+
include_raw: Whether to include raw model response alongside parsed output
|
|
565
|
+
strict: Schema enforcement strictness (provider-dependent)
|
|
566
|
+
**kwargs: Additional structured output parameters
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
Runnable configured for structured output extraction
|
|
570
|
+
"""
|
|
571
|
+
if method not in ["function_calling", "json_mode", "json_schema"]:
|
|
572
|
+
raise ValueError(
|
|
573
|
+
f"Unsupported method: {method}. Please choose from 'function_calling', 'json_mode', 'json_schema'."
|
|
574
|
+
)
|
|
575
|
+
if (
|
|
576
|
+
method == "json_schema"
|
|
577
|
+
and "json_schema" not in self.supported_response_format
|
|
578
|
+
):
|
|
579
|
+
method = "function_calling"
|
|
580
|
+
elif (
|
|
581
|
+
method == "json_mode" and "json_mode" not in self.supported_response_format
|
|
582
|
+
):
|
|
583
|
+
method = "function_calling"
|
|
584
|
+
|
|
585
|
+
return super().with_structured_output(
|
|
586
|
+
schema,
|
|
587
|
+
method=method,
|
|
588
|
+
include_raw=include_raw,
|
|
589
|
+
strict=strict,
|
|
590
|
+
**kwargs,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _validate_compatibility_options(
|
|
595
|
+
compatibility_options: Optional[CompatibilityOptions] = None,
|
|
596
|
+
) -> None:
|
|
597
|
+
"""Validate provider configuration against supported features.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
compatibility_options: Optional configuration for the provider
|
|
601
|
+
|
|
602
|
+
Raises:
|
|
603
|
+
ValueError: If provider configuration is invalid
|
|
604
|
+
"""
|
|
605
|
+
if compatibility_options is None:
|
|
606
|
+
compatibility_options = {}
|
|
607
|
+
|
|
608
|
+
if "supported_tool_choice" in compatibility_options:
|
|
609
|
+
_supported_tool_choice = compatibility_options["supported_tool_choice"]
|
|
610
|
+
for tool_choice in _supported_tool_choice:
|
|
611
|
+
if tool_choice not in ["auto", "none", "required", "specific"]:
|
|
612
|
+
raise ValueError(
|
|
613
|
+
f"Unsupported tool_choice: {tool_choice}. Please choose from 'auto', 'none', 'required','specific'."
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
if "supported_response_format" in compatibility_options:
|
|
617
|
+
_supported_response_format = compatibility_options["supported_response_format"]
|
|
618
|
+
for response_format in _supported_response_format:
|
|
619
|
+
if response_format not in ["json_schema", "json_mode"]:
|
|
620
|
+
raise ValueError(
|
|
621
|
+
f"Unsupported response_format: {response_format}. Please choose from 'json_schema', 'json_mode'."
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
if "reasoning_keep_policy" in compatibility_options:
|
|
625
|
+
_reasoning_keep_policy = compatibility_options["reasoning_keep_policy"]
|
|
626
|
+
if _reasoning_keep_policy not in ["never", "current", "all"]:
|
|
627
|
+
raise ValueError(
|
|
628
|
+
f"Unsupported reasoning_keep_policy: {_reasoning_keep_policy}. Please choose from 'never', 'current', 'all'."
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
if "include_usage" in compatibility_options:
|
|
632
|
+
_include_usage = compatibility_options["include_usage"]
|
|
633
|
+
if not isinstance(_include_usage, bool):
|
|
634
|
+
raise ValueError(
|
|
635
|
+
f"include_usage must be a boolean value. Received: {_include_usage}"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _create_openai_compatible_model(
|
|
640
|
+
provider: str,
|
|
641
|
+
base_url: str,
|
|
642
|
+
compatibility_options: Optional[CompatibilityOptions] = None,
|
|
643
|
+
profiles: Optional[dict[str, dict[str, Any]]] = None,
|
|
644
|
+
chat_model_cls_name: Optional[str] = None,
|
|
645
|
+
) -> Type[_BaseChatOpenAICompatible]:
|
|
646
|
+
"""Factory function for creating provider-specific OpenAI-compatible model classes.
|
|
647
|
+
|
|
648
|
+
Dynamically generates model classes for different OpenAI-compatible providers,
|
|
649
|
+
configuring environment variable mappings and default base URLs specific to each provider.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
provider: Provider identifier (e.g.`vllm`)
|
|
653
|
+
base_url: Default API base URL for the provider
|
|
654
|
+
compatibility_options: Optional configuration for the provider
|
|
655
|
+
profiles: Optional profiles for the provider
|
|
656
|
+
chat_model_cls_name: Optional name for the model class
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
Configured model class ready for instantiation with provider-specific settings
|
|
660
|
+
"""
|
|
661
|
+
chat_model_cls_name = chat_model_cls_name or f"Chat{provider.title()}"
|
|
662
|
+
if compatibility_options is None:
|
|
663
|
+
compatibility_options = {}
|
|
664
|
+
|
|
665
|
+
if profiles is not None:
|
|
666
|
+
_register_profile_with_provider(provider, profiles)
|
|
667
|
+
|
|
668
|
+
_validate_compatibility_options(compatibility_options)
|
|
669
|
+
|
|
670
|
+
_validate_provider_name(provider)
|
|
671
|
+
|
|
672
|
+
_validate_model_cls_name(chat_model_cls_name)
|
|
673
|
+
|
|
674
|
+
_validate_base_url(base_url)
|
|
675
|
+
|
|
676
|
+
return create_model(
|
|
677
|
+
chat_model_cls_name,
|
|
678
|
+
__base__=_BaseChatOpenAICompatible,
|
|
679
|
+
api_base=(
|
|
680
|
+
str,
|
|
681
|
+
Field(
|
|
682
|
+
default_factory=from_env(
|
|
683
|
+
f"{provider.upper()}_API_BASE", default=base_url
|
|
684
|
+
),
|
|
685
|
+
),
|
|
686
|
+
),
|
|
687
|
+
api_key=(
|
|
688
|
+
str,
|
|
689
|
+
Field(
|
|
690
|
+
default_factory=secret_from_env(
|
|
691
|
+
f"{provider.upper()}_API_KEY", default=None
|
|
692
|
+
),
|
|
693
|
+
),
|
|
694
|
+
),
|
|
695
|
+
_provider=(
|
|
696
|
+
str,
|
|
697
|
+
PrivateAttr(default=provider),
|
|
698
|
+
),
|
|
699
|
+
supported_tool_choice=(
|
|
700
|
+
ToolChoiceType,
|
|
701
|
+
Field(default=compatibility_options.get("supported_tool_choice", ["auto"])),
|
|
702
|
+
),
|
|
703
|
+
reasoning_keep_policy=(
|
|
704
|
+
ReasoningKeepPolicy,
|
|
705
|
+
Field(default=compatibility_options.get("reasoning_keep_policy", "never")),
|
|
706
|
+
),
|
|
707
|
+
supported_response_format=(
|
|
708
|
+
ResponseFormatType,
|
|
709
|
+
Field(default=compatibility_options.get("supported_response_format", [])),
|
|
710
|
+
),
|
|
711
|
+
include_usage=(
|
|
712
|
+
bool,
|
|
713
|
+
Field(default=compatibility_options.get("include_usage", True)),
|
|
714
|
+
),
|
|
715
|
+
)
|