mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09: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 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
|
||||||
@@ -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", "Ошибка"
|
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)
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": "Группа не найдена",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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-код в приложении-аутентификаторе, затем подтвердите кодом",
|
||||||
|
|||||||
Reference in new issue
Block a user