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