Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ See [`docs/jma-office-codes.md`](docs/jma-office-codes.md) for the forecast offi
At minimum, configure the following in `.env`:

- `LLM_PROVIDER` and `LLM_MODEL`;
- optionally, both `LLM_FALLBACK_PROVIDER` and `LLM_FALLBACK_MODEL`;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
- the credentials required by your chosen model service;
- for Telegram delivery: `PUBLISHER=telegram`, `TELEGRAM_BOT_TOKEN`, and `TELEGRAM_CHAT_ID`; or
- for Bark delivery: `PUBLISHER=bark` and `BARK_DEVICE_KEY`, plus both `BARK_ENCRYPTION_KEY` and `BARK_ENCRYPTION_IV` when encryption is enabled.
Expand All @@ -127,6 +128,8 @@ To enable it, set both `BARK_ENCRYPTION_KEY` and `BARK_ENCRYPTION_IV`. Follow th

Model calls are handled by any-llm. The credential variables needed by each service follow the [any-llm provider documentation](https://docs.mozilla.ai/any-llm/providers). The official image ships with the components required for DeepSeek, OpenAI, and OpenRouter.

When a fallback is configured, request failures from the primary provider are retried once with it. Responses that violate the structured output contract stay with the provider that returned them and use the normal bounded repair attempts.

RSS is optional and is not mounted by default. To enable RSS, create `rss-sources.json` based on [`rss-sources.example.json`](rss-sources.example.json), then add source names, URLs, and applicable locations. Add the following option to the `docker run` command before the image name:

```sh
Expand Down
3 changes: 3 additions & 0 deletions README_ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ NEA または JMA の予報内容が Open-Meteo と異なる場合は、現地
`.env` に最低限以下を記入してください:

- `LLM_PROVIDER` と `LLM_MODEL`。
- オプションの `LLM_FALLBACK_PROVIDER` と `LLM_FALLBACK_MODEL`(両方を設定)。
- 選択したモデルサービスに必要な認証情報。
- Telegram で配信する場合は `PUBLISHER=telegram`、`TELEGRAM_BOT_TOKEN`、`TELEGRAM_CHAT_ID`。または
- Bark で配信する場合は `PUBLISHER=bark` と `BARK_DEVICE_KEY`。暗号化を有効にする場合は `BARK_ENCRYPTION_KEY` と `BARK_ENCRYPTION_IV` の両方。
Expand All @@ -127,6 +128,8 @@ Bark は、暗号化キーと IV を設定しなければ平文で送信しま

モデル呼び出しは any-llm によって処理されます。各サービスに必要な認証情報の変数は [any-llm プロバイダドキュメント](https://docs.mozilla.ai/any-llm/providers)を参照してください。公式イメージには DeepSeek、OpenAI、OpenRouter に必要なコンポーネントが同梱されています。

フォールバックを設定すると、プライマリープロバイダーのリクエスト失敗時に一度だけ再試行します。構造化出力契約に違反する応答はプロバイダーを切り替えず、通常の回数制限付き修復を行います。

RSS はオプションで、デフォルトではマウントされません。有効にする場合は、[`rss-sources.example.json`](rss-sources.example.json) を参考に `rss-sources.json` を作成し、ソース名・URL・対象地点を記入します。そのうえで、以下のオプションを `docker run` コマンドのイメージ名より前に追加してください:

```sh
Expand Down
3 changes: 3 additions & 0 deletions README_zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ NEA 或 JMA 与 Open-Meteo 在同一时间和地区发生内容冲突时,简
`.env` 中至少要填写:

- `LLM_PROVIDER` 和 `LLM_MODEL`;
- 可选的 `LLM_FALLBACK_PROVIDER` 和 `LLM_FALLBACK_MODEL`,必须同时配置;
- 对应模型服务要求的凭据;
- Telegram 投递所需的 `PUBLISHER=telegram`、`TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID`;或
- Bark 投递所需的 `PUBLISHER=bark` 和 `BARK_DEVICE_KEY`;启用加密时还要同时配置 `BARK_ENCRYPTION_KEY` 和 `BARK_ENCRYPTION_IV`。
Expand All @@ -127,6 +128,8 @@ NEA 或 JMA 与 Open-Meteo 在同一时间和地区发生内容冲突时,简

模型调用由 any-llm 处理。不同服务需要的凭据变量以 [any-llm provider 文档](https://docs.mozilla.ai/any-llm/providers) 为准。官方镜像预装 DeepSeek、OpenAI 和 OpenRouter 所需组件。

配置备用模型后,主提供商请求失败时会用备用提供商重试一次。已经返回但不符合结构化输出契约的正文仍由原提供商进入有界修复,不触发跨提供商切换。

RSS 是可选功能,默认不会挂载。需要启用时,参考 [`rss-sources.example.json`](rss-sources.example.json) 创建 `rss-sources.json`,填写来源名称、URL 和适用地点,再把以下选项添加到 `docker run` 命令,并放在镜像名称之前:

```sh
Expand Down
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` 作为一组解析,并为 DeepSeek 备用适配器复用兼容的凭据与端点归一化。组合根用 `FallbackLLMProvider` 包装两个独立持有 SDK 资源的适配器;首次主适配器请求失败后,该包装器在生命周期内固定使用备用适配器。关闭包装器时会尝试释放两侧资源,并保留全部清理异常。
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## 调度与投递

`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 @@ -56,6 +56,14 @@ any-llm 当前没有统一的关闭接口。应用先尝试 provider 的 `aclose

any-llm 提供稳定的统一生命周期接口后,应删除属性探测并直接使用该接口。

## 模型故障切换在进程内保持粘性

any-llm 负责单个 provider 的统一调用,不编排跨 provider 的故障切换。应用因此用一个薄包装器捕获主适配器的 `LLMRequestError`,切换后在该包装器剩余生命周期内固定使用备用适配器。它替代在每个调用点手写故障切换分支,不替代 any-llm 的厂商适配器。这样同一进程里的契约修复不会回到刚刚失败的主服务,也不需要在领域编排中加入厂商分支。

这个选择假设一次 CLI 进程或 daemon 中的一次主服务请求失败,足以让后续调用继续使用备用服务;恢复主服务交给下次创建模型对象。包装器不接管 SDK 凭据、请求重试或输出验证。

如果 any-llm 提供可观察的跨 provider 路由,或者常驻进程需要在不重启的情况下探测并恢复主服务,就用带健康状态和冷却时间的路由替换当前粘性开关。

## 未知预警解除 ID 不阻断简报

模型可能根据历史内容返回已经过期或不存在的预警 ID。这些 ID 不会删除任何状态,也不会出现在用户正文中。
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
3 changes: 3 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
# bases use the environment names documented by any-llm for that provider.
LLM_PROVIDER=deepseek
LLM_MODEL=deepseek-v4-flash
# Optional request-failure fallback. Configure both values or neither.
# LLM_FALLBACK_PROVIDER=openai
# LLM_FALLBACK_MODEL=gpt-5-mini
# 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
71 changes: 71 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,37 @@ 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


@pytest.mark.parametrize("base_name", ("DEEPSEEK_API_BASE", "DEEPSEEK_BASE_URL"))
def test_deepseek_fallback_uses_normalized_connection_settings(monkeypatch, base_name: str) -> None:
_required_environment(monkeypatch)
monkeypatch.setenv("LLM_PROVIDER", "openai")
monkeypatch.setenv("LLM_MODEL", "primary-model")
monkeypatch.setenv("LLM_FALLBACK_PROVIDER", "deepseek")
monkeypatch.setenv("LLM_FALLBACK_MODEL", "deepseek-fallback")
monkeypatch.setenv("DEEPSEEK_API_KEY", "'fallback-key'")
monkeypatch.delenv("DEEPSEEK_API_BASE", raising=False)
monkeypatch.delenv("DEEPSEEK_BASE_URL", raising=False)
monkeypatch.setenv(base_name, "https://deepseek.example/v1/")

settings = Settings.from_env()

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


def test_deepseek_model_name_remains_compatible(monkeypatch) -> None:
_required_environment(monkeypatch)
monkeypatch.delenv("LLM_MODEL")
Expand Down Expand Up @@ -1197,6 +1232,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