From 651b72f5360b2fc58e14224a0e755a578381f6b8 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Mon, 27 Jul 2026 01:29:00 +0000 Subject: [PATCH] =?UTF-8?q?fix(llm):=20=D0=BD=D0=B5=20=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D1=83=20?= =?UTF-8?q?=D0=B8=D0=B7-=D0=B7=D0=B0=20=D0=BC=D1=91=D1=80=D1=82=D0=B2?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20BYOK-=D0=BA=D0=BB=D1=8E=D1=87=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter перебирает привязанные Google-ключи внутри одного запроса. Если у рабочих ключей исчерпана суточная квота модели, очередь доходит до ключа с удалённым service account, и провайдер отвечает 401 ACCOUNT_STATE_INVALID. Такой 401 обрывал перебор моделей, хотя следующие модели в списке доступны, и задача падала целиком. Теперь провайдерский отказ BYOK трактуется как отказ конкретного кандидата: модель уходит в cooldown, перебор продолжается. Невалидный ключ самого OpenRouter по-прежнему пробрасывается сразу. --- lecturelog/infrastructure/llm/llm_client.py | 32 +++++++++++++ tests/unit/test_llm_client.py | 52 +++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/lecturelog/infrastructure/llm/llm_client.py b/lecturelog/infrastructure/llm/llm_client.py index e83522a..4af7ae6 100644 --- a/lecturelog/infrastructure/llm/llm_client.py +++ b/lecturelog/infrastructure/llm/llm_client.py @@ -31,6 +31,7 @@ # Бэк-офф между ретраями сетевых ошибок (ConnectTimeout и т.п.): разовый флап # сети не должен ронять всю задачу — повтор почти всегда проходит. _NETWORK_BACKOFF_S = 2.0 +_BYOK_AUTH_COOLDOWN_S = 300.0 async def _emit_usage(on_usage: UsageCallback | None, payload: dict) -> None: @@ -65,6 +66,25 @@ def _extract_rate_limit_raw(error: openai.RateLimitError) -> str: return "" +def _is_google_byok_auth_error(error: openai.AuthenticationError) -> bool: + """True только для отказа привязанного Google BYOK-ключа. + + Невалидный ключ самого OpenRouter обязан пробрасываться сразу: это ошибка + конфигурации, а не отказ конкретного кандидата. + """ + body: Any = getattr(error, "body", None) + try: + if isinstance(body, str): + body = json.loads(body) + error_body = body.get("error", body) + metadata = error_body.get("metadata", {}) + return ( + metadata.get("is_byok") is True and metadata.get("provider_name") == "Google AI Studio" + ) + except (AttributeError, ValueError, TypeError): + return False + + def _detect_image_mime(image: bytes) -> str: """Определяет MIME по магическим байтам. По умолчанию — png (обратная совместимость).""" if image.startswith(b"\xff\xd8"): @@ -134,6 +154,18 @@ async def call( ) await self._cooldown.mark_rate_limited(model, ttl) continue + except openai.AuthenticationError as error: + # Мёртвый BYOK-ключ (удалённый service account) — отказ конкретной + # модели, а не всего ключа: пробуем следующую вместо падения задачи. + if not _is_google_byok_auth_error(error): + raise + last_error = error + logger.warning( + "Google BYOK credential rejected for %s; trying next model", + model, + ) + await self._cooldown.mark_rate_limited(model, _BYOK_AUTH_COOLDOWN_S) + continue except (openai.APITimeoutError, openai.APIConnectionError) as error: # Сетевой флап (не 429): модель не виновата, cooldown не трогаем — # ждём с нарастающим бэк-оффом и пробуем снова. diff --git a/tests/unit/test_llm_client.py b/tests/unit/test_llm_client.py index 189e856..06cbef5 100644 --- a/tests/unit/test_llm_client.py +++ b/tests/unit/test_llm_client.py @@ -269,3 +269,55 @@ async def test_network_errors_exhaust_retries(monkeypatch): client = LlmClient(fake, ModelCooldown()) with pytest.raises(RuntimeError): await client.call("q", models=["m1"], retries=3) + + +def _authentication_error(*, byok: bool, sdk_unwrapped: bool = False) -> openai.AuthenticationError: + error_body = { + "message": "authentication failed", + "metadata": { + "is_byok": byok, + "provider_name": "Google AI Studio" if byok else "OpenRouter", + }, + } + body = error_body if sdk_unwrapped else {"error": error_body} + request = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions") + response = httpx.Response(401, request=request, json=body) + return openai.AuthenticationError(message="authentication failed", response=response, body=body) + + +@pytest.mark.asyncio +async def test_google_byok_auth_error_falls_back_to_next_model(monkeypatch): + import lecturelog.infrastructure.llm.llm_client as mod + + monkeypatch.setattr(mod, "_BYOK_AUTH_COOLDOWN_S", 60.0) + cooldown = SpyModelCooldown() + fake = FakeAsyncOpenAI([_authentication_error(byok=True), _resp("fallback ok")]) + client = LlmClient(fake, cooldown) + + assert await client.call("q", models=["m1", "m2"]) == "fallback ok" + assert [item["model"] for item in fake.chat.completions.kwargs_history] == ["m1", "m2"] + assert cooldown.marked == [("m1", 60.0)] + + +@pytest.mark.asyncio +async def test_google_byok_auth_error_with_sdk_unwrapped_body_falls_back(monkeypatch): + import lecturelog.infrastructure.llm.llm_client as mod + + monkeypatch.setattr(mod, "_BYOK_AUTH_COOLDOWN_S", 60.0) + fake = FakeAsyncOpenAI( + [_authentication_error(byok=True, sdk_unwrapped=True), _resp("fallback ok")] + ) + client = LlmClient(fake, ModelCooldown()) + + assert await client.call("q", models=["m1", "m2"]) == "fallback ok" + + +@pytest.mark.asyncio +async def test_openrouter_auth_error_does_not_fallback(): + client = LlmClient( + FakeAsyncOpenAI([_authentication_error(byok=False)]), + ModelCooldown(), + ) + + with pytest.raises(openai.AuthenticationError): + await client.call("q", models=["m1", "m2"])