chibi-bot 1.6.0b0__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 (70) hide show
  1. chibi/__init__.py +0 -0
  2. chibi/__main__.py +343 -0
  3. chibi/cli.py +90 -0
  4. chibi/config/__init__.py +6 -0
  5. chibi/config/app.py +123 -0
  6. chibi/config/gpt.py +108 -0
  7. chibi/config/logging.py +15 -0
  8. chibi/config/telegram.py +43 -0
  9. chibi/config_generator.py +233 -0
  10. chibi/constants.py +362 -0
  11. chibi/exceptions.py +58 -0
  12. chibi/models.py +496 -0
  13. chibi/schemas/__init__.py +0 -0
  14. chibi/schemas/anthropic.py +20 -0
  15. chibi/schemas/app.py +54 -0
  16. chibi/schemas/cloudflare.py +65 -0
  17. chibi/schemas/mistralai.py +56 -0
  18. chibi/schemas/suno.py +83 -0
  19. chibi/service.py +135 -0
  20. chibi/services/bot.py +276 -0
  21. chibi/services/lock_manager.py +20 -0
  22. chibi/services/mcp/manager.py +242 -0
  23. chibi/services/metrics.py +54 -0
  24. chibi/services/providers/__init__.py +16 -0
  25. chibi/services/providers/alibaba.py +79 -0
  26. chibi/services/providers/anthropic.py +40 -0
  27. chibi/services/providers/cloudflare.py +98 -0
  28. chibi/services/providers/constants/suno.py +2 -0
  29. chibi/services/providers/customopenai.py +11 -0
  30. chibi/services/providers/deepseek.py +15 -0
  31. chibi/services/providers/eleven_labs.py +85 -0
  32. chibi/services/providers/gemini_native.py +489 -0
  33. chibi/services/providers/grok.py +40 -0
  34. chibi/services/providers/minimax.py +96 -0
  35. chibi/services/providers/mistralai_native.py +312 -0
  36. chibi/services/providers/moonshotai.py +20 -0
  37. chibi/services/providers/openai.py +74 -0
  38. chibi/services/providers/provider.py +892 -0
  39. chibi/services/providers/suno.py +130 -0
  40. chibi/services/providers/tools/__init__.py +23 -0
  41. chibi/services/providers/tools/cmd.py +132 -0
  42. chibi/services/providers/tools/common.py +127 -0
  43. chibi/services/providers/tools/constants.py +78 -0
  44. chibi/services/providers/tools/exceptions.py +1 -0
  45. chibi/services/providers/tools/file_editor.py +875 -0
  46. chibi/services/providers/tools/mcp_management.py +274 -0
  47. chibi/services/providers/tools/mcp_simple.py +72 -0
  48. chibi/services/providers/tools/media.py +451 -0
  49. chibi/services/providers/tools/memory.py +252 -0
  50. chibi/services/providers/tools/schemas.py +10 -0
  51. chibi/services/providers/tools/send.py +435 -0
  52. chibi/services/providers/tools/tool.py +163 -0
  53. chibi/services/providers/tools/utils.py +146 -0
  54. chibi/services/providers/tools/web.py +261 -0
  55. chibi/services/providers/utils.py +182 -0
  56. chibi/services/task_manager.py +93 -0
  57. chibi/services/user.py +269 -0
  58. chibi/storage/abstract.py +54 -0
  59. chibi/storage/database.py +86 -0
  60. chibi/storage/dynamodb.py +257 -0
  61. chibi/storage/local.py +70 -0
  62. chibi/storage/redis.py +91 -0
  63. chibi/utils/__init__.py +0 -0
  64. chibi/utils/app.py +249 -0
  65. chibi/utils/telegram.py +521 -0
  66. chibi_bot-1.6.0b0.dist-info/LICENSE +21 -0
  67. chibi_bot-1.6.0b0.dist-info/METADATA +340 -0
  68. chibi_bot-1.6.0b0.dist-info/RECORD +70 -0
  69. chibi_bot-1.6.0b0.dist-info/WHEEL +4 -0
  70. chibi_bot-1.6.0b0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,892 @@
1
+ import asyncio
2
+ import inspect
3
+ import json
4
+ import random
5
+ from abc import ABC
6
+ from asyncio import sleep
7
+ from functools import wraps
8
+ from io import BytesIO
9
+ from typing import Any, Awaitable, Callable, Generic, Literal, Optional, ParamSpec, TypeVar, cast
10
+
11
+ import httpx
12
+ from anthropic import AsyncClient, NotGiven, Omit
13
+ from anthropic.types import (
14
+ CacheControlEphemeralParam,
15
+ MessageParam,
16
+ TextBlock,
17
+ TextBlockParam,
18
+ ToolParam,
19
+ ToolResultBlockParam,
20
+ ToolUseBlock,
21
+ )
22
+ from anthropic.types import (
23
+ Message as AnthropicMessage,
24
+ )
25
+ from httpx import Response
26
+ from httpx._types import QueryParamTypes, RequestData
27
+ from loguru import logger
28
+ from openai import (
29
+ NOT_GIVEN,
30
+ APIConnectionError,
31
+ AsyncOpenAI,
32
+ AuthenticationError,
33
+ OpenAIError,
34
+ RateLimitError,
35
+ )
36
+ from openai import (
37
+ NotGiven as OpenAINotGiven,
38
+ )
39
+ from openai.types import ImagesResponse
40
+ from openai.types.chat import (
41
+ ChatCompletionAssistantMessageParam,
42
+ ChatCompletionMessageParam,
43
+ ChatCompletionMessageToolCall,
44
+ ChatCompletionMessageToolCallParam,
45
+ ChatCompletionSystemMessageParam,
46
+ ChatCompletionToolMessageParam,
47
+ )
48
+ from openai.types.chat.chat_completion import ChatCompletion, Choice
49
+ from openai.types.chat.chat_completion_message_tool_call_param import Function
50
+ from telegram import Update
51
+ from telegram.ext import ContextTypes
52
+
53
+ from chibi.config import application_settings, gpt_settings
54
+ from chibi.constants import IMAGE_SIZE_LITERAL
55
+ from chibi.exceptions import (
56
+ NoApiKeyProvidedError,
57
+ NoModelSelectedError,
58
+ NoResponseError,
59
+ NotAuthorizedError,
60
+ ServiceConnectionError,
61
+ ServiceRateLimitError,
62
+ ServiceResponseError,
63
+ )
64
+ from chibi.models import Message, User
65
+ from chibi.schemas.app import ChatResponseSchema, ModelChangeSchema, ModeratorsAnswer
66
+ from chibi.services.metrics import MetricsService
67
+ from chibi.services.providers.tools import RegisteredChibiTools
68
+ from chibi.services.providers.tools.constants import MODERATOR_PROMPT
69
+ from chibi.services.providers.utils import (
70
+ get_usage_from_anthropic_response,
71
+ get_usage_from_openai_response,
72
+ get_usage_msg,
73
+ prepare_system_prompt,
74
+ send_llm_thoughts,
75
+ )
76
+
77
+ P = ParamSpec("P")
78
+ R = TypeVar("R")
79
+
80
+
81
+ class RegisteredProviders:
82
+ all: dict[str, type["Provider"]] = {}
83
+ available: dict[str, type["Provider"]] = {}
84
+
85
+ def __init__(self, user_api_keys: dict[str, str] | None = None) -> None:
86
+ self.tokens = {} if not user_api_keys else user_api_keys
87
+ if gpt_settings.public_mode:
88
+ self.available: dict[str, type["Provider"]] = {
89
+ provider.name: provider for provider in RegisteredProviders.all.values() if provider.name in self.tokens
90
+ }
91
+
92
+ @classmethod
93
+ def register(cls, provider: type["Provider"]) -> None:
94
+ cls.all[provider.name] = provider
95
+
96
+ @classmethod
97
+ def register_as_available(cls, provider: type["Provider"]) -> None:
98
+ cls.available[provider.name] = provider
99
+
100
+ def get_api_key(self, provider: type["Provider"]) -> str | None:
101
+ if not gpt_settings.public_mode:
102
+ return provider.api_key
103
+
104
+ if provider.name not in self.tokens:
105
+ return None
106
+
107
+ return self.tokens[provider.name]
108
+
109
+ @property
110
+ def available_instances(self) -> list["Provider"]:
111
+ return [
112
+ provider(token=self.get_api_key(provider)) # type: ignore
113
+ for provider in self.available.values()
114
+ if self.get_api_key(provider) is not None
115
+ ]
116
+
117
+ @property
118
+ def chat_ready(self) -> dict[str, type["Provider"]]:
119
+ return {provider_name: provider for provider_name, provider in self.available.items() if provider.chat_ready}
120
+
121
+ @property
122
+ def moderation_ready(self) -> dict[str, type["Provider"]]:
123
+ return {
124
+ provider_name: provider for provider_name, provider in self.available.items() if provider.moderation_ready
125
+ }
126
+
127
+ @property
128
+ def image_generation_ready(self) -> dict[str, type["Provider"]]:
129
+ return {name: provider for name, provider in self.available.items() if provider.image_generation_ready}
130
+
131
+ @property
132
+ def stt_ready(self) -> dict[str, type["Provider"]]:
133
+ return {name: provider for name, provider in self.available.items() if provider.stt_ready}
134
+
135
+ @property
136
+ def tts_ready(self) -> dict[str, type["Provider"]]:
137
+ return {name: provider for name, provider in self.available.items() if provider.tts_ready}
138
+
139
+ def get_instance(self, provider: type["Provider"]) -> Optional["Provider"]:
140
+ api_key = self.get_api_key(provider)
141
+ if not api_key:
142
+ return None
143
+ return provider(token=api_key)
144
+
145
+ def get(self, provider_name: str) -> Optional["Provider"]:
146
+ if provider_name not in self.available:
147
+ return None
148
+ provider = self.available[provider_name]
149
+ return self.get_instance(provider=provider)
150
+
151
+ @classmethod
152
+ def get_class(cls, provider_name: str) -> Optional[type["Provider"]]:
153
+ return cls.all.get(provider_name)
154
+
155
+ @property
156
+ def first_tts_ready(self) -> Optional["Provider"]:
157
+ if provider := next(iter(self.tts_ready.values()), None):
158
+ return self.get_instance(provider=provider)
159
+ return None
160
+
161
+ @property
162
+ def first_stt_ready(self) -> Optional["Provider"]:
163
+ if provider := next(iter(self.stt_ready.values()), None):
164
+ return self.get_instance(provider=provider)
165
+ return None
166
+
167
+ @property
168
+ def first_image_generation_ready(self) -> Optional["Provider"]:
169
+ if provider := next(iter(self.image_generation_ready.values()), None):
170
+ return self.get_instance(provider=provider)
171
+ return None
172
+
173
+ @property
174
+ def first_chat_ready(self) -> Optional["Provider"]:
175
+ if provider := next(iter(self.chat_ready.values()), None):
176
+ return self.get_instance(provider=provider)
177
+ return None
178
+
179
+ @property
180
+ def first_moderation_ready(self) -> Optional["Provider"]:
181
+ if provider := next(reversed(self.moderation_ready.values()), None):
182
+ return self.get_instance(provider=provider)
183
+ return None
184
+
185
+
186
+ class Provider(ABC):
187
+ api_key: str | None = None
188
+ stt_ready: bool = False
189
+ tts_ready: bool = False
190
+ ocr_ready: bool = False
191
+ chat_ready: bool = False
192
+ moderation_ready: bool = False
193
+ image_generation_ready: bool = False
194
+
195
+ name: str
196
+ model_name_keywords: list[str] = []
197
+ model_name_prefixes: list[str] = []
198
+ model_name_keywords_exclude: list[str] = []
199
+ default_model: str
200
+ default_image_model: str | None = None
201
+ default_stt_model: str | None = None
202
+ default_tts_voice: str | None = None
203
+ default_tts_model: str | None = None
204
+ default_moderation_model: str | None = None
205
+ timeout: int = gpt_settings.timeout
206
+
207
+ def __init__(self, token: str) -> None:
208
+ self.token = token
209
+
210
+ def __init_subclass__(cls, **kwargs: Any) -> None:
211
+ super().__init_subclass__(**kwargs)
212
+
213
+ if hasattr(cls, "name"):
214
+ RegisteredProviders.register(cls)
215
+
216
+ if cls.api_key:
217
+ RegisteredProviders.register_as_available(cls)
218
+
219
+ async def get_chat_response(
220
+ self,
221
+ messages: list[Message],
222
+ user: User | None = None,
223
+ model: str | None = None,
224
+ system_prompt: str = gpt_settings.assistant_prompt,
225
+ update: Update | None = None,
226
+ context: ContextTypes.DEFAULT_TYPE | None = None,
227
+ ) -> tuple[ChatResponseSchema, list[Message]]:
228
+ raise NotImplementedError
229
+
230
+ async def get_available_models(self, image_generation: bool = False) -> list[ModelChangeSchema]:
231
+ raise NotImplementedError
232
+
233
+ def get_model_display_name(self, model_name: str) -> str:
234
+ raise NotImplementedError
235
+
236
+ async def transcribe(self, audio: BytesIO, model: str | None = None) -> str:
237
+ raise NotImplementedError
238
+
239
+ async def speech(self, text: str, voice: str | None = None, model: str | None = None) -> bytes:
240
+ raise NotImplementedError
241
+
242
+ async def moderate_command(self, cmd: str, model: str | None = None) -> ModeratorsAnswer:
243
+ raise NotImplementedError
244
+
245
+ async def api_key_is_valid(self) -> bool:
246
+ try:
247
+ await self.get_available_models()
248
+ except Exception: # Some providers return 403, others - 400... Okay..
249
+ return False
250
+ return True
251
+
252
+ @classmethod
253
+ def _model_name_has_prefix(cls, model_name: str) -> bool:
254
+ if not cls.model_name_prefixes:
255
+ return True
256
+ for prefix in cls.model_name_prefixes:
257
+ if model_name.startswith(prefix):
258
+ return True
259
+ return False
260
+
261
+ @classmethod
262
+ def _model_name_has_keyword(cls, model_name: str) -> bool:
263
+ if not cls.model_name_keywords:
264
+ return True
265
+ for keyword in cls.model_name_keywords:
266
+ if keyword in model_name:
267
+ return True
268
+ return False
269
+
270
+ @classmethod
271
+ def _model_name_has_keywords_exclude(cls, model_name: str) -> bool:
272
+ if not cls.model_name_keywords_exclude:
273
+ return False
274
+ for keyword in cls.model_name_keywords_exclude:
275
+ if keyword in model_name:
276
+ return True
277
+ return False
278
+
279
+ @classmethod
280
+ def is_chat_ready_model(cls, model_name: str) -> bool:
281
+ return all(
282
+ (
283
+ cls._model_name_has_prefix(model_name),
284
+ cls._model_name_has_keyword(model_name),
285
+ not cls._model_name_has_keywords_exclude(model_name),
286
+ )
287
+ )
288
+
289
+ @classmethod
290
+ def is_image_ready_model(cls, model_name: str) -> bool:
291
+ return "image" in model_name
292
+
293
+ async def get_images(self, prompt: str, model: str | None) -> list[str] | list[BytesIO]:
294
+ raise NotImplementedError
295
+
296
+
297
+ class OpenAIFriendlyProvider(Provider, Generic[P, R]):
298
+ temperature: float | OpenAINotGiven | None = gpt_settings.temperature
299
+ max_tokens: int | OpenAINotGiven | None = gpt_settings.max_tokens
300
+ presence_penalty: float | OpenAINotGiven | None = gpt_settings.presence_penalty
301
+ frequency_penalty: float | OpenAINotGiven | None = gpt_settings.frequency_penalty
302
+ image_quality: Literal["standard", "hd"] | OpenAINotGiven = gpt_settings.image_quality
303
+ image_size: IMAGE_SIZE_LITERAL | OpenAINotGiven | None = gpt_settings.image_size
304
+ base_url: str
305
+ image_n_choices: int = gpt_settings.image_n_choices
306
+
307
+ def __getattribute__(self, name: str) -> object:
308
+ attr = super().__getattribute__(name)
309
+
310
+ if callable(attr):
311
+ if inspect.iscoroutinefunction(attr):
312
+ attr_async_callable = cast(Callable[P, Awaitable[R]], attr)
313
+
314
+ @wraps(attr_async_callable)
315
+ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
316
+ model_name = cast(str, kwargs.get("model", "unknown"))
317
+ try:
318
+ return await attr_async_callable(*args, **kwargs)
319
+ except APIConnectionError:
320
+ raise ServiceConnectionError(provider=self.name, model=model_name)
321
+ except AuthenticationError:
322
+ raise NotAuthorizedError(provider=self.name, model=model_name)
323
+ except RateLimitError:
324
+ raise ServiceRateLimitError(provider=self.name, model=model_name)
325
+ except OpenAIError as e:
326
+ logger.error(e)
327
+ raise ServiceResponseError(provider=self.name, model=model_name)
328
+
329
+ return async_wrapper
330
+ else:
331
+ attr_callable = cast(Callable[P, R], attr)
332
+
333
+ @wraps(attr_callable)
334
+ def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None:
335
+ return attr_callable(*args, **kwargs)
336
+
337
+ return sync_wrapper
338
+
339
+ return attr
340
+
341
+ @property
342
+ def client(self) -> AsyncOpenAI:
343
+ if not self.token:
344
+ raise NoApiKeyProvidedError(provider=self.name)
345
+ return AsyncOpenAI(api_key=self.token, base_url=self.base_url)
346
+
347
+ async def get_chat_response(
348
+ self,
349
+ messages: list[Message],
350
+ user: User | None = None,
351
+ model: str | None = None,
352
+ system_prompt: str = gpt_settings.assistant_prompt,
353
+ update: Update | None = None,
354
+ context: ContextTypes.DEFAULT_TYPE | None = None,
355
+ ) -> tuple[ChatResponseSchema, list[Message]]:
356
+ model = model or self.default_model
357
+
358
+ initial_messages = [msg.to_openai() for msg in messages]
359
+ chat_response, updated_messages = await self._get_chat_completion_response(
360
+ messages=initial_messages.copy(),
361
+ model=model,
362
+ system_prompt=system_prompt,
363
+ user=user,
364
+ context=context,
365
+ update=update,
366
+ )
367
+ new_messages = [msg for msg in updated_messages if msg not in initial_messages]
368
+ return (
369
+ chat_response,
370
+ [Message.from_openai(msg) for msg in new_messages],
371
+ )
372
+
373
+ async def _get_chat_completion_response(
374
+ self,
375
+ messages: list[ChatCompletionMessageParam],
376
+ model: str,
377
+ user: User | None = None,
378
+ system_prompt: str | None = None,
379
+ update: Update | None = None,
380
+ context: ContextTypes.DEFAULT_TYPE | None = None,
381
+ ) -> tuple[ChatResponseSchema, list[ChatCompletionMessageParam]]:
382
+ if not system_prompt:
383
+ dialog = messages
384
+ else:
385
+ prepared_system_prompt = await prepare_system_prompt(base_system_prompt=system_prompt, user=user)
386
+ system_message = ChatCompletionSystemMessageParam(role="system", content=prepared_system_prompt)
387
+ dialog: list[ChatCompletionMessageParam] = [system_message] + messages # type: ignore
388
+
389
+ temperature = 1 if model.startswith("o") else self.temperature
390
+
391
+ response: ChatCompletion = await self.client.chat.completions.create(
392
+ model=model,
393
+ messages=dialog,
394
+ temperature=temperature,
395
+ max_tokens=self.max_tokens,
396
+ presence_penalty=self.presence_penalty,
397
+ frequency_penalty=self.frequency_penalty,
398
+ timeout=self.timeout,
399
+ tools=RegisteredChibiTools.get_tool_definitions(),
400
+ tool_choice="auto",
401
+ reasoning_effort="medium" if "reason" in model else NOT_GIVEN,
402
+ )
403
+ choices: list[Choice] = response.choices
404
+
405
+ if len(choices) == 0:
406
+ raise ServiceResponseError(provider=self.name, model=model, detail="Unexpected (empty) response received")
407
+
408
+ data = choices[0]
409
+ answer: str = data.message.content or ""
410
+
411
+ usage = get_usage_from_openai_response(response_message=response)
412
+ if application_settings.is_influx_configured:
413
+ MetricsService.send_usage_metrics(metric=usage, model=model, provider=self.name, user=user)
414
+ usage_message = get_usage_msg(usage=usage)
415
+
416
+ tool_calls: list[ChatCompletionMessageToolCall] | None = data.message.tool_calls
417
+
418
+ if not tool_calls:
419
+ messages.append(ChatCompletionAssistantMessageParam(**data.message.model_dump())) # type: ignore
420
+ return ChatResponseSchema(answer=answer, provider=self.name, model=model, usage=usage), messages
421
+
422
+ # Tool calls handling
423
+ logger.log("CALL", f"{model} requested the call of {len(tool_calls)} tools.")
424
+
425
+ thoughts = answer or "No thoughts"
426
+ if answer:
427
+ await send_llm_thoughts(thoughts=thoughts, context=context, update=update)
428
+ logger.log("THINK", f"{model}: {thoughts}. {usage_message}")
429
+
430
+ tool_context: dict[str, Any] = {
431
+ "user_id": user.id if user else None,
432
+ "telegram_context": context,
433
+ "telegram_update": update,
434
+ "model": model,
435
+ }
436
+
437
+ tool_coroutines = [
438
+ RegisteredChibiTools.call(
439
+ tool_name=tool_call.function.name, tools_args=tool_context | json.loads(tool_call.function.arguments)
440
+ )
441
+ for tool_call in tool_calls
442
+ ]
443
+ results = await asyncio.gather(*tool_coroutines)
444
+
445
+ for tool_call, result in zip(tool_calls, results):
446
+ tool_call_message = ChatCompletionAssistantMessageParam(
447
+ role="assistant",
448
+ content=answer,
449
+ tool_calls=[
450
+ ChatCompletionMessageToolCallParam(
451
+ id=tool_call.id,
452
+ type="function",
453
+ function=Function(
454
+ name=tool_call.function.name,
455
+ arguments=tool_call.function.arguments,
456
+ ),
457
+ )
458
+ ],
459
+ )
460
+ tool_result_message = ChatCompletionToolMessageParam(
461
+ tool_call_id=tool_call.id,
462
+ role="tool",
463
+ content=result.model_dump_json(),
464
+ )
465
+ messages.append(tool_call_message)
466
+ messages.append(tool_result_message)
467
+
468
+ logger.log("CALL", "All the function results have been obtained. Returning them to the LLM...")
469
+ return await self._get_chat_completion_response(
470
+ messages=messages,
471
+ model=model,
472
+ user=user,
473
+ system_prompt=system_prompt,
474
+ context=context,
475
+ update=update,
476
+ )
477
+
478
+ async def moderate_command(self, cmd: str, model: str | None = None) -> ModeratorsAnswer:
479
+ moderator_model = model or self.default_moderation_model or self.default_model
480
+ system_message = ChatCompletionSystemMessageParam(role="system", content=MODERATOR_PROMPT)
481
+
482
+ messages = [
483
+ Message(role="user", content=cmd).to_openai(),
484
+ ]
485
+
486
+ dialog: list[ChatCompletionMessageParam] = [system_message] + messages
487
+ temperature = (
488
+ 1 if moderator_model.startswith("o") or "mini" in moderator_model or "nano" in moderator_model else 0.0
489
+ )
490
+ response: ChatCompletion = await self.client.chat.completions.create(
491
+ model=moderator_model,
492
+ messages=dialog,
493
+ temperature=temperature,
494
+ max_completion_tokens=1024,
495
+ presence_penalty=self.presence_penalty,
496
+ frequency_penalty=self.frequency_penalty,
497
+ timeout=self.timeout,
498
+ # reasoning_effort="medium" if "reason" in moderator_model else NOT_GIVEN,
499
+ )
500
+
501
+ choices: list[Choice] = response.choices
502
+
503
+ if len(choices) == 0:
504
+ raise ServiceResponseError(
505
+ provider=self.name, model=moderator_model, detail="Unexpected (empty) response received"
506
+ )
507
+
508
+ data = choices[0]
509
+ answer: str = data.message.content or ""
510
+
511
+ usage = get_usage_from_openai_response(response_message=response)
512
+ if application_settings.is_influx_configured:
513
+ MetricsService.send_usage_metrics(metric=usage, model=moderator_model, provider=self.name)
514
+ # usage_message = get_usage_msg(usage=usage)
515
+ answer = answer.strip("```")
516
+ answer = answer.strip("json")
517
+ answer = answer.strip()
518
+ try:
519
+ result_data = json.loads(answer)
520
+ except Exception:
521
+ logger.error(f"Error parsing moderator's response: {answer}")
522
+ return ModeratorsAnswer(verdict="declined", reason=answer, status="error")
523
+
524
+ verdict = result_data.get("verdict", "declined")
525
+ if verdict == "accepted":
526
+ return ModeratorsAnswer(verdict="accepted", status="ok")
527
+
528
+ reason = result_data.get("reason", None)
529
+ if reason is None:
530
+ logger.error(f"Moderator did not provide reason properly: {answer}")
531
+
532
+ return ModeratorsAnswer(verdict="declined", reason=reason, status="operation aborted")
533
+
534
+ def get_model_display_name(self, model_name: str) -> str:
535
+ return model_name.replace("-", " ")
536
+
537
+ async def get_available_models(self, image_generation: bool = False) -> list[ModelChangeSchema]:
538
+ try:
539
+ models = await self.client.models.list()
540
+ except Exception as e:
541
+ logger.error(f"Failed to get available models for provider {self.name} due to exception: {e}")
542
+ return []
543
+
544
+ all_models = [
545
+ ModelChangeSchema(
546
+ provider=self.name,
547
+ name=model.id,
548
+ display_name=self.get_model_display_name(model.id),
549
+ image_generation=self.is_image_ready_model(model.id),
550
+ )
551
+ for model in models.data
552
+ ]
553
+ all_models.sort(key=lambda model: model.name)
554
+
555
+ if image_generation:
556
+ return [model for model in all_models if model.image_generation]
557
+
558
+ if gpt_settings.models_whitelist:
559
+ return [model for model in all_models if model.name in gpt_settings.models_whitelist]
560
+
561
+ return [model for model in all_models if self.is_chat_ready_model(model.name)]
562
+
563
+ async def _get_image_generation_response(self, prompt: str, model: str) -> ImagesResponse:
564
+ return await self.client.images.generate(
565
+ model=model,
566
+ prompt=prompt,
567
+ n=gpt_settings.image_n_choices,
568
+ quality=self.image_quality,
569
+ size=self.image_size,
570
+ timeout=gpt_settings.timeout,
571
+ response_format="url",
572
+ )
573
+
574
+ async def get_images(self, prompt: str, model: str | None = None) -> list[str] | list[BytesIO]:
575
+ model = model or self.default_image_model
576
+ if not model:
577
+ raise NoModelSelectedError(provider=self.name, detail="No image generation model selected")
578
+ response = await self._get_image_generation_response(prompt=prompt, model=model)
579
+ if not response.data:
580
+ raise ServiceResponseError(provider=self.name, model=model, detail="No image data received.")
581
+ return [image.url for image in response.data if image.url]
582
+
583
+
584
+ class RestApiFriendlyProvider(Provider):
585
+ @property
586
+ def _headers(self) -> dict[str, str]:
587
+ raise NotImplementedError
588
+
589
+ def get_async_httpx_client(self) -> httpx.AsyncClient:
590
+ transport = httpx.AsyncHTTPTransport(retries=gpt_settings.retries, proxy=gpt_settings.proxy)
591
+ return httpx.AsyncClient(transport=transport, timeout=gpt_settings.timeout)
592
+
593
+ async def _request(
594
+ self,
595
+ method: str,
596
+ url: str,
597
+ data: RequestData | None = None,
598
+ params: QueryParamTypes | None = None,
599
+ headers: dict[str, str] | None = None,
600
+ ) -> Response:
601
+ if not self.token:
602
+ raise NoApiKeyProvidedError(provider=self.name)
603
+
604
+ try:
605
+ async with self.get_async_httpx_client() as client:
606
+ response = await client.request(
607
+ method=method,
608
+ url=url,
609
+ json=data,
610
+ headers=headers or self._headers,
611
+ params=params,
612
+ )
613
+ except Exception as e:
614
+ logger.error(f"An error occurred while calling the {self.name} API: {e}")
615
+ raise ServiceResponseError(provider=self.name, detail=str(e))
616
+
617
+ if response.status_code == 200:
618
+ return response
619
+
620
+ logger.error(
621
+ f"Unexpected response from {self.name} API. Status code: {response.status_code}. Data: {response.text}"
622
+ )
623
+ if response.status_code == 401:
624
+ raise NotAuthorizedError(provider=self.name)
625
+ if response.status_code == 429:
626
+ raise ServiceRateLimitError(provider=self.name)
627
+ raise ServiceResponseError(provider=self.name)
628
+
629
+
630
+ class AnthropicFriendlyProvider(RestApiFriendlyProvider):
631
+ frequency_penalty: float | NotGiven | None = gpt_settings.frequency_penalty
632
+ max_tokens: int = gpt_settings.max_tokens
633
+ presence_penalty: float | NotGiven = gpt_settings.presence_penalty
634
+ temperature: float | Omit = gpt_settings.temperature
635
+
636
+ @property
637
+ def tools_list(self) -> list[ToolParam]:
638
+ anthropic_tools = [
639
+ ToolParam(
640
+ name=tool["function"]["name"],
641
+ description=tool["function"]["description"],
642
+ input_schema=tool["function"]["parameters"],
643
+ )
644
+ for tool in RegisteredChibiTools.get_tool_definitions()
645
+ ]
646
+ return anthropic_tools
647
+
648
+ @property
649
+ def client(self) -> AsyncClient:
650
+ raise NotImplementedError
651
+
652
+ async def _generate_content(
653
+ self,
654
+ model: str,
655
+ system_prompt: str,
656
+ messages: list[MessageParam],
657
+ ) -> AnthropicMessage:
658
+ for attempt in range(gpt_settings.retries):
659
+ response_message: AnthropicMessage = await self.client.messages.create(
660
+ model=model,
661
+ max_tokens=self.max_tokens,
662
+ temperature=self.temperature,
663
+ timeout=self.timeout,
664
+ tools=self.tools_list,
665
+ system=[
666
+ TextBlockParam(
667
+ text=system_prompt,
668
+ type="text",
669
+ cache_control=CacheControlEphemeralParam(type="ephemeral"),
670
+ )
671
+ ],
672
+ messages=messages,
673
+ )
674
+
675
+ if response_message.content and len(response_message.content) > 0:
676
+ return response_message
677
+
678
+ delay = gpt_settings.backoff_factor * (2**attempt)
679
+ jitter = delay * random.uniform(0.1, 0.5)
680
+ total_delay = delay + jitter
681
+
682
+ logger.warning(
683
+ f"Attempt #{attempt + 1}. Unexpected (empty) response received. Retrying in {total_delay} seconds..."
684
+ )
685
+ await sleep(total_delay)
686
+ raise NoResponseError(provider=self.name, model=model, detail="Unexpected (empty) response received")
687
+
688
+ async def get_chat_response(
689
+ self,
690
+ messages: list[Message],
691
+ user: User | None = None,
692
+ model: str | None = None,
693
+ system_prompt: str = gpt_settings.assistant_prompt,
694
+ update: Update | None = None,
695
+ context: ContextTypes.DEFAULT_TYPE | None = None,
696
+ ) -> tuple[ChatResponseSchema, list[Message]]:
697
+ model = model or self.default_model
698
+ initial_messages = [msg.to_anthropic() for msg in messages]
699
+
700
+ if len(initial_messages) >= 2:
701
+ initial_messages[-2]["content"][0]["cache_control"] = {"type": "ephemeral"} # type: ignore
702
+
703
+ chat_response, updated_messages = await self._get_chat_completion_response(
704
+ messages=initial_messages.copy(),
705
+ user=user,
706
+ model=model,
707
+ system_prompt=system_prompt,
708
+ context=context,
709
+ update=update,
710
+ )
711
+ new_messages = [msg for msg in updated_messages if msg not in initial_messages]
712
+ return (
713
+ chat_response,
714
+ [Message.from_anthropic(msg) for msg in new_messages],
715
+ )
716
+
717
+ async def _get_chat_completion_response(
718
+ self,
719
+ messages: list[MessageParam],
720
+ model: str,
721
+ user: User | None = None,
722
+ system_prompt: str = gpt_settings.assistant_prompt,
723
+ update: Update | None = None,
724
+ context: ContextTypes.DEFAULT_TYPE | None = None,
725
+ ) -> tuple[ChatResponseSchema, list[MessageParam]]:
726
+ prepared_system_prompt = await prepare_system_prompt(base_system_prompt=system_prompt, user=user)
727
+ response_message: AnthropicMessage = await self._generate_content(
728
+ model=model,
729
+ system_prompt=prepared_system_prompt,
730
+ messages=messages,
731
+ )
732
+ usage = get_usage_from_anthropic_response(response_message=response_message)
733
+
734
+ if application_settings.is_influx_configured:
735
+ MetricsService.send_usage_metrics(metric=usage, user=user, model=model, provider=self.name)
736
+
737
+ tool_call_parts = [part for part in response_message.content if isinstance(part, ToolUseBlock)]
738
+ if not tool_call_parts:
739
+ messages.append(
740
+ MessageParam(
741
+ role="assistant",
742
+ content=[content.model_dump() for content in response_message.content], # type: ignore
743
+ )
744
+ )
745
+ answer = None
746
+ for block in response_message.content:
747
+ if answer := getattr(block, "text", None):
748
+ break
749
+
750
+ return ChatResponseSchema(
751
+ answer=answer or "no data",
752
+ provider=self.name,
753
+ model=model,
754
+ usage=usage,
755
+ ), messages
756
+
757
+ # Tool calls handling
758
+ logger.log("CALL", f"{model} requested the call of {len(tool_call_parts)} tools.")
759
+ thoughts_part: TextBlock | None = next(
760
+ (part for part in response_message.content if isinstance(part, TextBlock)), None
761
+ )
762
+
763
+ if thoughts_part:
764
+ await send_llm_thoughts(thoughts=thoughts_part.text, context=context, update=update)
765
+
766
+ logger.log(
767
+ "THINK", f"{model}: {thoughts_part.text if thoughts_part else 'No thoughts'}. {get_usage_msg(usage=usage)}"
768
+ )
769
+
770
+ tool_context: dict[str, Any] = {
771
+ "user_id": user.id if user else None,
772
+ "telegram_context": context,
773
+ "telegram_update": update,
774
+ "model": model,
775
+ }
776
+
777
+ tool_coroutines = [
778
+ RegisteredChibiTools.call(tool_name=tool_call_part.name, tools_args=tool_context | tool_call_part.input)
779
+ for tool_call_part in tool_call_parts
780
+ ]
781
+ results = await asyncio.gather(*tool_coroutines)
782
+
783
+ for tool_call_part, result in zip(tool_call_parts, results):
784
+ tool_call_message = MessageParam(
785
+ role="assistant",
786
+ content=[part.model_dump() for part in (thoughts_part, tool_call_part) if part is not None], # type: ignore
787
+ )
788
+
789
+ tool_result_message = MessageParam(
790
+ role="user",
791
+ content=[
792
+ ToolResultBlockParam(
793
+ type="tool_result",
794
+ tool_use_id=tool_call_part.id,
795
+ content=result.model_dump_json(),
796
+ )
797
+ ],
798
+ )
799
+ messages.append(tool_call_message)
800
+ messages.append(tool_result_message)
801
+
802
+ logger.log("CALL", "All the function results have been obtained. Returning them to the LLM...")
803
+ return await self._get_chat_completion_response(
804
+ messages=messages,
805
+ model=model,
806
+ user=user,
807
+ system_prompt=system_prompt,
808
+ context=context,
809
+ update=update,
810
+ )
811
+
812
+ async def moderate_command(self, cmd: str, model: str | None = None) -> ModeratorsAnswer:
813
+ moderator_model = model or self.default_moderation_model or self.default_model
814
+ messages = [Message(role="user", content=cmd).to_anthropic()]
815
+ response_message: AnthropicMessage = await self.client.messages.create(
816
+ model=moderator_model,
817
+ max_tokens=1024,
818
+ temperature=0.1,
819
+ timeout=self.timeout,
820
+ system=[
821
+ TextBlockParam(
822
+ text=MODERATOR_PROMPT,
823
+ type="text",
824
+ )
825
+ ],
826
+ tools=[
827
+ {
828
+ "name": "print_moderator_verdict",
829
+ "description": "Provide moderator's verdict",
830
+ "input_schema": {
831
+ "type": "object",
832
+ "properties": {
833
+ "verdict": {"type": "string"},
834
+ "status": {"type": "string", "default": "ok"},
835
+ "reason": {"type": "string"},
836
+ },
837
+ "required": ["verdict"],
838
+ },
839
+ }
840
+ ],
841
+ tool_choice={"type": "tool", "name": "print_moderator_verdict"},
842
+ messages=messages,
843
+ )
844
+ if not response_message.content:
845
+ return ModeratorsAnswer(status="error", verdict="declined", reason="no response from moderator received")
846
+ usage = get_usage_from_anthropic_response(response_message=response_message)
847
+
848
+ if application_settings.is_influx_configured:
849
+ MetricsService.send_usage_metrics(metric=usage, model=moderator_model, provider=self.name)
850
+ tool_call: ToolUseBlock | None = next(
851
+ part for part in response_message.content if isinstance(part, ToolUseBlock)
852
+ )
853
+ if not tool_call:
854
+ return ModeratorsAnswer(status="error", verdict="declined", reason="no response from moderator received")
855
+ answer = tool_call.input
856
+
857
+ try:
858
+ return ModeratorsAnswer.model_validate(answer, extra="ignore")
859
+ except Exception as e:
860
+ msg = f"Error parsing moderator's response: {answer}. Error: {e}"
861
+ logger.error(msg)
862
+ return ModeratorsAnswer(verdict="declined", reason=msg, status="error")
863
+
864
+ async def get_available_models(self, image_generation: bool = False) -> list[ModelChangeSchema]:
865
+ if image_generation:
866
+ return []
867
+
868
+ url = "https://api.anthropic.com/v1/models"
869
+
870
+ try:
871
+ response = await self._request(method="GET", url=url)
872
+ except Exception as e:
873
+ logger.error(f"Failed to get available models for provider {self.name} due to exception: {e}")
874
+ return []
875
+
876
+ response_data = response.json().get("data", [])
877
+ all_models = [
878
+ ModelChangeSchema(
879
+ provider=self.name,
880
+ name=model.get("id"),
881
+ display_name=model.get("display_name"),
882
+ image_generation=False,
883
+ )
884
+ for model in response_data
885
+ if model.get("id") and model.get("type") == "model"
886
+ ]
887
+ all_models.sort(key=lambda model: model.name)
888
+
889
+ if gpt_settings.models_whitelist:
890
+ return [model for model in all_models if model.name in gpt_settings.models_whitelist]
891
+
892
+ return all_models