chatterer 0.1.24__py3-none-any.whl → 0.1.26__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.
Files changed (44) hide show
  1. chatterer/__init__.py +87 -93
  2. chatterer/common_types/__init__.py +21 -21
  3. chatterer/common_types/io.py +19 -19
  4. chatterer/examples/__main__.py +75 -75
  5. chatterer/examples/any2md.py +85 -85
  6. chatterer/examples/pdf2md.py +338 -338
  7. chatterer/examples/pdf2txt.py +54 -54
  8. chatterer/examples/ppt.py +486 -486
  9. chatterer/examples/pw.py +143 -137
  10. chatterer/examples/snippet.py +56 -55
  11. chatterer/examples/transcribe.py +192 -112
  12. chatterer/examples/upstage.py +89 -89
  13. chatterer/examples/web2md.py +80 -66
  14. chatterer/interactive.py +354 -354
  15. chatterer/language_model.py +536 -536
  16. chatterer/messages.py +21 -21
  17. chatterer/tools/__init__.py +46 -46
  18. chatterer/tools/caption_markdown_images.py +384 -384
  19. chatterer/tools/citation_chunking/__init__.py +3 -3
  20. chatterer/tools/citation_chunking/chunks.py +53 -53
  21. chatterer/tools/citation_chunking/citation_chunker.py +118 -118
  22. chatterer/tools/citation_chunking/citations.py +285 -285
  23. chatterer/tools/citation_chunking/prompt.py +157 -157
  24. chatterer/tools/citation_chunking/reference.py +26 -26
  25. chatterer/tools/citation_chunking/utils.py +138 -138
  26. chatterer/tools/convert_pdf_to_markdown.py +645 -625
  27. chatterer/tools/convert_to_text.py +446 -446
  28. chatterer/tools/upstage_document_parser.py +705 -705
  29. chatterer/tools/webpage_to_markdown.py +739 -739
  30. chatterer/tools/youtube.py +146 -146
  31. chatterer/utils/__init__.py +15 -15
  32. chatterer/utils/base64_image.py +350 -285
  33. chatterer/utils/bytesio.py +59 -59
  34. chatterer/utils/code_agent.py +237 -237
  35. chatterer/utils/imghdr.py +145 -148
  36. {chatterer-0.1.24.dist-info → chatterer-0.1.26.dist-info}/METADATA +390 -389
  37. chatterer-0.1.26.dist-info/RECORD +42 -0
  38. chatterer/strategies/__init__.py +0 -13
  39. chatterer/strategies/atom_of_thoughts.py +0 -975
  40. chatterer/strategies/base.py +0 -14
  41. chatterer-0.1.24.dist-info/RECORD +0 -45
  42. {chatterer-0.1.24.dist-info → chatterer-0.1.26.dist-info}/WHEEL +0 -0
  43. {chatterer-0.1.24.dist-info → chatterer-0.1.26.dist-info}/entry_points.txt +0 -0
  44. {chatterer-0.1.24.dist-info → chatterer-0.1.26.dist-info}/top_level.txt +0 -0
@@ -1,536 +1,536 @@
1
- import re
2
- from typing import (
3
- TYPE_CHECKING,
4
- Any,
5
- AsyncIterator,
6
- Callable,
7
- Iterable,
8
- Iterator,
9
- Literal,
10
- Optional,
11
- Self,
12
- Sequence,
13
- Type,
14
- TypeAlias,
15
- TypeVar,
16
- overload,
17
- )
18
-
19
- from langchain_core.language_models.base import LanguageModelInput
20
- from langchain_core.language_models.chat_models import BaseChatModel
21
- from langchain_core.runnables.base import Runnable
22
- from langchain_core.runnables.config import RunnableConfig
23
- from langchain_core.utils.utils import secret_from_env
24
- from pydantic import BaseModel, Field, SecretStr
25
-
26
- from .messages import AIMessage, BaseMessage, HumanMessage, UsageMetadata
27
- from .utils.code_agent import CodeExecutionResult, FunctionSignature, augment_prompt_for_toolcall
28
-
29
- if TYPE_CHECKING:
30
- from instructor import Partial
31
- from langchain_experimental.tools.python.tool import PythonAstREPLTool
32
-
33
- PydanticModelT = TypeVar("PydanticModelT", bound=BaseModel)
34
- StructuredOutputType: TypeAlias = dict[object, object] | BaseModel
35
-
36
- DEFAULT_IMAGE_DESCRIPTION_INSTRUCTION = "Provide a detailed description of all visible elements in the image, summarizing key details in a few clear sentences."
37
- DEFAULT_CODE_GENERATION_PROMPT = (
38
- "You are utilizing a Python code execution tool now.\n"
39
- "Your goal is to generate Python code that solves the task efficiently and appends both the code and its output to your context memory.\n"
40
- "\n"
41
- "To optimize tool efficiency, follow these guidelines:\n"
42
- "- Write concise, efficient code that directly serves the intended purpose.\n"
43
- "- Avoid unnecessary operations (e.g., excessive loops, recursion, or heavy computations).\n"
44
- "- Handle potential errors gracefully (e.g., using try-except blocks).\n"
45
- "\n"
46
- "Return your response strictly in the following JSON format:\n"
47
- '{\n "code": "<your_python_code_here>"\n}\n\n'
48
- )
49
-
50
-
51
- DEFAULT_FUNCTION_REFERENCE_PREFIX_PROMPT = (
52
- "Below functions are included in global scope and can be used in your code.\n"
53
- "Do not try to redefine the function(s).\n"
54
- "You don't have to force yourself to use these tools - use them only when you need to.\n"
55
- )
56
- DEFAULT_FUNCTION_REFERENCE_SEPARATOR = "\n---\n" # Separator to distinguish different function references
57
-
58
- PYTHON_CODE_PATTERN: re.Pattern[str] = re.compile(r"```(?:python\s*\n)?(.*?)```", re.DOTALL)
59
-
60
-
61
- class Chatterer(BaseModel):
62
- """Language model for generating text from a given input."""
63
-
64
- client: BaseChatModel
65
- structured_output_kwargs: dict[str, Any] = Field(default_factory=dict)
66
-
67
- @classmethod
68
- def from_provider(
69
- cls,
70
- provider_and_model: str,
71
- structured_output_kwargs: Optional[dict[str, object]] = {"strict": True},
72
- **kwargs: object,
73
- ) -> Self:
74
- backend, model = provider_and_model.split(":", 1)
75
- backends = cls.get_backends()
76
- if func := backends.get(backend):
77
- return func(model, structured_output_kwargs, **kwargs)
78
- else:
79
- raise ValueError(f"Unsupported provider: {backend}. Supported providers are: {', '.join(backends.keys())}.")
80
-
81
- @classmethod
82
- def get_backends(cls) -> dict[str, Callable[[str, Optional[dict[str, object]]], Self]]:
83
- return {
84
- "openai": cls.openai,
85
- "anthropic": cls.anthropic,
86
- "google": cls.google,
87
- "ollama": cls.ollama,
88
- "openrouter": cls.open_router,
89
- "xai": cls.xai,
90
- }
91
-
92
- @classmethod
93
- def openai(
94
- cls,
95
- model: str = "gpt-4.1",
96
- structured_output_kwargs: Optional[dict[str, object]] = {"strict": True},
97
- api_key: Optional[str] = None,
98
- **kwargs: Any,
99
- ) -> Self:
100
- from langchain_openai import ChatOpenAI
101
-
102
- return cls(
103
- client=ChatOpenAI(
104
- model=model,
105
- api_key=_get_api_key(api_key=api_key, env_key="OPENAI_API_KEY", raise_if_none=False),
106
- **kwargs,
107
- ),
108
- structured_output_kwargs=structured_output_kwargs or {},
109
- )
110
-
111
- @classmethod
112
- def anthropic(
113
- cls,
114
- model_name: str = "claude-3-7-sonnet-20250219",
115
- structured_output_kwargs: Optional[dict[str, object]] = None,
116
- api_key: Optional[str] = None,
117
- **kwargs: Any,
118
- ) -> Self:
119
- from langchain_anthropic import ChatAnthropic
120
-
121
- return cls(
122
- client=ChatAnthropic(
123
- model_name=model_name,
124
- api_key=_get_api_key(api_key=api_key, env_key="ANTHROPIC_API_KEY", raise_if_none=True),
125
- **kwargs,
126
- ),
127
- structured_output_kwargs=structured_output_kwargs or {},
128
- )
129
-
130
- @classmethod
131
- def google(
132
- cls,
133
- model: str = "gemini-2.5-flash-preview-04-17",
134
- structured_output_kwargs: Optional[dict[str, object]] = None,
135
- api_key: Optional[str] = None,
136
- **kwargs: Any,
137
- ) -> Self:
138
- from langchain_google_genai import ChatGoogleGenerativeAI
139
-
140
- return cls(
141
- client=ChatGoogleGenerativeAI(
142
- model=model,
143
- api_key=_get_api_key(api_key=api_key, env_key="GOOGLE_API_KEY", raise_if_none=False),
144
- **kwargs,
145
- ),
146
- structured_output_kwargs=structured_output_kwargs or {},
147
- )
148
-
149
- @classmethod
150
- def ollama(
151
- cls,
152
- model: str = "deepseek-r1:1.5b",
153
- structured_output_kwargs: Optional[dict[str, object]] = None,
154
- **kwargs: Any,
155
- ) -> Self:
156
- from langchain_ollama import ChatOllama
157
-
158
- return cls(
159
- client=ChatOllama(model=model, **kwargs),
160
- structured_output_kwargs=structured_output_kwargs or {},
161
- )
162
-
163
- @classmethod
164
- def open_router(
165
- cls,
166
- model: str = "openrouter/quasar-alpha",
167
- structured_output_kwargs: Optional[dict[str, object]] = None,
168
- api_key: Optional[str] = None,
169
- **kwargs: Any,
170
- ) -> Self:
171
- from langchain_openai import ChatOpenAI
172
-
173
- return cls(
174
- client=ChatOpenAI(
175
- model=model,
176
- base_url="https://openrouter.ai/api/v1",
177
- api_key=_get_api_key(api_key=api_key, env_key="OPENROUTER_API_KEY", raise_if_none=False),
178
- **kwargs,
179
- ),
180
- structured_output_kwargs=structured_output_kwargs or {},
181
- )
182
-
183
- @classmethod
184
- def xai(
185
- cls,
186
- model: str = "grok-3-mini",
187
- structured_output_kwargs: Optional[dict[str, object]] = None,
188
- base_url: str = "https://api.x.ai/v1",
189
- api_key: Optional[str] = None,
190
- **kwargs: Any,
191
- ) -> Self:
192
- from langchain_openai import ChatOpenAI
193
-
194
- return cls(
195
- client=ChatOpenAI(
196
- model=model,
197
- base_url=base_url,
198
- api_key=_get_api_key(api_key=api_key, env_key="XAI_API_KEY", raise_if_none=False),
199
- **kwargs,
200
- ),
201
- structured_output_kwargs=structured_output_kwargs or {},
202
- )
203
-
204
- @property
205
- def invoke(self):
206
- return self.client.invoke
207
-
208
- @property
209
- def ainvoke(self):
210
- return self.client.ainvoke
211
-
212
- @property
213
- def stream(self):
214
- return self.client.stream
215
-
216
- @property
217
- def astream(self):
218
- return self.client.astream
219
-
220
- @property
221
- def bind_tools(self): # pyright: ignore[reportUnknownParameterType]
222
- return self.client.bind_tools # pyright: ignore[reportUnknownParameterType, reportUnknownVariableType, reportUnknownMemberType]
223
-
224
- def __getattr__(self, name: str) -> Any:
225
- return getattr(self.client, name)
226
-
227
- @overload
228
- def __call__(
229
- self,
230
- messages: LanguageModelInput,
231
- response_model: Type[PydanticModelT],
232
- config: Optional[RunnableConfig] = None,
233
- stop: Optional[list[str]] = None,
234
- **kwargs: Any,
235
- ) -> PydanticModelT: ...
236
-
237
- @overload
238
- def __call__(
239
- self,
240
- messages: LanguageModelInput,
241
- response_model: None = None,
242
- config: Optional[RunnableConfig] = None,
243
- stop: Optional[list[str]] = None,
244
- **kwargs: Any,
245
- ) -> str: ...
246
-
247
- def __call__(
248
- self,
249
- messages: LanguageModelInput,
250
- response_model: Optional[Type[PydanticModelT]] = None,
251
- config: Optional[RunnableConfig] = None,
252
- stop: Optional[list[str]] = None,
253
- **kwargs: Any,
254
- ) -> str | PydanticModelT:
255
- if response_model:
256
- return self.generate_pydantic(response_model, messages, config, stop, **kwargs)
257
- return self.client.invoke(input=messages, config=config, stop=stop, **kwargs).text()
258
-
259
- def generate(
260
- self,
261
- messages: LanguageModelInput,
262
- config: Optional[RunnableConfig] = None,
263
- stop: Optional[list[str]] = None,
264
- **kwargs: Any,
265
- ) -> str:
266
- return self.client.invoke(input=messages, config=config, stop=stop, **kwargs).text()
267
-
268
- async def agenerate(
269
- self,
270
- messages: LanguageModelInput,
271
- config: Optional[RunnableConfig] = None,
272
- stop: Optional[list[str]] = None,
273
- **kwargs: Any,
274
- ) -> str:
275
- return (await self.client.ainvoke(input=messages, config=config, stop=stop, **kwargs)).text()
276
-
277
- def generate_stream(
278
- self,
279
- messages: LanguageModelInput,
280
- config: Optional[RunnableConfig] = None,
281
- stop: Optional[list[str]] = None,
282
- **kwargs: Any,
283
- ) -> Iterator[str]:
284
- for chunk in self.client.stream(input=messages, config=config, stop=stop, **kwargs):
285
- yield chunk.text()
286
-
287
- async def agenerate_stream(
288
- self,
289
- messages: LanguageModelInput,
290
- config: Optional[RunnableConfig] = None,
291
- stop: Optional[list[str]] = None,
292
- **kwargs: Any,
293
- ) -> AsyncIterator[str]:
294
- async for chunk in self.client.astream(input=messages, config=config, stop=stop, **kwargs):
295
- yield chunk.text()
296
-
297
- def generate_pydantic(
298
- self,
299
- response_model: Type[PydanticModelT],
300
- messages: LanguageModelInput,
301
- config: Optional[RunnableConfig] = None,
302
- stop: Optional[list[str]] = None,
303
- **kwargs: Any,
304
- ) -> PydanticModelT:
305
- result: StructuredOutputType = _with_structured_output(
306
- client=self.client,
307
- response_model=response_model,
308
- structured_output_kwargs=self.structured_output_kwargs,
309
- ).invoke(input=messages, config=config, stop=stop, **kwargs)
310
- if isinstance(result, response_model):
311
- return result
312
- else:
313
- return response_model.model_validate(result)
314
-
315
- async def agenerate_pydantic(
316
- self,
317
- response_model: Type[PydanticModelT],
318
- messages: LanguageModelInput,
319
- config: Optional[RunnableConfig] = None,
320
- stop: Optional[list[str]] = None,
321
- **kwargs: Any,
322
- ) -> PydanticModelT:
323
- result: StructuredOutputType = await _with_structured_output(
324
- client=self.client,
325
- response_model=response_model,
326
- structured_output_kwargs=self.structured_output_kwargs,
327
- ).ainvoke(input=messages, config=config, stop=stop, **kwargs)
328
- if isinstance(result, response_model):
329
- return result
330
- else:
331
- return response_model.model_validate(result)
332
-
333
- def generate_pydantic_stream(
334
- self,
335
- response_model: Type[PydanticModelT],
336
- messages: LanguageModelInput,
337
- config: Optional[RunnableConfig] = None,
338
- stop: Optional[list[str]] = None,
339
- **kwargs: Any,
340
- ) -> Iterator[PydanticModelT]:
341
- try:
342
- import instructor
343
- except ImportError:
344
- raise ImportError("Please install `instructor` with `pip install instructor` to use this feature.")
345
-
346
- partial_response_model = instructor.Partial[response_model]
347
- for chunk in _with_structured_output(
348
- client=self.client,
349
- response_model=partial_response_model,
350
- structured_output_kwargs=self.structured_output_kwargs,
351
- ).stream(input=messages, config=config, stop=stop, **kwargs):
352
- yield response_model.model_validate(chunk)
353
-
354
- async def agenerate_pydantic_stream(
355
- self,
356
- response_model: Type[PydanticModelT],
357
- messages: LanguageModelInput,
358
- config: Optional[RunnableConfig] = None,
359
- stop: Optional[list[str]] = None,
360
- **kwargs: Any,
361
- ) -> AsyncIterator[PydanticModelT]:
362
- try:
363
- import instructor
364
- except ImportError:
365
- raise ImportError("Please install `instructor` with `pip install instructor` to use this feature.")
366
-
367
- partial_response_model = instructor.Partial[response_model]
368
- async for chunk in _with_structured_output(
369
- client=self.client,
370
- response_model=partial_response_model,
371
- structured_output_kwargs=self.structured_output_kwargs,
372
- ).astream(input=messages, config=config, stop=stop, **kwargs):
373
- yield response_model.model_validate(chunk)
374
-
375
- def describe_image(self, image_url: str, instruction: str = DEFAULT_IMAGE_DESCRIPTION_INSTRUCTION) -> str:
376
- """
377
- Create a detailed description of an image using the Vision Language Model.
378
- - image_url: Image URL to describe
379
- """
380
- return self.generate([
381
- HumanMessage(
382
- content=[{"type": "text", "text": instruction}, {"type": "image_url", "image_url": {"url": image_url}}],
383
- )
384
- ])
385
-
386
- async def adescribe_image(self, image_url: str, instruction: str = DEFAULT_IMAGE_DESCRIPTION_INSTRUCTION) -> str:
387
- """
388
- Create a detailed description of an image using the Vision Language Model asynchronously.
389
- - image_url: Image URL to describe
390
- """
391
- return await self.agenerate([
392
- HumanMessage(
393
- content=[{"type": "text", "text": instruction}, {"type": "image_url", "image_url": {"url": image_url}}],
394
- )
395
- ])
396
-
397
- def get_approximate_token_count(self, message: BaseMessage) -> int:
398
- return self.client.get_num_tokens_from_messages([message]) # pyright: ignore[reportUnknownMemberType]
399
-
400
- def get_usage_metadata(self, message: BaseMessage) -> UsageMetadata:
401
- if isinstance(message, AIMessage):
402
- usage_metadata = message.usage_metadata
403
- if usage_metadata is not None:
404
- input_tokens = usage_metadata["input_tokens"]
405
- output_tokens = usage_metadata["output_tokens"]
406
- return {
407
- "input_tokens": input_tokens,
408
- "output_tokens": output_tokens,
409
- "total_tokens": input_tokens + output_tokens,
410
- }
411
- else:
412
- approx_tokens = self.get_approximate_token_count(message)
413
- return {"input_tokens": 0, "output_tokens": approx_tokens, "total_tokens": approx_tokens}
414
- else:
415
- approx_tokens = self.get_approximate_token_count(message)
416
- return {
417
- "input_tokens": approx_tokens,
418
- "output_tokens": 0,
419
- "total_tokens": approx_tokens,
420
- }
421
-
422
- def exec(
423
- self,
424
- messages: LanguageModelInput,
425
- repl_tool: Optional["PythonAstREPLTool"] = None,
426
- prompt_for_code_invoke: Optional[str] = DEFAULT_CODE_GENERATION_PROMPT,
427
- function_signatures: Optional[FunctionSignature | Iterable[FunctionSignature]] = None,
428
- function_reference_prefix: Optional[str] = DEFAULT_FUNCTION_REFERENCE_PREFIX_PROMPT,
429
- function_reference_seperator: str = DEFAULT_FUNCTION_REFERENCE_SEPARATOR,
430
- config: Optional[RunnableConfig] = None,
431
- stop: Optional[list[str]] = None,
432
- **kwargs: Any,
433
- ) -> CodeExecutionResult:
434
- if not function_signatures:
435
- function_signatures = []
436
- elif isinstance(function_signatures, FunctionSignature):
437
- function_signatures = [function_signatures]
438
- messages = augment_prompt_for_toolcall(
439
- function_signatures=function_signatures,
440
- messages=messages,
441
- prompt_for_code_invoke=prompt_for_code_invoke,
442
- function_reference_prefix=function_reference_prefix,
443
- function_reference_seperator=function_reference_seperator,
444
- )
445
- code_obj: PythonCodeToExecute = self.generate_pydantic(
446
- response_model=PythonCodeToExecute, messages=messages, config=config, stop=stop, **kwargs
447
- )
448
- return CodeExecutionResult.from_code(
449
- code=code_obj.code,
450
- config=config,
451
- repl_tool=repl_tool,
452
- function_signatures=function_signatures,
453
- **kwargs,
454
- )
455
-
456
- @property
457
- def invoke_code_execution(self) -> Callable[..., CodeExecutionResult]:
458
- """Alias for exec method for backward compatibility."""
459
- return self.exec
460
-
461
- async def aexec(
462
- self,
463
- messages: LanguageModelInput,
464
- repl_tool: Optional["PythonAstREPLTool"] = None,
465
- prompt_for_code_invoke: Optional[str] = DEFAULT_CODE_GENERATION_PROMPT,
466
- additional_callables: Optional[Callable[..., object] | Sequence[Callable[..., object]]] = None,
467
- function_reference_prefix: Optional[str] = DEFAULT_FUNCTION_REFERENCE_PREFIX_PROMPT,
468
- function_reference_seperator: str = DEFAULT_FUNCTION_REFERENCE_SEPARATOR,
469
- config: Optional[RunnableConfig] = None,
470
- stop: Optional[list[str]] = None,
471
- **kwargs: Any,
472
- ) -> CodeExecutionResult:
473
- function_signatures: list[FunctionSignature] = FunctionSignature.from_callable(additional_callables)
474
- messages = augment_prompt_for_toolcall(
475
- function_signatures=function_signatures,
476
- messages=messages,
477
- prompt_for_code_invoke=prompt_for_code_invoke,
478
- function_reference_prefix=function_reference_prefix,
479
- function_reference_seperator=function_reference_seperator,
480
- )
481
- code_obj: PythonCodeToExecute = await self.agenerate_pydantic(
482
- response_model=PythonCodeToExecute, messages=messages, config=config, stop=stop, **kwargs
483
- )
484
- return await CodeExecutionResult.afrom_code(
485
- code=code_obj.code,
486
- config=config,
487
- repl_tool=repl_tool,
488
- function_signatures=function_signatures,
489
- **kwargs,
490
- )
491
-
492
- @property
493
- def ainvoke_code_execution(self):
494
- """Alias for aexec method for backward compatibility."""
495
- return self.aexec
496
-
497
-
498
- class PythonCodeToExecute(BaseModel):
499
- code: str = Field(description="Python code to execute")
500
-
501
- def model_post_init(self, context: object) -> None:
502
- super().model_post_init(context)
503
-
504
- codes: list[str] = []
505
- for match in PYTHON_CODE_PATTERN.finditer(self.code):
506
- codes.append(match.group(1))
507
- if codes:
508
- self.code = "\n".join(codes)
509
-
510
-
511
- def _with_structured_output(
512
- client: BaseChatModel,
513
- response_model: Type["PydanticModelT | Partial[PydanticModelT]"],
514
- structured_output_kwargs: dict[str, Any],
515
- ) -> Runnable[LanguageModelInput, dict[object, object] | BaseModel]:
516
- return client.with_structured_output(schema=response_model, **structured_output_kwargs) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
517
-
518
-
519
- @overload
520
- def _get_api_key(api_key: Optional[str], env_key: str, raise_if_none: Literal[True]) -> SecretStr: ...
521
- @overload
522
- def _get_api_key(api_key: Optional[str], env_key: str, raise_if_none: Literal[False]) -> Optional[SecretStr]: ...
523
- def _get_api_key(api_key: Optional[str], env_key: str, raise_if_none: bool) -> Optional[SecretStr]:
524
- if api_key is None:
525
- api_key_found: SecretStr | None = secret_from_env(env_key, default=None)()
526
- if raise_if_none and api_key_found is None:
527
- raise ValueError(
528
- (
529
- f"Did not find API key, please add an environment variable"
530
- f" `{env_key}` which contains it, or pass"
531
- f" api_key as a named parameter."
532
- )
533
- )
534
- return api_key_found
535
- else:
536
- return SecretStr(api_key)
1
+ import re
2
+ from typing import (
3
+ TYPE_CHECKING,
4
+ Any,
5
+ AsyncIterator,
6
+ Callable,
7
+ Iterable,
8
+ Iterator,
9
+ Literal,
10
+ Optional,
11
+ Self,
12
+ Sequence,
13
+ Type,
14
+ TypeAlias,
15
+ TypeVar,
16
+ overload,
17
+ )
18
+
19
+ from langchain_core.language_models.base import LanguageModelInput
20
+ from langchain_core.language_models.chat_models import BaseChatModel
21
+ from langchain_core.runnables.base import Runnable
22
+ from langchain_core.runnables.config import RunnableConfig
23
+ from langchain_core.utils.utils import secret_from_env
24
+ from pydantic import BaseModel, Field, SecretStr
25
+
26
+ from .messages import AIMessage, BaseMessage, HumanMessage, UsageMetadata
27
+ from .utils.code_agent import CodeExecutionResult, FunctionSignature, augment_prompt_for_toolcall
28
+
29
+ if TYPE_CHECKING:
30
+ from instructor import Partial # pyright: ignore[reportMissingTypeStubs]
31
+ from langchain_experimental.tools.python.tool import PythonAstREPLTool
32
+
33
+ PydanticModelT = TypeVar("PydanticModelT", bound=BaseModel)
34
+ StructuredOutputType: TypeAlias = dict[object, object] | BaseModel
35
+
36
+ DEFAULT_IMAGE_DESCRIPTION_INSTRUCTION = "Provide a detailed description of all visible elements in the image, summarizing key details in a few clear sentences."
37
+ DEFAULT_CODE_GENERATION_PROMPT = (
38
+ "You are utilizing a Python code execution tool now.\n"
39
+ "Your goal is to generate Python code that solves the task efficiently and appends both the code and its output to your context memory.\n"
40
+ "\n"
41
+ "To optimize tool efficiency, follow these guidelines:\n"
42
+ "- Write concise, efficient code that directly serves the intended purpose.\n"
43
+ "- Avoid unnecessary operations (e.g., excessive loops, recursion, or heavy computations).\n"
44
+ "- Handle potential errors gracefully (e.g., using try-except blocks).\n"
45
+ "\n"
46
+ "Return your response strictly in the following JSON format:\n"
47
+ '{\n "code": "<your_python_code_here>"\n}\n\n'
48
+ )
49
+
50
+
51
+ DEFAULT_FUNCTION_REFERENCE_PREFIX_PROMPT = (
52
+ "Below functions are included in global scope and can be used in your code.\n"
53
+ "Do not try to redefine the function(s).\n"
54
+ "You don't have to force yourself to use these tools - use them only when you need to.\n"
55
+ )
56
+ DEFAULT_FUNCTION_REFERENCE_SEPARATOR = "\n---\n" # Separator to distinguish different function references
57
+
58
+ PYTHON_CODE_PATTERN: re.Pattern[str] = re.compile(r"```(?:python\s*\n)?(.*?)```", re.DOTALL)
59
+
60
+
61
+ class Chatterer(BaseModel):
62
+ """Language model for generating text from a given input."""
63
+
64
+ client: BaseChatModel
65
+ structured_output_kwargs: dict[str, Any] = Field(default_factory=dict)
66
+
67
+ @classmethod
68
+ def from_provider(
69
+ cls,
70
+ provider_and_model: str,
71
+ structured_output_kwargs: Optional[dict[str, object]] = {"strict": True},
72
+ **kwargs: object,
73
+ ) -> Self:
74
+ backend, model = provider_and_model.split(":", 1)
75
+ backends = cls.get_backends()
76
+ if func := backends.get(backend):
77
+ return func(model, structured_output_kwargs, **kwargs)
78
+ else:
79
+ raise ValueError(f"Unsupported provider: {backend}. Supported providers are: {', '.join(backends.keys())}.")
80
+
81
+ @classmethod
82
+ def get_backends(cls) -> dict[str, Callable[[str, Optional[dict[str, object]]], Self]]:
83
+ return {
84
+ "openai": cls.openai,
85
+ "anthropic": cls.anthropic,
86
+ "google": cls.google,
87
+ "ollama": cls.ollama,
88
+ "openrouter": cls.open_router,
89
+ "xai": cls.xai,
90
+ }
91
+
92
+ @classmethod
93
+ def openai(
94
+ cls,
95
+ model: str = "gpt-4.1",
96
+ structured_output_kwargs: Optional[dict[str, object]] = {"strict": True},
97
+ api_key: Optional[str] = None,
98
+ **kwargs: Any,
99
+ ) -> Self:
100
+ from langchain_openai import ChatOpenAI
101
+
102
+ return cls(
103
+ client=ChatOpenAI(
104
+ model=model,
105
+ api_key=_get_api_key(api_key=api_key, env_key="OPENAI_API_KEY", raise_if_none=False),
106
+ **kwargs,
107
+ ),
108
+ structured_output_kwargs=structured_output_kwargs or {},
109
+ )
110
+
111
+ @classmethod
112
+ def anthropic(
113
+ cls,
114
+ model_name: str = "claude-3-7-sonnet-20250219",
115
+ structured_output_kwargs: Optional[dict[str, object]] = None,
116
+ api_key: Optional[str] = None,
117
+ **kwargs: Any,
118
+ ) -> Self:
119
+ from langchain_anthropic import ChatAnthropic
120
+
121
+ return cls(
122
+ client=ChatAnthropic(
123
+ model_name=model_name,
124
+ api_key=_get_api_key(api_key=api_key, env_key="ANTHROPIC_API_KEY", raise_if_none=True),
125
+ **kwargs,
126
+ ),
127
+ structured_output_kwargs=structured_output_kwargs or {},
128
+ )
129
+
130
+ @classmethod
131
+ def google(
132
+ cls,
133
+ model: str = "gemini-2.5-flash-preview-04-17",
134
+ structured_output_kwargs: Optional[dict[str, object]] = None,
135
+ api_key: Optional[str] = None,
136
+ **kwargs: Any,
137
+ ) -> Self:
138
+ from langchain_google_genai import ChatGoogleGenerativeAI
139
+
140
+ return cls(
141
+ client=ChatGoogleGenerativeAI(
142
+ model=model,
143
+ api_key=_get_api_key(api_key=api_key, env_key="GOOGLE_API_KEY", raise_if_none=False),
144
+ **kwargs,
145
+ ),
146
+ structured_output_kwargs=structured_output_kwargs or {},
147
+ )
148
+
149
+ @classmethod
150
+ def ollama(
151
+ cls,
152
+ model: str = "deepseek-r1:1.5b",
153
+ structured_output_kwargs: Optional[dict[str, object]] = None,
154
+ **kwargs: Any,
155
+ ) -> Self:
156
+ from langchain_ollama import ChatOllama
157
+
158
+ return cls(
159
+ client=ChatOllama(model=model, **kwargs),
160
+ structured_output_kwargs=structured_output_kwargs or {},
161
+ )
162
+
163
+ @classmethod
164
+ def open_router(
165
+ cls,
166
+ model: str = "openrouter/quasar-alpha",
167
+ structured_output_kwargs: Optional[dict[str, object]] = None,
168
+ api_key: Optional[str] = None,
169
+ **kwargs: Any,
170
+ ) -> Self:
171
+ from langchain_openai import ChatOpenAI
172
+
173
+ return cls(
174
+ client=ChatOpenAI(
175
+ model=model,
176
+ base_url="https://openrouter.ai/api/v1",
177
+ api_key=_get_api_key(api_key=api_key, env_key="OPENROUTER_API_KEY", raise_if_none=False),
178
+ **kwargs,
179
+ ),
180
+ structured_output_kwargs=structured_output_kwargs or {},
181
+ )
182
+
183
+ @classmethod
184
+ def xai(
185
+ cls,
186
+ model: str = "grok-3-mini",
187
+ structured_output_kwargs: Optional[dict[str, object]] = None,
188
+ base_url: str = "https://api.x.ai/v1",
189
+ api_key: Optional[str] = None,
190
+ **kwargs: Any,
191
+ ) -> Self:
192
+ from langchain_openai import ChatOpenAI
193
+
194
+ return cls(
195
+ client=ChatOpenAI(
196
+ model=model,
197
+ base_url=base_url,
198
+ api_key=_get_api_key(api_key=api_key, env_key="XAI_API_KEY", raise_if_none=False),
199
+ **kwargs,
200
+ ),
201
+ structured_output_kwargs=structured_output_kwargs or {},
202
+ )
203
+
204
+ @property
205
+ def invoke(self):
206
+ return self.client.invoke
207
+
208
+ @property
209
+ def ainvoke(self):
210
+ return self.client.ainvoke
211
+
212
+ @property
213
+ def stream(self):
214
+ return self.client.stream
215
+
216
+ @property
217
+ def astream(self):
218
+ return self.client.astream
219
+
220
+ @property
221
+ def bind_tools(self): # pyright: ignore[reportUnknownParameterType]
222
+ return self.client.bind_tools # pyright: ignore[reportUnknownParameterType, reportUnknownVariableType, reportUnknownMemberType]
223
+
224
+ def __getattr__(self, name: str) -> Any:
225
+ return getattr(self.client, name)
226
+
227
+ @overload
228
+ def __call__(
229
+ self,
230
+ messages: LanguageModelInput,
231
+ response_model: Type[PydanticModelT],
232
+ config: Optional[RunnableConfig] = None,
233
+ stop: Optional[list[str]] = None,
234
+ **kwargs: Any,
235
+ ) -> PydanticModelT: ...
236
+
237
+ @overload
238
+ def __call__(
239
+ self,
240
+ messages: LanguageModelInput,
241
+ response_model: None = None,
242
+ config: Optional[RunnableConfig] = None,
243
+ stop: Optional[list[str]] = None,
244
+ **kwargs: Any,
245
+ ) -> str: ...
246
+
247
+ def __call__(
248
+ self,
249
+ messages: LanguageModelInput,
250
+ response_model: Optional[Type[PydanticModelT]] = None,
251
+ config: Optional[RunnableConfig] = None,
252
+ stop: Optional[list[str]] = None,
253
+ **kwargs: Any,
254
+ ) -> str | PydanticModelT:
255
+ if response_model:
256
+ return self.generate_pydantic(response_model, messages, config, stop, **kwargs)
257
+ return self.client.invoke(input=messages, config=config, stop=stop, **kwargs).text()
258
+
259
+ def generate(
260
+ self,
261
+ messages: LanguageModelInput,
262
+ config: Optional[RunnableConfig] = None,
263
+ stop: Optional[list[str]] = None,
264
+ **kwargs: Any,
265
+ ) -> str:
266
+ return self.client.invoke(input=messages, config=config, stop=stop, **kwargs).text()
267
+
268
+ async def agenerate(
269
+ self,
270
+ messages: LanguageModelInput,
271
+ config: Optional[RunnableConfig] = None,
272
+ stop: Optional[list[str]] = None,
273
+ **kwargs: Any,
274
+ ) -> str:
275
+ return (await self.client.ainvoke(input=messages, config=config, stop=stop, **kwargs)).text()
276
+
277
+ def generate_stream(
278
+ self,
279
+ messages: LanguageModelInput,
280
+ config: Optional[RunnableConfig] = None,
281
+ stop: Optional[list[str]] = None,
282
+ **kwargs: Any,
283
+ ) -> Iterator[str]:
284
+ for chunk in self.client.stream(input=messages, config=config, stop=stop, **kwargs):
285
+ yield chunk.text()
286
+
287
+ async def agenerate_stream(
288
+ self,
289
+ messages: LanguageModelInput,
290
+ config: Optional[RunnableConfig] = None,
291
+ stop: Optional[list[str]] = None,
292
+ **kwargs: Any,
293
+ ) -> AsyncIterator[str]:
294
+ async for chunk in self.client.astream(input=messages, config=config, stop=stop, **kwargs):
295
+ yield chunk.text()
296
+
297
+ def generate_pydantic(
298
+ self,
299
+ response_model: Type[PydanticModelT],
300
+ messages: LanguageModelInput,
301
+ config: Optional[RunnableConfig] = None,
302
+ stop: Optional[list[str]] = None,
303
+ **kwargs: Any,
304
+ ) -> PydanticModelT:
305
+ result: StructuredOutputType = _with_structured_output(
306
+ client=self.client,
307
+ response_model=response_model,
308
+ structured_output_kwargs=self.structured_output_kwargs,
309
+ ).invoke(input=messages, config=config, stop=stop, **kwargs)
310
+ if isinstance(result, response_model):
311
+ return result
312
+ else:
313
+ return response_model.model_validate(result)
314
+
315
+ async def agenerate_pydantic(
316
+ self,
317
+ response_model: Type[PydanticModelT],
318
+ messages: LanguageModelInput,
319
+ config: Optional[RunnableConfig] = None,
320
+ stop: Optional[list[str]] = None,
321
+ **kwargs: Any,
322
+ ) -> PydanticModelT:
323
+ result: StructuredOutputType = await _with_structured_output(
324
+ client=self.client,
325
+ response_model=response_model,
326
+ structured_output_kwargs=self.structured_output_kwargs,
327
+ ).ainvoke(input=messages, config=config, stop=stop, **kwargs)
328
+ if isinstance(result, response_model):
329
+ return result
330
+ else:
331
+ return response_model.model_validate(result)
332
+
333
+ def generate_pydantic_stream(
334
+ self,
335
+ response_model: Type[PydanticModelT],
336
+ messages: LanguageModelInput,
337
+ config: Optional[RunnableConfig] = None,
338
+ stop: Optional[list[str]] = None,
339
+ **kwargs: Any,
340
+ ) -> Iterator[PydanticModelT]:
341
+ try:
342
+ import instructor # pyright: ignore[reportMissingTypeStubs]
343
+ except ImportError:
344
+ raise ImportError("Please install `instructor` with `pip install instructor` to use this feature.")
345
+
346
+ partial_response_model = instructor.Partial[response_model]
347
+ for chunk in _with_structured_output(
348
+ client=self.client,
349
+ response_model=partial_response_model,
350
+ structured_output_kwargs=self.structured_output_kwargs,
351
+ ).stream(input=messages, config=config, stop=stop, **kwargs):
352
+ yield response_model.model_validate(chunk)
353
+
354
+ async def agenerate_pydantic_stream(
355
+ self,
356
+ response_model: Type[PydanticModelT],
357
+ messages: LanguageModelInput,
358
+ config: Optional[RunnableConfig] = None,
359
+ stop: Optional[list[str]] = None,
360
+ **kwargs: Any,
361
+ ) -> AsyncIterator[PydanticModelT]:
362
+ try:
363
+ import instructor # pyright: ignore[reportMissingTypeStubs]
364
+ except ImportError:
365
+ raise ImportError("Please install `instructor` with `pip install instructor` to use this feature.")
366
+
367
+ partial_response_model = instructor.Partial[response_model]
368
+ async for chunk in _with_structured_output(
369
+ client=self.client,
370
+ response_model=partial_response_model,
371
+ structured_output_kwargs=self.structured_output_kwargs,
372
+ ).astream(input=messages, config=config, stop=stop, **kwargs):
373
+ yield response_model.model_validate(chunk)
374
+
375
+ def describe_image(self, image_url: str, instruction: str = DEFAULT_IMAGE_DESCRIPTION_INSTRUCTION) -> str:
376
+ """
377
+ Create a detailed description of an image using the Vision Language Model.
378
+ - image_url: Image URL to describe
379
+ """
380
+ return self.generate([
381
+ HumanMessage(
382
+ content=[{"type": "text", "text": instruction}, {"type": "image_url", "image_url": {"url": image_url}}],
383
+ )
384
+ ])
385
+
386
+ async def adescribe_image(self, image_url: str, instruction: str = DEFAULT_IMAGE_DESCRIPTION_INSTRUCTION) -> str:
387
+ """
388
+ Create a detailed description of an image using the Vision Language Model asynchronously.
389
+ - image_url: Image URL to describe
390
+ """
391
+ return await self.agenerate([
392
+ HumanMessage(
393
+ content=[{"type": "text", "text": instruction}, {"type": "image_url", "image_url": {"url": image_url}}],
394
+ )
395
+ ])
396
+
397
+ def get_approximate_token_count(self, message: BaseMessage) -> int:
398
+ return self.client.get_num_tokens_from_messages([message]) # pyright: ignore[reportUnknownMemberType]
399
+
400
+ def get_usage_metadata(self, message: BaseMessage) -> UsageMetadata:
401
+ if isinstance(message, AIMessage):
402
+ usage_metadata = message.usage_metadata
403
+ if usage_metadata is not None:
404
+ input_tokens = usage_metadata["input_tokens"]
405
+ output_tokens = usage_metadata["output_tokens"]
406
+ return {
407
+ "input_tokens": input_tokens,
408
+ "output_tokens": output_tokens,
409
+ "total_tokens": input_tokens + output_tokens,
410
+ }
411
+ else:
412
+ approx_tokens = self.get_approximate_token_count(message)
413
+ return {"input_tokens": 0, "output_tokens": approx_tokens, "total_tokens": approx_tokens}
414
+ else:
415
+ approx_tokens = self.get_approximate_token_count(message)
416
+ return {
417
+ "input_tokens": approx_tokens,
418
+ "output_tokens": 0,
419
+ "total_tokens": approx_tokens,
420
+ }
421
+
422
+ def exec(
423
+ self,
424
+ messages: LanguageModelInput,
425
+ repl_tool: Optional["PythonAstREPLTool"] = None,
426
+ prompt_for_code_invoke: Optional[str] = DEFAULT_CODE_GENERATION_PROMPT,
427
+ function_signatures: Optional[FunctionSignature | Iterable[FunctionSignature]] = None,
428
+ function_reference_prefix: Optional[str] = DEFAULT_FUNCTION_REFERENCE_PREFIX_PROMPT,
429
+ function_reference_seperator: str = DEFAULT_FUNCTION_REFERENCE_SEPARATOR,
430
+ config: Optional[RunnableConfig] = None,
431
+ stop: Optional[list[str]] = None,
432
+ **kwargs: Any,
433
+ ) -> CodeExecutionResult:
434
+ if not function_signatures:
435
+ function_signatures = []
436
+ elif isinstance(function_signatures, FunctionSignature):
437
+ function_signatures = [function_signatures]
438
+ messages = augment_prompt_for_toolcall(
439
+ function_signatures=function_signatures,
440
+ messages=messages,
441
+ prompt_for_code_invoke=prompt_for_code_invoke,
442
+ function_reference_prefix=function_reference_prefix,
443
+ function_reference_seperator=function_reference_seperator,
444
+ )
445
+ code_obj: PythonCodeToExecute = self.generate_pydantic(
446
+ response_model=PythonCodeToExecute, messages=messages, config=config, stop=stop, **kwargs
447
+ )
448
+ return CodeExecutionResult.from_code(
449
+ code=code_obj.code,
450
+ config=config,
451
+ repl_tool=repl_tool,
452
+ function_signatures=function_signatures,
453
+ **kwargs,
454
+ )
455
+
456
+ @property
457
+ def invoke_code_execution(self) -> Callable[..., CodeExecutionResult]:
458
+ """Alias for exec method for backward compatibility."""
459
+ return self.exec
460
+
461
+ async def aexec(
462
+ self,
463
+ messages: LanguageModelInput,
464
+ repl_tool: Optional["PythonAstREPLTool"] = None,
465
+ prompt_for_code_invoke: Optional[str] = DEFAULT_CODE_GENERATION_PROMPT,
466
+ additional_callables: Optional[Callable[..., object] | Sequence[Callable[..., object]]] = None,
467
+ function_reference_prefix: Optional[str] = DEFAULT_FUNCTION_REFERENCE_PREFIX_PROMPT,
468
+ function_reference_seperator: str = DEFAULT_FUNCTION_REFERENCE_SEPARATOR,
469
+ config: Optional[RunnableConfig] = None,
470
+ stop: Optional[list[str]] = None,
471
+ **kwargs: Any,
472
+ ) -> CodeExecutionResult:
473
+ function_signatures: list[FunctionSignature] = FunctionSignature.from_callable(additional_callables)
474
+ messages = augment_prompt_for_toolcall(
475
+ function_signatures=function_signatures,
476
+ messages=messages,
477
+ prompt_for_code_invoke=prompt_for_code_invoke,
478
+ function_reference_prefix=function_reference_prefix,
479
+ function_reference_seperator=function_reference_seperator,
480
+ )
481
+ code_obj: PythonCodeToExecute = await self.agenerate_pydantic(
482
+ response_model=PythonCodeToExecute, messages=messages, config=config, stop=stop, **kwargs
483
+ )
484
+ return await CodeExecutionResult.afrom_code(
485
+ code=code_obj.code,
486
+ config=config,
487
+ repl_tool=repl_tool,
488
+ function_signatures=function_signatures,
489
+ **kwargs,
490
+ )
491
+
492
+ @property
493
+ def ainvoke_code_execution(self):
494
+ """Alias for aexec method for backward compatibility."""
495
+ return self.aexec
496
+
497
+
498
+ class PythonCodeToExecute(BaseModel):
499
+ code: str = Field(description="Python code to execute")
500
+
501
+ def model_post_init(self, context: object) -> None:
502
+ super().model_post_init(context)
503
+
504
+ codes: list[str] = []
505
+ for match in PYTHON_CODE_PATTERN.finditer(self.code):
506
+ codes.append(match.group(1))
507
+ if codes:
508
+ self.code = "\n".join(codes)
509
+
510
+
511
+ def _with_structured_output(
512
+ client: BaseChatModel,
513
+ response_model: Type["PydanticModelT | Partial[PydanticModelT]"],
514
+ structured_output_kwargs: dict[str, Any],
515
+ ) -> Runnable[LanguageModelInput, dict[object, object] | BaseModel]:
516
+ return client.with_structured_output(schema=response_model, **structured_output_kwargs) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
517
+
518
+
519
+ @overload
520
+ def _get_api_key(api_key: Optional[str], env_key: str, raise_if_none: Literal[True]) -> SecretStr: ...
521
+ @overload
522
+ def _get_api_key(api_key: Optional[str], env_key: str, raise_if_none: Literal[False]) -> Optional[SecretStr]: ...
523
+ def _get_api_key(api_key: Optional[str], env_key: str, raise_if_none: bool) -> Optional[SecretStr]:
524
+ if api_key is None:
525
+ api_key_found: SecretStr | None = secret_from_env(env_key, default=None)()
526
+ if raise_if_none and api_key_found is None:
527
+ raise ValueError(
528
+ (
529
+ f"Did not find API key, please add an environment variable"
530
+ f" `{env_key}` which contains it, or pass"
531
+ f" api_key as a named parameter."
532
+ )
533
+ )
534
+ return api_key_found
535
+ else:
536
+ return SecretStr(api_key)