mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
🔥 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:
1 parent
b76871caf0
commit
09fb0177b1
39 files changed
+73
-249
No files matched your search
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
# Фактическая стоимость, сообщённая провайдером (micro-USD). 0 — провайдер не
|
||||
# вернул цену, тогда считаем по прайс-таблице (ai/pricing.py).
|
||||
cost_micros: int = 0
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
|
||||
@@ -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,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Группа не найдена",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
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),
|
||||
status=OrganizationStatus.ACTIVE,
|
||||
timezone=clean.timezone,
|
||||
currency=clean.currency,
|
||||
language=clean.language,
|
||||
)
|
||||
organization.save(force_insert=True)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -24,7 +24,6 @@ def provisioning_result_payload(
|
||||
"slug": organization.slug,
|
||||
"status": organization.status,
|
||||
"timezone": organization.timezone,
|
||||
"currency": organization.currency,
|
||||
},
|
||||
"provisioning": {
|
||||
"status": provisioning.status,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -41,8 +41,7 @@ class PlatformRoleProvisioningTests(TransactionTestCase):
|
||||
"slug": slug,
|
||||
"owner_email": owner_email,
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
HTTP_IDEMPOTENCY_KEY=key,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ export async function saveOrganizationSettings(
|
||||
body: JSON.stringify({
|
||||
name: settings.name,
|
||||
timezone: settings.timezone,
|
||||
currency: settings.currency,
|
||||
language: settings.language,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ export type AdministrationSection = "organization";
|
||||
export type OrganizationSettings = {
|
||||
name: string;
|
||||
timezone: string;
|
||||
currency: string;
|
||||
// Пустая строка — «как в установке»: организация не обязана выбирать язык.
|
||||
language: string;
|
||||
logoUrl: string | null;
|
||||
|
||||
@@ -59,7 +59,6 @@ export const en: Record<MessageKey, Message> = {
|
||||
"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<MessageKey, Message> = {
|
||||
"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",
|
||||
|
||||
@@ -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-код в приложении-аутентификаторе, затем подтвердите кодом",
|
||||
|
||||
Reference in new issue
Block a user