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 调用中抛出的 HTTP 传输错误,以及带请求或响应状态的厂商原生错误归一化为 `LLMRequestError`,使配置正确的 fallback 能够启动。结构化输出的 Pydantic 校验错误和 SDK 或直接注入协议实现所抛出的编程错误继续原样传播。

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

这个选择成立的条件是一次主服务请求失败足以让当前模型对象的后续调用继续使用备用服务,恢复主服务交给下次创建模型对象。如果 any-llm 提供可观察的跨 provider 路由,或者常驻进程需要在不重启的情况下探测并恢复主服务,就重新评估并用带健康状态和冷却时间的路由替换当前粘性开关。
Expand Down
308 changes: 304 additions & 4 deletions tests/test_any_llm_provider.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import json
import logging
from collections.abc import Mapping
import os
from collections.abc import Callable, Mapping
from types import SimpleNamespace
from typing import Any
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 any_llm.types.completion import ChatCompletionMessage
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 All @@ -31,7 +37,7 @@ async def acompletion(
self,
*,
model: str,
messages: list[dict[str, str]],
messages: list[dict[str, Any] | ChatCompletionMessage],
response_format: type[BaseModel],
temperature: float,
max_tokens: int,
Expand All @@ -48,6 +54,40 @@ 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)


class _RequestMetadataTransportError(Exception):
def __init__(self, method: str = "POST") -> None:
super().__init__("Upstream connection failed")
self.request = SimpleNamespace(method=method, url="https://api.example.invalid/chat/completions")


class _RequestInfoTransportError(Exception):
def __init__(self) -> None:
super().__init__("Upstream connection failed")
self.request_info = SimpleNamespace(method="GET", real_url="https://api.example.invalid/models")


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 +216,266 @@ 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_normalizes_transport_errors() -> None:
request = httpx.Request("POST", "https://api.example.invalid/chat/completions")
error = httpx.ConnectError("Upstream connection failed", request=request)
client = AsyncMock()
client.acompletion.side_effect = error
provider = AnyLLMStructuredProvider(
client,
provider="wrapped-provider",
model="requested-model",
max_output_tokens=4096,
normalize_native_errors=True,
)

with pytest.raises(LLMRequestError, match="^LLM request failed$") as exc_info:
await provider.summarize("Return JSON", {"input": "data"})

assert exc_info.value.__cause__ is error


async def test_protocol_client_normalizes_request_metadata_errors() -> None:
error = _RequestMetadataTransportError()
client = AsyncMock()
client.acompletion.side_effect = error
provider = AnyLLMStructuredProvider(
client,
provider="wrapped-provider",
model="requested-model",
max_output_tokens=4096,
normalize_native_errors=True,
)

with pytest.raises(LLMRequestError, match="^LLM request failed$") as exc_info:
await provider.summarize("Return JSON", {"input": "data"})

assert exc_info.value.__cause__ is error


async def test_protocol_client_normalizes_request_info_errors() -> None:
error = _RequestInfoTransportError()
client = AsyncMock()
client.acompletion.side_effect = error
provider = AnyLLMStructuredProvider(
client,
provider="wrapped-provider",
model="requested-model",
max_output_tokens=4096,
normalize_native_errors=True,
)

with pytest.raises(LLMRequestError, match="^LLM request failed$") as exc_info:
await provider.summarize("Return JSON", {"input": "data"})

assert exc_info.value.__cause__ is error


async def test_protocol_client_preserves_non_http_request_metadata() -> None:
error = _RequestMetadataTransportError(method="FETCH")
client = AsyncMock()
client.acompletion.side_effect = error
provider = AnyLLMStructuredProvider(
client,
provider="wrapped-provider",
model="requested-model",
max_output_tokens=4096,
normalize_native_errors=True,
)

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

assert exc_info.value is error


async def test_protocol_client_preserves_native_errors_without_opt_in() -> None:
request = httpx.Request("POST", "https://api.example.invalid/chat/completions")
error = httpx.ConnectError("Upstream connection failed", request=request)
client = AsyncMock()
client.acompletion.side_effect = error
provider = AnyLLMStructuredProvider(
client,
provider="wrapped-provider",
model="requested-model",
max_output_tokens=4096,
)

with pytest.raises(httpx.ConnectError) as exc_info:
await provider.summarize("Return JSON", {"input": "data"})

assert exc_info.value is error


async def test_completion_programming_error_does_not_switch_to_fallback(monkeypatch) -> None:
error = TypeError("SDK programming failure")
primary_client = AsyncMock(spec=AnyLLM)
primary_client.acompletion.side_effect = error
monkeypatch.setattr(AnyLLM, "create", lambda *args, **kwargs: primary_client)
fallback_client = AsyncMock(spec=AnyLLM)
provider = FallbackLLMProvider(
create_any_llm_provider("openai", "primary-model", 4096),
AnyLLMStructuredProvider(
fallback_client,
provider="anthropic",
model="fallback-model",
max_output_tokens=4096,
),
primary_name="openai/primary-model",
fallback_name="anthropic/fallback-model",
)

with pytest.raises(TypeError, match="SDK programming failure") as exc_info:
await provider.summarize("Return JSON", {"input": "data"})

assert exc_info.value is error
fallback_client.acompletion.assert_not_awaited()


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
4 changes: 2 additions & 2 deletions tests/test_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pendulum
import pytest
from any_llm.exceptions import LengthFinishReasonError, ProviderError
from any_llm.types.completion import ParsedChatCompletion
from any_llm.types.completion import ChatCompletionMessage, ParsedChatCompletion
from pydantic import BaseModel

from weather_briefing.llm import (
Expand Down Expand Up @@ -36,7 +36,7 @@ async def acompletion(
self,
*,
model: str,
messages: list[dict[str, str]],
messages: list[dict[str, Any] | ChatCompletionMessage],
response_format: type[BaseModel],
temperature: float,
max_tokens: int,
Expand Down
Loading