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
2 changes: 2 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ Open-Meteo 的逐小时空气质量和花粉预报按目标日峰值生成生活

CLI 负责关闭自己创建的模型服务对象及其网络资源。测试或外部调用方注入的对象视为借用,不由应用关闭。

配置边界将 `LLM_FALLBACK_PROVIDER` 和 `LLM_FALLBACK_MODEL` 作为一组解析。可选的 `LLM_FALLBACK_API_KEY` 和 `LLM_FALLBACK_BASE_URL` 只传给备用适配器;未配置时由 any-llm 按所选 provider 的规则解析默认连接配置。组合根用 `FallbackLLMProvider` 包装两个独立持有 SDK 资源的适配器;首次主适配器请求失败后,该包装器在生命周期内固定使用备用适配器。关闭包装器时会尝试释放两侧资源;两侧都发生普通清理异常时通过 `ExceptionGroup` 聚合,取消异常保持传播并优先于普通清理异常,竞争的普通异常只按异常类型记录。

## 调度与投递

`daemon` 创建 forecast 和 briefing 两类 APScheduler 任务,并保持常驻。它不接受立即运行参数。
Expand Down
8 changes: 8 additions & 0 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@

如果以后发布明确允许删除旧配置的重大版本,并且迁移说明已经给已有部署留出足够时间,就可以移除这两个后备变量。在此之前,修改模型配置边界时必须保留并测试这一优先级。

## FallbackLLMProvider 在进程内保持粘性

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

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

这个选择成立的条件是一次主服务请求失败足以让当前模型对象的后续调用继续使用备用服务,恢复主服务交给下次创建模型对象。如果 any-llm 提供可观察的跨 provider 路由,或者常驻进程需要在不重启的情况下探测并恢复主服务,就重新评估并用带健康状态和冷却时间的路由替换当前粘性开关。

## 模型对象的关闭使用兼容探测

any-llm 当前没有统一的关闭接口。应用先尝试 provider 的 `aclose()` 或 `close()`,再清理它直接持有的已知 SDK 资源。
Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
- 服务状态必须与天气简报独立调度,默认每五分钟采集一次,并允许两类任务同时启用。
- 状态页发生变化后仍须判断是否值得打扰用户;没有新增影响或行动价值的变化不能推送。
- 故障、恢复等官方消息使用英语或用户指定语言时必须原样转发;语言不匹配时可以用大语言模型忠实翻译,但不得添加事实。
- 用户可以配置一个备用大语言模型;主模型请求失败时,系统应自动尝试备用模型,但不得用切换供应商掩盖模型已经返回的无效内容。
- 服务状态必须可以投递到一个或多个独立于天气简报的平台。
- 没有配置可选来源,或可选来源暂时失败时,天气简报仍应运行。
- 每轮只读取按地点时区判断为当天发布的文章。
Expand Down
11 changes: 9 additions & 2 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@
# python-dotenv and Docker Compose accept quoted values, but Docker CLI
# can pass the wrapping quotes into the container as literal characters.

# Required: select an any-llm provider and model. Provider credentials and API
# bases use the environment names documented by any-llm for that provider.
# Required: select an any-llm provider and model. Primary-provider credentials
# and API bases use the environment names documented by any-llm.
LLM_PROVIDER=deepseek
LLM_MODEL=deepseek-v4-flash
# Optional request-failure fallback, disabled by default. Configure both values
# or neither.
# LLM_FALLBACK_PROVIDER=openai
# LLM_FALLBACK_MODEL=gpt-5-mini
# Optional fallback-only credentials and endpoint overrides.
# LLM_FALLBACK_API_KEY=replace-in-runtime-environment
# LLM_FALLBACK_BASE_URL=
# DeepSeek example. DEEPSEEK_API_BASE is optional.
DEEPSEEK_API_KEY=replace-in-runtime-environment
# DEEPSEEK_API_BASE=
Expand Down
61 changes: 61 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from weather_briefing.composition.providers import weather_provider_metadata as _weather_provider_metadata
from weather_briefing.config import ConfigurationError, Settings
from weather_briefing.delivery import BarkTextRenderer
from weather_briefing.llm import FallbackLLMProvider
from weather_briefing.models import LocationSpec, ResolvedLocation
from weather_briefing.persistence import StateDirectoryInUseError, daemon_state_owner
from weather_briefing.registries import PublisherName, WeatherProviderName
Expand Down Expand Up @@ -763,6 +764,10 @@ async def fail_run(
llm_provider="deepseek",
llm_model="m",
llm_base_url=None,
llm_fallback_provider=None,
llm_fallback_model=None,
llm_fallback_api_key=None,
llm_fallback_base_url=None,
llm_max_output_tokens=8192,
llm_max_attempts=3,
http_timeout_seconds=30.0,
Expand Down Expand Up @@ -1204,6 +1209,62 @@ async def test_arbitrary_any_llm_provider_is_forwarded(self, monkeypatch) -> Non
"diagnostics": None,
}

async def test_configured_fallback_llm_provider_is_composed(self, monkeypatch) -> None:
providers: list[SimpleNamespace] = []
calls: list[tuple[tuple[object, ...], dict[str, object]]] = []

def create_provider(*args, **kwargs):
provider = SimpleNamespace()
providers.append(provider)
calls.append((args, kwargs))
return provider

monkeypatch.setattr("weather_briefing.llm.any_llm.create_any_llm_provider", create_provider)
settings = replace(
_make_fake_settings(),
llm_fallback_provider="openai",
llm_fallback_model="gpt-fallback",
)

provider = _llm_provider(settings)

assert isinstance(provider, FallbackLLMProvider)
assert len(providers) == 2
assert calls[1] == (
("openai", "gpt-fallback", 8192),
{
"api_key": None,
"api_base": None,
"diagnostics": None,
},
)

async def test_deepseek_fallback_forwards_normalized_connection_settings(self, monkeypatch) -> None:
calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
monkeypatch.setattr(
"weather_briefing.llm.any_llm.create_any_llm_provider",
lambda *args, **kwargs: calls.append((args, kwargs)) or SimpleNamespace(),
)
settings = replace(
_make_fake_settings(),
llm_fallback_provider="deepseek",
llm_fallback_model="deepseek-fallback",
llm_fallback_api_key="fallback-key",
llm_fallback_base_url="https://deepseek.example/v1",
)

provider = _llm_provider(settings)

assert isinstance(provider, FallbackLLMProvider)
assert calls[1] == (
("deepseek", "deepseek-fallback", 8192),
{
"api_key": "fallback-key",
"api_base": "https://deepseek.example/v1",
"diagnostics": None,
},
)


class TestDeliveryProvider:
async def test_stdout(self, async_client: httpx.AsyncClient) -> None:
Expand Down
75 changes: 75 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ def test_mainland_weather_providers_default_to_qweather_then_open_meteo(monkeypa
assert [feed.id for feed in settings.feeds] == ["authority-weather"]
assert settings.llm_provider == "deepseek"
assert settings.llm_base_url is None
assert settings.llm_fallback_provider is None
assert settings.llm_fallback_model is None
assert settings.llm_fallback_api_key is None
assert settings.llm_fallback_base_url is None
assert settings.llm_max_attempts == 3
assert settings.qweather_jwt_lifetime_seconds == 900
assert settings.llm_history_max_documents == 8
Expand Down Expand Up @@ -1052,6 +1056,41 @@ def test_any_llm_provider_uses_sdk_managed_configuration(monkeypatch) -> None:
assert settings.llm_base_url is None


def test_llm_fallback_provider_and_model_are_loaded(monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("LLM_FALLBACK_PROVIDER", "openai")
monkeypatch.setenv("LLM_FALLBACK_MODEL", "gpt-fallback")

settings = Settings.from_env()

assert settings.llm_fallback_provider == "openai"
assert settings.llm_fallback_model == "gpt-fallback"
assert settings.llm_fallback_api_key is None
assert settings.llm_fallback_base_url is None


def test_llm_fallback_uses_dedicated_connection_settings(monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("LLM_FALLBACK_PROVIDER", "openai")
monkeypatch.setenv("LLM_FALLBACK_MODEL", "gpt-fallback")
monkeypatch.setenv("LLM_FALLBACK_API_KEY", "'fallback-key'")
monkeypatch.setenv("LLM_FALLBACK_BASE_URL", "https://gateway.example.invalid/v1/")

settings = Settings.from_env()

assert settings.llm_fallback_api_key == "fallback-key"
assert settings.llm_fallback_base_url == "https://gateway.example.invalid/v1"


@pytest.mark.parametrize("name", ("LLM_FALLBACK_API_KEY", "LLM_FALLBACK_BASE_URL"))
def test_llm_fallback_connection_settings_require_fallback(monkeypatch, name: str) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv(name, "configured")

with pytest.raises(ConfigurationError, match=rf"{name} requires LLM_FALLBACK_PROVIDER and LLM_FALLBACK_MODEL"):
Settings.from_env()


def test_deepseek_model_name_remains_compatible(monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.delenv("LLM_MODEL")
Expand Down Expand Up @@ -1197,6 +1236,42 @@ def test_llm_provider_without_completion_raises_error(self, monkeypatch) -> None
with pytest.raises(ConfigurationError, match="does not support completion"):
Settings.from_env()

@pytest.mark.parametrize(
("configured_name", "missing_name"),
(
("LLM_FALLBACK_PROVIDER", "LLM_FALLBACK_MODEL"),
("LLM_FALLBACK_MODEL", "LLM_FALLBACK_PROVIDER"),
),
)
def test_incomplete_llm_fallback_configuration_raises_error(
self,
monkeypatch,
configured_name: str,
missing_name: str,
) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv(configured_name, "openai")
monkeypatch.delenv(missing_name, raising=False)

with pytest.raises(ConfigurationError, match="must be configured together"):
Settings.from_env()

def test_unsupported_llm_fallback_provider_raises_error(self, monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("LLM_FALLBACK_PROVIDER", "unsupported")
monkeypatch.setenv("LLM_FALLBACK_MODEL", "fallback-model")

with pytest.raises(ConfigurationError, match="Unsupported LLM_FALLBACK_PROVIDER"):
Settings.from_env()

def test_llm_fallback_provider_without_completion_raises_error(self, monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("LLM_FALLBACK_PROVIDER", "voyage")
monkeypatch.setenv("LLM_FALLBACK_MODEL", "fallback-model")

with pytest.raises(ConfigurationError, match="LLM_FALLBACK_PROVIDER does not support completion"):
Settings.from_env()

def test_invalid_float_env_value_raises_error(self, monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("HTTP_TIMEOUT_SECONDS", "not-a-number")
Expand Down
Loading