Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions lecturelog/infrastructure/llm/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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 не трогаем —
# ждём с нарастающим бэк-оффом и пробуем снова.
Expand Down
52 changes: 52 additions & 0 deletions tests/unit/test_llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Loading