🔥 refactor(ai): убрать учёт расхода на модель и валюту организации

Учёт стоимости вызовов остался без потребителя, когда сняли лимиты, и сам
по себе не работал: цену присылает только 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 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-13 09:02:01 +03:00
1 parent b76871caf0
commit 09fb0177b1
39 files changed
+73 -249

No files matched your search

+1 -28
View File
@@ -2,7 +2,6 @@ import time
from django.conf import settings from django.conf import settings
from chatballs.ai import limits, pricing
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
from chatballs.ai.pii import redact from chatballs.ai.pii import redact
from chatballs.ai.provider import routing from chatballs.ai.provider import routing
@@ -19,18 +18,6 @@ from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilienc
_breaker = CircuitBreaker() _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]: def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]:
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции # BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
# организации с fallback на модель агента. Без интеграции модель остаётся # организации с fallback на модель агента. Без интеграции модель остаётся
@@ -52,18 +39,7 @@ def invoke_chat(
params: dict | None = None, params: dict | None = None,
used_fragment_ids: list | None = None, used_fragment_ids: list | None = None,
) -> ChatResult: ) -> ChatResult:
fallback_model = model or channel.ai_agent.model provider, model = _prepare_invocation(channel=channel, requested_model=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
safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages] safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages]
started = time.monotonic() started = time.monotonic()
@@ -96,8 +72,6 @@ def invoke_chat(
prompt_tokens=result.prompt_tokens, prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens, completion_tokens=result.completion_tokens,
total_tokens=result.total_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), latency_ms=int((time.monotonic() - started) * 1000),
status=LlmInvocationStatus.SUCCESS, status=LlmInvocationStatus.SUCCESS,
used_fragment_ids=used_fragment_ids or [], used_fragment_ids=used_fragment_ids or [],
@@ -128,7 +102,6 @@ def embed_texts(
model=model, model=model,
prompt_tokens=tokens, prompt_tokens=tokens,
total_tokens=tokens, total_tokens=tokens,
cost_micros=pricing.cost_micros(model, tokens, 0),
status=LlmInvocationStatus.SUCCESS, status=LlmInvocationStatus.SUCCESS,
) )
return results return results
-34
View File
@@ -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")
@@ -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,
),
),
]
-6
View File
@@ -459,8 +459,6 @@ class LlmInvocationStatus(models.TextChoices):
ERROR = "ERROR", "Ошибка" ERROR = "ERROR", "Ошибка"
BLOCKED = "BLOCKED", "Заблокировано лимитом"
@@ -485,10 +483,6 @@ class LlmInvocation(TenantRelationModel):
total_tokens = models.PositiveIntegerField(default=0) 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) latency_ms = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS) status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS)
-17
View File
@@ -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"])
@@ -18,9 +18,6 @@ class ChatResult:
model: str model: str
prompt_tokens: int prompt_tokens: int
completion_tokens: int completion_tokens: int
# Фактическая стоимость, сообщённая провайдером (micro-USD). 0 — провайдер не
# вернул цену, тогда считаем по прайс-таблице (ai/pricing.py).
cost_micros: int = 0
@property @property
def total_tokens(self) -> int: def total_tokens(self) -> int:
@@ -12,7 +12,7 @@ speak the same Chat Completions shape:
- POST /chat/completions with {model, messages, ...}; response has - 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. - 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 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, 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."""
"""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.
"""
payload: dict = { 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, data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
payload=payload, timeout=timeout, proxy_url=proxy_url) 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 {} usage = data.get("usage") or {}
cost = usage.get("cost")
return ChatResult( return ChatResult(
text=text, 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)), completion_tokens=int(usage.get("completion_tokens", 0)),
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
) )
@@ -22,10 +22,9 @@ class OpenRouterProvider(LLMProvider):
self.proxy_url = proxy_url or "" self.proxy_url = proxy_url or ""
def chat(self, *, messages: list[ChatMessage], model: str, params: dict | None = None) -> ChatResult: 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( return openai_http.chat_completions(
base_url=self.base_url, api_key=self.api_key, messages=messages, model=model, 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]: def embed(self, *, texts: list[str], model: str) -> list[EmbeddingResult]:
-33
View File
@@ -343,15 +343,6 @@ class ChatInvocationTests(TestCase):
invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat") invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat")
self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS) self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS)
self.assertGreater(invocation.total_tokens, 0) 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: def test_pii_is_redacted_before_reaching_provider(self) -> None:
from unittest import mock from unittest import mock
@@ -378,30 +369,6 @@ class ChatInvocationTests(TestCase):
self.assertNotIn("a@b.com", captured["messages"][0].content) 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: def test_invocation_records_used_fragment_ids(self) -> None:
from chatballs.ai.invocation import invoke_chat from chatballs.ai.invocation import invoke_chat
from chatballs.ai.models import LlmInvocation from chatballs.ai.models import LlmInvocation
@@ -13,7 +13,6 @@ from dataclasses import dataclass
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.utils import timezone from django.utils import timezone
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.provider.base import ProviderError from chatballs.ai.provider.base import ProviderError
from chatballs.ai.runtime import HANDOFF_TOKEN from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.channels.runtime import run_channel_turn from chatballs.channels.runtime import run_channel_turn
@@ -382,10 +381,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
try: try:
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation)) result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
except (ProviderError, LimitExceeded) as error: except ProviderError as error:
# Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны # Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
# «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и # оператору, уведомляем и отвечаем клиенту понятным fallback.
# отвечаем клиенту понятным fallback.
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error) logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
conversation.control_mode = ControlMode.PAUSED conversation.control_mode = ControlMode.PAUSED
conversation.expected_responder = ExpectedResponder.OPERATOR conversation.expected_responder = ExpectedResponder.OPERATOR
+1 -15
View File
@@ -8,11 +8,10 @@ from __future__ import annotations
from datetime import datetime, timedelta 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.db.models.functions import TruncDate, TruncHour
from django.utils import timezone from django.utils import timezone
from chatballs.ai.models import LlmInvocation
from chatballs.channels.selectors import channels_in_organization from chatballs.channels.selectors import channels_in_organization
from chatballs.conversations.models import ( from chatballs.conversations.models import (
ControlMode, ControlMode,
@@ -64,17 +63,6 @@ def _chart(
return {"values": values, "labels": labels} 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: def sales_overview_stats(context, period: str) -> dict:
organization_id = context.organization_id organization_id = context.organization_id
now = timezone.now() now = timezone.now()
@@ -105,8 +93,6 @@ def sales_overview_stats(context, period: str) -> dict:
conversation__organization_id=organization_id, conversation__organization_id=organization_id,
created_at__gte=start, created_at__gte=start,
).count(), ).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"))) open_by_channel = dict(open_qs.values_list("channel_id").annotate(c=Count("id")))
@@ -3,7 +3,6 @@ from unittest import mock
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from chatballs.ai.limits import LimitExceeded
from chatballs.ai.models import AIAgent, AIAgentStatus from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider.base import ProviderError from chatballs.ai.provider.base import ProviderError
from chatballs.channels.models import Channel from chatballs.channels.models import Channel
@@ -42,10 +41,9 @@ def _messenger_connection(channel):
) )
class IngestLimitHandlingTests(TestCase): class IngestProviderFailureTests(TestCase):
"""При срабатывании дневного лимита стоимости (LimitExceeded) диалог не должен """При сбое провайдера диалог не должен «зависать»: его передают оператору с
«зависать»: его передают оператору с fallback-ответом клиенту (как при сбое fallback-ответом клиенту. См. ingest.ingest_inbound.
провайдера). См. ingest.ingest_inbound.
""" """
def setUp(self) -> None: 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="Гость" 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 from chatballs.conversations.ingest import ingest_inbound
with ( with (
mock.patch( mock.patch(
"chatballs.conversations.ingest.run_channel_turn", "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, mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
): ):
@@ -4,7 +4,6 @@ from __future__ import annotations
MESSAGES: dict[str, object] = { MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Choose a logo file", "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.employee_not_found": "Operator not found",
"admin.group_name_taken": "A group with this name already exists", "admin.group_name_taken": "A group with this name already exists",
"admin.group_not_found": "Group not found", "admin.group_not_found": "Group not found",
@@ -8,7 +8,6 @@ from __future__ import annotations
MESSAGES: dict[str, object] = { MESSAGES: dict[str, object] = {
"admin.choose_logo_file": "Выберите файл логотипа", "admin.choose_logo_file": "Выберите файл логотипа",
"admin.currency_rub_only": "Поддерживается только российский рубль (RUB)",
"admin.employee_not_found": "Сотрудник не найден", "admin.employee_not_found": "Сотрудник не найден",
"admin.group_name_taken": "Группа с таким именем уже есть", "admin.group_name_taken": "Группа с таким именем уже есть",
"admin.group_not_found": "Группа не найдена", "admin.group_not_found": "Группа не найдена",
+1 -1
View File
@@ -35,7 +35,7 @@ class HumanUserAdmin(UserAdmin):
@admin.register(Organization) @admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin): class OrganizationAdmin(admin.ModelAdmin):
list_display = ["slug", "name", "currency", "timezone"] list_display = ["slug", "name", "timezone"]
search_fields = ["slug", "name"] search_fields = ["slug", "name"]
@@ -43,7 +43,6 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
return { return {
"name": organization.name, "name": organization.name,
"timezone": organization.timezone, "timezone": organization.timezone,
"currency": organization.currency,
# Пустая строка доезжает до интерфейса как есть: там это отдельный # Пустая строка доезжает до интерфейса как есть: там это отдельный
# пункт «Как в установке», а не отсутствие значения. # пункт «Как в установке», а не отсутствие значения.
"language": organization.language, "language": organization.language,
@@ -28,7 +28,6 @@ MAX_LOGO_BYTES = 2 * 1024 * 1024
class OrganizationSettingsInput: class OrganizationSettingsInput:
name: str name: str
timezone: str timezone: str
currency: str
# Пустая строка — «как в установке»: организация не обязана выбирать язык, # Пустая строка — «как в установке»: организация не обязана выбирать язык,
# и владелец, который его не трогал, не должен получить жёсткий русский # и владелец, который его не трогал, не должен получить жёсткий русский
# после того, как язык установки сменили. # после того, как язык установки сменили.
@@ -36,12 +35,11 @@ class OrganizationSettingsInput:
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput: def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
"""Имя, часовой пояс, валюта и язык организации — одни правила для """Имя, часовой пояс и язык организации — одни правила для «Настроек» и
«Настроек» и для страницы создания организации.""" для страницы создания организации."""
name = data.name.strip() name = data.name.strip()
timezone = data.timezone.strip() timezone = data.timezone.strip()
currency = data.currency.strip().upper()
language = normalize_language(data.language) language = normalize_language(data.language)
if data.language.strip() and not language: if data.language.strip() and not language:
raise ValidationError({"language": t("settings.language_unsupported")}) raise ValidationError({"language": t("settings.language_unsupported")})
@@ -55,13 +53,7 @@ def validate_organization_settings(data: OrganizationSettingsInput) -> Organizat
raise ValidationError( raise ValidationError(
{"timezone": t("admin.invalid_timezone")} {"timezone": t("admin.invalid_timezone")}
) from error ) from error
if currency != "RUB": return OrganizationSettingsInput(name=name, timezone=timezone, language=language)
raise ValidationError(
{"currency": t("admin.currency_rub_only")}
)
return OrganizationSettingsInput(
name=name, timezone=timezone, currency=currency, language=language
)
@transaction.atomic @transaction.atomic
@@ -76,9 +68,8 @@ def update_organization_settings(
) )
organization.name = clean.name organization.name = clean.name
organization.timezone = clean.timezone organization.timezone = clean.timezone
organization.currency = clean.currency
organization.language = clean.language organization.language = clean.language
organization.save(update_fields=["name", "timezone", "currency", "language"]) organization.save(update_fields=["name", "timezone", "language"])
return organization return organization
@@ -74,7 +74,6 @@ class OrganizationSettingsView(APIView):
data=OrganizationSettingsInput( data=OrganizationSettingsInput(
name=str(body.get("name", organization.name)), name=str(body.get("name", organization.name)),
timezone=str(body.get("timezone", organization.timezone)), timezone=str(body.get("timezone", organization.timezone)),
currency=str(body.get("currency", organization.currency)),
language=str(body.get("language", organization.language)), language=str(body.get("language", organization.language)),
), ),
) )
@@ -28,7 +28,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
defaults={ defaults={
"name": "Demo", "name": "Demo",
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
"currency": "RUB",
}, },
) )
from chatballs.ai.knowledge_categories import ensure_uncategorized_category from chatballs.ai.knowledge_categories import ensure_uncategorized_category
@@ -30,9 +30,6 @@
"how-to-measure", "how-to-measure",
"lead-times" "lead-times"
], ],
"limits": {
"dailyCostUsd": 300
},
"createdDaysAgo": 41 "createdDaysAgo": 41
}, },
{ {
@@ -53,7 +50,6 @@
"returns-and-exchanges", "returns-and-exchanges",
"your-account" "your-account"
], ],
"limits": {},
"createdDaysAgo": 30, "createdDaysAgo": 30,
"policy": { "policy": {
"allow_anonymous_sessions": true, "allow_anonymous_sessions": true,
@@ -78,7 +74,6 @@
"your-account", "your-account",
"returns-and-exchanges" "returns-and-exchanges"
], ],
"limits": {},
"createdDaysAgo": 28, "createdDaysAgo": 28,
"policy": { "policy": {
"allow_anonymous_sessions": true, "allow_anonymous_sessions": true,
@@ -96,7 +91,6 @@
"instructions": "", "instructions": "",
"knowledge": [], "knowledge": [],
"portalArticles": [], "portalArticles": [],
"limits": {},
"createdDaysAgo": 3 "createdDaysAgo": 3
}, },
{ {
@@ -113,7 +107,6 @@
"pricing" "pricing"
], ],
"portalArticles": [], "portalArticles": [],
"limits": {},
"createdDaysAgo": 180 "createdDaysAgo": 180
} }
], ],
@@ -378,7 +371,6 @@
80, 80,
260 260
], ],
"costMicrosPerToken": 0.6,
"failuresTotal": 3 "failuresTotal": 3
} }
} }
@@ -30,9 +30,6 @@
"kak-snyat-merki", "kak-snyat-merki",
"sroki-dostavki" "sroki-dostavki"
], ],
"limits": {
"dailyCostUsd": 300
},
"createdDaysAgo": 41 "createdDaysAgo": 41
}, },
{ {
@@ -53,7 +50,6 @@
"vozvrat-i-obmen", "vozvrat-i-obmen",
"lichnyj-kabinet" "lichnyj-kabinet"
], ],
"limits": {},
"createdDaysAgo": 30, "createdDaysAgo": 30,
"policy": { "policy": {
"allow_anonymous_sessions": true, "allow_anonymous_sessions": true,
@@ -78,7 +74,6 @@
"lichnyj-kabinet", "lichnyj-kabinet",
"vozvrat-i-obmen" "vozvrat-i-obmen"
], ],
"limits": {},
"createdDaysAgo": 28, "createdDaysAgo": 28,
"policy": { "policy": {
"allow_anonymous_sessions": true, "allow_anonymous_sessions": true,
@@ -96,7 +91,6 @@
"instructions": "", "instructions": "",
"knowledge": [], "knowledge": [],
"portalArticles": [], "portalArticles": [],
"limits": {},
"createdDaysAgo": 3 "createdDaysAgo": 3
}, },
{ {
@@ -113,7 +107,6 @@
"pricing" "pricing"
], ],
"portalArticles": [], "portalArticles": [],
"limits": {},
"createdDaysAgo": 180 "createdDaysAgo": 180
} }
], ],
@@ -378,7 +371,6 @@
80, 80,
260 260
], ],
"costMicrosPerToken": 0.6,
"failuresTotal": 3 "failuresTotal": 3
} }
} }
@@ -255,7 +255,6 @@ def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None:
prompt_tokens=prompt, prompt_tokens=prompt,
completion_tokens=0 if failed else completion, completion_tokens=0 if failed else completion,
total_tokens=prompt + (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), latency_ms=random.randint(900, 4200),
status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS, status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS,
error="Provider timeout after 30s" if failed else "", 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, prompt_tokens=tokens,
completion_tokens=0, completion_tokens=0,
total_tokens=tokens, total_tokens=tokens,
cost_micros=int(tokens * 0.02),
latency_ms=random.randint(200, 900), latency_ms=random.randint(200, 900),
status=LlmInvocationStatus.SUCCESS, status=LlmInvocationStatus.SUCCESS,
) )
@@ -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",
),
]
@@ -125,7 +125,6 @@ class Organization(models.Model):
default=OrganizationStatus.ACTIVE, default=OrganizationStatus.ACTIVE,
) )
timezone = models.CharField(max_length=64, default="Europe/Moscow") timezone = models.CharField(max_length=64, default="Europe/Moscow")
currency = models.CharField(max_length=3, default="RUB")
# Язык рабочего места по умолчанию: на нём организация открывается всем, # Язык рабочего места по умолчанию: на нём организация открывается всем,
# кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой — # кто не выбрал свой в профиле. Стоит рядом с часовым поясом и валютой —
# это такой же региональный параметр организации, и в «Настройках» они # это такой же региональный параметр организации, и в «Настройках» они
@@ -106,7 +106,6 @@ def create_organization(
slug=unique_organization_slug(clean.name), slug=unique_organization_slug(clean.name),
status=OrganizationStatus.ACTIVE, status=OrganizationStatus.ACTIVE,
timezone=clean.timezone, timezone=clean.timezone,
currency=clean.currency,
language=clean.language, language=clean.language,
) )
organization.save(force_insert=True) organization.save(force_insert=True)
@@ -60,7 +60,6 @@ class OrganizationCreateView(APIView):
data=OrganizationSettingsInput( data=OrganizationSettingsInput(
name=str(body.get("name", "")), name=str(body.get("name", "")),
timezone=str(body.get("timezone", "") or "Europe/Moscow"), timezone=str(body.get("timezone", "") or "Europe/Moscow"),
currency=str(body.get("currency", "") or "RUB"),
language=str(body.get("language", "")), language=str(body.get("language", "")),
), ),
owner=request.user, owner=request.user,
@@ -28,7 +28,6 @@ class AdministrationApiTests(TestCase):
name="Example", name="Example",
slug="administration", slug="administration",
timezone="Europe/Moscow", timezone="Europe/Moscow",
currency="RUB",
) )
self.owner = HumanUser.objects.create_user( self.owner = HumanUser.objects.create_user(
email="owner@administration.test", email="owner@administration.test",
@@ -60,7 +59,6 @@ class AdministrationApiTests(TestCase):
{ {
"name": "Example", "name": "Example",
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
"currency": "RUB",
# Пустой язык — «как в установке»: организация своего не выбрала. # Пустой язык — «как в установке»: организация своего не выбрала.
"language": "", "language": "",
"logoUrl": None, "logoUrl": None,
@@ -78,7 +76,6 @@ class AdministrationApiTests(TestCase):
{ {
"name": "Новая компания", "name": "Новая компания",
"timezone": "Asia/Yekaterinburg", "timezone": "Asia/Yekaterinburg",
"currency": "rub",
}, },
format="json", format="json",
) )
@@ -87,7 +84,6 @@ class AdministrationApiTests(TestCase):
self.organization.refresh_from_db() self.organization.refresh_from_db()
self.assertEqual(self.organization.name, "Новая компания") self.assertEqual(self.organization.name, "Новая компания")
self.assertEqual(self.organization.timezone, "Asia/Yekaterinburg") self.assertEqual(self.organization.timezone, "Asia/Yekaterinburg")
self.assertEqual(self.organization.currency, "RUB")
def test_invalid_timezone_is_rejected(self) -> None: def test_invalid_timezone_is_rejected(self) -> None:
response = self.client.patch( response = self.client.patch(
@@ -99,16 +95,6 @@ class AdministrationApiTests(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn("timezone", response.json()["errors"]) 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: 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" png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
uploaded = self.client.post( uploaded = self.client.post(
@@ -60,8 +60,6 @@ class OrganizationCreationTests(TestCase):
self.assertEqual(created.name, "Вторая компания") self.assertEqual(created.name, "Вторая компания")
self.assertEqual(created.language, "en") self.assertEqual(created.language, "en")
self.assertEqual(created.status, "ACTIVE") self.assertEqual(created.status, "ACTIVE")
# Валюту интерфейс не спрашивает и не шлёт: сервер ставит её сам.
self.assertEqual(created.currency, "RUB")
membership = OrganizationMembership.objects.get(organization=created, user=self.owner) membership = OrganizationMembership.objects.get(organization=created, user=self.owner)
self.assertEqual(membership.role, EmployeeRole.OWNER) self.assertEqual(membership.role, EmployeeRole.OWNER)
self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists()) self.assertTrue(KnowledgeCategory.objects.filter(organization=created).exists())
@@ -24,7 +24,6 @@ def provisioning_result_payload(
"slug": organization.slug, "slug": organization.slug,
"status": organization.status, "status": organization.status,
"timezone": organization.timezone, "timezone": organization.timezone,
"currency": organization.currency,
}, },
"provisioning": { "provisioning": {
"status": provisioning.status, "status": provisioning.status,
@@ -21,7 +21,6 @@ class ProvisioningCommand:
source: str source: str
idempotency_key: str idempotency_key: str
timezone: str = "Europe/Moscow" timezone: str = "Europe/Moscow"
currency: str = "RUB"
locale: str = "" locale: str = ""
legal_name: str = "" legal_name: str = ""
tax_profile: dict[str, Any] | None = None tax_profile: dict[str, Any] | None = None
@@ -158,7 +158,6 @@ def _create_organization(
slug=command.organization_slug.strip(), slug=command.organization_slug.strip(),
status=OrganizationStatus.ACTIVE if active else OrganizationStatus.PENDING_OWNER, status=OrganizationStatus.ACTIVE if active else OrganizationStatus.PENDING_OWNER,
timezone=command.timezone, timezone=command.timezone,
currency=command.currency.upper(),
) )
except ValidationError as error: except ValidationError as error:
raise ProvisioningValidation(str(error), code="organization_invalid") from error raise ProvisioningValidation(str(error), code="organization_invalid") from error
@@ -28,7 +28,6 @@ class PlatformAuthTests(TestCase):
"slug": "acme", "slug": "acme",
"owner_email": "owner-acme@example.test", "owner_email": "owner-acme@example.test",
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
"currency": "RUB",
}, },
format="json", format="json",
HTTP_IDEMPOTENCY_KEY="idem-api-1", HTTP_IDEMPOTENCY_KEY="idem-api-1",
@@ -70,7 +69,6 @@ class PlatformCapabilityGateTests(TestCase):
"slug": "acme", "slug": "acme",
"owner_email": "owner-acme@example.test", "owner_email": "owner-acme@example.test",
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
"currency": "RUB",
}, },
format="json", format="json",
HTTP_IDEMPOTENCY_KEY="idem-api-2", HTTP_IDEMPOTENCY_KEY="idem-api-2",
@@ -41,8 +41,7 @@ class PlatformRoleProvisioningTests(TransactionTestCase):
"slug": slug, "slug": slug,
"owner_email": owner_email, "owner_email": owner_email,
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
"currency": "RUB", },
},
format="json", format="json",
HTTP_IDEMPOTENCY_KEY=key, HTTP_IDEMPOTENCY_KEY=key,
) )
@@ -10,7 +10,6 @@ _REQUIRED_FIELDS = (
"slug", "slug",
"owner_email", "owner_email",
"timezone", "timezone",
"currency",
) )
@@ -32,7 +31,6 @@ def parse_provisioning_body(
source=source, source=source,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
timezone=str(body.get("timezone", "Europe/Moscow")), timezone=str(body.get("timezone", "Europe/Moscow")),
currency=str(body.get("currency", "RUB")),
locale=str(body.get("locale", "")), locale=str(body.get("locale", "")),
legal_name=str(body.get("legal_name", "")), legal_name=str(body.get("legal_name", "")),
tax_profile=body.get("tax_profile") if isinstance(body.get("tax_profile"), dict) else None, tax_profile=body.get("tax_profile") if isinstance(body.get("tax_profile"), dict) else None,
@@ -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_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_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_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") CHATBALLS_AI_EMBEDDING_MODEL = os.environ.get("CHATBALLS_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small")
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions). # Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
@@ -25,7 +25,6 @@ export async function saveOrganizationSettings(
body: JSON.stringify({ body: JSON.stringify({
name: settings.name, name: settings.name,
timezone: settings.timezone, timezone: settings.timezone,
currency: settings.currency,
language: settings.language, language: settings.language,
}), }),
}); });
@@ -9,7 +9,6 @@ export type AdministrationSection = "organization";
export type OrganizationSettings = { export type OrganizationSettings = {
name: string; name: string;
timezone: string; timezone: string;
currency: string;
// Пустая строка — «как в установке»: организация не обязана выбирать язык. // Пустая строка — «как в установке»: организация не обязана выбирать язык.
language: string; language: string;
logoUrl: string | null; logoUrl: string | null;
-2
View File
@@ -59,7 +59,6 @@ export const en: Record<MessageKey, Message> = {
"admin.creation_failed": "Creation failed", "admin.creation_failed": "Creation failed",
"admin.credentials": "Credentials", "admin.credentials": "Credentials",
"admin.credentials_role_groups": "Credentials · role · groups", "admin.credentials_role_groups": "Credentials · role · groups",
"admin.currency": "Currency",
"admin.danger_zone": "Danger zone", "admin.danger_zone": "Danger zone",
"admin.e_g_operator": "e.g. Operator", "admin.e_g_operator": "e.g. Operator",
"admin.earlier": "Earlier", "admin.earlier": "Earlier",
@@ -170,7 +169,6 @@ export const en: Record<MessageKey, Message> = {
"admin.result": "Result", "admin.result": "Result",
"admin.roles_groups_showing": "Roles and groups · showing", "admin.roles_groups_showing": "Roles and groups · showing",
"admin.roles_groups_shown": "Roles and groups · showing {shown} of {total}", "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.save_sign": "Save and sign in",
"admin.scan_qr_code": "Scan the QR code", "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", "admin.scan_qr_code_authenticator_app": "Scan the QR code in your authenticator app, then confirm with a code",
-2
View File
@@ -60,7 +60,6 @@ export const ru = {
"admin.creation_failed": "Ошибка создания", "admin.creation_failed": "Ошибка создания",
"admin.credentials": "Учётные данные", "admin.credentials": "Учётные данные",
"admin.credentials_role_groups": "Учётные данные · роль · группы", "admin.credentials_role_groups": "Учётные данные · роль · группы",
"admin.currency": "Валюта",
"admin.danger_zone": "Опасная зона", "admin.danger_zone": "Опасная зона",
"admin.e_g_operator": "напр. Оператор", "admin.e_g_operator": "напр. Оператор",
"admin.earlier": "Ранее", "admin.earlier": "Ранее",
@@ -171,7 +170,6 @@ export const ru = {
"admin.result": "Результат", "admin.result": "Результат",
"admin.roles_groups_showing": "Роли и группы · показано", "admin.roles_groups_showing": "Роли и группы · показано",
"admin.roles_groups_shown": "Роли и группы · показано {shown} из {total}", "admin.roles_groups_shown": "Роли и группы · показано {shown} из {total}",
"admin.russian_rouble_rub": "Российский рубль (RUB)",
"admin.save_sign": "Сохранить и войти", "admin.save_sign": "Сохранить и войти",
"admin.scan_qr_code": "Отсканируйте QR-код", "admin.scan_qr_code": "Отсканируйте QR-код",
"admin.scan_qr_code_authenticator_app": "Отсканируйте QR-код в приложении-аутентификаторе, затем подтвердите кодом", "admin.scan_qr_code_authenticator_app": "Отсканируйте QR-код в приложении-аутентификаторе, затем подтвердите кодом",