Skip to content
Merged
2 changes: 2 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@

这是一个有意保留的自定义外部服务集成。选择 `FallbackLLMProvider` 的原因是 any-llm 只统一调用单个 provider,不编排跨 provider 的故障切换。包装器捕获主适配器的 `LLMRequestError`,切换后在剩余生命周期内固定使用备用适配器,使同一进程里的契约修复不会回到刚刚失败的主服务。

适配器把应用工厂创建的 AnyLLM provider client 在 completion 调用中抛出的异常归一化为 `LLMRequestError`,使配置正确的 fallback 能够启动。请求消息在进入该边界前完成序列化,因此应用自身的序列化错误不会触发 fallback;结构化输出的 Pydantic 校验错误和直接注入协议实现所抛出的异常继续原样传播。

它替代的是每个调用点手写的故障切换分支,不替代 any-llm 的厂商适配器,也不接管 SDK 凭据、请求重试或输出验证。

这个选择成立的条件是一次主服务请求失败足以让当前模型对象的后续调用继续使用备用服务,恢复主服务交给下次创建模型对象。如果 any-llm 提供可观察的跨 provider 路由,或者常驻进程需要在不重启的情况下探测并恢复主服务,就重新评估并用带健康状态和冷却时间的路由替换当前粘性开关。
Expand Down
193 changes: 190 additions & 3 deletions tests/test_any_llm_provider.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import json
import logging
from collections.abc import Mapping
import os
from collections.abc import Callable, Mapping
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock

import httpx
import pytest
from anthropic import BadRequestError as AnthropicBadRequestError
from any_llm import AnyLLM
from any_llm.providers.openai.base import BaseOpenAIProvider
from openai import AsyncOpenAI
from pydantic import BaseModel
from openai import AsyncOpenAI, BadRequestError
from pydantic import BaseModel, ValidationError

from weather_briefing.api_client import LoggedAsyncClient
from weather_briefing.llm import (
AnyLLMStructuredProvider,
FallbackLLMProvider,
LazyServiceStatusLLM,
LLMRequestError,
LLMStructuredOutput,
create_any_llm_provider,
)
Expand Down Expand Up @@ -48,6 +52,28 @@ async def acompletion(
return self._response


def _openai_bad_request(response: httpx.Response) -> Exception:
return BadRequestError("Upstream request failed", response=response, body={"error": "upstream"})


def _anthropic_bad_request(response: httpx.Response) -> Exception:
return AnthropicBadRequestError("Upstream request failed", response=response, body={"error": "upstream"})


def _httpx_connection_error(response: httpx.Response) -> Exception:
return httpx.ConnectError("Upstream connection failed", request=response.request)


class _ProviderStatusError(Exception):
def __init__(self, response: httpx.Response) -> None:
super().__init__("Upstream request failed")
self.response = response


def _provider_status_error(response: httpx.Response) -> Exception:
return _ProviderStatusError(response)


async def test_service_status_llm_is_created_only_on_first_operation() -> None:
provider = AsyncMock()
provider.assess_notification.return_value = NotificationDecision(True)
Expand Down Expand Up @@ -176,6 +202,167 @@ async def test_any_llm_provider_assesses_notification_value_with_a_narrow_schema
assert client.calls[0]["max_tokens"] == 256


@pytest.mark.parametrize(
("provider_name", "error_factory", "operation", "args", "message"),
(
(
"openai",
_openai_bad_request,
"summarize",
("Return JSON", {"input": "data"}),
"LLM request failed",
),
(
"anthropic",
_anthropic_bad_request,
"summarize",
("Return JSON", {"input": "data"}),
"LLM request failed",
),
(
"openrouter",
_httpx_connection_error,
"summarize",
("Return JSON", {"input": "data"}),
"LLM request failed",
),
(
"gemini",
_provider_status_error,
"summarize",
("Return JSON", {"input": "data"}),
"LLM request failed",
),
(
"openai",
_openai_bad_request,
"assess_notification",
({"current": {"status": "operational"}},),
"LLM notification decision request failed",
),
(
"openai",
_openai_bad_request,
"translate_service_status",
("Incident", "Elevated errors", "en"),
"LLM translation request failed",
),
),
)
async def test_factory_normalizes_provider_native_request_errors(
monkeypatch,
provider_name: str,
error_factory: Callable[[httpx.Response], Exception],
operation: str,
args: tuple[object, ...],
message: str,
) -> None:
request = httpx.Request("POST", "https://api.example.invalid/chat/completions")
response = httpx.Response(400, request=request)
error = error_factory(response)
client = AsyncMock(spec=AnyLLM)
client.acompletion.side_effect = error
monkeypatch.delenv("ANY_LLM_UNIFIED_EXCEPTIONS", raising=False)
monkeypatch.setattr(AnyLLM, "create", lambda *args, **kwargs: client)
provider = create_any_llm_provider(provider_name, "requested-model", 4096)

with pytest.raises(LLMRequestError, match=f"^{message}$") as exc_info:
await getattr(provider, operation)(*args)

assert exc_info.value.__cause__ is error
assert "ANY_LLM_UNIFIED_EXCEPTIONS" not in os.environ


async def test_factory_preserves_sdk_output_validation_errors(monkeypatch) -> None:
with pytest.raises(ValidationError) as validation:
LLMStructuredOutput.model_validate({})
client = AsyncMock(spec=AnyLLM)
client.acompletion.side_effect = validation.value
monkeypatch.setattr(AnyLLM, "create", lambda *args, **kwargs: client)
provider = create_any_llm_provider("openai", "requested-model", 4096)

with pytest.raises(ValidationError) as propagated:
await provider.summarize("Return JSON", {"input": "data"})

assert propagated.value is validation.value


async def test_any_llm_client_does_not_mask_payload_serialization_errors() -> None:
client = AsyncMock(spec=AnyLLM)
provider = AnyLLMStructuredProvider(
client,
provider="openai",
model="requested-model",
max_output_tokens=4096,
)

with pytest.raises(TypeError, match="not JSON serializable"):
await provider.summarize("Return JSON", {"input": object()})

client.acompletion.assert_not_awaited()


async def test_protocol_client_preserves_completion_errors() -> None:
error = RuntimeError("Injected client failed")
client = AsyncMock()
client.acompletion.side_effect = error
provider = AnyLLMStructuredProvider(
client,
provider="wrapped-provider",
model="requested-model",
max_output_tokens=4096,
)

with pytest.raises(RuntimeError, match="Injected client failed") as exc_info:
await provider.summarize("Return JSON", {"input": "data"})

assert exc_info.value is error


async def test_provider_native_request_error_switches_to_fallback(monkeypatch) -> None:
request = httpx.Request("POST", "https://api.example.invalid/chat/completions")
response = httpx.Response(400, request=request)
error = BadRequestError(
"Upstream request failed",
response=response,
body={"error": "upstream"},
)
primary_client = AsyncMock(spec=AnyLLM)
primary_client.acompletion.side_effect = error
monkeypatch.delenv("ANY_LLM_UNIFIED_EXCEPTIONS", raising=False)
monkeypatch.setattr(AnyLLM, "create", lambda *args, **kwargs: primary_client)
fallback_result = {
"headline": "Fallback briefing",
"headline_source_ids": ["source"],
"conclusions": [],
"active_warnings": [],
"resolved_warning_ids": [],
"advice": [],
"disaster_tracking": [],
"should_publish": True,
}
fallback_client = _CompletionClientStub(
SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=json.dumps(fallback_result)))])
)
provider = FallbackLLMProvider(
create_any_llm_provider("openai", "primary-model", 4096),
AnyLLMStructuredProvider(
fallback_client,
provider="openai",
model="fallback-model",
max_output_tokens=4096,
),
primary_name="openai/primary-model",
fallback_name="openai/fallback-model",
)

result = await provider.summarize("Return JSON", {"input": "data"})

assert result == fallback_result
primary_client.acompletion.assert_awaited_once()
assert len(fallback_client.calls) == 1


async def test_factory_accepts_every_any_llm_completion_provider(monkeypatch) -> None:
created: list[tuple[str, dict[str, object]]] = []

Expand Down
3 changes: 1 addition & 2 deletions tests/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import logging
from copy import deepcopy
from types import SimpleNamespace
from typing import Any

import pendulum
import pytest
Expand Down Expand Up @@ -80,7 +79,7 @@ async def test_completion_client_stub_requires_a_configured_response() -> None:
)


def _valid_payload() -> dict[str, Any]:
def _valid_payload() -> dict[str, object]:
return {
"headline": "Briefing",
"headline_source_ids": ["source"],
Expand Down
16 changes: 12 additions & 4 deletions tests/test_llm_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,20 +163,28 @@ async def test_output_contract_failure_does_not_use_fallback() -> None:
async def test_fallback_failure_preserves_primary_as_context() -> None:
primary = _provider()
fallback = _provider()
primary.summarize.side_effect = LLMRequestError("primary unavailable")
fallback.summarize.side_effect = LLMRequestError("fallback unavailable")
primary_cause = TimeoutError("primary unavailable")
primary_error = LLMRequestError("primary request failed")
primary_error.__cause__ = primary_cause
fallback_cause = ConnectionError("fallback unavailable")
fallback_error = LLMRequestError("fallback request failed")
fallback_error.__cause__ = fallback_cause
primary.summarize.side_effect = primary_error
fallback.summarize.side_effect = fallback_error
provider = FallbackLLMProvider(
primary,
fallback,
primary_name="primary",
fallback_name="fallback",
)

with pytest.raises(LLMRequestError, match="fallback unavailable") as exc_info:
with pytest.raises(LLMRequestError, match="fallback request failed") as exc_info:
await provider.summarize("system", {"input": "value"})

assert exc_info.value.__cause__ is fallback_cause
assert isinstance(exc_info.value.__context__, LLMRequestError)
assert str(exc_info.value.__context__) == "primary unavailable"
assert str(exc_info.value.__context__) == "primary request failed"
assert exc_info.value.__context__.__cause__ is primary_cause


async def test_fallback_log_excludes_exception_details(caplog) -> None:
Expand Down
Loading