From 09fb0177b18741ddddb8e74afe5e3acf21bba7ed Mon Sep 17 00:00:00 2001 From: Andrey Date: Sun, 13 Sep 2026 09:02:01 +0300 Subject: [PATCH] =?UTF-8?q?:fire:=20refactor(ai):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=83=D1=87=D1=91=D1=82=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D1=85=D0=BE=D0=B4=D0=B0=20=D0=BD=D0=B0=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=20=D0=B8=20=D0=B2=D0=B0=D0=BB=D1=8E?= =?UTF-8?q?=D1=82=D1=83=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Учёт стоимости вызовов остался без потребителя, когда сняли лимиты, и сам по себе не работал: цену присылает только OpenRouter, для остальных провайдеров была прайс-таблица из двух моделей и ноль для всех прочих. Ни одна цифра расхода в интерфейсе не показывалась. Удалены ai/pricing.py, ai/limits.py, поля LlmInvocation.cost_micros и currency, статус BLOCKED вместе со строками журнала, ChatResult.cost_micros и чтение usage.cost, настройки CHATBALLS_AI_PRICING и CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS, поля aiCostMicros/aiCostPrevMicros в сводке и стоимость в демо-данных. Обработка входящих ловит теперь только сбой провайдера — лимиту неоткуда сработать. Валюта организации удалена целиком: поле принимало единственное значение RUB, не читалось нигде и ни одна сумма в продукте в ней не считалась. Убраны колонка с миграцией, валидация «только рубль», payload настроек и платформенной выдачи, обязательное поле currency в API провижининга, типы и транспорт на фронте. Co-Authored-By: Claude Opus 5 --- apps/backend/chatballs/ai/invocation.py | 29 +--------------- apps/backend/chatballs/ai/limits.py | 34 ------------------- .../0019_drop_llm_cost_accounting.py | 34 +++++++++++++++++++ apps/backend/chatballs/ai/models.py | 6 ---- apps/backend/chatballs/ai/pricing.py | 17 ---------- apps/backend/chatballs/ai/provider/base.py | 3 -- .../chatballs/ai/provider/openai_http.py | 28 +++------------ .../chatballs/ai/provider/openrouter.py | 3 +- apps/backend/chatballs/ai/tests.py | 33 ------------------ .../backend/chatballs/conversations/ingest.py | 8 ++--- apps/backend/chatballs/conversations/stats.py | 16 +-------- apps/backend/chatballs/conversations/tests.py | 12 +++---- apps/backend/chatballs/i18n/messages/en.py | 1 - apps/backend/chatballs/i18n/messages/ru.py | 1 - apps/backend/chatballs/identity/admin.py | 2 +- .../identity/administration_payloads.py | 1 - .../identity/administration_services.py | 17 +++------- .../identity/administration_views.py | 1 - apps/backend/chatballs/identity/bootstrap.py | 1 - .../demo_seed/data/en/channels_ai.json | 8 ----- .../demo_seed/data/ru/channels_ai.json | 8 ----- .../identity/demo_seed/loaders/channels_ai.py | 2 -- .../0039_remove_organization_currency.py | 18 ++++++++++ apps/backend/chatballs/identity/models.py | 1 - .../identity/organization_creation.py | 1 - .../chatballs/identity/organization_views.py | 1 - .../identity/test_administration_api.py | 14 -------- .../identity/test_organization_creation.py | 2 -- apps/backend/chatballs/platform/payloads.py | 1 - .../platform/provisioning_command.py | 1 - .../platform/provisioning_service.py | 1 - .../platform/tests/test_platform_auth.py | 2 -- .../platform/tests/test_platform_role.py | 3 +- apps/backend/chatballs/platform/validation.py | 2 -- .../chatballs_backend/settings_base.py | 4 --- .../src/features/administration/api.ts | 1 - .../src/features/administration/model.ts | 1 - apps/internal-ui/src/i18n/en.ts | 2 -- apps/internal-ui/src/i18n/ru.ts | 2 -- 39 files changed, 73 insertions(+), 249 deletions(-) delete mode 100644 apps/backend/chatballs/ai/limits.py create mode 100644 apps/backend/chatballs/ai/migrations/0019_drop_llm_cost_accounting.py delete mode 100644 apps/backend/chatballs/ai/pricing.py create mode 100644 apps/backend/chatballs/identity/migrations/0039_remove_organization_currency.py diff --git a/apps/backend/chatballs/ai/invocation.py b/apps/backend/chatballs/ai/invocation.py index 846da94..5bdd0fb 100644 --- a/apps/backend/chatballs/ai/invocation.py +++ b/apps/backend/chatballs/ai/invocation.py @@ -2,7 +2,6 @@ import time from django.conf import settings -from chatballs.ai import limits, pricing from chatballs.ai.models import LlmInvocation, LlmInvocationStatus from chatballs.ai.pii import redact from chatballs.ai.provider import routing @@ -19,18 +18,6 @@ from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilienc _breaker = CircuitBreaker() -def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> None: - LlmInvocation.objects.create( - organization=channel.organization, - channel=channel, - purpose=purpose, - operation="chat", - model=model, - status=LlmInvocationStatus.BLOCKED, - error=str(error), - ) - - def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]: # BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции # организации с fallback на модель агента. Без интеграции модель остаётся @@ -52,18 +39,7 @@ def invoke_chat( params: dict | None = None, used_fragment_ids: list | None = None, ) -> ChatResult: - fallback_model = model or channel.ai_agent.model - try: - provider, model = _prepare_invocation(channel=channel, requested_model=model) - limits.assert_within_limits() - except limits.LimitExceeded as error: - _record_blocked( - channel=channel, - purpose=purpose, - model=fallback_model, - error=error, - ) - raise + provider, model = _prepare_invocation(channel=channel, requested_model=model) safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages] started = time.monotonic() @@ -96,8 +72,6 @@ def invoke_chat( prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, total_tokens=result.total_tokens, - cost_micros=result.cost_micros - or pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens), latency_ms=int((time.monotonic() - started) * 1000), status=LlmInvocationStatus.SUCCESS, used_fragment_ids=used_fragment_ids or [], @@ -128,7 +102,6 @@ def embed_texts( model=model, prompt_tokens=tokens, total_tokens=tokens, - cost_micros=pricing.cost_micros(model, tokens, 0), status=LlmInvocationStatus.SUCCESS, ) return results diff --git a/apps/backend/chatballs/ai/limits.py b/apps/backend/chatballs/ai/limits.py deleted file mode 100644 index 82c19c6..0000000 --- a/apps/backend/chatballs/ai/limits.py +++ /dev/null @@ -1,34 +0,0 @@ -from django.conf import settings -from django.db.models import Sum -from django.utils import timezone - -from chatballs.ai.models import LlmInvocation, LlmInvocationStatus - - -class LimitExceeded(Exception): - pass - - -def _day_start(): - now = timezone.localtime() - return now.replace(hour=0, minute=0, second=0, microsecond=0) - - -def daily_cost_micros(channel=None) -> int: - queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS) - if channel is not None: - queryset = queryset.filter(channel=channel) - return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0 - - -def assert_within_limits() -> None: - """Единственный лимит расхода — общий по установке, из переменной окружения. - - Дневного бюджета на агенте нет: он считался по прайс-таблице, где всего две - модели, и на любой другой расход оставался нулевым — лимит не срабатывал - никогда и давал ложное чувство защиты. - """ - - global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS - if global_limit and daily_cost_micros() >= global_limit: - raise LimitExceeded("Global daily AI cost limit reached") diff --git a/apps/backend/chatballs/ai/migrations/0019_drop_llm_cost_accounting.py b/apps/backend/chatballs/ai/migrations/0019_drop_llm_cost_accounting.py new file mode 100644 index 0000000..1f6087c --- /dev/null +++ b/apps/backend/chatballs/ai/migrations/0019_drop_llm_cost_accounting.py @@ -0,0 +1,34 @@ +# Учёт стоимости вызовов удалён вместе с лимитами. Считать было нечем: цена +# бралась из ответа провайдера, а его присылает только OpenRouter; на остальных +# оставалась прайс-таблица из двух моделей и ноль для всех прочих. Ни одна +# цифра расхода в продукте не показывалась. +# +# Статус BLOCKED уходит вместе с лимитами — блокировать вызовы больше нечему. +from django.db import migrations, models + + +def drop_blocked_rows(apps, schema_editor): + """Строк со снятым статусом в журнале остаться не должно.""" + + apps.get_model("ai", "LlmInvocation").objects.filter(status="BLOCKED").delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("ai", "0018_remove_agent_limits"), + ] + + operations = [ + migrations.RunPython(drop_blocked_rows, migrations.RunPython.noop), + migrations.RemoveField(model_name="llminvocation", name="cost_micros"), + migrations.RemoveField(model_name="llminvocation", name="currency"), + migrations.AlterField( + model_name="llminvocation", + name="status", + field=models.CharField( + choices=[("SUCCESS", "Успех"), ("ERROR", "Ошибка")], + default="SUCCESS", + max_length=16, + ), + ), + ] diff --git a/apps/backend/chatballs/ai/models.py b/apps/backend/chatballs/ai/models.py index 8efb503..0a4d79f 100644 --- a/apps/backend/chatballs/ai/models.py +++ b/apps/backend/chatballs/ai/models.py @@ -459,8 +459,6 @@ class LlmInvocationStatus(models.TextChoices): ERROR = "ERROR", "Ошибка" - BLOCKED = "BLOCKED", "Заблокировано лимитом" - @@ -485,10 +483,6 @@ class LlmInvocation(TenantRelationModel): total_tokens = models.PositiveIntegerField(default=0) - cost_micros = models.PositiveBigIntegerField(default=0) - - currency = models.CharField(max_length=3, default="USD") - latency_ms = models.PositiveIntegerField(default=0) status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS) diff --git a/apps/backend/chatballs/ai/pricing.py b/apps/backend/chatballs/ai/pricing.py deleted file mode 100644 index e100291..0000000 --- a/apps/backend/chatballs/ai/pricing.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.conf import settings - -# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов. -# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost). -# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING. -DEFAULT_PRICING = { - "openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60}, - "anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0}, -} - - -def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int: - table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})} - price = table.get(model) - if not price: - return 0 - return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"]) diff --git a/apps/backend/chatballs/ai/provider/base.py b/apps/backend/chatballs/ai/provider/base.py index 475d108..153b0b0 100644 --- a/apps/backend/chatballs/ai/provider/base.py +++ b/apps/backend/chatballs/ai/provider/base.py @@ -18,9 +18,6 @@ class ChatResult: model: str prompt_tokens: int completion_tokens: int - # Фактическая стоимость, сообщённая провайдером (micro-USD). 0 — провайдер не - # вернул цену, тогда считаем по прайс-таблице (ai/pricing.py). - cost_micros: int = 0 @property def total_tokens(self) -> int: diff --git a/apps/backend/chatballs/ai/provider/openai_http.py b/apps/backend/chatballs/ai/provider/openai_http.py index cc3c96f..40fef0f 100644 --- a/apps/backend/chatballs/ai/provider/openai_http.py +++ b/apps/backend/chatballs/ai/provider/openai_http.py @@ -12,7 +12,7 @@ speak the same Chat Completions shape: - POST /chat/completions with {model, messages, ...}; response has - choices[0].message.content and usage (optionally usage.cost in USD). + choices[0].message.content and usage (prompt/completion tokens). - POST /embeddings with {model, input}; response has data[].embedding and usage. @@ -24,7 +24,7 @@ This module owns the HTTP transport and response parsing so the three adapters do not duplicate it. Adapters stay responsible for their own product semantics -(name, cost handling, catalog). Stdlib only — no third-party HTTP client. +(name, catalog). Stdlib only — no third-party HTTP client. """ @@ -116,21 +116,9 @@ def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_ur def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str, - timeout: float, proxy_url: str = "", params: dict | None = None, + timeout: float, proxy_url: str = "", params: dict | None = None) -> ChatResult: - include_cost: bool = False) -> ChatResult: - - """POST /chat/completions and parse the OpenAI-shaped response. - - - - `include_cost=True` requests the OpenRouter-style usage.include flag and reads - - usage.cost (USD, converted to micros). Providers that do not report cost - - (Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback. - - """ + """POST /chat/completions and parse the OpenAI-shaped response.""" payload: dict = { @@ -142,10 +130,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage] } - if include_cost: - - payload["usage"] = {"include": True} - data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key, payload=payload, timeout=timeout, proxy_url=proxy_url) @@ -160,8 +144,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage] usage = data.get("usage") or {} - cost = usage.get("cost") - return ChatResult( text=text, @@ -172,8 +154,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage] completion_tokens=int(usage.get("completion_tokens", 0)), - cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0, - ) diff --git a/apps/backend/chatballs/ai/provider/openrouter.py b/apps/backend/chatballs/ai/provider/openrouter.py index 666777b..9d10cb4 100644 --- a/apps/backend/chatballs/ai/provider/openrouter.py +++ b/apps/backend/chatballs/ai/provider/openrouter.py @@ -22,10 +22,9 @@ class OpenRouterProvider(LLMProvider): self.proxy_url = proxy_url or "" def chat(self, *, messages: list[ChatMessage], model: str, params: dict | None = None) -> ChatResult: - # usage.include=true — OpenRouter возвращает фактическую стоимость в usage.cost (USD). return openai_http.chat_completions( base_url=self.base_url, api_key=self.api_key, messages=messages, model=model, - timeout=self.timeout, proxy_url=self.proxy_url, params=params, include_cost=True, + timeout=self.timeout, proxy_url=self.proxy_url, params=params, ) def embed(self, *, texts: list[str], model: str) -> list[EmbeddingResult]: diff --git a/apps/backend/chatballs/ai/tests.py b/apps/backend/chatballs/ai/tests.py index f999e1c..a9cc54f 100644 --- a/apps/backend/chatballs/ai/tests.py +++ b/apps/backend/chatballs/ai/tests.py @@ -343,15 +343,6 @@ class ChatInvocationTests(TestCase): invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat") self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS) self.assertGreater(invocation.total_tokens, 0) - # Технический учёт стоимости (ADR-CHATBALLS-0042 §2): считается по прайсу модели. - from chatballs.ai import pricing - - self.assertEqual( - invocation.cost_micros, - pricing.cost_micros( - invocation.model, invocation.prompt_tokens, invocation.completion_tokens - ), - ) def test_pii_is_redacted_before_reaching_provider(self) -> None: from unittest import mock @@ -378,30 +369,6 @@ class ChatInvocationTests(TestCase): self.assertNotIn("a@b.com", captured["messages"][0].content) - def test_global_limit_blocks_and_records(self) -> None: - """Единственный лимит расхода — общий по установке, из окружения. - - Дневного бюджета на агенте больше нет: он опирался на прайс-таблицу из - двух моделей и на всех прочих не срабатывал. - """ - - from django.test import override_settings - - from chatballs.ai import limits as ai_limits - from chatballs.ai.invocation import invoke_chat - from chatballs.ai.models import LlmInvocation, LlmInvocationStatus - from chatballs.ai.provider.base import ChatMessage - - LlmInvocation.objects.create( - channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10_001, - status=LlmInvocationStatus.SUCCESS, - ) - - with override_settings(CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=10_000): - with self.assertRaises(ai_limits.LimitExceeded): - invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat") - self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists()) - def test_invocation_records_used_fragment_ids(self) -> None: from chatballs.ai.invocation import invoke_chat from chatballs.ai.models import LlmInvocation diff --git a/apps/backend/chatballs/conversations/ingest.py b/apps/backend/chatballs/conversations/ingest.py index 8c0c2d2..424e10b 100644 --- a/apps/backend/chatballs/conversations/ingest.py +++ b/apps/backend/chatballs/conversations/ingest.py @@ -13,7 +13,6 @@ from dataclasses import dataclass from django.db import IntegrityError, transaction from django.utils import timezone -from chatballs.ai.limits import LimitExceeded from chatballs.ai.provider.base import ProviderError from chatballs.ai.runtime import HANDOFF_TOKEN from chatballs.channels.runtime import run_channel_turn @@ -382,10 +381,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None: try: result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation)) - except (ProviderError, LimitExceeded) as error: - # Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны - # «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и - # отвечаем клиенту понятным fallback. + except ProviderError as error: + # Сбой AI не должен «терять» сообщение: переводим диалог в очередь к + # оператору, уведомляем и отвечаем клиенту понятным fallback. logger.warning("AI turn failed for conversation %s: %s", conversation.id, error) conversation.control_mode = ControlMode.PAUSED conversation.expected_responder = ExpectedResponder.OPERATOR diff --git a/apps/backend/chatballs/conversations/stats.py b/apps/backend/chatballs/conversations/stats.py index ce804ab..176d41d 100644 --- a/apps/backend/chatballs/conversations/stats.py +++ b/apps/backend/chatballs/conversations/stats.py @@ -8,11 +8,10 @@ from __future__ import annotations from datetime import datetime, timedelta -from django.db.models import Count, Sum +from django.db.models import Count from django.db.models.functions import TruncDate, TruncHour from django.utils import timezone -from chatballs.ai.models import LlmInvocation from chatballs.channels.selectors import channels_in_organization from chatballs.conversations.models import ( ControlMode, @@ -64,17 +63,6 @@ def _chart( return {"values": values, "labels": labels} -def _ai_cost( - org_id: int, - start: datetime, - end: datetime | None = None, -) -> int: - qs = LlmInvocation.objects.filter(channel__organization_id=org_id, created_at__gte=start) - if end is not None: - qs = qs.filter(created_at__lt=end) - return qs.aggregate(total=Sum("cost_micros"))["total"] or 0 - - def sales_overview_stats(context, period: str) -> dict: organization_id = context.organization_id now = timezone.now() @@ -105,8 +93,6 @@ def sales_overview_stats(context, period: str) -> dict: conversation__organization_id=organization_id, created_at__gte=start, ).count(), - "aiCostMicros": _ai_cost(organization_id, start), - "aiCostPrevMicros": _ai_cost(organization_id, prev_start, start), } open_by_channel = dict(open_qs.values_list("channel_id").annotate(c=Count("id"))) diff --git a/apps/backend/chatballs/conversations/tests.py b/apps/backend/chatballs/conversations/tests.py index c751dc0..a54fecc 100644 --- a/apps/backend/chatballs/conversations/tests.py +++ b/apps/backend/chatballs/conversations/tests.py @@ -3,7 +3,6 @@ from unittest import mock from django.test import TestCase, override_settings -from chatballs.ai.limits import LimitExceeded from chatballs.ai.models import AIAgent, AIAgentStatus from chatballs.ai.provider.base import ProviderError from chatballs.channels.models import Channel @@ -42,10 +41,9 @@ def _messenger_connection(channel): ) -class IngestLimitHandlingTests(TestCase): - """При срабатывании дневного лимита стоимости (LimitExceeded) диалог не должен - «зависать»: его передают оператору с fallback-ответом клиенту (как при сбое - провайдера). См. ingest.ingest_inbound. +class IngestProviderFailureTests(TestCase): + """При сбое провайдера диалог не должен «зависать»: его передают оператору с + fallback-ответом клиенту. См. ingest.ingest_inbound. """ def setUp(self) -> None: @@ -63,13 +61,13 @@ class IngestLimitHandlingTests(TestCase): external_id="ext-1", user_id="user-1", chat_id="chat-1", text="Здравствуйте", display_name="Гость" ) - def test_limit_exceeded_hands_off_to_operator(self) -> None: + def test_provider_failure_hands_off_to_operator(self) -> None: from chatballs.conversations.ingest import ingest_inbound with ( mock.patch( "chatballs.conversations.ingest.run_channel_turn", - side_effect=LimitExceeded("Channel daily AI cost limit reached"), + side_effect=ProviderError("provider is down"), ), mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send, ): diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index d797ce0..17e9f3e 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -4,7 +4,6 @@ from __future__ import annotations MESSAGES: dict[str, object] = { "admin.choose_logo_file": "Choose a logo file", - "admin.currency_rub_only": "Only the Russian rouble (RUB) is supported", "admin.employee_not_found": "Operator not found", "admin.group_name_taken": "A group with this name already exists", "admin.group_not_found": "Group not found", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 1b6e9e8..5d9b20b 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -8,7 +8,6 @@ from __future__ import annotations MESSAGES: dict[str, object] = { "admin.choose_logo_file": "Выберите файл логотипа", - "admin.currency_rub_only": "Поддерживается только российский рубль (RUB)", "admin.employee_not_found": "Сотрудник не найден", "admin.group_name_taken": "Группа с таким именем уже есть", "admin.group_not_found": "Группа не найдена", diff --git a/apps/backend/chatballs/identity/admin.py b/apps/backend/chatballs/identity/admin.py index 6d93193..ca8849c 100644 --- a/apps/backend/chatballs/identity/admin.py +++ b/apps/backend/chatballs/identity/admin.py @@ -35,7 +35,7 @@ class HumanUserAdmin(UserAdmin): @admin.register(Organization) class OrganizationAdmin(admin.ModelAdmin): - list_display = ["slug", "name", "currency", "timezone"] + list_display = ["slug", "name", "timezone"] search_fields = ["slug", "name"] diff --git a/apps/backend/chatballs/identity/administration_payloads.py b/apps/backend/chatballs/identity/administration_payloads.py index 4787dd4..d2efdec 100644 --- a/apps/backend/chatballs/identity/administration_payloads.py +++ b/apps/backend/chatballs/identity/administration_payloads.py @@ -43,7 +43,6 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec return { "name": organization.name, "timezone": organization.timezone, - "currency": organization.currency, # Пустая строка доезжает до интерфейса как есть: там это отдельный # пункт «Как в установке», а не отсутствие значения. "language": organization.language, diff --git a/apps/backend/chatballs/identity/administration_services.py b/apps/backend/chatballs/identity/administration_services.py index 0394c16..1e3fdcb 100644 --- a/apps/backend/chatballs/identity/administration_services.py +++ b/apps/backend/chatballs/identity/administration_services.py @@ -28,7 +28,6 @@ MAX_LOGO_BYTES = 2 * 1024 * 1024 class OrganizationSettingsInput: name: str timezone: str - currency: str # Пустая строка — «как в установке»: организация не обязана выбирать язык, # и владелец, который его не трогал, не должен получить жёсткий русский # после того, как язык установки сменили. @@ -36,12 +35,11 @@ class OrganizationSettingsInput: def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput: - """Имя, часовой пояс, валюта и язык организации — одни правила для - «Настроек» и для страницы создания организации.""" + """Имя, часовой пояс и язык организации — одни правила для «Настроек» и + для страницы создания организации.""" name = data.name.strip() timezone = data.timezone.strip() - currency = data.currency.strip().upper() language = normalize_language(data.language) if data.language.strip() and not language: raise ValidationError({"language": t("settings.language_unsupported")}) @@ -55,13 +53,7 @@ def validate_organization_settings(data: OrganizationSettingsInput) -> Organizat raise ValidationError( {"timezone": t("admin.invalid_timezone")} ) from error - if currency != "RUB": - raise ValidationError( - {"currency": t("admin.currency_rub_only")} - ) - return OrganizationSettingsInput( - name=name, timezone=timezone, currency=currency, language=language - ) + return OrganizationSettingsInput(name=name, timezone=timezone, language=language) @transaction.atomic @@ -76,9 +68,8 @@ def update_organization_settings( ) organization.name = clean.name organization.timezone = clean.timezone - organization.currency = clean.currency organization.language = clean.language - organization.save(update_fields=["name", "timezone", "currency", "language"]) + organization.save(update_fields=["name", "timezone", "language"]) return organization diff --git a/apps/backend/chatballs/identity/administration_views.py b/apps/backend/chatballs/identity/administration_views.py index a848268..154c6c1 100644 --- a/apps/backend/chatballs/identity/administration_views.py +++ b/apps/backend/chatballs/identity/administration_views.py @@ -74,7 +74,6 @@ class OrganizationSettingsView(APIView): data=OrganizationSettingsInput( name=str(body.get("name", organization.name)), timezone=str(body.get("timezone", organization.timezone)), - currency=str(body.get("currency", organization.currency)), language=str(body.get("language", organization.language)), ), ) diff --git a/apps/backend/chatballs/identity/bootstrap.py b/apps/backend/chatballs/identity/bootstrap.py index aa0407b..b6458b6 100644 --- a/apps/backend/chatballs/identity/bootstrap.py +++ b/apps/backend/chatballs/identity/bootstrap.py @@ -28,7 +28,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst defaults={ "name": "Demo", "timezone": "Europe/Moscow", - "currency": "RUB", }, ) from chatballs.ai.knowledge_categories import ensure_uncategorized_category diff --git a/apps/backend/chatballs/identity/demo_seed/data/en/channels_ai.json b/apps/backend/chatballs/identity/demo_seed/data/en/channels_ai.json index c0a3f97..ca5130e 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/en/channels_ai.json +++ b/apps/backend/chatballs/identity/demo_seed/data/en/channels_ai.json @@ -30,9 +30,6 @@ "how-to-measure", "lead-times" ], - "limits": { - "dailyCostUsd": 300 - }, "createdDaysAgo": 41 }, { @@ -53,7 +50,6 @@ "returns-and-exchanges", "your-account" ], - "limits": {}, "createdDaysAgo": 30, "policy": { "allow_anonymous_sessions": true, @@ -78,7 +74,6 @@ "your-account", "returns-and-exchanges" ], - "limits": {}, "createdDaysAgo": 28, "policy": { "allow_anonymous_sessions": true, @@ -96,7 +91,6 @@ "instructions": "", "knowledge": [], "portalArticles": [], - "limits": {}, "createdDaysAgo": 3 }, { @@ -113,7 +107,6 @@ "pricing" ], "portalArticles": [], - "limits": {}, "createdDaysAgo": 180 } ], @@ -378,7 +371,6 @@ 80, 260 ], - "costMicrosPerToken": 0.6, "failuresTotal": 3 } } diff --git a/apps/backend/chatballs/identity/demo_seed/data/ru/channels_ai.json b/apps/backend/chatballs/identity/demo_seed/data/ru/channels_ai.json index a4491aa..06b96a7 100644 --- a/apps/backend/chatballs/identity/demo_seed/data/ru/channels_ai.json +++ b/apps/backend/chatballs/identity/demo_seed/data/ru/channels_ai.json @@ -30,9 +30,6 @@ "kak-snyat-merki", "sroki-dostavki" ], - "limits": { - "dailyCostUsd": 300 - }, "createdDaysAgo": 41 }, { @@ -53,7 +50,6 @@ "vozvrat-i-obmen", "lichnyj-kabinet" ], - "limits": {}, "createdDaysAgo": 30, "policy": { "allow_anonymous_sessions": true, @@ -78,7 +74,6 @@ "lichnyj-kabinet", "vozvrat-i-obmen" ], - "limits": {}, "createdDaysAgo": 28, "policy": { "allow_anonymous_sessions": true, @@ -96,7 +91,6 @@ "instructions": "", "knowledge": [], "portalArticles": [], - "limits": {}, "createdDaysAgo": 3 }, { @@ -113,7 +107,6 @@ "pricing" ], "portalArticles": [], - "limits": {}, "createdDaysAgo": 180 } ], @@ -378,7 +371,6 @@ 80, 260 ], - "costMicrosPerToken": 0.6, "failuresTotal": 3 } } diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py index d4b91b3..b5cb6ac 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py @@ -255,7 +255,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None: prompt_tokens=prompt, completion_tokens=0 if failed else completion, total_tokens=prompt + (0 if failed else completion), - cost_micros=0 if failed else int((prompt + completion) * spec["costMicrosPerToken"]), latency_ms=random.randint(900, 4200), status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS, error="Provider timeout after 30s" if failed else "", @@ -273,7 +272,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None: prompt_tokens=tokens, completion_tokens=0, total_tokens=tokens, - cost_micros=int(tokens * 0.02), latency_ms=random.randint(200, 900), status=LlmInvocationStatus.SUCCESS, ) diff --git a/apps/backend/chatballs/identity/migrations/0039_remove_organization_currency.py b/apps/backend/chatballs/identity/migrations/0039_remove_organization_currency.py new file mode 100644 index 0000000..313c84d --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0039_remove_organization_currency.py @@ -0,0 +1,18 @@ +# Валюта организации удалена: поле принимало только RUB, не читалось нигде и +# ни одна сумма в продукте в ней не считалась. Осталось от эпохи CRM и пережило +# пивот в поддержку. Расход на модель — единственные деньги в системе — тоже +# снят вместе с лимитами. +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("identity", "0038_membership_onboarding_dismissed_at"), + ] + + operations = [ + migrations.RemoveField( + model_name="organization", + name="currency", + ), + ] diff --git a/apps/backend/chatballs/identity/models.py b/apps/backend/chatballs/identity/models.py index 619729d..52b0b00 100644 --- a/apps/backend/chatballs/identity/models.py +++ b/apps/backend/chatballs/identity/models.py @@ -125,7 +125,6 @@ class Organization(models.Model): default=OrganizationStatus.ACTIVE, ) timezone = models.CharField(max_length=64, default="Europe/Moscow") - currency = models.CharField(max_length=3, default="RUB") # Язык рабочего места по умолчанию: на нём организация открывается всем, # кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой — # это такой же региональный параметр организации, и в «Настройках» они diff --git a/apps/backend/chatballs/identity/organization_creation.py b/apps/backend/chatballs/identity/organization_creation.py index b31d05d..d9e139c 100644 --- a/apps/backend/chatballs/identity/organization_creation.py +++ b/apps/backend/chatballs/identity/organization_creation.py @@ -106,7 +106,6 @@ def create_organization( slug=unique_organization_slug(clean.name), status=OrganizationStatus.ACTIVE, timezone=clean.timezone, - currency=clean.currency, language=clean.language, ) organization.save(force_insert=True) diff --git a/apps/backend/chatballs/identity/organization_views.py b/apps/backend/chatballs/identity/organization_views.py index 7e398fe..bd3cdb1 100644 --- a/apps/backend/chatballs/identity/organization_views.py +++ b/apps/backend/chatballs/identity/organization_views.py @@ -60,7 +60,6 @@ class OrganizationCreateView(APIView): data=OrganizationSettingsInput( name=str(body.get("name", "")), timezone=str(body.get("timezone", "") or "Europe/Moscow"), - currency=str(body.get("currency", "") or "RUB"), language=str(body.get("language", "")), ), owner=request.user, diff --git a/apps/backend/chatballs/identity/test_administration_api.py b/apps/backend/chatballs/identity/test_administration_api.py index 97c5362..95f51c3 100644 --- a/apps/backend/chatballs/identity/test_administration_api.py +++ b/apps/backend/chatballs/identity/test_administration_api.py @@ -28,7 +28,6 @@ class AdministrationApiTests(TestCase): name="Example", slug="administration", timezone="Europe/Moscow", - currency="RUB", ) self.owner = HumanUser.objects.create_user( email="owner@administration.test", @@ -60,7 +59,6 @@ class AdministrationApiTests(TestCase): { "name": "Example", "timezone": "Europe/Moscow", - "currency": "RUB", # Пустой язык — «как в установке»: организация своего не выбрала. "language": "", "logoUrl": None, @@ -78,7 +76,6 @@ class AdministrationApiTests(TestCase): { "name": "Новая компания", "timezone": "Asia/Yekaterinburg", - "currency": "rub", }, format="json", ) @@ -87,7 +84,6 @@ class AdministrationApiTests(TestCase): self.organization.refresh_from_db() self.assertEqual(self.organization.name, "Новая компания") self.assertEqual(self.organization.timezone, "Asia/Yekaterinburg") - self.assertEqual(self.organization.currency, "RUB") def test_invalid_timezone_is_rejected(self) -> None: response = self.client.patch( @@ -99,16 +95,6 @@ class AdministrationApiTests(TestCase): self.assertEqual(response.status_code, 400) self.assertIn("timezone", response.json()["errors"]) - def test_only_ruble_currency_is_accepted(self) -> None: - response = self.client.patch( - "/api/v1/company/administration/", - {"currency": "USD"}, - format="json", - ) - - self.assertEqual(response.status_code, 400) - self.assertIn("российский рубль", response.json()["errors"]["currency"]) - def test_logo_upload_download_and_delete(self) -> None: png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" uploaded = self.client.post( diff --git a/apps/backend/chatballs/identity/test_organization_creation.py b/apps/backend/chatballs/identity/test_organization_creation.py index a2be55f..84223b9 100644 --- a/apps/backend/chatballs/identity/test_organization_creation.py +++ b/apps/backend/chatballs/identity/test_organization_creation.py @@ -60,8 +60,6 @@ class OrganizationCreationTests(TestCase): self.assertEqual(created.name, "Вторая компания") self.assertEqual(created.language, "en") self.assertEqual(created.status, "ACTIVE") - # Валюту интерфейс не спрашивает и не шлёт: сервер ставит её сам. - self.assertEqual(created.currency, "RUB") membership = OrganizationMembership.objects.get(organization=created, user=self.owner) self.assertEqual(membership.role, EmployeeRole.OWNER) self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists()) diff --git a/apps/backend/chatballs/platform/payloads.py b/apps/backend/chatballs/platform/payloads.py index 85d766b..b5fcab1 100644 --- a/apps/backend/chatballs/platform/payloads.py +++ b/apps/backend/chatballs/platform/payloads.py @@ -24,7 +24,6 @@ def provisioning_result_payload( "slug": organization.slug, "status": organization.status, "timezone": organization.timezone, - "currency": organization.currency, }, "provisioning": { "status": provisioning.status, diff --git a/apps/backend/chatballs/platform/provisioning_command.py b/apps/backend/chatballs/platform/provisioning_command.py index 4a49a3b..df6e479 100644 --- a/apps/backend/chatballs/platform/provisioning_command.py +++ b/apps/backend/chatballs/platform/provisioning_command.py @@ -21,7 +21,6 @@ class ProvisioningCommand: source: str idempotency_key: str timezone: str = "Europe/Moscow" - currency: str = "RUB" locale: str = "" legal_name: str = "" tax_profile: dict[str, Any] | None = None diff --git a/apps/backend/chatballs/platform/provisioning_service.py b/apps/backend/chatballs/platform/provisioning_service.py index c8f7cf6..e789c7b 100644 --- a/apps/backend/chatballs/platform/provisioning_service.py +++ b/apps/backend/chatballs/platform/provisioning_service.py @@ -158,7 +158,6 @@ def _create_organization( slug=command.organization_slug.strip(), status=OrganizationStatus.ACTIVE if active else OrganizationStatus.PENDING_OWNER, timezone=command.timezone, - currency=command.currency.upper(), ) except ValidationError as error: raise ProvisioningValidation(str(error), code="organization_invalid") from error diff --git a/apps/backend/chatballs/platform/tests/test_platform_auth.py b/apps/backend/chatballs/platform/tests/test_platform_auth.py index 145c945..47a091d 100644 --- a/apps/backend/chatballs/platform/tests/test_platform_auth.py +++ b/apps/backend/chatballs/platform/tests/test_platform_auth.py @@ -28,7 +28,6 @@ class PlatformAuthTests(TestCase): "slug": "acme", "owner_email": "owner-acme@example.test", "timezone": "Europe/Moscow", - "currency": "RUB", }, format="json", HTTP_IDEMPOTENCY_KEY="idem-api-1", @@ -70,7 +69,6 @@ class PlatformCapabilityGateTests(TestCase): "slug": "acme", "owner_email": "owner-acme@example.test", "timezone": "Europe/Moscow", - "currency": "RUB", }, format="json", HTTP_IDEMPOTENCY_KEY="idem-api-2", diff --git a/apps/backend/chatballs/platform/tests/test_platform_role.py b/apps/backend/chatballs/platform/tests/test_platform_role.py index b6e6005..7d2b189 100644 --- a/apps/backend/chatballs/platform/tests/test_platform_role.py +++ b/apps/backend/chatballs/platform/tests/test_platform_role.py @@ -41,8 +41,7 @@ class PlatformRoleProvisioningTests(TransactionTestCase): "slug": slug, "owner_email": owner_email, "timezone": "Europe/Moscow", - "currency": "RUB", - }, + }, format="json", HTTP_IDEMPOTENCY_KEY=key, ) diff --git a/apps/backend/chatballs/platform/validation.py b/apps/backend/chatballs/platform/validation.py index edf5be7..38a0ea0 100644 --- a/apps/backend/chatballs/platform/validation.py +++ b/apps/backend/chatballs/platform/validation.py @@ -10,7 +10,6 @@ _REQUIRED_FIELDS = ( "slug", "owner_email", "timezone", - "currency", ) @@ -32,7 +31,6 @@ def parse_provisioning_body( source=source, idempotency_key=idempotency_key, timezone=str(body.get("timezone", "Europe/Moscow")), - currency=str(body.get("currency", "RUB")), locale=str(body.get("locale", "")), legal_name=str(body.get("legal_name", "")), tax_profile=body.get("tax_profile") if isinstance(body.get("tax_profile"), dict) else None, diff --git a/apps/backend/chatballs_backend/settings_base.py b/apps/backend/chatballs_backend/settings_base.py index 591d52b..9686b28 100644 --- a/apps/backend/chatballs_backend/settings_base.py +++ b/apps/backend/chatballs_backend/settings_base.py @@ -196,10 +196,6 @@ if TESTING and not CHATBALLS_AI_PROVIDER: CHATBALLS_OPENROUTER_BASE_URL = os.environ.get("CHATBALLS_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") CHATBALLS_AI_REQUEST_TIMEOUT = float(os.environ.get("CHATBALLS_AI_REQUEST_TIMEOUT", "30")) CHATBALLS_AI_MAX_RETRIES = int(os.environ.get("CHATBALLS_AI_MAX_RETRIES", "2")) -CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS = int( - os.environ.get("CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", "0") -) # 0 = без лимита -CHATBALLS_AI_PRICING: dict = {} # переопределение цен micro-USD/токен по модели CHATBALLS_AI_EMBEDDING_MODEL = os.environ.get("CHATBALLS_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small") # Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions). diff --git a/apps/internal-ui/src/features/administration/api.ts b/apps/internal-ui/src/features/administration/api.ts index 0379d6f..947fccb 100644 --- a/apps/internal-ui/src/features/administration/api.ts +++ b/apps/internal-ui/src/features/administration/api.ts @@ -25,7 +25,6 @@ export async function saveOrganizationSettings( body: JSON.stringify({ name: settings.name, timezone: settings.timezone, - currency: settings.currency, language: settings.language, }), }); diff --git a/apps/internal-ui/src/features/administration/model.ts b/apps/internal-ui/src/features/administration/model.ts index 3595ca2..a44ee65 100644 --- a/apps/internal-ui/src/features/administration/model.ts +++ b/apps/internal-ui/src/features/administration/model.ts @@ -9,7 +9,6 @@ export type AdministrationSection = "organization"; export type OrganizationSettings = { name: string; timezone: string; - currency: string; // Пустая строка — «как в установке»: организация не обязана выбирать язык. language: string; logoUrl: string | null; diff --git a/apps/internal-ui/src/i18n/en.ts b/apps/internal-ui/src/i18n/en.ts index 80e3966..059daa0 100644 --- a/apps/internal-ui/src/i18n/en.ts +++ b/apps/internal-ui/src/i18n/en.ts @@ -59,7 +59,6 @@ export const en: Record = { "admin.creation_failed": "Creation failed", "admin.credentials": "Credentials", "admin.credentials_role_groups": "Credentials · role · groups", - "admin.currency": "Currency", "admin.danger_zone": "Danger zone", "admin.e_g_operator": "e.g. Operator", "admin.earlier": "Earlier", @@ -170,7 +169,6 @@ export const en: Record = { "admin.result": "Result", "admin.roles_groups_showing": "Roles and groups · showing", "admin.roles_groups_shown": "Roles and groups · showing {shown} of {total}", - "admin.russian_rouble_rub": "Russian rouble (RUB)", "admin.save_sign": "Save and sign in", "admin.scan_qr_code": "Scan the QR code", "admin.scan_qr_code_authenticator_app": "Scan the QR code in your authenticator app, then confirm with a code", diff --git a/apps/internal-ui/src/i18n/ru.ts b/apps/internal-ui/src/i18n/ru.ts index f5fdce2..2d889db 100644 --- a/apps/internal-ui/src/i18n/ru.ts +++ b/apps/internal-ui/src/i18n/ru.ts @@ -60,7 +60,6 @@ export const ru = { "admin.creation_failed": "Ошибка создания", "admin.credentials": "Учётные данные", "admin.credentials_role_groups": "Учётные данные · роль · группы", - "admin.currency": "Валюта", "admin.danger_zone": "Опасная зона", "admin.e_g_operator": "напр. Оператор", "admin.earlier": "Ранее", @@ -171,7 +170,6 @@ export const ru = { "admin.result": "Результат", "admin.roles_groups_showing": "Роли и группы · показано", "admin.roles_groups_shown": "Роли и группы · показано {shown} из {total}", - "admin.russian_rouble_rub": "Российский рубль (RUB)", "admin.save_sign": "Сохранить и войти", "admin.scan_qr_code": "Отсканируйте QR-код", "admin.scan_qr_code_authenticator_app": "Отсканируйте QR-код в приложении-аутентификаторе, затем подтвердите кодом",