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

Дневной бюджет агента (limits.dailyCostUsd) снят вместе с полем. Проверка
работала, а учёт — нет: расход брался из ответа провайдера, а его шлёт
только OpenRouter; для Custom и локальной модели оставалась прайс-таблица
из двух моделей, и на любой другой стоимость записывалась нулём. То есть
на всех провайдерах, кроме OpenRouter, лимит не срабатывал никогда и давал
ложное чувство защиты. Общий лимит установки из переменной окружения
CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS остаётся.

Выбор валюты убран из настроек организации и из формы её создания. Сервер
принимал только RUB, то есть в списке был один вариант, а само поле не
читается нигде: ни одна сумма в продукте не считается в валюте
организации. Колонка в базе остаётся, интерфейс её больше не спрашивает —
сервер проставляет значение сам.

Второй шаг онбординга больше не велит задавать дневной бюджет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-13 07:54:14 +03:00
1 parent 1cff9e2183
commit eff6d7ac0c
18 files changed
+62 -159

No files matched your search

-6
View File
@@ -128,7 +128,6 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
"model": agent.model,
"providerIntegrationId": agent.provider_integration_id,
"modelParams": agent.model_params,
"limits": agent.limits,
"answerLanguage": agent.answer_language,
"persona": agent.persona,
"tone": agent.tone,
@@ -232,7 +231,6 @@ def update_agent_card(
ai_fields = {
"providerIntegrationId",
"modelParams",
"limits",
"persona",
"tone",
"instructions",
@@ -249,11 +247,8 @@ def update_agent_card(
):
raise ValidationError({"knowledgeIds": t("api.list_of_ids_required")})
model_params = body.get("modelParams", agent.model_params)
limits = body.get("limits", agent.limits)
if not isinstance(model_params, dict):
raise ValidationError({"modelParams": t("api.object_required")})
if not isinstance(limits, dict):
raise ValidationError({"limits": t("api.object_required")})
provider_integration_id = body.get(
"providerIntegrationId", agent.provider_integration_id
)
@@ -270,7 +265,6 @@ def update_agent_card(
provider_integration_id=provider_integration_id,
model_params=model_params,
allowed_tools=agent.allowed_tools,
limits=limits,
persona=str(body.get("persona", agent.persona)),
tone=str(body.get("tone", agent.tone)),
instructions=str(body.get("instructions", agent.instructions)),
+1 -1
View File
@@ -55,7 +55,7 @@ def invoke_chat(
fallback_model = model or channel.ai_agent.model
try:
provider, model = _prepare_invocation(channel=channel, requested_model=model)
limits.assert_within_limits(channel, channel.ai_agent)
limits.assert_within_limits()
except limits.LimitExceeded as error:
_record_blocked(
channel=channel,
+8 -6
View File
@@ -21,12 +21,14 @@ def daily_cost_micros(channel=None) -> int:
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
def assert_within_limits(channel, agent) -> None:
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")
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается
# в micro-USD. 1 цент = 10 000 micro-USD.
channel_limit = (agent.limits or {}).get("dailyCostUsd")
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:
raise LimitExceeded("Channel daily AI cost limit reached")
@@ -0,0 +1,18 @@
# Дневной бюджет агента снят вместе с полем `limits`: расход считался по
# прайс-таблице из двух моделей, а для всех остальных оставался нулевым — лимит
# не срабатывал никогда. Единственный оставшийся предохранитель — общий лимит
# установки из переменной окружения (CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS).
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("ai", "0017_agent_answer_language"),
]
operations = [
migrations.RemoveField(
model_name="aiagent",
name="limits",
),
]
-4
View File
@@ -417,10 +417,6 @@ class AIAgent(TenantRelationModel):
allowed_tools = models.JSONField(default=list, blank=True)
# Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).
limits = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
-17
View File
@@ -21,7 +21,6 @@ class AgentInput:
provider_integration_id: int | None
model_params: dict
allowed_tools: list
limits: dict
persona: str
tone: str
instructions: str
@@ -39,20 +38,6 @@ class AgentCreateInput:
knowledge_ids: list[int]
# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD
# (dailyCostUsd). Прочие исторические ключи (dailyCostMicros, dailyBudgetRub,
# dailyDialogs, maxMessagesPerDialog) бэкендом не используются и отбрасываются.
def _normalize_limits(raw: dict | None) -> dict:
if not isinstance(raw, dict):
return {}
value = raw.get("dailyCostUsd")
try:
cents = int(value)
except (TypeError, ValueError):
return {}
return {"dailyCostUsd": cents} if cents > 0 else {}
def knowledge_for_agent_ids(
*, context: TenantContext, channel: Channel, knowledge_ids: list[int]
) -> list[Knowledge]:
@@ -146,7 +131,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
locked.provider_integration = selection.integration
locked.model_params = data.model_params
locked.allowed_tools = data.allowed_tools
locked.limits = _normalize_limits(data.limits)
locked.persona = data.persona
locked.tone = data.tone
locked.instructions = data.instructions
@@ -158,7 +142,6 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
"provider_integration",
"model_params",
"allowed_tools",
"limits",
"persona",
"tone",
"instructions",
+12 -6
View File
@@ -378,22 +378,28 @@ class ChatInvocationTests(TestCase):
self.assertNotIn("a@b.com", captured["messages"][0].content)
def test_limit_blocks_and_records(self) -> None:
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
self.agent.limits = {"dailyCostUsd": 1}
self.agent.save(update_fields=["limits"])
# 1 цент = 10 000 micro-USD; лимит превышен расходом в 10_001 micros.
LlmInvocation.objects.create(
channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10_001,
status=LlmInvocationStatus.SUCCESS,
)
with self.assertRaises(ai_limits.LimitExceeded):
invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat")
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:
@@ -82,7 +82,6 @@ def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integ
agent.persona = item.get("persona", "")
agent.tone = item.get("tone", "")
agent.instructions = item.get("instructions", "")
agent.limits = item.get("limits", {})
if agent.status in ("ACTIVE", "DISABLED"):
agent.provider_integration = llm
agent.model = (llm.config or {}).get("default_model", "demo")
@@ -60,6 +60,8 @@ 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())
@@ -71,13 +71,6 @@ export function OrganizationSettingsForm({
onChange={(timezone) => onChange({ ...organization, timezone })}
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label={t("admin.currency")}
value={organization.currency}
disabled={!canManage}
onChange={(currency) => onChange({ ...organization, currency })}
options={[["RUB", t("admin.russian_rouble_rub")]]}
/>
</div>
{/* Язык — сегментом, как «Тема» в профиле и схема в «Платформе»: вариантов
три, и выбор лучше видеть целиком, чем разворачивать список. В сетку
@@ -22,8 +22,6 @@ import {
connectionStatusMeta,
connectionSubtitle,
createdLabel,
dailyCostCents,
dailyCostInput,
deleteAgent,
fetchAgent,
knowledgeLine,
@@ -534,17 +532,9 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
busy: boolean;
apply: (patch: AgentPatch) => Promise<boolean>;
}) {
const [limit, setLimit] = useState(dailyCostInput(card.limits));
useEffect(() => setLimit(dailyCostInput(card.limits)), [card.limits]);
const missingProvider = card.providerIntegrationId === null;
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
function saveLimit() {
const cents = dailyCostCents(limit);
if (cents === Number(card.limits.dailyCostUsd ?? 0)) return;
void apply({ limits: { ...card.limits, dailyCostUsd: cents } });
}
return (
<section className="agent-card is-side">
<h3>{t("common.model")}</h3>
@@ -567,15 +557,6 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
<Icon name="search" size={14} strokeWidth={1.8} />
</span>
</label>
<div className="agent-limit">
<span>{t("ai.daily_limit")}</span>
<span>
{canManage
? <input value={limit} inputMode="decimal" placeholder="0.00" onChange={(event) => setLimit(event.target.value)} onBlur={saveLimit} />
: <span className="agent-limit-value">{limit || "0.00"}</span>}
<small>USD</small>
</span>
</div>
</div>
</section>
);
@@ -37,7 +37,6 @@ export type AgentCard = {
model: string;
providerIntegrationId: number | null;
modelParams: Record<string, unknown>;
limits: Record<string, unknown>;
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
answerLanguage: string;
persona: string;
@@ -63,7 +62,6 @@ export type AgentPatch = Partial<{
tone: string;
instructions: string;
knowledgeIds: number[];
limits: Record<string, unknown>;
}>;
// Страница списка агентов (кадр G1): группа, поиск и страница — на сервере.
@@ -255,15 +253,3 @@ export function knowledgeLine(card: AgentCard): string {
return `${head} · ${parts.join(" + ")}`;
}
// Дневной бюджет агента хранится в целых центах USD (limits.dailyCostUsd,
// ADR-CHATBALLS-0023); на экране — доллары с двумя знаками.
export function dailyCostInput(limits: Record<string, unknown>): string {
const cents = Number(limits.dailyCostUsd ?? 0);
return Number.isFinite(cents) && cents > 0 ? (cents / 100).toFixed(2) : "";
}
export function dailyCostCents(value: string): number {
const amount = Number(value.replace(",", ".").trim());
if (!Number.isFinite(amount) || amount <= 0) return 0;
return Math.round(amount * 100);
}
@@ -841,15 +841,14 @@
.agent-field textarea:focus,
.agent-field input:focus,
.agent-create-modal .form-field.is-editable input:focus,
.agent-create-modal .select-like select:focus {
.agent-create-modal .select-box:focus-visible {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
}
/* Тот же фокус для общего селекта карточки: с боксом (группа) и без (провайдер). */
.agent-side-fields .select-like select:focus,
.agent-side-fields .select-box:focus-within {
/* Тот же фокус для общего селекта карточки. */
.agent-side-fields .select-box:focus-visible {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
@@ -1045,54 +1044,6 @@
color: var(--n-5);
}
.agent-limit {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 6px;
}
.agent-limit > span:first-child {
color: var(--n-3);
font-size: 12.5px;
font-weight: 600;
}
.agent-limit > span:last-child {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--n-1);
font-size: 13px;
}
.agent-limit input,
.agent-limit-value {
display: inline-flex;
width: 56px;
height: 30px;
align-items: center;
padding: 0 10px;
border: 1px solid var(--n-7);
border-radius: 7px;
outline: none;
background: var(--n-10);
color: var(--n-1);
font-family: var(--font-mono);
font-size: 12.5px;
}
.agent-limit input:focus {
border-color: var(--primary);
background: var(--surface-card);
box-shadow: 0 0 0 3px var(--primary-bg);
}
.agent-limit small {
color: var(--n-4);
}
/* Подключения. */
.agent-connection {
@@ -33,7 +33,6 @@ export function OrganizationCreatePage({ user, onCreated, onBack }: {
// Новая организация наследует региональные параметры текущей: чаще всего
// человек заводит вторую компанию там же, где первую.
timezone: "Europe/Moscow",
currency: "RUB",
language: "",
});
const [logo, setLogo] = useState<File | null>(null);
@@ -123,12 +122,6 @@ export function OrganizationCreatePage({ user, onCreated, onBack }: {
onChange={(timezone) => setDraft({ ...draft, timezone })}
options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])}
/>
<SelectField
label={t("admin.currency")}
value={draft.currency}
onChange={(currency) => setDraft({ ...draft, currency })}
options={[["RUB", t("admin.russian_rouble_rub")]]}
/>
</div>
<div className="appearance-row administration-language">
<span>{t("settings.language")}</span>
@@ -8,13 +8,13 @@ const BASE = "/api/v1/organizations/";
export type OrganizationCreateOptions = {
timezones: string[];
languages: Array<{ code: string; label: string }>;
currencies: string[];
};
// Валюты в черновике нет: в продукте нет ни одной суммы в валюте организации,
// сервер проставляет её сам.
export type OrganizationDraft = {
name: string;
timezone: string;
currency: string;
// Пустая строка — «как в установке».
language: string;
};
+7 -8
View File
@@ -308,7 +308,6 @@ export const en: Record<MessageKey, Message> = {
"ai.created_on": "created {date}",
"ai.creating": "Creating…",
"ai.customers_reach_agent_through_these": "Customers reach the agent through these. One connection, one agent.",
"ai.daily_limit": "Daily limit",
"ai.delete_agent": "Delete the agent",
"ai.delete_agent_2": "Delete the agent?",
"ai.delete_attachment": "Delete the attachment",
@@ -1063,24 +1062,24 @@ export const en: Record<MessageKey, Message> = {
"onboarding.s2.name": "AI agent",
"onboarding.s2.time": "4 min",
"onboarding.s2.title": "Create an AI agent",
"onboarding.s2.lead": "The agent is who talks to the customer: who they are, how they answer, what rules they follow and how much they may spend per day.",
"onboarding.s2.lead": "The agent is who talks to the customer: who they are, how they answer and what rules they follow.",
"onboarding.s2.path": "Agents → Create agent",
"onboarding.s2.do_1": "Open Agents in the sidebar and click Create agent.",
"onboarding.s2.do_2": "Describe the role and tone: who they are to the customer, what they may promise, what they must not do.",
"onboarding.s2.do_3": "Pick a model from the connected provider and a daily budget in dollars.",
"onboarding.s2.do_3": "Pick a model from the connected provider.",
"onboarding.s2.do_4": "Attach knowledge base articles — the agent answers only from those.",
"onboarding.s2.do_5": "Switch the status from Draft to Active, otherwise the agent stays silent.",
"onboarding.s2.warning": "If there is nothing to answer from, the agent says so honestly and offers to call a human — it will not make things up.",
"onboarding.s2.preview": "Agents → Consultant",
"onboarding.s2.row1_label": "Model",
"onboarding.s2.row1_value": "openai/gpt-4o-mini",
"onboarding.s2.row2_label": "Daily budget",
"onboarding.s2.row2_value": "$5.00",
"onboarding.s2.row3_label": "Knowledge",
"onboarding.s2.row3_value": "12 articles",
"onboarding.s2.row2_label": "Knowledge",
"onboarding.s2.row2_value": "12 articles",
"onboarding.s2.row3_label": "Answer language",
"onboarding.s2.row3_value": "Same as the customer",
"onboarding.s2.chip": "Active",
"onboarding.s2.tour_title": "The Agents section",
"onboarding.s2.tour_text": "The organization's agents. An agent card is its role, its model, its budget and the knowledge attached to it.",
"onboarding.s2.tour_text": "The organization's agents. An agent card is its role, its model and the knowledge attached to it.",
"onboarding.s3.name": "Knowledge base",
"onboarding.s3.time": "from 10 min",
"onboarding.s3.title": "Fill the knowledge base",
+7 -8
View File
@@ -309,7 +309,6 @@ export const ru = {
"ai.created_on": "создан {date}",
"ai.creating": "Создание…",
"ai.customers_reach_agent_through_these": "Через них клиенты попадают к агенту. Одно подключение — один агент.",
"ai.daily_limit": "Лимит в день",
"ai.delete_agent": "Удалить агента",
"ai.delete_agent_2": "Удалить агента?",
"ai.delete_attachment": "Удалить вложение",
@@ -1064,24 +1063,24 @@ export const ru = {
"onboarding.s2.name": "AI-агент",
"onboarding.s2.time": "4 мин",
"onboarding.s2.title": "Создайте AI-агента",
"onboarding.s2.lead": "Агент — тот, кто говорит с клиентом: кто он, как отвечает, каким правилам подчиняется и сколько может потратить в день.",
"onboarding.s2.lead": "Агент — тот, кто говорит с клиентом: кто он, как отвечает и каким правилам подчиняется.",
"onboarding.s2.path": "Агенты → Создать агента",
"onboarding.s2.do_1": "Откройте «Агенты» в сайдбаре и нажмите «Создать агента».",
"onboarding.s2.do_2": "Опишите роль и тон: кто он для клиента, что можно обещать, чего делать нельзя.",
"onboarding.s2.do_3": "Выберите модель подключённого провайдера и дневной бюджет в долларах.",
"onboarding.s2.do_3": "Выберите модель подключённого провайдера.",
"onboarding.s2.do_4": "Прикрепите статьи базы знаний — агент отвечает только из них.",
"onboarding.s2.do_5": "Переведите статус из «Черновик» в «Активен», иначе агент молчит.",
"onboarding.s2.warning": "Если данных для ответа нет, агент честно это скажет и предложит позвать сотрудника — придумывать он не станет.",
"onboarding.s2.preview": "Агенты → Консультант",
"onboarding.s2.row1_label": "Модель",
"onboarding.s2.row1_value": "openai/gpt-4o-mini",
"onboarding.s2.row2_label": "Дневной бюджет",
"onboarding.s2.row2_value": "$5.00",
"onboarding.s2.row3_label": "Знания",
"onboarding.s2.row3_value": "12 статей",
"onboarding.s2.row2_label": "Знания",
"onboarding.s2.row2_value": "12 статей",
"onboarding.s2.row3_label": "Язык ответов",
"onboarding.s2.row3_value": "Как у клиента",
"onboarding.s2.chip": "Активен",
"onboarding.s2.tour_title": "Раздел «Агенты»",
"onboarding.s2.tour_text": "Список агентов организации. Карточка агента — это его роль, модель, бюджет и прикреплённые знания.",
"onboarding.s2.tour_text": "Список агентов организации. Карточка агента — это его роль, модель и прикреплённые знания.",
"onboarding.s3.name": "База знаний",
"onboarding.s3.time": "от 10 мин",
"onboarding.s3.title": "Наполните базу знаний",