Compare commits

...
2 Commits
Author SHA1 Message Date
Andrey bd3b9309bf 🔖 release: 1.9.1 2026-09-15 08:10:11 +03:00
AndreyandClaude Opus 5 e35133b159 🐛 fix(integrations): продукт представляется своим именем, отказ объясняется словами
Исходящие запросы уходили с умолчанием urllib — «Python-urllib/3.12». Защита перед чужим API такой запрос банит раньше, чем его увидит сам провайдер: на боевом стенде Groq через рабочий прокси отвечал 403 «error code: 1010», а с обычным User-Agent тот же запрос и тот же прокси проходят. Теперь opener представляется как «Chatballs/<версия>», свой User-Agent конкретного запроса он не перебивает.

Проверка подключения больше не показывает голый код. Причину, если провайдер её назвал, видно сразу («Invalid API Key»), HTML-страница защиты сжимается в строку, молчаливый 401/403 отправляет смотреть ключ и доступность провайдера из сети. Ответ целиком уходит в журнал.

Проверено на боевом стенде: без User-Agent — 403 «error code: 1010», с любым User-Agent — ответ самого Groq. Пять новых тестов, тесты интеграций, транспортов, провайдеров и i18n.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 08:07:33 +03:00
6 changed files with 162 additions and 6 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.9.0
1.9.1
@@ -503,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",
@@ -507,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",
+54 -1
View File
@@ -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), {}
+26 -4
View File
@@ -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: