Files
sentinel-home-ai/fam-edge/tests/test_gemini_adapter.py
ericwyuan 5caeb299a4 refactor(fam-edge): 问答链路抽离到独立 ai-gateway 服务,fam-edge 改为转发客户端
原本嵌在 fam-edge 里的问答模型降级链(NVIDIA 文字模型 -> Gemini 非 flash 文字
模型 -> 本地 Ollama 兜底,含 key 轮换/熔断)跟视频分析业务无关,是通用能力,
抽成独立 ai-gateway 服务(OpenAI 兼容协议),除了 fam-edge 自己,别的项目也能
直接接入。

- qa.py 重写为 HTTP 转发客户端,调 ai-gateway 的 /v1/chat/completions,翻译回
  原有 run_qa/run_qa_stream 契约,api_gateway.py 和 fam-core 调用方零改动
- 删除 model_adapters/ollama_adapter.py 及其测试(问答专用,视频分析不需要本地模型)
- gemini_adapter.py / nvidia_adapter.py 移除 chat()/chat_stream() 及问答专用超时
  (只保留视频分析用的 analyze_video)
- app.py 移除 Ollama 预热逻辑(现在由 ai-gateway 自己负责)
- config.yaml 移除 3 个问答专用 model 条目,新增 ai_gateway 客户端配置块

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:12:01 +08:00

111 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fam_edge.model_adapters.gemini_adapter import GeminiAdapter
def _cfg(**overrides):
base = {
"provider": "gemini",
"model_name": "gemini-flash-latest",
"api_key": "key-primary",
"circuit_breaker": {"enabled": False},
}
base.update(overrides)
return base
def test_single_key_backward_compat():
a = GeminiAdapter(_cfg())
assert a.api_keys == ["key-primary"]
assert a.api_key == "key-primary"
def test_extra_keys_appended_in_order():
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
assert a.api_keys == ["key-primary", "key-2", "key-3", "key-4"]
def test_extra_keys_dedup_against_primary():
a = GeminiAdapter(_cfg(extra_api_keys=["key-primary", "key-2"]))
assert a.api_keys == ["key-primary", "key-2"]
def test_missing_env_var_keys_are_dropped():
"""extra_api_keys 里未设置的 ${ENV_VAR} 解析为空字符串,不应该混进 api_keys 列表。"""
a = GeminiAdapter(_cfg(extra_api_keys=["${SOME_UNSET_GEMINI_KEY_VAR}", "key-2"]))
assert a.api_keys == ["key-primary", "key-2"]
def test_no_keys_at_all():
a = GeminiAdapter(_cfg(api_key=""))
assert a.api_keys == []
assert a.api_key == ""
def test_key_labels_default_to_generic_when_not_configured():
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3"]))
assert a.key_labels == ["key1", "key2", "key3"]
def test_key_labels_use_configured_project_names():
a = GeminiAdapter(_cfg(
extra_api_keys=["key-2", "key-3", "key-4"],
key_labels=["智能摄像头-1", "智能摄像头-2", "智能摄像头-3", "智能摄像头-4"],
))
assert a.key_labels == ["智能摄像头-1", "智能摄像头-2", "智能摄像头-3", "智能摄像头-4"]
def test_key_labels_stay_aligned_when_a_key_is_dropped():
"""如果某个 ${ENV_VAR} 没设置被丢弃,剩下的 key_labels 要跟着剩下的 key 对齐,
不能因为下标错位把别的项目名安到错的 key 上。"""
a = GeminiAdapter(_cfg(
extra_api_keys=["${SOME_UNSET_GEMINI_KEY_VAR}", "key-2"],
key_labels=["智能摄像头-1", "智能摄像头-2", "智能摄像头-3"],
))
assert a.api_keys == ["key-primary", "key-2"]
assert a.key_labels == ["智能摄像头-1", "智能摄像头-3"]
def test_rotated_keys_cascades_from_random_start(monkeypatch):
"""起点由 random.randrange 决定;固定住随机数就能验证级联顺序是"从起点绕一圈""""
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
monkeypatch.setattr(
"fam_edge.model_adapters.gemini_adapter.random.randrange", lambda n: 2)
order = a._rotated_keys()
assert [k for _, k in order] == ["key-3", "key-4", "key-primary", "key-2"]
assert [i for i, _ in order] == [2, 3, 0, 1]
def test_rotated_keys_uses_full_key_range(monkeypatch):
"""random.randrange 的调用范围必须是 len(api_keys),否则会漏掉某些 key 永远选不到。"""
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
seen_n = []
def fake_randrange(n):
seen_n.append(n)
return 0
monkeypatch.setattr(
"fam_edge.model_adapters.gemini_adapter.random.randrange", fake_randrange)
a._rotated_keys()
assert seen_n == [4]
def test_rotated_keys_starts_roughly_uniform_over_many_calls():
"""核心诉求: 每次调用应该是真随机(每个 key 命中概率均等 1/n而不是像旧的
round-robin 那样顺序推进——用大样本统计每个 key 被选为起点的频率,应该接近
1/4且不应该出现某个 key 明显被冷落或独占(对应此前"全部流量压在同一个
key"的 bug"""
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
n_trials = 4000
counts = {"key-primary": 0, "key-2": 0, "key-3": 0, "key-4": 0}
for _ in range(n_trials):
counts[a._rotated_keys()[0][1]] += 1
for key, c in counts.items():
share = c / n_trials
assert 0.20 <= share <= 0.30, f"{key} 起点占比 {share} 明显偏离 1/4"
def test_rotated_keys_single_key_never_errors():
a = GeminiAdapter(_cfg())
for _ in range(3):
assert a._rotated_keys() == [(0, "key-primary")]