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