改用甲骨文机上已有的 ai-gateway (129.146.203.203:5100):它本身就 OpenAI 兼容,内部串联 nvidia/gemini/ollama 并轮换 4 个 Gemini key, 比在客户端自己串联更能吸收单厂商的配额和超时。回包里的 provider 字段透传为 meta.upstream,网关侧发生降级时前端也看得见。 fix(ai): 目录里两个 NVIDIA 模型 id 根本不存在 - qwen/qwen2.5-72b-instruct 和 deepseek-ai/deepseek-r1 是我凭印象写的, 实际 GET /v1/models 里没有,调用一律 404 - 改为该账号清单里确实存在的 nemotron-49b / mistral-large, 并在注释里写明 id 必须取自实时清单、不能猜 fix(ai): 请求被本机代理劫持导致网关不可达 - requests 默认读 HTTP_PROXY/ALL_PROXY,把发往甲骨文公网 IP 的请求 也塞进了 127.0.0.1:7897,120s 后超时 - 按 provider 区分:境外厂商(Gemini/NVIDIA)仍走代理,自建网关直连 (session.trust_env=False) fix(ai): 承诺的按模型裁剪从未实现 - 模块注释写着 payload 按 (模型窗口, 天数预算) 取小者裁剪,但实际是 用全局预算构建一次 prompt 发给链上所有模型;365 天数据对 Gemini 的 1M 窗口无碍,却会撑爆 128k 的模型 - 新增 max_days_for(),在循环内按各模型窗口分别构建 prompt fix(ai): 推理模型的思考过程吃光输出预算 - 网关首选 nemotron-3-ultra-550b 是推理模型,回答前先输出一段 chain-of-thought;默认 1024 tokens 全被思考占用,JSON 还没开始 就被截断 - max_tokens 改为可按 provider 声明,网关条目给 3000 fix(ai): 配置在 import 时被冻结 - DEFAULT_CHAIN/TIMEOUT/DAY_BUDGET 是模块级常量,改环境变量不生效, 且让开发机 .env 泄漏进测试进程(测试会读到真实 key 和链配置) - 改为 default_chain()/default_timeout()/default_day_budget() 按调用读取 - conftest 增加 autouse fixture 清空全部 AI_* 变量,测试不再继承 .env 测试 (184 passed, 1 skipped): - 新增 TestGatewayProvider: 透传 upstream、目标 URL/鉴权头、 token 失效时继续降级 - 新增 TestProxyPolicy: 境外厂商与自建端点的代理策略相反 - 新增 TestPerModelSizing: 128k 模型收到的 prompt 必须小于 1M 模型 - 新增 TestMaxTokens: 推理端点预算大于默认,且真正写进两种 payload - 新增 TestLazyConfig: 改环境变量立即生效 - mock 目标从 requests.post 改为 requests.Session.post 实测: 网关链路可返回合法 JSON,但 nemotron-550B 排队较久(约 160s), 故 AI_TIMEOUT_SECONDS 默认调到 180。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
680 lines
28 KiB
Python
680 lines
28 KiB
Python
"""
|
|
Unit tests for the multi-provider LLM layer.
|
|
|
|
Every HTTP call is mocked — the suite never touches the network and never
|
|
needs a real API key.
|
|
"""
|
|
import json
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from services import ai as ai_svc
|
|
from services import analysis as analysis_svc
|
|
|
|
|
|
VALID_REPLY = json.dumps(
|
|
[
|
|
{"category": "睡眠", "recommendation": "固定就寝时间,目标 7-8 小时。",
|
|
"priority": "high", "basedOn": ["sleep_duration"]},
|
|
{"category": "运动", "recommendation": "每天增加 20 分钟快走。",
|
|
"priority": "medium", "basedOn": ["steps"]},
|
|
],
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
SUMMARY = [
|
|
{"date": "2026-08-20", "steps": 6500, "heartRate": 70,
|
|
"heartRateVariability": 45, "stress": 55, "caloriesBurned": 260,
|
|
"sleep": {"duration": 6, "quality": 80}},
|
|
{"date": "2026-08-21", "steps": 9000, "heartRate": 62,
|
|
"heartRateVariability": 46, "stress": 40, "caloriesBurned": 360,
|
|
"sleep": {"duration": 8, "quality": 79}},
|
|
]
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, status_code=200, payload=None, text=""):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
self.text = text or json.dumps(payload or {})
|
|
|
|
def json(self):
|
|
if self._payload is None:
|
|
raise ValueError("no json")
|
|
return self._payload
|
|
|
|
|
|
def gemini_payload(text):
|
|
return {"candidates": [{"content": {"parts": [{"text": text}]}}]}
|
|
|
|
|
|
def openai_payload(text):
|
|
return {"choices": [{"message": {"content": text}}]}
|
|
|
|
|
|
@pytest.fixture
|
|
def keys(monkeypatch):
|
|
"""Direct vendor keys configured; the gateway stays out of the chain."""
|
|
monkeypatch.setenv("GEMINI_API_KEY", "test-gemini-key")
|
|
monkeypatch.setenv("NVIDIA_API_KEY", "test-nvidia-key")
|
|
monkeypatch.delenv("AI_GATEWAY_TOKEN", raising=False)
|
|
monkeypatch.delenv("AI_GATEWAY_BASE_URL", raising=False)
|
|
return True
|
|
|
|
|
|
@pytest.fixture
|
|
def no_keys(monkeypatch):
|
|
for var in (
|
|
"GEMINI_API_KEY", "NVIDIA_API_KEY",
|
|
"AI_GATEWAY_TOKEN", "AI_GATEWAY_BASE_URL",
|
|
):
|
|
monkeypatch.delenv(var, raising=False)
|
|
return True
|
|
|
|
|
|
@pytest.fixture
|
|
def gateway(monkeypatch):
|
|
"""Only the self-hosted gateway is configured."""
|
|
monkeypatch.setenv("AI_GATEWAY_TOKEN", "test-gateway-token")
|
|
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gw.test:5100/v1")
|
|
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
|
monkeypatch.delenv("NVIDIA_API_KEY", raising=False)
|
|
return True
|
|
|
|
|
|
# --- prompt construction ----------------------------------------------------
|
|
class TestBuildPrompt:
|
|
def test_includes_every_day_as_a_csv_row(self):
|
|
prompt = ai_svc.build_prompt(SUMMARY)
|
|
assert "2026-08-20" in prompt and "2026-08-21" in prompt
|
|
assert "共 2 天" in prompt
|
|
|
|
def test_uses_csv_not_json(self):
|
|
"""CSV keeps a year of history affordable; JSON would not."""
|
|
prompt = ai_svc.build_prompt(SUMMARY)
|
|
assert "6500,45" in prompt.replace(" ", "") or "6500" in prompt
|
|
assert '"steps":' not in prompt
|
|
|
|
def test_missing_metrics_become_empty_cells_not_the_word_none(self):
|
|
prompt = ai_svc.build_prompt([{"date": "2026-08-20", "steps": None}])
|
|
assert "None" not in prompt
|
|
|
|
def test_sleep_is_flattened_into_columns(self):
|
|
prompt = ai_svc.build_prompt(SUMMARY)
|
|
assert "sleep_h,sleep_q" in prompt
|
|
|
|
def test_day_budget_trims_to_the_most_recent_days(self):
|
|
many = [{"date": f"2026-01-{d:02d}", "steps": d} for d in range(1, 32)]
|
|
prompt = ai_svc.build_prompt(many, day_budget=5)
|
|
assert "共 5 天" in prompt
|
|
assert "2026-01-31" in prompt, "must keep the newest days"
|
|
assert "2026-01-01" not in prompt, "must drop the oldest days"
|
|
|
|
def test_activities_included_when_supplied(self):
|
|
prompt = ai_svc.build_prompt(
|
|
SUMMARY, [{"activity_type": "running", "distance": 5.0}]
|
|
)
|
|
assert "running" in prompt
|
|
|
|
def test_activities_capped(self):
|
|
acts = [{"activity_type": f"run{i}"} for i in range(500)]
|
|
prompt = ai_svc.build_prompt(SUMMARY, acts)
|
|
assert "共 200 条" in prompt
|
|
|
|
def test_prompt_forbids_fabricating_numbers(self):
|
|
assert "不要编造" in ai_svc.build_prompt(SUMMARY)
|
|
|
|
def test_prompt_disclaims_medical_advice(self):
|
|
assert "不是医生" in ai_svc.build_prompt(SUMMARY)
|
|
|
|
def test_a_year_of_data_stays_compact(self):
|
|
year = [
|
|
{"date": f"2026-{m:02d}-{d:02d}", "steps": 8000, "heartRate": 60,
|
|
"sleep": {"duration": 7, "quality": 80}}
|
|
for m in range(1, 13) for d in range(1, 29)
|
|
]
|
|
prompt = ai_svc.build_prompt(year)
|
|
# ~4 chars/token: a year must stay far under even the smallest window.
|
|
assert len(prompt) / 4 < 50_000
|
|
|
|
|
|
# --- response parsing -------------------------------------------------------
|
|
class TestParseRecommendations:
|
|
def test_plain_json_array(self):
|
|
recs = ai_svc.parse_recommendations(VALID_REPLY)
|
|
assert len(recs) == 2
|
|
assert recs[0]["category"] == "睡眠"
|
|
|
|
def test_markdown_fenced_json(self):
|
|
recs = ai_svc.parse_recommendations(f"```json\n{VALID_REPLY}\n```")
|
|
assert len(recs) == 2
|
|
|
|
def test_json_with_a_preamble_sentence(self):
|
|
recs = ai_svc.parse_recommendations(f"好的,分析结果如下:\n{VALID_REPLY}")
|
|
assert len(recs) == 2
|
|
|
|
def test_single_object_is_wrapped(self):
|
|
recs = ai_svc.parse_recommendations(
|
|
'{"category":"睡眠","recommendation":"早点睡","priority":"high"}'
|
|
)
|
|
assert len(recs) == 1
|
|
|
|
def test_results_are_sorted_by_priority(self):
|
|
reply = json.dumps([
|
|
{"category": "a", "recommendation": "low one", "priority": "low"},
|
|
{"category": "b", "recommendation": "high one", "priority": "high"},
|
|
{"category": "c", "recommendation": "medium one", "priority": "medium"},
|
|
])
|
|
assert [r["priority"] for r in ai_svc.parse_recommendations(reply)] == [
|
|
"high", "medium", "low"
|
|
]
|
|
|
|
def test_invalid_priority_defaults_to_medium(self):
|
|
reply = json.dumps([
|
|
{"category": "a", "recommendation": "x", "priority": "URGENT!!"}
|
|
])
|
|
assert ai_svc.parse_recommendations(reply)[0]["priority"] == "medium"
|
|
|
|
def test_entries_without_recommendation_text_are_dropped(self):
|
|
reply = json.dumps([
|
|
{"category": "a", "recommendation": ""},
|
|
{"category": "b", "recommendation": "keep me"},
|
|
])
|
|
recs = ai_svc.parse_recommendations(reply)
|
|
assert len(recs) == 1 and recs[0]["recommendation"] == "keep me"
|
|
|
|
def test_non_list_based_on_is_normalised(self):
|
|
reply = json.dumps([
|
|
{"category": "a", "recommendation": "x", "basedOn": "steps"}
|
|
])
|
|
assert ai_svc.parse_recommendations(reply)[0]["basedOn"] == []
|
|
|
|
def test_results_are_tagged_as_ai_generated(self):
|
|
assert all(r["source"] == "ai" for r in ai_svc.parse_recommendations(VALID_REPLY))
|
|
|
|
@pytest.mark.parametrize(
|
|
"reply", ["", " ", "抱歉,我无法回答。", "[", "null", "[]", "[1,2,3]"]
|
|
)
|
|
def test_unusable_replies_raise_aierror(self, reply):
|
|
with pytest.raises(ai_svc.AIError):
|
|
ai_svc.parse_recommendations(reply)
|
|
|
|
|
|
# --- providers --------------------------------------------------------------
|
|
class TestGeminiProvider:
|
|
def test_successful_call(self, keys, monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_post(self, url, **kwargs):
|
|
captured["url"] = url
|
|
captured["headers"] = kwargs.get("headers", {})
|
|
captured["json"] = kwargs.get("json")
|
|
return FakeResponse(200, gemini_payload("hello"))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
out = ai_svc.CATALOG["gemini-flash"].generate("prompt text")
|
|
|
|
assert out.text == "hello"
|
|
assert "gemini-flash-latest:generateContent" in captured["url"]
|
|
assert captured["headers"]["X-goog-api-key"] == "test-gemini-key"
|
|
assert captured["json"]["contents"][0]["parts"][0]["text"] == "prompt text"
|
|
|
|
def test_http_error_becomes_aierror(self, keys, monkeypatch):
|
|
monkeypatch.setattr(
|
|
requests.Session, "post", lambda *a, **k: FakeResponse(429, text="rate limited")
|
|
)
|
|
with pytest.raises(ai_svc.AIError, match="429"):
|
|
ai_svc.CATALOG["gemini-flash"].generate("p")
|
|
|
|
def test_timeout_becomes_aierror(self, keys, monkeypatch):
|
|
def boom(self, *a, **k):
|
|
raise requests.Timeout("timed out")
|
|
|
|
monkeypatch.setattr(requests.Session, "post", boom)
|
|
with pytest.raises(ai_svc.AIError, match="请求失败"):
|
|
ai_svc.CATALOG["gemini-flash"].generate("p")
|
|
|
|
def test_unexpected_shape_becomes_aierror(self, keys, monkeypatch):
|
|
monkeypatch.setattr(
|
|
requests.Session, "post", lambda *a, **k: FakeResponse(200, {"unexpected": True})
|
|
)
|
|
with pytest.raises(ai_svc.AIError, match="响应格式异常"):
|
|
ai_svc.CATALOG["gemini-flash"].generate("p")
|
|
|
|
def test_missing_key_raises_before_any_request(self, no_keys, monkeypatch):
|
|
def boom(self, *a, **k):
|
|
raise AssertionError("must not issue a request without a key")
|
|
|
|
monkeypatch.setattr(requests.Session, "post", boom)
|
|
with pytest.raises(ai_svc.AIError, match="GEMINI_API_KEY"):
|
|
ai_svc.CATALOG["gemini-flash"].generate("p")
|
|
|
|
|
|
class TestOpenAICompatProvider:
|
|
def test_successful_call(self, keys, monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_post(self, url, **kwargs):
|
|
captured["url"] = url
|
|
captured["headers"] = kwargs.get("headers", {})
|
|
captured["json"] = kwargs.get("json")
|
|
return FakeResponse(200, openai_payload("hi"))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
out = ai_svc.CATALOG["llama-70b"].generate("prompt text")
|
|
|
|
assert out.text == "hi"
|
|
assert out.upstream is None, "stock OpenAI replies carry no provider field"
|
|
assert captured["url"].endswith("/chat/completions")
|
|
assert captured["headers"]["Authorization"] == "Bearer test-nvidia-key"
|
|
assert captured["json"]["model"] == "meta/llama-3.3-70b-instruct"
|
|
|
|
def test_http_error_becomes_aierror(self, keys, monkeypatch):
|
|
monkeypatch.setattr(
|
|
requests.Session, "post", lambda *a, **k: FakeResponse(500, text="boom")
|
|
)
|
|
with pytest.raises(ai_svc.AIError, match="500"):
|
|
ai_svc.CATALOG["llama-70b"].generate("p")
|
|
|
|
|
|
class TestGatewayProvider:
|
|
"""The self-hosted gateway: OpenAI-compatible, plus a `provider` field
|
|
naming whichever upstream actually served the request."""
|
|
|
|
def test_reports_the_upstream_that_answered(self, gateway, monkeypatch):
|
|
payload = {**openai_payload("hi"), "provider": "nvidia"}
|
|
monkeypatch.setattr(requests.Session, "post", lambda *a, **k: FakeResponse(200, payload))
|
|
out = ai_svc.CATALOG["gateway"].generate("p")
|
|
assert out.text == "hi"
|
|
assert out.upstream == "nvidia"
|
|
|
|
def test_targets_the_configured_base_url(self, gateway, monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_post(self, url, **kwargs):
|
|
captured["url"] = url
|
|
captured["headers"] = kwargs.get("headers", {})
|
|
captured["model"] = (kwargs.get("json") or {}).get("model")
|
|
return FakeResponse(200, openai_payload("hi"))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
ai_svc.CATALOG["gateway"].generate("p")
|
|
|
|
assert captured["url"] == "http://gw.test:5100/v1/chat/completions"
|
|
assert captured["headers"]["Authorization"] == "Bearer test-gateway-token"
|
|
assert captured["model"] == "ai-gateway-auto"
|
|
|
|
def test_upstream_surfaces_in_generate_meta(self, gateway, monkeypatch):
|
|
payload = {**openai_payload(VALID_REPLY), "provider": "gemini"}
|
|
monkeypatch.setattr(requests.Session, "post", lambda *a, **k: FakeResponse(200, payload))
|
|
_, meta = ai_svc.generate(SUMMARY)
|
|
assert meta["model"] == "gateway"
|
|
assert meta["upstream"] == "gemini"
|
|
|
|
def test_gateway_401_falls_through(self, monkeypatch):
|
|
"""A stale gateway token must not strand the request."""
|
|
monkeypatch.setenv("AI_GATEWAY_TOKEN", "expired")
|
|
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gw.test:5100/v1")
|
|
monkeypatch.setenv("GEMINI_API_KEY", "k")
|
|
|
|
def fake_post(self, url, **kwargs):
|
|
if "gw.test" in url:
|
|
return FakeResponse(401, text="unauthorized")
|
|
return FakeResponse(200, gemini_payload(VALID_REPLY))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
_, meta = ai_svc.generate(SUMMARY)
|
|
assert meta["model"] == "gemini-flash"
|
|
assert meta["fallbackFrom"] == ["gateway"]
|
|
|
|
|
|
# --- proxy handling ---------------------------------------------------------
|
|
class TestProxyPolicy:
|
|
"""Overseas vendors may only be reachable through a local proxy, while a
|
|
self-hosted box on a public IP breaks when forced through one — so the two
|
|
must not share a policy."""
|
|
|
|
def test_hosted_vendors_honour_environment_proxies(self):
|
|
assert ai_svc.CATALOG["gemini-flash"].use_proxy is True
|
|
assert ai_svc.CATALOG["llama-70b"].use_proxy is True
|
|
|
|
def test_self_hosted_gateway_bypasses_proxies(self):
|
|
assert ai_svc.CATALOG["gateway"].use_proxy is False
|
|
|
|
def test_session_trust_env_follows_the_flag(self):
|
|
"""Regression: requests picked up ALL_PROXY and routed the gateway
|
|
call through a local proxy, which timed out after 120s."""
|
|
assert ai_svc.CATALOG["gateway"]._session().trust_env is False
|
|
assert ai_svc.CATALOG["gemini-flash"]._session().trust_env is True
|
|
|
|
|
|
# --- output budget ----------------------------------------------------------
|
|
class TestMaxTokens:
|
|
def test_default_applies_to_ordinary_models(self):
|
|
assert ai_svc.CATALOG["gemini-flash"].max_tokens == ai_svc.FALLBACK_MAX_TOKENS
|
|
|
|
def test_reasoning_endpoint_declares_a_larger_budget(self):
|
|
"""Regression: the gateway's primary upstream thinks out loud before
|
|
answering; at the default cap the trace consumed the whole budget and
|
|
the reply was truncated before any JSON appeared."""
|
|
assert ai_svc.CATALOG["gateway"].max_tokens > ai_svc.FALLBACK_MAX_TOKENS
|
|
|
|
def test_env_overrides_the_default_but_not_an_explicit_budget(self, monkeypatch):
|
|
monkeypatch.setenv("AI_MAX_TOKENS", "77")
|
|
assert ai_svc.CATALOG["gemini-flash"].max_tokens == 77
|
|
assert ai_svc.CATALOG["gateway"].max_tokens == 3000
|
|
|
|
def test_budget_reaches_the_openai_payload(self, gateway, monkeypatch):
|
|
seen = {}
|
|
|
|
def fake_post(self, url, **kwargs):
|
|
seen["max_tokens"] = kwargs["json"]["max_tokens"]
|
|
return FakeResponse(200, openai_payload("hi"))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
ai_svc.CATALOG["gateway"].generate("p")
|
|
assert seen["max_tokens"] == 3000
|
|
|
|
def test_budget_reaches_the_gemini_payload(self, keys, monkeypatch):
|
|
seen = {}
|
|
|
|
def fake_post(self, url, **kwargs):
|
|
seen["cap"] = kwargs["json"]["generationConfig"]["maxOutputTokens"]
|
|
return FakeResponse(200, gemini_payload("hi"))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
ai_svc.CATALOG["gemini-flash"].generate("p")
|
|
assert seen["cap"] == ai_svc.FALLBACK_MAX_TOKENS
|
|
|
|
|
|
# --- lazily-read configuration ----------------------------------------------
|
|
class TestLazyConfig:
|
|
"""Regression: these were module-level constants, so they froze whatever
|
|
the environment held at import — hiding config changes and letting a
|
|
developer's .env leak into the test run."""
|
|
|
|
def test_chain_reflects_the_current_environment(self, monkeypatch):
|
|
monkeypatch.setenv("AI_MODEL_CHAIN", "llama-70b,gemini-flash")
|
|
assert ai_svc.default_chain() == ["llama-70b", "gemini-flash"]
|
|
monkeypatch.setenv("AI_MODEL_CHAIN", "gateway")
|
|
assert ai_svc.default_chain() == ["gateway"]
|
|
|
|
def test_timeout_reflects_the_current_environment(self, monkeypatch):
|
|
monkeypatch.setenv("AI_TIMEOUT_SECONDS", "7")
|
|
assert ai_svc.default_timeout() == 7.0
|
|
|
|
def test_day_budget_reflects_the_current_environment(self, monkeypatch):
|
|
monkeypatch.setenv("AI_DAY_BUDGET", "42")
|
|
assert ai_svc.default_day_budget() == 42
|
|
|
|
def test_defaults_apply_when_unset(self):
|
|
assert ai_svc.default_timeout() == ai_svc.FALLBACK_TIMEOUT
|
|
assert ai_svc.default_day_budget() == ai_svc.FALLBACK_DAY_BUDGET
|
|
assert ai_svc.default_chain()[0] == "gateway"
|
|
|
|
def test_default_flag_tracks_the_chain_head(self, monkeypatch, keys):
|
|
monkeypatch.setenv("AI_MODEL_CHAIN", "llama-70b,gemini-flash")
|
|
by_id = {m["id"]: m for m in ai_svc.list_models()}
|
|
assert by_id["llama-70b"]["default"] is True
|
|
assert by_id["gemini-flash"]["default"] is False
|
|
|
|
|
|
# --- context sizing ---------------------------------------------------------
|
|
class TestPerModelSizing:
|
|
"""Chain members' windows differ by >30x, so the payload is sized per
|
|
model rather than once for the whole chain."""
|
|
|
|
def test_small_window_gets_fewer_days_than_a_large_one(self):
|
|
# A budget above what 128k can hold, so the window is what binds.
|
|
budget = 100_000
|
|
small = ai_svc.max_days_for(ai_svc.CATALOG["llama-70b"], budget) # 128k
|
|
large = ai_svc.max_days_for(ai_svc.CATALOG["gemini-flash"], budget) # 1M
|
|
assert small < large
|
|
|
|
def test_budget_binds_when_it_is_the_tighter_limit(self):
|
|
"""At the default 365-day budget every model gets the same 365 days —
|
|
no window in the catalog is small enough to bind first."""
|
|
budget = ai_svc.default_day_budget()
|
|
days = {
|
|
mid: ai_svc.max_days_for(p, budget) for mid, p in ai_svc.CATALOG.items()
|
|
}
|
|
assert set(days.values()) == {budget}
|
|
|
|
def test_never_exceeds_the_configured_budget(self):
|
|
assert ai_svc.max_days_for(ai_svc.CATALOG["gemini-flash"], day_budget=30) == 30
|
|
|
|
def test_always_allows_at_least_one_day(self):
|
|
tiny = ai_svc.OpenAICompatProvider(
|
|
model_id="tiny", context_window=10,
|
|
base_url_env="X", default_base_url="http://x", requires_key=False,
|
|
)
|
|
assert ai_svc.max_days_for(tiny) >= 1
|
|
|
|
def test_each_model_gets_a_prompt_sized_for_itself(self, keys, monkeypatch):
|
|
"""Regression: one prompt was built for the whole chain, so a payload
|
|
sized for Gemini's 1M window was also sent to 128k models.
|
|
|
|
Needs more days than the 128k window holds (~6.4k) for the trimming to
|
|
bite, hence the deliberately oversized history.
|
|
"""
|
|
history = [{"date": "2026-01-01", "steps": 8000} for _ in range(8000)]
|
|
sizes = {}
|
|
|
|
def fake_post(self, url, **kwargs):
|
|
if "generativelanguage" in url:
|
|
sizes["gemini"] = len(kwargs["json"]["contents"][0]["parts"][0]["text"])
|
|
raise requests.Timeout("force fallback")
|
|
sizes["nvidia"] = len(kwargs["json"]["messages"][0]["content"])
|
|
return FakeResponse(200, openai_payload(VALID_REPLY))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
ai_svc.generate(history, day_budget=100_000)
|
|
|
|
assert sizes["nvidia"] < sizes["gemini"], (
|
|
"the 128k model must receive a smaller prompt than the 1M model"
|
|
)
|
|
|
|
|
|
# --- catalog & chain --------------------------------------------------------
|
|
class TestCatalog:
|
|
def test_all_models_listed(self, keys):
|
|
assert {m["id"] for m in ai_svc.list_models()} == {
|
|
"gateway", "gemini-flash", "llama-70b", "nemotron-49b", "mistral-large"
|
|
}
|
|
|
|
def test_configured_flag_tracks_the_environment(self, no_keys, monkeypatch):
|
|
assert all(not m["configured"] for m in ai_svc.list_models())
|
|
monkeypatch.setenv("GEMINI_API_KEY", "k")
|
|
by_id = {m["id"]: m for m in ai_svc.list_models()}
|
|
assert by_id["gemini-flash"]["configured"] is True
|
|
assert by_id["llama-70b"]["configured"] is False
|
|
|
|
def test_every_model_declares_a_large_window(self):
|
|
assert all(m["contextWindow"] >= 128_000 for m in ai_svc.list_models())
|
|
|
|
def test_no_vision_models_registered(self):
|
|
assert not any("vision" in m["model"] for m in ai_svc.list_models())
|
|
|
|
|
|
class TestResolveChain:
|
|
def test_preferred_model_goes_first(self, keys):
|
|
assert ai_svc.resolve_chain("nemotron-49b")[0] == "nemotron-49b"
|
|
|
|
def test_chain_has_no_duplicates(self, keys):
|
|
chain = ai_svc.resolve_chain("gemini-flash")
|
|
assert len(chain) == len(set(chain))
|
|
|
|
def test_unconfigured_models_are_skipped(self, no_keys, monkeypatch):
|
|
monkeypatch.setenv("GEMINI_API_KEY", "k")
|
|
assert ai_svc.resolve_chain() == ["gemini-flash"]
|
|
|
|
def test_unknown_model_raises(self, keys):
|
|
with pytest.raises(ai_svc.AIError, match="未知模型"):
|
|
ai_svc.resolve_chain("gpt-nonexistent")
|
|
|
|
def test_no_credentials_raises_with_actionable_message(self, no_keys):
|
|
with pytest.raises(ai_svc.AIError, match="GEMINI_API_KEY"):
|
|
ai_svc.resolve_chain()
|
|
|
|
def test_gateway_needs_both_token_and_base_url(self, no_keys, monkeypatch):
|
|
monkeypatch.setenv("AI_GATEWAY_TOKEN", "t")
|
|
assert ai_svc.CATALOG["gateway"].is_configured() is False
|
|
monkeypatch.setenv("AI_GATEWAY_BASE_URL", "http://gw.test:5100/v1")
|
|
assert ai_svc.CATALOG["gateway"].is_configured() is True
|
|
|
|
|
|
# --- generate + fallback ----------------------------------------------------
|
|
class TestGenerate:
|
|
def test_returns_recommendations_and_meta(self, keys, monkeypatch):
|
|
monkeypatch.setattr(
|
|
requests.Session, "post", lambda *a, **k: FakeResponse(200, gemini_payload(VALID_REPLY))
|
|
)
|
|
recs, meta = ai_svc.generate(SUMMARY)
|
|
assert len(recs) == 2
|
|
assert meta["model"] == "gemini-flash"
|
|
assert meta["days"] == 2
|
|
assert meta["fallbackFrom"] == []
|
|
|
|
def test_falls_back_to_the_next_model(self, keys, monkeypatch):
|
|
calls = []
|
|
|
|
def fake_post(self, url, **kwargs):
|
|
calls.append(url)
|
|
if "generativelanguage" in url:
|
|
raise requests.Timeout("gemini down")
|
|
return FakeResponse(200, openai_payload(VALID_REPLY))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
recs, meta = ai_svc.generate(SUMMARY)
|
|
|
|
assert len(recs) == 2
|
|
assert meta["model"] == "llama-70b"
|
|
assert meta["fallbackFrom"] == ["gemini-flash"]
|
|
assert len(calls) == 2
|
|
|
|
def test_falls_back_when_a_model_returns_unparseable_text(self, keys, monkeypatch):
|
|
def fake_post(self, url, **kwargs):
|
|
if "generativelanguage" in url:
|
|
return FakeResponse(200, gemini_payload("抱歉,我帮不了你。"))
|
|
return FakeResponse(200, openai_payload(VALID_REPLY))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
_, meta = ai_svc.generate(SUMMARY)
|
|
assert meta["model"] == "llama-70b"
|
|
|
|
def test_raises_when_every_model_fails(self, keys, monkeypatch):
|
|
def boom(self, *a, **k):
|
|
raise requests.Timeout("all down")
|
|
|
|
monkeypatch.setattr(requests.Session, "post", boom)
|
|
with pytest.raises(ai_svc.AIError, match="所有模型均失败"):
|
|
ai_svc.generate(SUMMARY)
|
|
|
|
def test_preferred_model_is_honoured(self, keys, monkeypatch):
|
|
seen = {}
|
|
|
|
def fake_post(self, url, **kwargs):
|
|
seen["model"] = (kwargs.get("json") or {}).get("model")
|
|
return FakeResponse(200, openai_payload(VALID_REPLY))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
_, meta = ai_svc.generate(SUMMARY, preferred_model="nemotron-49b")
|
|
assert meta["model"] == "nemotron-49b"
|
|
assert seen["model"] == "nvidia/llama-3.3-nemotron-super-49b-v1.5"
|
|
|
|
def test_no_second_call_after_the_first_succeeds(self, keys, monkeypatch):
|
|
calls = []
|
|
|
|
def fake_post(self, url, **kwargs):
|
|
calls.append(url)
|
|
return FakeResponse(200, gemini_payload(VALID_REPLY))
|
|
|
|
monkeypatch.setattr(requests.Session, "post", fake_post)
|
|
ai_svc.generate(SUMMARY)
|
|
assert len(calls) == 1
|
|
|
|
|
|
# --- service + endpoint integration -----------------------------------------
|
|
class TestAiRecommendationsService:
|
|
def test_uses_the_rule_engine_when_there_is_no_data(self, db, user, keys):
|
|
out = analysis_svc.get_ai_recommendations(user["id"])
|
|
assert out["meta"]["source"] == "rules"
|
|
assert out["recommendations"][0]["id"] == "no-data"
|
|
|
|
def test_returns_ai_results_when_a_model_answers(
|
|
self, seed_health, user, keys, monkeypatch
|
|
):
|
|
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
|
monkeypatch.setattr(
|
|
requests.Session, "post", lambda *a, **k: FakeResponse(200, gemini_payload(VALID_REPLY))
|
|
)
|
|
out = analysis_svc.get_ai_recommendations(user["id"])
|
|
assert out["meta"]["source"] == "ai"
|
|
assert len(out["recommendations"]) == 2
|
|
|
|
def test_degrades_to_rules_when_all_models_fail(
|
|
self, seed_health, user, keys, monkeypatch
|
|
):
|
|
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
|
|
|
def boom(self, *a, **k):
|
|
raise requests.Timeout("down")
|
|
|
|
monkeypatch.setattr(requests.Session, "post", boom)
|
|
out = analysis_svc.get_ai_recommendations(user["id"])
|
|
assert out["meta"]["source"] == "rules"
|
|
assert "所有模型均失败" in out["meta"]["reason"]
|
|
assert out["recommendations"], "must still return rule-based advice"
|
|
|
|
def test_degrades_to_rules_when_no_key_is_configured(
|
|
self, seed_health, user, no_keys
|
|
):
|
|
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
|
out = analysis_svc.get_ai_recommendations(user["id"])
|
|
assert out["meta"]["source"] == "rules"
|
|
assert "GEMINI_API_KEY" in out["meta"]["reason"]
|
|
|
|
|
|
class TestEndpoints:
|
|
def test_models_requires_auth(self, client):
|
|
assert client.get("/api/analysis/models").status_code == 401
|
|
|
|
def test_ai_recommendations_requires_auth(self, client):
|
|
assert client.get("/api/analysis/ai-recommendations").status_code == 401
|
|
|
|
def test_models_endpoint_lists_catalog(self, client, auth, keys):
|
|
r = client.get("/api/analysis/models", headers=auth)
|
|
assert r.status_code == 200
|
|
assert {m["id"] for m in r.get_json()} >= {"gemini-flash", "llama-70b"}
|
|
|
|
def test_models_endpoint_never_leaks_api_keys(self, client, auth, keys):
|
|
body = client.get("/api/analysis/models", headers=auth).get_data(as_text=True)
|
|
assert "test-gemini-key" not in body
|
|
assert "test-nvidia-key" not in body
|
|
|
|
def test_ai_endpoint_returns_200_even_with_no_models(self, client, auth, no_keys):
|
|
r = client.get("/api/analysis/ai-recommendations", headers=auth)
|
|
assert r.status_code == 200
|
|
assert r.get_json()["meta"]["source"] == "rules"
|
|
|
|
def test_ai_endpoint_passes_model_param_through(
|
|
self, client, auth, seed_health, keys, monkeypatch
|
|
):
|
|
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
|
monkeypatch.setattr(
|
|
requests.Session, "post", lambda *a, **k: FakeResponse(200, openai_payload(VALID_REPLY))
|
|
)
|
|
r = client.get(
|
|
"/api/analysis/ai-recommendations?model=nemotron-49b", headers=auth
|
|
)
|
|
assert r.get_json()["meta"]["model"] == "nemotron-49b"
|
|
|
|
def test_unknown_model_param_degrades_to_rules(
|
|
self, client, auth, seed_health, keys
|
|
):
|
|
seed_health([{"date": "2026-08-20", "steps": 5000}])
|
|
r = client.get("/api/analysis/ai-recommendations?model=bogus", headers=auth)
|
|
assert r.status_code == 200
|
|
assert r.get_json()["meta"]["source"] == "rules"
|