mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 17:14:59 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
089dd6ddc2 | ||
|
|
ae46508d22 | ||
|
|
bd3b9309bf | ||
|
|
e35133b159 | ||
|
|
e9b64b6afc | ||
|
|
65f7de6bc3 | ||
|
|
290b4ccea9 | ||
|
|
ca1b892e11 | ||
|
|
e5d3e98ce3 |
No files matched your search
@@ -108,6 +108,13 @@ def knowledge_total_for_organization(organization_id: int) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _integration_model(integration, key: str) -> str:
|
||||
"""Модель, заданная в интеграции: подсказка в поле модели на карточке."""
|
||||
if integration is None:
|
||||
return ""
|
||||
return str((integration.config or {}).get(key) or "")
|
||||
|
||||
|
||||
def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) -> dict[str, object]:
|
||||
agent: AIAgent = channel.ai_agent
|
||||
connections = _connections_payload(channel)
|
||||
@@ -125,8 +132,18 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
|
||||
# Цвет группы задаётся в настройках — точка у названия (кадры G1/G3).
|
||||
"groupColor": channel.group.color if channel.group_id else "",
|
||||
"aiStatus": agent.status,
|
||||
# Модели агента: пустая строка означает «как в интеграции», и тогда
|
||||
# карточка показывает модель интеграции подсказкой в поле.
|
||||
"model": agent.model,
|
||||
"transcriptionModel": agent.transcription_model,
|
||||
"providerModel": _integration_model(agent.provider_integration, "default_model"),
|
||||
"transcriptionProviderModel": _integration_model(
|
||||
agent.transcription_integration or agent.provider_integration,
|
||||
"transcription_model",
|
||||
),
|
||||
"providerIntegrationId": agent.provider_integration_id,
|
||||
# Чем расшифровывать голосовые; пусто — тем же провайдером, что отвечает.
|
||||
"transcriptionIntegrationId": agent.transcription_integration_id,
|
||||
"modelParams": agent.model_params,
|
||||
"answerLanguage": agent.answer_language,
|
||||
"persona": agent.persona,
|
||||
@@ -230,6 +247,9 @@ def update_agent_card(
|
||||
|
||||
ai_fields = {
|
||||
"providerIntegrationId",
|
||||
"transcriptionIntegrationId",
|
||||
"model",
|
||||
"transcriptionModel",
|
||||
"modelParams",
|
||||
"persona",
|
||||
"tone",
|
||||
@@ -256,6 +276,15 @@ def update_agent_card(
|
||||
provider_integration_id, int
|
||||
):
|
||||
raise ValidationError({"providerIntegrationId": t("api.integer_id_required")})
|
||||
transcription_integration_id = body.get(
|
||||
"transcriptionIntegrationId", agent.transcription_integration_id
|
||||
)
|
||||
if transcription_integration_id is not None and not isinstance(
|
||||
transcription_integration_id, int
|
||||
):
|
||||
raise ValidationError(
|
||||
{"transcriptionIntegrationId": t("api.integer_id_required")}
|
||||
)
|
||||
update_agent(
|
||||
context=context,
|
||||
agent=agent,
|
||||
@@ -263,6 +292,11 @@ def update_agent_card(
|
||||
# Имя агента следует за именем карточки: сущность одна.
|
||||
name=channel.name,
|
||||
provider_integration_id=provider_integration_id,
|
||||
transcription_integration_id=transcription_integration_id,
|
||||
model=str(body.get("model", agent.model)),
|
||||
transcription_model=str(
|
||||
body.get("transcriptionModel", agent.transcription_model)
|
||||
),
|
||||
model_params=model_params,
|
||||
allowed_tools=agent.allowed_tools,
|
||||
persona=str(body.get("persona", agent.persona)),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-15 04:24
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0019_drop_llm_cost_accounting'),
|
||||
('integrations', '0008_encrypted_column_width'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aiagent',
|
||||
name='transcription_integration',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='transcribing_agents', to='integrations.integration'),
|
||||
),
|
||||
]
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-15 05:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0020_aiagent_transcription_integration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aiagent',
|
||||
name='transcription_model',
|
||||
field=models.CharField(blank=True, default='', max_length=128),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aiagent',
|
||||
name='model',
|
||||
field=models.CharField(blank=True, default='', max_length=128),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Агент перестаёт дублировать модель интеграции.
|
||||
|
||||
Раньше модель копировалась на агента при каждом сохранении, и поле означало
|
||||
«то же, что у интеграции». Теперь заполненное поле означает выбор человека:
|
||||
агент отвечает именно этой моделью, даже если у интеграции другая по
|
||||
умолчанию. Чтобы смена настройки провайдера не перестала доезжать до агентов,
|
||||
которым модель никто не выбирал, совпадающее значение очищается — такие агенты
|
||||
продолжают следовать за интеграцией.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def release_copied_models(apps, schema_editor):
|
||||
AIAgent = apps.get_model("ai", "AIAgent")
|
||||
updated = []
|
||||
for agent in AIAgent.objects.select_related("provider_integration").exclude(model=""):
|
||||
integration = agent.provider_integration
|
||||
default_model = str((integration.config or {}).get("default_model") or "") if integration else ""
|
||||
if agent.model == default_model:
|
||||
agent.model = ""
|
||||
updated.append(agent)
|
||||
AIAgent.objects.bulk_update(updated, ["model"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("ai", "0021_aiagent_transcription_model_alter_aiagent_model"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(release_copied_models, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -334,7 +334,7 @@ class KnowledgeFragment(TenantRelationModel):
|
||||
|
||||
class AIAgent(TenantRelationModel):
|
||||
|
||||
tenant_relation_fields = ("channel", "provider_integration")
|
||||
tenant_relation_fields = ("channel", "provider_integration", "transcription_integration")
|
||||
|
||||
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
|
||||
|
||||
@@ -358,6 +358,18 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
)
|
||||
|
||||
# Чем расшифровывать голосовые. Обычно это тот же провайдер, что и отвечает,
|
||||
# но не всегда: модель, которая пишет ответы, может не уметь речь в текст
|
||||
# (у Anthropic и Yandex Foundation Models аудио-эндпоинта нет вовсе).
|
||||
# Пусто — расшифровка идёт к провайдеру ответов, как было.
|
||||
transcription_integration = models.ForeignKey(
|
||||
"integrations.Integration",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="transcribing_agents",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
status = models.CharField(
|
||||
@@ -372,7 +384,13 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
lifecycle_version = models.PositiveIntegerField(default=0)
|
||||
|
||||
model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL)
|
||||
# Модель ответов. Пусто — берётся модель по умолчанию из интеграции; так
|
||||
# агент следует за настройкой провайдера. Заполнено — решает агент: на одном
|
||||
# ключе живут разные агенты, и дорогая модель нужна не каждому.
|
||||
model = models.CharField(max_length=128, blank=True, default="")
|
||||
# Модель расшифровки голосовых. Пусто — модель из интеграции, которая
|
||||
# расшифровывает, а если и там пусто — whisper-1.
|
||||
transcription_model = models.CharField(max_length=128, blank=True, default="")
|
||||
|
||||
model_params = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
@@ -30,3 +30,16 @@ def get_provider(*, channel=None) -> LLMProvider:
|
||||
t("ai.provider_not_configured")
|
||||
)
|
||||
return routing.resolve_provider(channel)
|
||||
|
||||
|
||||
def get_transcription_provider(*, channel=None) -> LLMProvider:
|
||||
"""Провайдер расшифровки голосовых.
|
||||
|
||||
Отличается от `get_provider` одним: агент может расшифровывать другим
|
||||
провайдером, чем отвечает (chatballs.ai.provider.routing).
|
||||
"""
|
||||
if settings.CHATBALLS_AI_PROVIDER == "test":
|
||||
return _test_provider()
|
||||
if channel is None:
|
||||
raise ProviderError(t("ai.provider_not_configured"))
|
||||
return routing.resolve_transcription_provider(channel)
|
||||
@@ -36,7 +36,12 @@ class OpenRouterProvider(LLMProvider):
|
||||
def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str:
|
||||
# OpenAI-совместимый POST /audio/transcriptions (whisper). Формат ответа
|
||||
# {"text": "..."}; ошибки транслируются в ProviderError.
|
||||
#
|
||||
# Наружу уходит фраза для человека, а не ответ провайдера: оператору
|
||||
# в ленте сообщений нечего делать с JSON чужого API. Сам ответ пишется
|
||||
# в журнал — по нему разбирают настройку.
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
@@ -44,6 +49,8 @@ class OpenRouterProvider(LLMProvider):
|
||||
from chatballs.conversations.transports.base import multipart_body
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
body, body_type = multipart_body(
|
||||
{"model": model},
|
||||
file_field="file",
|
||||
@@ -65,9 +72,24 @@ class OpenRouterProvider(LLMProvider):
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
detail = error.read().decode("utf-8", "replace")[:300]
|
||||
raise ProviderError(t("ai.transcription_failed_http", code=error.code, detail=detail)) from error
|
||||
logger.warning(
|
||||
"Transcription rejected by %s: HTTP %s %s (model=%s)",
|
||||
self.base_url,
|
||||
error.code,
|
||||
detail,
|
||||
model,
|
||||
)
|
||||
# 401/403 — ключ или доступ; 404 — у провайдера нет эндпоинта
|
||||
# расшифровки (так отвечают Anthropic и Yandex Foundation Models);
|
||||
# остальное — временный отказ, который лечится повтором.
|
||||
if error.code in (401, 403):
|
||||
raise ProviderError(t("ai.transcription_denied")) from error
|
||||
if error.code == 404:
|
||||
raise ProviderError(t("ai.transcription_unsupported")) from error
|
||||
raise ProviderError(t("ai.transcription_failed")) from error
|
||||
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
|
||||
raise ProviderError(t("ai.transcription_failed", error=error)) from error
|
||||
logger.warning("Transcription request to %s failed: %s", self.base_url, error)
|
||||
raise ProviderError(t("ai.transcription_unreachable")) from error
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not text:
|
||||
raise ProviderError(t("ai.empty_transcript"))
|
||||
|
||||
@@ -57,6 +57,17 @@ def resolve_provider_and_model(channel, *, fallback_model: str) -> tuple[LLMProv
|
||||
|
||||
|
||||
def resolve_model(channel, *, fallback_model: str) -> str:
|
||||
"""Модель ответов: выбранная на карточке агента, иначе модель интеграции.
|
||||
|
||||
Порядок именно такой: ключ провайдера один на организацию, а агентов на нём
|
||||
несколько, и модель — свойство агента, а не ключа. Пустое поле на карточке
|
||||
означает «как у интеграции», поэтому агент, которому модель не назначали,
|
||||
продолжает следовать за настройкой провайдера.
|
||||
"""
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
chosen = str(getattr(agent, "model", "") or "").strip()
|
||||
if chosen:
|
||||
return chosen
|
||||
integration = _channel_integration(channel)
|
||||
return str(integration.config.get("default_model") or "").strip() or fallback_model
|
||||
|
||||
@@ -64,10 +75,34 @@ def resolve_model(channel, *, fallback_model: str) -> str:
|
||||
DEFAULT_TRANSCRIPTION_MODEL = "whisper-1"
|
||||
|
||||
|
||||
def _transcription_integration(channel) -> Integration:
|
||||
"""Чем расшифровывать голосовые.
|
||||
|
||||
Обычно тем же провайдером, что и отвечает, но выбор отдельный: модель
|
||||
ответов может не уметь речь в текст. У Anthropic и Yandex Foundation Models
|
||||
эндпоинта `/audio/transcriptions` нет вовсе, и без отдельного выбора
|
||||
голосовые у такого агента расшифровать было нечем.
|
||||
"""
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
integration = getattr(agent, "transcription_integration", None) if agent else None
|
||||
if integration is None or not integration.secret:
|
||||
return _channel_integration(channel)
|
||||
return integration
|
||||
|
||||
|
||||
def resolve_transcription_provider(channel) -> LLMProvider:
|
||||
"""Провайдер расшифровки: отдельная интеграция агента либо провайдер ответов."""
|
||||
return _provider_from_integration(_transcription_integration(channel))
|
||||
|
||||
|
||||
def resolve_transcription_model(channel) -> str:
|
||||
"""Модель расшифровки голосовых из настроек AI-провайдера («Настройки →
|
||||
AI-провайдер», поле «Модель расшифровки»); по умолчанию whisper-1."""
|
||||
integration = _channel_integration(channel)
|
||||
"""Модель расшифровки: выбранная на карточке агента, иначе модель той
|
||||
интеграции, которая расшифровывает, иначе whisper-1."""
|
||||
agent = getattr(channel, "ai_agent", None)
|
||||
chosen = str(getattr(agent, "transcription_model", "") or "").strip()
|
||||
if chosen:
|
||||
return chosen
|
||||
integration = _transcription_integration(channel)
|
||||
return str(integration.config.get("transcription_model") or "").strip() or DEFAULT_TRANSCRIPTION_MODEL
|
||||
|
||||
|
||||
|
||||
@@ -56,3 +56,31 @@ def configure_agent_provider(
|
||||
{"providerIntegrationId": t("ai.integration_model_required")}
|
||||
)
|
||||
return ProviderSelection(model, integration)
|
||||
|
||||
|
||||
def configure_agent_transcription(
|
||||
*, context: TenantContext, integration_id: int | None
|
||||
) -> Integration | None:
|
||||
"""Интеграция, которой агент расшифровывает голосовые.
|
||||
|
||||
Пусто — расшифровка идёт к провайдеру ответов. Модель для неё живёт в самой
|
||||
интеграции («Модель расшифровки голосовых»), поэтому здесь проверяется
|
||||
только, что интеграция принадлежит организации и умеет быть провайдером.
|
||||
"""
|
||||
if integration_id is None:
|
||||
return None
|
||||
try:
|
||||
return Integration.objects.get(
|
||||
id=integration_id,
|
||||
organization_id=context.organization_id,
|
||||
kind=IntegrationKind.LLM_PROVIDER,
|
||||
provider__in=[
|
||||
IntegrationProvider.OPENROUTER,
|
||||
IntegrationProvider.CUSTOM,
|
||||
IntegrationProvider.DEMO,
|
||||
],
|
||||
)
|
||||
except (Integration.DoesNotExist, TypeError, ValueError) as error:
|
||||
raise ValidationError(
|
||||
{"transcriptionIntegrationId": t("ai.unknown_provider_integration")}
|
||||
) from error
|
||||
@@ -9,7 +9,10 @@ from chatballs.ai.models import (
|
||||
AIAgentStatus,
|
||||
Knowledge,
|
||||
)
|
||||
from chatballs.ai.provider_selection import configure_agent_provider
|
||||
from chatballs.ai.provider_selection import (
|
||||
configure_agent_provider,
|
||||
configure_agent_transcription,
|
||||
)
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.i18n import t
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
@@ -19,6 +22,11 @@ from chatballs.tenancy.context import TenantContext
|
||||
class AgentInput:
|
||||
name: str
|
||||
provider_integration_id: int | None
|
||||
# Чем расшифровывать голосовые; None — тем же провайдером, что и отвечает.
|
||||
transcription_integration_id: int | None
|
||||
# Модели агента; пустая строка — «как в интеграции».
|
||||
model: str
|
||||
transcription_model: str
|
||||
model_params: dict
|
||||
allowed_tools: list
|
||||
persona: str
|
||||
@@ -90,7 +98,9 @@ def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent:
|
||||
channel=channel,
|
||||
name=f"{channel.name} Agent",
|
||||
status=AIAgentStatus.DRAFT,
|
||||
model=selection.model,
|
||||
# Модель новой карточки не фиксируется: агент следует за интеграцией,
|
||||
# пока человек не выберет свою.
|
||||
model="",
|
||||
provider_integration=selection.integration,
|
||||
persona=data.persona,
|
||||
tone=data.tone,
|
||||
@@ -124,11 +134,17 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
context=context,
|
||||
integration_id=data.provider_integration_id,
|
||||
)
|
||||
# Модель принадлежит интеграции; без провайдера прежняя модель сохраняется,
|
||||
# чтобы PATCH инструкций не стирал её у черновика.
|
||||
locked.model = selection.model if selection.integration else locked.model
|
||||
# Модель выбирают на карточке агента: на одном ключе провайдера живут разные
|
||||
# агенты, и модель им нужна разная. Пустое поле означает «как в интеграции»
|
||||
# и разрешается в момент вызова (ai.provider.routing).
|
||||
locked.model = data.model.strip()[:128]
|
||||
locked.transcription_model = data.transcription_model.strip()[:128]
|
||||
# Провайдер живёт на агенте: канал больше не изменяется при сохранении агента.
|
||||
locked.provider_integration = selection.integration
|
||||
locked.transcription_integration = configure_agent_transcription(
|
||||
context=context,
|
||||
integration_id=data.transcription_integration_id,
|
||||
)
|
||||
locked.model_params = data.model_params
|
||||
locked.allowed_tools = data.allowed_tools
|
||||
locked.persona = data.persona
|
||||
@@ -139,7 +155,9 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
update_fields=[
|
||||
"name",
|
||||
"model",
|
||||
"transcription_model",
|
||||
"provider_integration",
|
||||
"transcription_integration",
|
||||
"model_params",
|
||||
"allowed_tools",
|
||||
"persona",
|
||||
|
||||
@@ -274,6 +274,55 @@ class AgentCardActivationTests(AgentCardTestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_transcription_integration_is_chosen_separately(self) -> None:
|
||||
# Модель ответов не обязана уметь речь в текст: у части провайдеров
|
||||
# аудио-эндпоинта нет вовсе, поэтому расшифровку можно увести к другому.
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.integrations.services import IntegrationInput, create_integration
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
answering = self._byok_integration()
|
||||
whisper = create_integration(
|
||||
context=system_tenant_context(self.organization),
|
||||
data=IntegrationInput(
|
||||
provider=IntegrationProvider.CUSTOM,
|
||||
name="Whisper",
|
||||
secret="sk-whisper",
|
||||
config={
|
||||
"baseUrl": "https://api.groq.com/openai/v1",
|
||||
"defaultModel": "any",
|
||||
"transcriptionModel": "whisper-large-v3",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
patched = self.client.patch(
|
||||
f"/api/v1/agents/{self.card['id']}/",
|
||||
data=json.dumps(
|
||||
{
|
||||
"providerIntegrationId": answering.id,
|
||||
"transcriptionIntegrationId": whisper.id,
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(patched.status_code, 200)
|
||||
self.assertEqual(
|
||||
patched.json()["agent"]["transcriptionIntegrationId"], whisper.id
|
||||
)
|
||||
agent = AIAgent.objects.get(id=self.card["aiAgentId"])
|
||||
self.assertEqual(agent.transcription_integration_id, whisper.id)
|
||||
self.assertEqual(agent.provider_integration_id, answering.id)
|
||||
|
||||
cleared = self.client.patch(
|
||||
f"/api/v1/agents/{self.card['id']}/",
|
||||
data=json.dumps({"transcriptionIntegrationId": None}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(cleared.status_code, 200)
|
||||
self.assertIsNone(cleared.json()["agent"]["transcriptionIntegrationId"])
|
||||
|
||||
def test_activation_without_provider_integration_is_rejected(self) -> None:
|
||||
# Активация требует выбранного провайдера организации (ADR-CHATBALLS-0042 §2);
|
||||
# деактивация свободна.
|
||||
@@ -292,8 +341,10 @@ class AgentCardActivationTests(AgentCardTestCase):
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(patched.status_code, 200)
|
||||
# Модель принадлежит интеграции: агент получает её default_model.
|
||||
self.assertEqual(patched.json()["agent"]["model"], "byok-model")
|
||||
# Своей модели у агента нет — он следует за интеграцией, и карточка
|
||||
# показывает её модель подсказкой.
|
||||
self.assertEqual(patched.json()["agent"]["model"], "")
|
||||
self.assertEqual(patched.json()["agent"]["providerModel"], "byok-model")
|
||||
|
||||
activated = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/")
|
||||
self.assertEqual(activated.status_code, 200)
|
||||
|
||||
@@ -58,7 +58,10 @@ class ProviderModeTests(TestCase):
|
||||
)
|
||||
agent = self.channel.ai_agent
|
||||
agent.provider_integration = integration
|
||||
agent.save(update_fields=["provider_integration"])
|
||||
# Пустая модель на агенте означает «как в интеграции» — именно так живёт
|
||||
# агент, которому модель не выбирали на карточке.
|
||||
agent.model = ""
|
||||
agent.save(update_fields=["provider_integration", "model"])
|
||||
self.channel.refresh_from_db()
|
||||
return integration
|
||||
|
||||
@@ -199,7 +202,9 @@ class AgentProviderOwnershipTests(TestCase):
|
||||
self.channel.provider_integration = integration
|
||||
self.channel.save(update_fields=["provider_integration"])
|
||||
self.agent.provider_integration = None
|
||||
self.agent.save(update_fields=["provider_integration"])
|
||||
# Агент следует за интеграцией: своей модели у него нет.
|
||||
self.agent.model = ""
|
||||
self.agent.save(update_fields=["provider_integration", "model"])
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
@@ -220,9 +225,23 @@ class AgentProviderOwnershipTests(TestCase):
|
||||
),
|
||||
)
|
||||
self.agent.provider_integration = current
|
||||
self.agent.save(update_fields=["provider_integration"])
|
||||
self.agent.model = ""
|
||||
self.agent.save(update_fields=["provider_integration", "model"])
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
resolve_model(self.channel, fallback_model="agent-model"), "current-model"
|
||||
)
|
||||
|
||||
def test_model_chosen_on_the_card_wins_over_the_integration(self) -> None:
|
||||
# Ключ провайдера один на организацию, агентов на нём несколько: модель
|
||||
# выбирают агенту, и она не должна теряться при смене настройки ключа.
|
||||
integration = self._integration(default_model="integration-model")
|
||||
self.agent.provider_integration = integration
|
||||
self.agent.model = "own-model"
|
||||
self.agent.save(update_fields=["provider_integration", "model"])
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
resolve_model(self.channel, fallback_model="agent-model"), "own-model"
|
||||
)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Расшифровка голосовых может идти не к тому провайдеру, который отвечает.
|
||||
|
||||
Модель ответов часто не умеет речь в текст: у Anthropic и Yandex Foundation
|
||||
Models эндпоинта `/audio/transcriptions` нет вовсе. Поэтому интеграция для
|
||||
расшифровки выбирается на агенте отдельно.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.provider.custom import CustomProvider
|
||||
from chatballs.ai.provider.routing import (
|
||||
resolve_model,
|
||||
resolve_transcription_model,
|
||||
resolve_transcription_provider,
|
||||
)
|
||||
from chatballs.ai.tests import make_channel_with_agent
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
from chatballs.integrations.services import IntegrationInput, create_integration
|
||||
from chatballs.testing import system_tenant_context
|
||||
|
||||
|
||||
class TranscriptionRoutingTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
bootstrap_owner(email="owner@example.com", password="temporary-password")
|
||||
self.organization = Organization.objects.get(slug="demo")
|
||||
self.context = system_tenant_context(self.organization)
|
||||
self.channel, self.agent = make_channel_with_agent(
|
||||
self.organization, code="voice-agent", name="Голосовой агент"
|
||||
)
|
||||
|
||||
def _integration(self, *, name: str, base_url: str, transcription_model: str = ""):
|
||||
config = {"baseUrl": base_url, "defaultModel": "answer-model"}
|
||||
if transcription_model:
|
||||
config["transcriptionModel"] = transcription_model
|
||||
return create_integration(
|
||||
context=self.context,
|
||||
data=IntegrationInput(
|
||||
provider=IntegrationProvider.CUSTOM,
|
||||
name=name,
|
||||
secret="sk-key",
|
||||
config=config,
|
||||
),
|
||||
)
|
||||
|
||||
def test_without_a_choice_transcription_goes_to_the_answering_provider(self) -> None:
|
||||
answering = self._integration(
|
||||
name="Ответы", base_url="https://answers.example.test/v1"
|
||||
)
|
||||
self.agent.provider_integration = answering
|
||||
self.agent.save(update_fields=["provider_integration"])
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
provider = resolve_transcription_provider(self.channel)
|
||||
self.assertIsInstance(provider, CustomProvider)
|
||||
self.assertEqual(provider.base_url, "https://answers.example.test/v1")
|
||||
|
||||
def test_chosen_integration_takes_the_voice(self) -> None:
|
||||
answering = self._integration(
|
||||
name="Ответы", base_url="https://answers.example.test/v1"
|
||||
)
|
||||
whisper = self._integration(
|
||||
name="Whisper",
|
||||
base_url="https://whisper.example.test/v1",
|
||||
transcription_model="whisper-large-v3",
|
||||
)
|
||||
self.agent.provider_integration = answering
|
||||
self.agent.transcription_integration = whisper
|
||||
self.agent.save(
|
||||
update_fields=["provider_integration", "transcription_integration"]
|
||||
)
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
provider = resolve_transcription_provider(self.channel)
|
||||
self.assertEqual(provider.base_url, "https://whisper.example.test/v1")
|
||||
self.assertEqual(resolve_transcription_model(self.channel), "whisper-large-v3")
|
||||
|
||||
def test_model_defaults_to_whisper_of_the_chosen_integration(self) -> None:
|
||||
answering = self._integration(
|
||||
name="Ответы",
|
||||
base_url="https://answers.example.test/v1",
|
||||
transcription_model="answer-side-model",
|
||||
)
|
||||
whisper = self._integration(
|
||||
name="Whisper", base_url="https://whisper.example.test/v1"
|
||||
)
|
||||
self.agent.provider_integration = answering
|
||||
self.agent.transcription_integration = whisper
|
||||
self.agent.save(
|
||||
update_fields=["provider_integration", "transcription_integration"]
|
||||
)
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(resolve_transcription_model(self.channel), "whisper-1")
|
||||
|
||||
|
||||
class TranscriptionModelTests(TranscriptionRoutingTests):
|
||||
"""Модель расшифровки тоже выбирается на агенте, а не только в интеграции."""
|
||||
|
||||
def test_model_from_the_card_wins(self) -> None:
|
||||
whisper = self._integration(
|
||||
name="Whisper",
|
||||
base_url="https://whisper.example.test/v1",
|
||||
transcription_model="whisper-large-v3",
|
||||
)
|
||||
self.agent.provider_integration = whisper
|
||||
self.agent.transcription_integration = whisper
|
||||
self.agent.transcription_model = "gpt-4o-mini-transcribe"
|
||||
self.agent.save(
|
||||
update_fields=[
|
||||
"provider_integration",
|
||||
"transcription_integration",
|
||||
"transcription_model",
|
||||
]
|
||||
)
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
resolve_transcription_model(self.channel), "gpt-4o-mini-transcribe"
|
||||
)
|
||||
|
||||
def test_text_and_voice_models_are_independent(self) -> None:
|
||||
answering = self._integration(
|
||||
name="Ответы", base_url="https://answers.example.test/v1"
|
||||
)
|
||||
whisper = self._integration(
|
||||
name="Whisper", base_url="https://whisper.example.test/v1"
|
||||
)
|
||||
self.agent.provider_integration = answering
|
||||
self.agent.transcription_integration = whisper
|
||||
self.agent.model = "yandexgpt/rc"
|
||||
self.agent.transcription_model = "whisper-large-v3-turbo"
|
||||
self.agent.save(
|
||||
update_fields=[
|
||||
"provider_integration",
|
||||
"transcription_integration",
|
||||
"model",
|
||||
"transcription_model",
|
||||
]
|
||||
)
|
||||
self.channel.refresh_from_db()
|
||||
|
||||
self.assertEqual(resolve_model(self.channel, fallback_model=""), "yandexgpt/rc")
|
||||
self.assertEqual(
|
||||
resolve_transcription_model(self.channel), "whisper-large-v3-turbo"
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_transcription_provider(self.channel).base_url,
|
||||
"https://whisper.example.test/v1",
|
||||
)
|
||||
|
||||
|
||||
class TranscriptionErrorTextTests(TestCase):
|
||||
"""Оператору — фраза, провайдеру — журнал: сырого ответа API в ленте нет."""
|
||||
|
||||
def _provider(self) -> CustomProvider:
|
||||
return CustomProvider(
|
||||
api_key="sk-key", base_url="https://api.example.test/v1", timeout=5
|
||||
)
|
||||
|
||||
def _fail_with(self, code: int, body: bytes):
|
||||
error = urllib.error.HTTPError(
|
||||
"https://api.example.test/v1/audio/transcriptions",
|
||||
code,
|
||||
"error",
|
||||
{},
|
||||
BytesIO(body),
|
||||
)
|
||||
return mock.patch(
|
||||
"chatballs.integrations.proxy.build_opener",
|
||||
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
|
||||
)
|
||||
|
||||
def _transcribe(self):
|
||||
return self._provider().transcribe(
|
||||
audio=b"0" * 16, filename="voice.ogg", content_type="audio/ogg", model="m"
|
||||
)
|
||||
|
||||
def test_denied_request_does_not_leak_the_provider_answer(self) -> None:
|
||||
body = json.dumps(
|
||||
{"error": {"message": "Subscription is not supported for service accounts"}}
|
||||
).encode()
|
||||
with self._fail_with(403, body), self.assertRaises(ProviderError) as caught:
|
||||
self._transcribe()
|
||||
message = str(caught.exception)
|
||||
self.assertNotIn("Subscription", message)
|
||||
self.assertNotIn("403", message)
|
||||
self.assertIn("ключ", message)
|
||||
|
||||
def test_missing_endpoint_tells_where_to_look(self) -> None:
|
||||
with self._fail_with(404, b"not found"), self.assertRaises(ProviderError) as caught:
|
||||
self._transcribe()
|
||||
self.assertIn("расшифров", str(caught.exception).lower())
|
||||
@@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.calls.event_handlers import CallInviteDeliveryError, handle_call_invite_send
|
||||
from chatballs.calls.lifecycle import transition_call
|
||||
from chatballs.calls.models import (
|
||||
CallEndedBy,
|
||||
CallInvite,
|
||||
@@ -16,7 +17,6 @@ from chatballs.calls.models import (
|
||||
CallStatus,
|
||||
InviteDeliveryStatus,
|
||||
)
|
||||
from chatballs.calls.lifecycle import transition_call
|
||||
from chatballs.calls.services import (
|
||||
decline_call_for_identity,
|
||||
open_call_for_identity,
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.db.models import (
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from chatballs.conversations.contact_avatars import contact_avatar_url_in
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
@@ -26,7 +27,6 @@ from chatballs.conversations.models import (
|
||||
Conversation,
|
||||
LifecycleState,
|
||||
)
|
||||
from chatballs.conversations.contact_avatars import contact_avatar_url_in
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.audit_catalog import (
|
||||
audit_action_label,
|
||||
|
||||
@@ -100,7 +100,7 @@ class TranscriptionJob:
|
||||
|
||||
def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
|
||||
"""Шаг в транзакции: провайдер организации, модель и байты аудио."""
|
||||
from chatballs.ai.provider.factory import get_provider
|
||||
from chatballs.ai.provider.factory import get_transcription_provider
|
||||
from chatballs.ai.provider.routing import (
|
||||
DEFAULT_TRANSCRIPTION_MODEL,
|
||||
resolve_transcription_model,
|
||||
@@ -108,7 +108,7 @@ def prepare_transcription(channel, message: Message) -> TranscriptionJob | None:
|
||||
|
||||
if not message.audio:
|
||||
return None
|
||||
provider = get_provider(channel=channel)
|
||||
provider = get_transcription_provider(channel=channel)
|
||||
try:
|
||||
model = resolve_transcription_model(channel)
|
||||
except ProviderError:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.db.models import Count, Max, Q
|
||||
|
||||
from chatballs.conversations.contact_avatars import contact_avatar_url_in
|
||||
from chatballs.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Conversation,
|
||||
@@ -8,7 +9,6 @@ from chatballs.conversations.models import (
|
||||
MessageKind,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.conversations.contact_avatars import contact_avatar_url_in
|
||||
from chatballs.identity.avatars import user_avatar_url_in
|
||||
from chatballs.integrations.features import features_payload
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
@@ -373,8 +373,10 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.activity_started": "Conversation · {channel}",
|
||||
"conversations.field_too_long": "Field {field}: no longer than {limit} characters",
|
||||
"ai.provider_no_transcription": "The {provider} provider does not support audio transcription",
|
||||
"ai.transcription_failed": "Transcription failed: {error}",
|
||||
"ai.transcription_failed_http": "Transcription failed: HTTP {code} {detail}",
|
||||
"ai.transcription_denied": "The provider refused the transcription request: check the key and the access to the transcription model in the integration settings",
|
||||
"ai.transcription_failed": "The provider could not transcribe the recording. Try again",
|
||||
"ai.transcription_unreachable": "The transcription provider is unavailable. Try again",
|
||||
"ai.transcription_unsupported": "This provider cannot transcribe speech. Pick another integration for transcription on the agent card",
|
||||
"api.expected_integer": "{name}: an integer is expected",
|
||||
"api.expected_positive": "{name}: a number greater than zero is expected",
|
||||
"api.expected_record_id": "{name}: a record identifier is expected",
|
||||
@@ -501,6 +503,9 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.type_dialog_assigned": "A conversation was assigned to me",
|
||||
"notifications.type_dialog_waiting_long": "A conversation has been waiting a long time",
|
||||
"notifications.type_ai_stopped": "AI stopped by an error",
|
||||
"integrations.check_rejected": "The provider refused the check ({status})",
|
||||
"integrations.check_rejected_access": "The provider refused the check ({status}): check the key and whether the provider is reachable from your network — region, proxy",
|
||||
"integrations.check_rejected_reason": "The provider refused the check ({status}): {reason}",
|
||||
"integrations.check_no_connection": "No connection: {error}",
|
||||
"integrations.check_api_key_missing": "The API key is not set",
|
||||
"integrations.check_base_url_missing": "Base URL is not set",
|
||||
|
||||
@@ -377,8 +377,10 @@ MESSAGES: dict[str, object] = {
|
||||
"conversations.activity_started": "Диалог · {channel}",
|
||||
"conversations.field_too_long": "Поле {field}: не длиннее {limit} символов",
|
||||
"ai.provider_no_transcription": "Провайдер {provider} не поддерживает расшифровку аудио",
|
||||
"ai.transcription_failed": "Расшифровка не удалась: {error}",
|
||||
"ai.transcription_failed_http": "Расшифровка не удалась: HTTP {code} {detail}",
|
||||
"ai.transcription_denied": "Провайдер не принял запрос на расшифровку: проверьте ключ и доступ к модели расшифровки в настройках интеграции",
|
||||
"ai.transcription_failed": "Провайдер не смог расшифровать запись. Попробуйте ещё раз",
|
||||
"ai.transcription_unreachable": "Провайдер расшифровки недоступен. Попробуйте ещё раз",
|
||||
"ai.transcription_unsupported": "Этот провайдер не умеет расшифровывать речь. Выберите на карточке агента другую интеграцию для расшифровки",
|
||||
"api.expected_integer": "{name}: ожидается целое число",
|
||||
"api.expected_positive": "{name}: ожидается число больше нуля",
|
||||
"api.expected_record_id": "{name}: ожидается идентификатор записи",
|
||||
@@ -505,6 +507,9 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.type_dialog_assigned": "Диалог назначили на меня",
|
||||
"notifications.type_dialog_waiting_long": "Диалог долго ждёт человека",
|
||||
"notifications.type_ai_stopped": "AI остановлен ошибкой",
|
||||
"integrations.check_rejected": "Провайдер отклонил проверку ({status})",
|
||||
"integrations.check_rejected_access": "Провайдер отклонил проверку ({status}): проверьте ключ и доступность провайдера из вашей сети — регион, прокси",
|
||||
"integrations.check_rejected_reason": "Провайдер отклонил проверку ({status}): {reason}",
|
||||
"integrations.check_no_connection": "Нет связи: {error}",
|
||||
"integrations.check_api_key_missing": "Не указан API-ключ",
|
||||
"integrations.check_base_url_missing": "Не указан Base URL",
|
||||
|
||||
@@ -16,6 +16,8 @@ from __future__ import annotations
|
||||
|
||||
import imaplib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -25,6 +27,8 @@ from django.conf import settings
|
||||
from chatballs.i18n import t, tn
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
# platform-api2.max.ru отдаёт неполную цепочку сертификата (verify failed);
|
||||
# рабочий и с валидным сертификатом — platform-api.max.ru.
|
||||
@@ -46,11 +50,60 @@ def _get(url: str, *, headers: dict[str, str] | None = None, proxy_url: str = ""
|
||||
return response.status, data
|
||||
|
||||
|
||||
def _error_reason(body: str) -> str:
|
||||
"""Короткая причина из ответа провайдера.
|
||||
|
||||
Ответ бывает и JSON'ом провайдера, и HTML-страницей защиты перед ним —
|
||||
человеку нужна одна фраза, а не то и другое целиком.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
return str(error.get("message") or "")[:160]
|
||||
for key in ("message", "detail", "error_description"):
|
||||
if payload.get(key):
|
||||
return str(payload[key])[:160]
|
||||
if isinstance(error, str):
|
||||
return error[:160]
|
||||
text = re.sub(r"<[^>]+>", " ", body)
|
||||
text = " ".join(text.split())
|
||||
return text[:160]
|
||||
|
||||
|
||||
def _http_failure(error: urllib.error.HTTPError) -> str:
|
||||
"""Отказ провайдера словами, а не кодом.
|
||||
|
||||
Голый «HTTP 403» не говорит ничего: так отвечают и на чужой ключ, и на
|
||||
запрос из закрытого региона, и на блокировку самого прокси. Причину, если
|
||||
провайдер её назвал, показываем сразу; ответ целиком уходит в журнал.
|
||||
"""
|
||||
try:
|
||||
body = error.read().decode("utf-8", "replace")
|
||||
except (OSError, ValueError):
|
||||
body = ""
|
||||
logger.warning(
|
||||
"Integration check rejected: HTTP %s %s — %s",
|
||||
error.code,
|
||||
getattr(error, "url", ""),
|
||||
body[:500],
|
||||
)
|
||||
reason = _error_reason(body)
|
||||
if reason:
|
||||
return t("integrations.check_rejected_reason", status=error.code, reason=reason)
|
||||
if error.code in (401, 403):
|
||||
return t("integrations.check_rejected_access", status=error.code)
|
||||
return t("integrations.check_rejected", status=error.code)
|
||||
|
||||
|
||||
def _safe(fn) -> CheckResult:
|
||||
try:
|
||||
return fn()
|
||||
except urllib.error.HTTPError as error:
|
||||
return False, f"HTTP {error.code}: {error.reason}", {}
|
||||
return False, _http_failure(error), {}
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||
return False, t("integrations.check_no_connection", error=error), {}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import ssl
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.outbound import OutboundUrlRejected
|
||||
|
||||
@@ -85,6 +87,24 @@ def _blocked_scheme_handlers() -> list[urllib.request.BaseHandler]:
|
||||
return [_RefusedFileHandler(), _RefusedFTPHandler(), _RefusedDataHandler()]
|
||||
|
||||
|
||||
def user_agent() -> str:
|
||||
"""Чем продукт представляется чужим API.
|
||||
|
||||
Умолчание urllib — «Python-urllib/3.x», и защита перед API (Cloudflare)
|
||||
банит такой запрос до того, как его увидит сам провайдер: на бою это
|
||||
выглядело как 403 «error code: 1010» у провайдера, который через тот же
|
||||
прокси прекрасно отвечает браузеру. С обычным именем клиента запрос
|
||||
проходит. Заголовок ставится на opener, поэтому свой User-Agent
|
||||
конкретного запроса он не перебивает.
|
||||
"""
|
||||
return f"Chatballs/{getattr(settings, 'CHATBALLS_VERSION', 'dev')}"
|
||||
|
||||
|
||||
def _named(opener):
|
||||
opener.addheaders = [("User-Agent", user_agent())]
|
||||
return opener
|
||||
|
||||
|
||||
def build_opener(proxy_url: str, *, validate_redirect=None):
|
||||
"""urllib opener, проксирующий http/https/socks5 запросы.
|
||||
|
||||
@@ -96,12 +116,14 @@ def build_opener(proxy_url: str, *, validate_redirect=None):
|
||||
if validate_redirect is not None:
|
||||
blocked.append(_GuardedRedirectHandler(validate_redirect))
|
||||
if not proxy_url:
|
||||
return urllib.request.build_opener(*blocked)
|
||||
return _named(urllib.request.build_opener(*blocked))
|
||||
scheme = urllib.parse.urlparse(proxy_url).scheme.lower()
|
||||
if scheme in SOCKS_SCHEMES:
|
||||
return urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked)
|
||||
return urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
|
||||
return _named(urllib.request.build_opener(_SocksProxyHandler(proxy_url), *blocked))
|
||||
return _named(
|
||||
urllib.request.build_opener(
|
||||
urllib.request.ProxyHandler({"http": proxy_url, "https": proxy_url}), *blocked
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -423,6 +423,81 @@ class CheckProxyTransportTests(TestCase):
|
||||
|
||||
|
||||
|
||||
class OutboundUserAgentTests(TestCase):
|
||||
|
||||
"""Продукт представляется своим именем: «Python-urllib» защита перед чужим
|
||||
API банит до самого API (на бою — Cloudflare «error code: 1010»)."""
|
||||
|
||||
def test_opener_introduces_the_product(self) -> None:
|
||||
from chatballs.integrations.proxy import build_opener, user_agent
|
||||
|
||||
for proxy in ("", "http://proxy:8080"):
|
||||
with self.subTest(proxy=proxy or "без прокси"):
|
||||
agents = dict(build_opener(proxy).addheaders)
|
||||
self.assertEqual(agents["User-Agent"], user_agent())
|
||||
self.assertNotIn("urllib", agents["User-Agent"])
|
||||
|
||||
def test_request_keeps_its_own_agent(self) -> None:
|
||||
import urllib.request
|
||||
|
||||
from chatballs.integrations.proxy import build_opener
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://api.example.test/v1/models", headers={"User-Agent": "Mine/1.0"}
|
||||
)
|
||||
opener = build_opener("")
|
||||
# urllib добавляет заголовки opener'а только к тем, которых нет в запросе.
|
||||
self.assertEqual(request.get_header("User-agent"), "Mine/1.0")
|
||||
self.assertTrue(any(name == "User-Agent" for name, _ in opener.addheaders))
|
||||
|
||||
|
||||
class CheckFailureTextTests(TestCase):
|
||||
|
||||
"""Отказ провайдера объясняется словами: голый код ничего не говорит."""
|
||||
|
||||
def _reject(self, code: int, body: bytes):
|
||||
import urllib.error
|
||||
from io import BytesIO
|
||||
|
||||
error = urllib.error.HTTPError(
|
||||
"https://api.example.test/v1/models", code, "Forbidden", {}, BytesIO(body)
|
||||
)
|
||||
return mock.patch(
|
||||
"chatballs.integrations.checks.build_opener",
|
||||
return_value=mock.Mock(open=mock.Mock(side_effect=error)),
|
||||
)
|
||||
|
||||
def test_reason_from_the_provider_reaches_the_screen(self) -> None:
|
||||
body = json.dumps(
|
||||
{"error": {"message": "Your API key is invalid"}}
|
||||
).encode()
|
||||
with self._reject(403, body):
|
||||
ok, detail, _meta = checks.check_custom(
|
||||
secret="sk-test", base_url="https://api.example.test/v1"
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("Your API key is invalid", detail)
|
||||
self.assertIn("403", detail)
|
||||
|
||||
def test_html_block_page_is_squeezed_into_one_line(self) -> None:
|
||||
body = b"<html><head><title>Access denied</title></head><body><h1>Sorry, you have been blocked</h1></body></html>"
|
||||
with self._reject(403, body):
|
||||
ok, detail, _meta = checks.check_custom(
|
||||
secret="sk-test", base_url="https://api.example.test/v1"
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertNotIn("<", detail)
|
||||
self.assertIn("blocked", detail.lower())
|
||||
|
||||
def test_silent_refusal_tells_where_to_look(self) -> None:
|
||||
with self._reject(403, b""):
|
||||
ok, detail, _meta = checks.check_custom(
|
||||
secret="sk-test", base_url="https://api.example.test/v1"
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("регион", detail)
|
||||
|
||||
|
||||
class OpenRouterProviderProxyTests(TestCase):
|
||||
|
||||
def test_provider_routes_through_proxy_handler(self) -> None:
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LANGUAGES } from "@chatballs/shared";
|
||||
|
||||
import { api, ApiError } from "../../api/client";
|
||||
import { ChannelGlyph } from "../../shared/badges";
|
||||
import { SelectField } from "../../shared/form-controls";
|
||||
import { FormField, SelectField } from "../../shared/form-controls";
|
||||
import { Icon } from "../../shared/icons";
|
||||
import { EmptyState, ErrorScreen, LoadingState } from "../../shared/ui";
|
||||
import { BackLink, Button, CopyButton } from "../../shared/ui-controls";
|
||||
@@ -534,6 +534,13 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
|
||||
}) {
|
||||
const missingProvider = card.providerIntegrationId === null;
|
||||
const providerName = providers.find((item) => item.id === card.providerIntegrationId)?.name ?? "";
|
||||
const transcriptionName = providers.find((item) => item.id === card.transcriptionIntegrationId)?.name ?? "";
|
||||
// Поля моделей редактируются свободно и уходят на сервер по потере фокуса:
|
||||
// сохранять каждую букву — это запрос на символ.
|
||||
const [modelDraft, setModelDraft] = useState(card.model);
|
||||
const [transcriptionDraft, setTranscriptionDraft] = useState(card.transcriptionModel);
|
||||
useEffect(() => { setModelDraft(card.model); }, [card.model]);
|
||||
useEffect(() => { setTranscriptionDraft(card.transcriptionModel); }, [card.transcriptionModel]);
|
||||
|
||||
return (
|
||||
<section className="agent-card is-side">
|
||||
@@ -550,13 +557,38 @@ function ModelCard({ card, providers, canManage, busy, apply }: {
|
||||
onChange={(next) => void apply({ providerIntegrationId: next ? Number(next) : null })}
|
||||
options={[["", t("ai.not_selected")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
|
||||
/>
|
||||
<label className="agent-field is-model">
|
||||
<span>{t("common.model")}</span>
|
||||
<span className="agent-field-control">
|
||||
<span className={`agent-field-static ${missingProvider ? "is-placeholder" : ""}`}>{missingProvider ? t("ai.pick_provider") : card.model}</span>
|
||||
<Icon name="search" size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
</label>
|
||||
{/* Ключ провайдера один на организацию, а агентов на нём несколько:
|
||||
модель принадлежит агенту. Пустое поле — «как в интеграции», и
|
||||
подсказкой в нём стоит её модель. */}
|
||||
<FormField
|
||||
disabled={busy || !canManage}
|
||||
label={t("common.model")}
|
||||
mono
|
||||
placeholder={missingProvider ? t("ai.pick_provider") : card.providerModel || t("ai.model_of_integration")}
|
||||
value={modelDraft}
|
||||
onChange={setModelDraft}
|
||||
onBlur={() => { if (modelDraft !== card.model) void apply({ model: modelDraft }); }}
|
||||
/>
|
||||
{/* Речь в текст умеет не всякая модель, которой агент отвечает: у части
|
||||
провайдеров аудио-эндпоинта нет вовсе. Поэтому выбор отдельный. */}
|
||||
<SelectField
|
||||
disabled={busy}
|
||||
label={t("ai.transcription_provider")}
|
||||
readOnly={!canManage}
|
||||
readOnlyText={transcriptionName || t("ai.same_as_answers")}
|
||||
value={card.transcriptionIntegrationId ? String(card.transcriptionIntegrationId) : ""}
|
||||
onChange={(next) => void apply({ transcriptionIntegrationId: next ? Number(next) : null })}
|
||||
options={[["", t("ai.same_as_answers")], ...providers.map((item) => [String(item.id), item.name] as [string, string])]}
|
||||
/>
|
||||
<FormField
|
||||
disabled={busy || !canManage}
|
||||
label={t("ai.transcription_model")}
|
||||
mono
|
||||
placeholder={card.transcriptionProviderModel || t("ai.model_of_integration")}
|
||||
value={transcriptionDraft}
|
||||
onChange={setTranscriptionDraft}
|
||||
onBlur={() => { if (transcriptionDraft !== card.transcriptionModel) void apply({ transcriptionModel: transcriptionDraft }); }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,14 @@ export type AgentCard = {
|
||||
groupColor: string;
|
||||
aiStatus: AgentAiStatus;
|
||||
model: string;
|
||||
/** Модель ответов; пусто — как в интеграции. */
|
||||
transcriptionModel: string;
|
||||
/** Модели, заданные в интеграциях: подсказка в пустом поле. */
|
||||
providerModel: string;
|
||||
transcriptionProviderModel: string;
|
||||
providerIntegrationId: number | null;
|
||||
/** Чем расшифровывать голосовые; null — тем же провайдером, что отвечает. */
|
||||
transcriptionIntegrationId: number | null;
|
||||
modelParams: Record<string, unknown>;
|
||||
// Режим языка ответов: MIRROR, ORGANIZATION или код языка.
|
||||
answerLanguage: string;
|
||||
@@ -57,6 +64,9 @@ export type AgentPatch = Partial<{
|
||||
groupId: number | null;
|
||||
isActive: boolean;
|
||||
providerIntegrationId: number | null;
|
||||
transcriptionIntegrationId: number | null;
|
||||
model: string;
|
||||
transcriptionModel: string;
|
||||
answerLanguage: string;
|
||||
persona: string;
|
||||
tone: string;
|
||||
@@ -155,9 +165,11 @@ export function agentTile(card: Pick<AgentCard, "id" | "isActive">): { color: st
|
||||
return { color, background: `color-mix(in srgb, ${color} 12%, var(--surface-card))` };
|
||||
}
|
||||
|
||||
/** Модель в списке: пока провайдер не выбран, показывать нечего (кадр G1). */
|
||||
/** Модель в списке: своя модель агента, иначе та, что стоит в интеграции.
|
||||
* Пока провайдер не выбран, показывать нечего (кадр G1). */
|
||||
export function agentModelLabel(card: AgentCard): string {
|
||||
return card.providerIntegrationId ? card.model : "—";
|
||||
if (!card.providerIntegrationId) return "—";
|
||||
return card.model || card.providerModel || "—";
|
||||
}
|
||||
|
||||
export function agentTint(provider: string): { color: string; bg: string; full: string } {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveAudioMode } from "./AudioCallOverlay";
|
||||
import type { ApiCall } from "./model";
|
||||
|
||||
// Экран аудиозвонка у оператора: что он показывает в каждой фазе.
|
||||
|
||||
function call(status: ApiCall["status"]): ApiCall {
|
||||
return { id: "c1", conversationId: 1, status, kind: "AUDIO", connectedAt: null, endedBy: null, durationSeconds: null } as ApiCall;
|
||||
}
|
||||
|
||||
describe("resolveAudioMode", () => {
|
||||
it("показывает разговор, пока звонок идёт", () => {
|
||||
expect(resolveAudioMode(call("ACTIVE"), "", "connected", "none")).toBe("active");
|
||||
});
|
||||
|
||||
it("считает звонок завершённым, даже когда соединение ещё считает себя живым", () => {
|
||||
// Клиент положил трубку: сервер уже в терминале, а peer connection узнаёт
|
||||
// об этом позже — или не узнаёт вовсе, если сокет мёртв.
|
||||
expect(resolveAudioMode(call("ENDED"), "", "connected", "none")).toBe("status");
|
||||
expect(resolveAudioMode(call("DECLINED"), "", "connected", "none")).toBe("status");
|
||||
expect(resolveAudioMode(call("FAILED"), "", "connected", "none")).toBe("status");
|
||||
});
|
||||
|
||||
it("до ответа клиента звонок исходящий", () => {
|
||||
expect(resolveAudioMode(call("RINGING"), "", "idle", "none")).toBe("ringing");
|
||||
});
|
||||
|
||||
it("принятый звонок без соединения — соединение", () => {
|
||||
expect(resolveAudioMode(call("ACCEPTED"), "", "idle", "none")).toBe("connecting");
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,13 @@ export function AudioCallOverlay(props: Props) {
|
||||
// состоянии (карточку звонка могли открыть из списка), а сам запрос может
|
||||
// упасть — тогда звонок заканчивается вторым путём, по сессии оператора.
|
||||
const finish = async () => {
|
||||
if (!call || isTerminalCallStatus(call.status)) return;
|
||||
if (!call) return;
|
||||
// Звонок уже закончил кто-то другой — завершать нечего, окно закрывается.
|
||||
if (isTerminalCallStatus(call.status)) {
|
||||
rtc.stop();
|
||||
props.onClose();
|
||||
return;
|
||||
}
|
||||
const token = props.access?.accessToken;
|
||||
try {
|
||||
if (token) props.onCallChange(await endCallByAccess(token));
|
||||
@@ -123,16 +129,21 @@ export function AudioCallOverlay(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode {
|
||||
export function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode {
|
||||
if (errorText || !call) return "status";
|
||||
if (mediaIssue === "devices" || mediaIssue === "unsupported") return "status";
|
||||
// Завершённый звонок завершён, чем бы ни было занято RTC-соединение. Клиент
|
||||
// кладёт трубку, сервер переводит звонок в терминал, а peer connection у
|
||||
// оператора об этом узнаёт позже — а если сокет уже мёртв, то и не узнаёт
|
||||
// вовсе. Пока эта проверка стояла последней, у оператора оставался экран
|
||||
// активного разговора с кнопкой «Завершить», которой нечего было завершать.
|
||||
if (isTerminalCallStatus(call.status)) return "status";
|
||||
// Звонок всегда инициирует оператор, поэтому до ответа клиента это исходящий.
|
||||
if (call.status === "REQUESTED" || call.status === "RINGING") return "ringing";
|
||||
if (call.status === "ACCEPTED") return connection === "connected" ? "active" : "connecting";
|
||||
if (connection === "reconnecting") return "reconnecting";
|
||||
if (connection === "failed") return "status";
|
||||
if (call.status === "ACTIVE" || connection === "connected") return "active";
|
||||
if (isTerminalCallStatus(call.status)) return "status";
|
||||
return "connecting";
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,13 @@ export function VideoCallOverlay(props: Props) {
|
||||
// «Завершить» обязано сработать всегда: нет токена доступа или запрос не
|
||||
// прошёл — звонок заканчивается вторым путём, по сессии оператора.
|
||||
const finish = async () => {
|
||||
if (!call || TERMINAL[call.status]) return;
|
||||
if (!call) return;
|
||||
// Звонок уже закончил кто-то другой — завершать нечего, окно закрывается.
|
||||
if (TERMINAL[call.status]) {
|
||||
rtc.stop();
|
||||
props.onClose();
|
||||
return;
|
||||
}
|
||||
const token = props.access?.accessToken;
|
||||
try {
|
||||
if (token) props.onCallChange(await endCallByAccess(token));
|
||||
|
||||
@@ -384,6 +384,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.manage": "Manage",
|
||||
"ai.material_import": "Material import",
|
||||
"ai.move": "Move",
|
||||
"ai.model_of_integration": "Same as in the integration",
|
||||
"ai.move_category": "Move to a category",
|
||||
"ai.name_group_all_takes_start": "A name and a group are all it takes to start. Instructions, knowledge and connections are set later on the card.",
|
||||
"ai.name_unique_within_its_parent": "The name is unique within its parent.",
|
||||
@@ -436,6 +437,7 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.replies_2": "IN REPLIES",
|
||||
"ai.rules": "Rules",
|
||||
"ai.save_reindex": "Save and reindex",
|
||||
"ai.same_as_answers": "Same as answers",
|
||||
"ai.saving": "Saving…",
|
||||
"ai.search_by_title": "Search by title",
|
||||
"ai.search_by_title_description": "Search by title and description",
|
||||
@@ -490,6 +492,8 @@ export const en: Record<MessageKey, Message> = {
|
||||
"ai.up_25_mb": "· up to 25 MB",
|
||||
"ai.update": "update",
|
||||
"ai.updated": "· Updated:",
|
||||
"ai.transcription_model": "Transcription model",
|
||||
"ai.transcription_provider": "Voice transcription",
|
||||
"ai.updated_2": "UPDATED",
|
||||
"ai.updated_by_at": "updated by {name} · {date}",
|
||||
"ai.updated_on": "updated {date}",
|
||||
|
||||
@@ -385,6 +385,7 @@ export const ru = {
|
||||
"ai.manage": "Управлять",
|
||||
"ai.material_import": "Импорт материалов",
|
||||
"ai.move": "Переместить",
|
||||
"ai.model_of_integration": "Как в интеграции",
|
||||
"ai.move_category": "Переместить в категорию",
|
||||
"ai.name_group_all_takes_start": "Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.",
|
||||
"ai.name_unique_within_its_parent": "Имя уникально внутри родителя.",
|
||||
@@ -437,6 +438,7 @@ export const ru = {
|
||||
"ai.replies_2": "В ОТВЕТАХ",
|
||||
"ai.rules": "Правила",
|
||||
"ai.save_reindex": "Сохранить и переиндексировать",
|
||||
"ai.same_as_answers": "Как у ответов",
|
||||
"ai.saving": "Сохранение…",
|
||||
"ai.search_by_title": "Поиск по названию",
|
||||
"ai.search_by_title_description": "Поиск по заголовку и описанию",
|
||||
@@ -491,6 +493,8 @@ export const ru = {
|
||||
"ai.up_25_mb": "· до 25 МБ",
|
||||
"ai.update": "обновить",
|
||||
"ai.updated": "· Обновлено:",
|
||||
"ai.transcription_model": "Модель расшифровки",
|
||||
"ai.transcription_provider": "Расшифровка голосовых",
|
||||
"ai.updated_2": "ОБНОВЛЕНО",
|
||||
"ai.updated_by_at": "обновил {name} · {date}",
|
||||
"ai.updated_on": "обновлено {date}",
|
||||
|
||||
@@ -9,6 +9,9 @@ type FormFieldProps = {
|
||||
label: string;
|
||||
mono?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
/** Значение уходит на сервер по потере фокуса: запрос на каждую букву — это
|
||||
* запрос на каждую букву. */
|
||||
onBlur?: () => void;
|
||||
placeholder?: string;
|
||||
type?: "password" | "text";
|
||||
value: string;
|
||||
@@ -21,6 +24,7 @@ export function FormField({
|
||||
label,
|
||||
mono = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
placeholder = "",
|
||||
type = "text",
|
||||
value,
|
||||
@@ -39,7 +43,7 @@ export function FormField({
|
||||
return (
|
||||
<label className={className}>
|
||||
<span>{label}</span>
|
||||
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} />
|
||||
<input className={mono ? "mono" : ""} type={type} value={value} placeholder={placeholder} disabled={disabled} readOnly={!editable} onChange={(event) => onChange?.(event.target.value)} onBlur={onBlur} />
|
||||
{error && <small className="form-field-error" role="alert">{error}</small>}
|
||||
</label>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user