#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ services/llm_model_router.py Operation Ollama-First v5.0 / Phase 21 — Caller × Context 動態 Model Router 設計原則: - 不同 caller 在不同 context 下動態選擇最佳 model(同 provider) 例:sales_copy 短文 → gemma3:4b / 長文 → llama3.1:8b / Hermes 複雜 SKU → qwen3:14b - 純規則引擎,零 LLM 成本 - caller 透過 select_model(caller, context) 取 model name - feature flag MODEL_ROUTER_ENABLED 預設 OFF(不影響既有預設值) - 失敗 fallback:規則沒命中 → 回 caller 預設 model(向下相容) 對應 ADR-028 caller 白名單 + ADR-034 動態路由(待寫)。 GCP Primary + Secondary 已備齊 10 模型支援所有路由規則。 """ from __future__ import annotations import os import logging from typing import Dict, Any, Optional, Callable logger = logging.getLogger(__name__) def is_model_router_enabled() -> bool: """Runtime check(避免 import-time freeze)""" return os.getenv('MODEL_ROUTER_ENABLED', 'false').strip().lower() in ('true', '1', 'yes', 'on') # ───────────────────────────────────────────────────────────────────────────── # Routing 規則(ADR-034 規格) # ───────────────────────────────────────────────────────────────────────────── # 結構:caller → list of (predicate(context) → model_name) tuples # 取第一個 predicate 回 True 的 model;都不命中 → None(caller 用預設) # ───────────────────────────────────────────────────────────────────────────── ROUTING_RULES: Dict[str, list] = { # Sales Copy: 短文走 gemma3:4b(輕量快),長文走 llama3.1:8b 'sales_copy': [ (lambda ctx: int(ctx.get('expected_length', 0) or 0) > 0 and int(ctx.get('expected_length', 0)) < 100, 'gemma3:4b'), (lambda ctx: True, # 預設 'llama3.1:8b'), ], # Hermes 競價:簡單比價走 hermes3,複雜分析(gap > 20% 或銷量大跌)升 qwen3:14b 'hermes_analyst': [ (lambda ctx: float(ctx.get('max_gap_pct', 0) or 0) > 20 or float(ctx.get('min_sales_delta', 0) or 0) < -50, 'qwen3:14b'), (lambda ctx: True, 'hermes3:latest'), ], # AiderHeal: 簡單 syntax fix 走 qwen2.5-coder:7b,重構級(diff > 200 行)升 32b 'aider_heal': [ (lambda ctx: int(ctx.get('diff_lines', 0) or 0) > 200, 'qwen2.5-coder:32b'), (lambda ctx: True, 'qwen2.5-coder:7b'), ], # OpenClaw Q&A: 簡單問題走 qwen2.5:7b-instruct,複雜走 qwen3:14b 'openclaw_qa': [ (lambda ctx: int(ctx.get('query_length', 0) or 0) > 200 or bool(ctx.get('multi_turn', False)), 'qwen3:14b'), (lambda ctx: True, 'qwen2.5:7b-instruct'), ], # PPT vision: 主用 minicpm-v,主機標 unhealthy 時切 llava 'ppt_vision': [ (lambda ctx: bool(ctx.get('minicpm_unhealthy', False)), 'llava:latest'), (lambda ctx: True, 'minicpm-v:latest'), ], # 推理增強場景(EA HITL 戰略決策;Gemini 不可作為預設模型) 'ea_engine': [ (lambda ctx: bool(ctx.get('require_chain_of_thought', False)), 'deepseek-r1:14b'), (lambda ctx: True, 'hermes3:latest'), ], } _CALLER_SAFE_DEFAULT_MODELS = { 'ea_engine': 'hermes3:latest', } def _sanitize_default_model(caller: str, default: Optional[str]) -> Optional[str]: """Model router must not hand Gemini back as an Ollama-route default.""" if default and default.strip().lower().startswith('gemini'): safe_default = _CALLER_SAFE_DEFAULT_MODELS.get(caller) if safe_default: logger.warning( "[ModelRouter] %s default=%s rejected; using %s", caller, default, safe_default, ) return safe_default return default def select_model( caller: str, context: Optional[Dict[str, Any]] = None, default: Optional[str] = None, ) -> Optional[str]: """主入口:依 caller × context 選 model。 Args: caller: 在 ROUTING_RULES key 內才路由;否則直接回 default context: 路由判斷依據(如 expected_length / diff_lines / max_gap_pct) default: caller 不在 rules 或所有 rule 都不命中時回傳 Returns: model name 字串 / None(None 代表 caller 用既有預設) flag OFF 時直接回 default(不評估規則,向下相容) """ default = _sanitize_default_model(caller, default) if not is_model_router_enabled(): return default if caller not in ROUTING_RULES: return default ctx = context or {} for predicate, model_name in ROUTING_RULES[caller]: try: if predicate(ctx): if model_name is None: return default # 規則命中但要走預設 logger.debug("[ModelRouter] %s ctx=%s → %s", caller, ctx, model_name) return model_name except Exception as exc: logger.warning("[ModelRouter] %s rule eval failed: %s", caller, exc) continue # 沒命中 → default return default def list_routes_for_caller(caller: str) -> list: """除錯:列出 caller 的所有路由規則 model""" rules = ROUTING_RULES.get(caller, []) return [model for _, model in rules] def all_callers_with_routes() -> list: """所有有動態路由規則的 caller""" return list(ROUTING_RULES.keys()) __all__ = [ 'select_model', 'is_model_router_enabled', 'list_routes_for_caller', 'all_callers_with_routes', 'ROUTING_RULES', ]