380 lines
14 KiB
Python
380 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
from .shared import *
|
|
|
|
class LLMTierEngineSelectionMixin:
|
|
_PROTECTED_STRUCTURED_FUNCTIONS = frozenset(
|
|
{
|
|
"pmflow_launch_draft_v1",
|
|
"pmflow_requirement_plan_draft",
|
|
"pmflow_requirement_plan_round",
|
|
"project_requirement_extraction",
|
|
"generate_task_breakdown",
|
|
"decompose_tasks_from_charter",
|
|
"sstc_ai_breakdown",
|
|
"pmflow_os_changeset_extraction",
|
|
"pmflow_change_set_extraction",
|
|
"project_changeset_extraction",
|
|
}
|
|
)
|
|
|
|
@staticmethod
|
|
def _is_same_runtime_engine(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
|
return (
|
|
str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower()
|
|
and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower()
|
|
and str(a.get("base_url") or "").strip().rstrip("/").lower()
|
|
== str(b.get("base_url") or "").strip().rstrip("/").lower()
|
|
)
|
|
|
|
async def _resolve_high_tier_engine_for_escalation(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
tenant_id: int,
|
|
primary_engine: dict[str, Any],
|
|
) -> Optional[dict[str, Any]]:
|
|
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
|
active_engine = self._resolve_active_engine_from_bundle(bundle)
|
|
engines: list[dict[str, Any]] = [
|
|
e for e in bundle.get("engines", []) if isinstance(e, dict) and self._engine_enabled(e)
|
|
]
|
|
routed = self._resolve_tier_engine(
|
|
tier="high",
|
|
engines=engines,
|
|
active_engine=active_engine,
|
|
slot_bundle=await self._load_capability_slot_bundle(db),
|
|
)
|
|
if not routed:
|
|
return None
|
|
if self._is_same_runtime_engine(routed, primary_engine):
|
|
return None
|
|
|
|
provider = str(routed.get("provider") or "").strip().lower()
|
|
api_key = str(routed.get("api_key") or "").strip()
|
|
if provider != "ollama" and not api_key:
|
|
return None
|
|
return routed
|
|
|
|
def _pick_tier_fallback_engine(
|
|
self,
|
|
*,
|
|
tier: str,
|
|
engines: list[dict[str, Any]],
|
|
active_engine: dict[str, Any],
|
|
) -> Optional[dict[str, Any]]:
|
|
for item in engines:
|
|
candidate = self._normalize_engine_runtime(item)
|
|
provider = str(candidate.get("provider") or "")
|
|
api_key = str(candidate.get("api_key") or "").strip()
|
|
if provider != "ollama" and not api_key:
|
|
continue
|
|
if tier == "high" and provider == "ollama":
|
|
continue
|
|
if str(candidate.get("id") or "") == str(active_engine.get("id") or ""):
|
|
continue
|
|
_runtime_log(
|
|
"LLM路由",
|
|
(
|
|
"分层路由回退引擎生效:"
|
|
f"tier={tier}, decision=tier_fallback_engine"
|
|
),
|
|
)
|
|
return candidate
|
|
return None
|
|
|
|
def _resolve_tier_engine(
|
|
self,
|
|
*,
|
|
tier: str,
|
|
engines: list[dict[str, Any]],
|
|
active_engine: dict[str, Any],
|
|
slot_bundle: dict[str, Any] | None = None,
|
|
) -> Optional[dict[str, Any]]:
|
|
tier = str(tier or "").strip().lower()
|
|
if tier not in {"low", "high"}:
|
|
return None
|
|
|
|
slot_routed = self._resolve_engine_from_slot_binding(
|
|
tier=tier,
|
|
engines=engines,
|
|
slot_bundle=slot_bundle,
|
|
)
|
|
if slot_routed is not None:
|
|
return self._ensure_control_plane_engine(
|
|
_merge_engine_routing_meta(
|
|
slot_routed,
|
|
tier_alias=tier,
|
|
planned_tier=tier,
|
|
executed_tier=tier,
|
|
route_source="slot_binding",
|
|
routing_decision="capability_slot",
|
|
)
|
|
)
|
|
|
|
tier_defaults = dict(TIER_DEFAULTS.get(tier) or {})
|
|
provider_env = str(tier_defaults.get("provider") or "dashscope").strip()
|
|
|
|
routed: Optional[dict[str, Any]] = None
|
|
if routed is None and provider_env:
|
|
try:
|
|
provider = _normalize_provider(provider_env)
|
|
except Exception:
|
|
provider = ""
|
|
if provider:
|
|
for item in engines:
|
|
if str(item.get("provider") or "").strip().lower() == provider:
|
|
routed = self._normalize_engine_runtime(item)
|
|
break
|
|
|
|
if routed is None:
|
|
return None
|
|
|
|
provider = str(routed.get("provider") or "").strip().lower()
|
|
if tier_defaults.get("model"):
|
|
routed["model"] = str(tier_defaults.get("model") or routed.get("model") or "").strip()
|
|
if tier_defaults.get("base_url"):
|
|
routed["base_url"] = str(tier_defaults.get("base_url") or routed.get("base_url") or "").strip().rstrip("/")
|
|
|
|
routed = _merge_engine_routing_meta(
|
|
routed,
|
|
tier_alias=tier,
|
|
planned_tier=tier,
|
|
executed_tier=tier,
|
|
route_source="default",
|
|
routing_decision="tier_default",
|
|
)
|
|
|
|
if str(routed.get("id") or "") != str(active_engine.get("id") or ""):
|
|
_runtime_log(
|
|
"LLM路由",
|
|
(
|
|
"混合路由已命中:"
|
|
f"tier={tier}, decision=tier_default"
|
|
),
|
|
)
|
|
return self._ensure_control_plane_engine(routed)
|
|
|
|
def _resolve_capability_slot_engine(
|
|
self,
|
|
*,
|
|
slot_key: str,
|
|
engines: list[dict[str, Any]],
|
|
slot_bundle: dict[str, Any] | None,
|
|
) -> Optional[dict[str, Any]]:
|
|
slot_key = str(slot_key or "").strip().lower()
|
|
if slot_key not in CAPABILITY_SLOT_ALIASES:
|
|
return None
|
|
routed = self._resolve_engine_from_slot_binding(
|
|
tier=slot_key,
|
|
engines=engines,
|
|
slot_bundle=slot_bundle,
|
|
)
|
|
if routed is None:
|
|
return None
|
|
return self._ensure_control_plane_engine(
|
|
_merge_engine_routing_meta(
|
|
routed,
|
|
tier_alias=slot_key,
|
|
planned_tier=slot_key,
|
|
executed_tier=slot_key,
|
|
route_source="slot_binding",
|
|
routing_decision="capability_slot",
|
|
slot_key=slot_key,
|
|
slot_version=self._slot_bundle_active_version(slot_bundle),
|
|
slot_runtime_source="active_slot",
|
|
slot_fallback_index=routed.get("_slot_fallback_index", 0),
|
|
slot_degraded=False,
|
|
)
|
|
)
|
|
|
|
def _resolve_structured_slot_engine(
|
|
self,
|
|
*,
|
|
routing_meta: dict[str, Any] | None,
|
|
engines: list[dict[str, Any]],
|
|
slot_bundle: dict[str, Any] | None,
|
|
) -> Optional[dict[str, Any]]:
|
|
meta = _normalize_routing_meta(routing_meta)
|
|
if str(meta.get("response_contract") or "").strip().lower() != "structured":
|
|
return None
|
|
return self._resolve_capability_slot_engine(
|
|
slot_key="structured",
|
|
engines=engines,
|
|
slot_bundle=slot_bundle,
|
|
)
|
|
|
|
@staticmethod
|
|
def _requested_runtime_capability_slot(routing_meta: dict[str, Any] | None) -> str:
|
|
meta = _normalize_routing_meta(routing_meta)
|
|
if str(meta.get("response_contract") or "").strip().lower() == "structured":
|
|
return "structured"
|
|
explicit_slot = _optional_tier_alias(
|
|
meta.get("slot_key")
|
|
or meta.get("planned_tier")
|
|
or meta.get("tier_alias")
|
|
or meta.get("executed_tier")
|
|
)
|
|
if explicit_slot in {"long_context", "tool"}:
|
|
return explicit_slot
|
|
execution_mode = str(meta.get("execution_mode") or "").strip().lower()
|
|
if execution_mode in {"local_planner", "local_worker"}:
|
|
return "tool"
|
|
return ""
|
|
|
|
def _resolve_requested_capability_slot_engine(
|
|
self,
|
|
*,
|
|
routing_meta: dict[str, Any] | None,
|
|
engines: list[dict[str, Any]],
|
|
slot_bundle: dict[str, Any] | None,
|
|
) -> Optional[dict[str, Any]]:
|
|
slot_key = self._requested_runtime_capability_slot(routing_meta)
|
|
if slot_key not in {"structured", "long_context", "tool"}:
|
|
return None
|
|
return self._resolve_capability_slot_engine(
|
|
slot_key=slot_key,
|
|
engines=engines,
|
|
slot_bundle=slot_bundle,
|
|
)
|
|
|
|
def _resolve_engine_from_slot_binding(
|
|
self,
|
|
*,
|
|
tier: str,
|
|
engines: list[dict[str, Any]],
|
|
slot_bundle: dict[str, Any] | None,
|
|
) -> Optional[dict[str, Any]]:
|
|
candidates = self._resolve_engines_from_slot_binding(
|
|
tier=tier,
|
|
engines=engines,
|
|
slot_bundle=slot_bundle,
|
|
)
|
|
return candidates[0] if candidates else None
|
|
|
|
@staticmethod
|
|
def _active_slot_map(slot_bundle: dict[str, Any] | None) -> dict[str, Any]:
|
|
if not isinstance(slot_bundle, dict):
|
|
return {}
|
|
active = slot_bundle.get("active") if isinstance(slot_bundle.get("active"), dict) else {}
|
|
if isinstance(active.get("slots"), dict):
|
|
return active["slots"]
|
|
if isinstance(slot_bundle.get("slots"), dict):
|
|
return slot_bundle["slots"]
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _slot_bundle_active_version(slot_bundle: dict[str, Any] | None) -> str:
|
|
if not isinstance(slot_bundle, dict):
|
|
return ""
|
|
return str(slot_bundle.get("active_version") or slot_bundle.get("version") or "").strip()
|
|
|
|
def _slot_binding_configured(
|
|
self,
|
|
*,
|
|
slot_bundle: dict[str, Any] | None,
|
|
tier: str,
|
|
) -> bool:
|
|
slot = self._active_slot_map(slot_bundle).get(str(tier or "").strip().lower())
|
|
return isinstance(slot, dict) and bool(str(slot.get("primary_engine_id") or "").strip())
|
|
|
|
def _engine_satisfies_slot_runtime_requirements(
|
|
self,
|
|
*,
|
|
tier: str,
|
|
engine: dict[str, Any],
|
|
) -> bool:
|
|
normalized_tier = str(tier or "").strip().lower()
|
|
if normalized_tier not in {"structured", "long_context", "tool"}:
|
|
return True
|
|
capabilities = self._normalize_engine_capabilities(
|
|
engine.get("capabilities"),
|
|
provider=str(engine.get("provider") or ""),
|
|
model=str(engine.get("model") or ""),
|
|
)
|
|
if normalized_tier == "structured":
|
|
return bool(capabilities.get("structured_output")) and bool(capabilities.get("json_schema"))
|
|
if normalized_tier == "tool":
|
|
return bool(capabilities.get("tool_calling"))
|
|
context_tokens = capabilities.get("max_context_tokens")
|
|
try:
|
|
max_context_tokens = int(context_tokens) if context_tokens is not None else 0
|
|
except (TypeError, ValueError):
|
|
max_context_tokens = 0
|
|
return bool(capabilities.get("long_context")) and max_context_tokens >= 32768
|
|
|
|
def _resolve_engines_from_slot_binding(
|
|
self,
|
|
*,
|
|
tier: str,
|
|
engines: list[dict[str, Any]],
|
|
slot_bundle: dict[str, Any] | None,
|
|
) -> list[dict[str, Any]]:
|
|
tier = str(tier or "").strip().lower()
|
|
slots = self._active_slot_map(slot_bundle)
|
|
slot = slots.get(tier)
|
|
if not isinstance(slot, dict):
|
|
return []
|
|
ordered_ids = [str(slot.get("primary_engine_id") or "").strip()]
|
|
if isinstance(slot.get("fallback_engine_ids"), list):
|
|
ordered_ids.extend(str(item or "").strip() for item in slot.get("fallback_engine_ids", []))
|
|
seen: set[str] = set()
|
|
candidates: list[dict[str, Any]] = []
|
|
for fallback_index, engine_id in enumerate(ordered_ids):
|
|
if not engine_id or engine_id in seen:
|
|
continue
|
|
seen.add(engine_id)
|
|
for item in engines:
|
|
if str(item.get("id") or "").strip() != engine_id:
|
|
continue
|
|
candidate = self._normalize_engine_runtime(item)
|
|
provider = str(candidate.get("provider") or "").strip().lower()
|
|
if provider != "ollama" and not str(candidate.get("api_key") or "").strip():
|
|
continue
|
|
if not self._engine_satisfies_slot_runtime_requirements(tier=tier, engine=candidate):
|
|
required = {
|
|
"structured": "structured_output/json_schema",
|
|
"long_context": "long_context/max_context_tokens>=32768",
|
|
"tool": "tool_calling",
|
|
}.get(tier, "slot_capability")
|
|
_runtime_log(
|
|
"LLM路由",
|
|
(
|
|
"能力槽运行时因能力不足被跳过:"
|
|
f"slot={tier}, engine={engine_id}, required={required}"
|
|
),
|
|
)
|
|
continue
|
|
candidate["_slot_fallback_index"] = fallback_index
|
|
candidates.append(candidate)
|
|
break
|
|
return candidates
|
|
|
|
async def _resolve_engine_for_test(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
tenant_id: int,
|
|
engine_id: Optional[str],
|
|
) -> dict[str, Any]:
|
|
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
|
settings = self._normalize_engine_settings(bundle.get("settings"))
|
|
if not engine_id:
|
|
engine = self._resolve_active_engine_from_bundle(bundle)
|
|
return self._ensure_engine_allowed_for_test(
|
|
engine=engine,
|
|
settings=settings,
|
|
engine_label="当前激活引擎",
|
|
)
|
|
|
|
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
|
|
for item in engines:
|
|
if str(item.get("id") or "") == str(engine_id):
|
|
engine = self._normalize_engine_runtime(item)
|
|
return self._ensure_engine_allowed_for_test(
|
|
engine=engine,
|
|
settings=settings,
|
|
engine_label="目标引擎",
|
|
)
|
|
raise ValueError("engine 不存在")
|