""" tests/test_llm_model_router.py ───────────────────────────────────────────────────────────────── Operation Ollama-First v5.0 / Phase 21 — Caller × Context 動態路由驗證 """ import pytest @pytest.fixture(autouse=True) def _reset_env(monkeypatch): monkeypatch.delenv('MODEL_ROUTER_ENABLED', raising=False) yield # ═══════════════════════════════════════════════════════════════════════════ # T1: feature flag OFF 時不路由(向下相容) # ═══════════════════════════════════════════════════════════════════════════ def test_flag_off_returns_default(): from services.llm_model_router import select_model # flag OFF 直接回 default(不評估規則) result = select_model( caller='sales_copy', context={'expected_length': 50}, default='llama3.1:8b', ) assert result == 'llama3.1:8b' def test_flag_off_unknown_caller_returns_default(): from services.llm_model_router import select_model result = select_model(caller='nonexistent', default='hermes3:latest') assert result == 'hermes3:latest' # ═══════════════════════════════════════════════════════════════════════════ # T2: sales_copy 路由(短文 vs 長文) # ═══════════════════════════════════════════════════════════════════════════ def test_sales_copy_short_text_routes_to_gemma3(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model # 50 字短文 → gemma3:4b 輕量 result = select_model( caller='sales_copy', context={'expected_length': 50}, default='llama3.1:8b', ) assert result == 'gemma3:4b' def test_sales_copy_long_text_routes_to_llama(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model result = select_model( caller='sales_copy', context={'expected_length': 200}, default='llama3.1:8b', ) assert result == 'llama3.1:8b' def test_sales_copy_no_length_falls_back_to_default(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model # 沒給 expected_length → 規則 1 不觸發 → 規則 2 always True → 回 llama3.1:8b result = select_model( caller='sales_copy', context={}, default='llama3.1:8b', ) assert result == 'llama3.1:8b' # ═══════════════════════════════════════════════════════════════════════════ # T3: Hermes 競價(簡單 vs 複雜 SKU) # ═══════════════════════════════════════════════════════════════════════════ def test_hermes_simple_routes_to_hermes3(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model result = select_model( caller='hermes_analyst', context={'max_gap_pct': 5.2, 'min_sales_delta': -10.0}, default='hermes3:latest', ) assert result == 'hermes3:latest' def test_hermes_high_gap_routes_to_qwen3(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model # gap > 20% → 升 qwen3:14b result = select_model( caller='hermes_analyst', context={'max_gap_pct': 25.0, 'min_sales_delta': -5.0}, default='hermes3:latest', ) assert result == 'qwen3:14b' def test_hermes_sales_crash_routes_to_qwen3(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model # 銷量 < -50% → 升 qwen3:14b result = select_model( caller='hermes_analyst', context={'max_gap_pct': 5.0, 'min_sales_delta': -60.0}, default='hermes3:latest', ) assert result == 'qwen3:14b' # ═══════════════════════════════════════════════════════════════════════════ # T4: AiderHeal(簡單 vs 重構) # ═══════════════════════════════════════════════════════════════════════════ def test_aider_heal_small_diff_routes_to_7b(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model result = select_model( caller='aider_heal', context={'diff_lines': 50}, default='qwen2.5-coder:7b', ) assert result == 'qwen2.5-coder:7b' def test_aider_heal_large_refactor_routes_to_32b(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model # diff > 200 行 → 32b 重構級 result = select_model( caller='aider_heal', context={'diff_lines': 350}, default='qwen2.5-coder:7b', ) assert result == 'qwen2.5-coder:32b' # ═══════════════════════════════════════════════════════════════════════════ # T5: PPT vision(主備援) # ═══════════════════════════════════════════════════════════════════════════ def test_ppt_vision_normal_routes_to_minicpm(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model result = select_model( caller='ppt_vision', context={}, default='minicpm-v:latest', ) assert result == 'minicpm-v:latest' def test_ppt_vision_minicpm_unhealthy_routes_to_llava(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model result = select_model( caller='ppt_vision', context={'minicpm_unhealthy': True}, default='minicpm-v:latest', ) assert result == 'llava:latest' # ═══════════════════════════════════════════════════════════════════════════ # T6: EA engine(推理需求 → deepseek-r1) # ═══════════════════════════════════════════════════════════════════════════ def test_ea_engine_no_cot_returns_default(monkeypatch): """EA 不需要深推理時走免費 Ollama Hermes,不得回 Gemini 預設。""" monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model result = select_model( caller='ea_engine', context={'require_chain_of_thought': False}, default='gemini-2.0-flash', ) assert result == 'hermes3:latest' def test_ea_engine_rejects_gemini_default_even_when_router_disabled(monkeypatch): monkeypatch.delenv('MODEL_ROUTER_ENABLED', raising=False) from services.llm_model_router import select_model result = select_model( caller='ea_engine', context={'require_chain_of_thought': False}, default='gemini-2.0-flash', ) assert result == 'hermes3:latest' def test_ea_engine_cot_routes_to_deepseek_r1(monkeypatch): monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model result = select_model( caller='ea_engine', context={'require_chain_of_thought': True}, default='gemini-2.0-flash', ) assert result == 'deepseek-r1:14b' # ═══════════════════════════════════════════════════════════════════════════ # T7: 規則例外不阻擋(容錯) # ═══════════════════════════════════════════════════════════════════════════ def test_predicate_exception_skipped_to_next_rule(monkeypatch): """predicate 拋例外應 skip 到下一條(不 raise 給 caller)""" monkeypatch.setenv('MODEL_ROUTER_ENABLED', 'true') from services.llm_model_router import select_model # context 給非數字會讓 int() 拋例外 # 規則 1 期待 expected_length 可 int 化;給 'abc' 會炸 # 但規則應 catch + skip 到規則 2 (always True → llama3.1:8b) result = select_model( caller='sales_copy', context={'expected_length': 'abc'}, # 故意給壞值 default='llama3.1:8b', ) # 結果:規則 1 失敗(int('abc') raise)→ skip → 規則 2 命中 → 'llama3.1:8b' assert result == 'llama3.1:8b' # ═══════════════════════════════════════════════════════════════════════════ # T8: utility 函數 # ═══════════════════════════════════════════════════════════════════════════ def test_list_routes_for_known_caller(): from services.llm_model_router import list_routes_for_caller sales_routes = list_routes_for_caller('sales_copy') assert 'gemma3:4b' in sales_routes assert 'llama3.1:8b' in sales_routes def test_list_routes_for_unknown_caller(): from services.llm_model_router import list_routes_for_caller assert list_routes_for_caller('nonexistent') == [] def test_all_callers_with_routes(): from services.llm_model_router import all_callers_with_routes callers = all_callers_with_routes() expected = {'sales_copy', 'hermes_analyst', 'aider_heal', 'openclaw_qa', 'ppt_vision', 'ea_engine'} assert expected.issubset(set(callers))