PMFlow/backend/app/services/llm.py

2727 lines
109 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.

"""统一 LLM 服务(DeepSeek / GLM / OpenAI / Ollama)。
能力:
1. 多引擎配置持久化(Integration.type=llm_engine)
2. 动态选择激活引擎
3. OpenAI-Compatible chat/completions 调用
4. 基础 AI 调用日志写入(ai_call_logs)
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import hashlib
import json
import logging
import os
import re
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
from uuid import uuid4
import httpx
from sqlalchemy import and_, select
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from ..config.ai_prompts import build_default_role_prompt_bundle
from ..database import AsyncSessionLocal
from ..models import AICallLog, Integration
from ..utils.timezone import now_cn_aware
try:
import redis.asyncio as redis_async
REDIS_AVAILABLE = True
except Exception: # pragma: no cover
REDIS_AVAILABLE = False
redis_async = None
logger = logging.getLogger(__name__)
SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"}
OPENCLAW_PRIMARY_GATEWAY_URL = "http://openclaw:18789/v1"
OPENCLAW_DEFAULT_LOW_MODEL = "GLM-4.7"
OPENCLAW_DEFAULT_HIGH_MODEL = "MiniMax-M2.5"
OPENCLAW_DEFAULT_SAFETY_MODEL = "deepseek-chat"
PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
"deepseek": {
"name": "DeepSeek",
"base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat",
"api_env": "DEEPSEEK_API_KEY",
},
"glm": {
"name": "GLM",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"model": "GLM-4.7",
"api_env": "GLM_API_KEY",
},
"openai": {
"name": "OpenAI",
"base_url": OPENCLAW_PRIMARY_GATEWAY_URL,
"model": OPENCLAW_DEFAULT_LOW_MODEL,
"api_env": "OPENAI_API_KEY",
},
"ollama": {
"name": "Ollama",
"base_url": "http://127.0.0.1:11434",
"model": "llama3:latest",
"api_env": "",
},
}
PROVIDER_ALIASES = {
"zhipu": "glm",
"chatglm": "glm",
"deep-seek": "deepseek",
"local": "ollama",
}
PROMPT_PROFILE_CACHE_TTL_SECONDS = 60.0
BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"}
OPENCLAW_ROUTING_CACHE_TTL_SECONDS = 30.0
OPENCLAW_DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json"
OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS = 120
OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX = 120
DEFAULT_TIER_MODEL_OVERRIDES = {
"low": OPENCLAW_DEFAULT_LOW_MODEL,
"high": OPENCLAW_DEFAULT_HIGH_MODEL,
"safety": OPENCLAW_DEFAULT_SAFETY_MODEL,
}
DEFAULT_HIGH_TIER_HINTS = (
"classify_intent_complex",
"project_from_text",
"feishu_project_prefill",
"feishu_task_orchestration",
"feishu_project_signal_classification",
"adaptive_rag_answer_deep",
"rag_db_project_semantic_retrieval",
"analyze_food_calories",
"sstc_ai_breakdown",
"generate_task_breakdown",
"recommend_assignee",
"alignment",
"charter",
"cognitive_conflict",
"blocker",
"model_requirement",
)
DEFAULT_LOW_TIER_HINTS = (
"feishu_level_classification",
"feishu_signal_extraction",
"feishu_humanized_reply",
"signal_source_onboarding_plan",
"openclaw_command_dispatch",
"signal_source_ai_worker_execution",
"extract_context_insights",
"adaptive_rag_answer_normal",
"rag_intent_classification",
"classify_intent",
)
STRUCTURED_OUTPUT_FUNCTION_HINTS = (
"feishu_level_classification",
"feishu_signal_extraction",
"signal_source_onboarding_plan",
"signal_source_ai_worker_execution",
"rag_intent_classification",
"classify_intent",
"feishu_project_prefill",
"feishu_task_orchestration",
"openclaw_command_dispatch",
"generate_task_breakdown",
"generate_charter_from_signal",
"decompose_tasks_from_charter",
"recommend_assignees_for_task",
"evaluate_model_requirement",
"calculate_alignment_score",
"detect_cognitive_conflicts",
"rag_db_project_semantic_retrieval",
"workload_optimize_prompt",
"analyze_cost_roi",
"analyze_food_calories",
"sstc_ai_breakdown",
"sstc_charter_localize_zh",
"sstc_charter_prefill",
)
LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS = (
"不知道",
"不清楚",
"无法回答",
"无法确定",
"无法判断",
"信息不足",
"缺少信息",
"没有足够信息",
"请提供更多信息",
"抱歉",
"不能提供",
"无法访问",
"as an ai",
"i'm sorry",
"cannot answer",
"don't know",
)
FAST_LOCAL_FALLBACK_FUNCTION_HINTS = (
"signal_source_onboarding_plan",
"openclaw_command_dispatch",
"signal_source_ai_worker_execution",
)
FAST_LOCAL_PLANNER_FUNCTION_HINTS = (
"signal_source_onboarding_plan",
"openclaw_command_dispatch",
)
FAST_LOCAL_WORKER_FUNCTION_HINTS = ("signal_source_ai_worker_execution",)
FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS = 3.0
FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 6.0
STRUCTURED_CACHE_TTL_SECONDS = 300
FAIL_FAST_ERROR_MARKERS = (
"primary_engine_empty_response",
"high_tier_empty_response",
"llm 返回空内容",
"返回空内容",
"empty_response",
"provider_circuit_open",
)
CIRCUIT_BREAKER_FAILURE_THRESHOLD = 2
CIRCUIT_BREAKER_COOLDOWN_SECONDS = 300.0
STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
"signal_source_onboarding_plan": {
"required_keys": ("should_onboard", "summary", "worker_roles", "openclaw_instruction"),
"type_checks": {
"should_onboard": "bool",
"summary": "str",
"worker_roles": "list",
"openclaw_instruction": "str",
},
"optional_type_checks": {
"source": ("dict_or_none",),
},
},
"openclaw_command_dispatch": {
"required_keys": ("summary", "confidence", "operations"),
"type_checks": {
"summary": "str",
"confidence": "number",
"operations": "list",
},
},
"signal_source_ai_worker_execution": {
"required_keys": ("summary", "deliverables", "risks", "next_actions"),
"type_checks": {
"summary": "str",
"deliverables": "list",
"risks": "list",
"next_actions": "list",
},
},
"classify_intent_ai_pmo_kernel": {
"required_keys": ("intent", "confidence", "reasoning"),
"type_checks": {
"intent": "str",
"confidence": "number",
"reasoning": "str",
},
"optional_type_checks": {
"project_id": ("number_or_none",),
},
},
"classify_intent_complex": {
"required_keys": ("intent", "confidence", "reasoning"),
"type_checks": {
"intent": "str",
"confidence": "number",
"reasoning": "str",
},
"optional_type_checks": {
"project_id": ("number_or_none",),
},
},
}
def _normalize_provider(raw: Optional[str]) -> str:
provider = str(raw or "").strip().lower()
provider = PROVIDER_ALIASES.get(provider, provider)
if provider not in SUPPORTED_PROVIDERS:
raise ValueError("provider 仅支持 deepseek/glm/openai/ollama")
return provider
def _mask_secret(secret: str) -> str:
value = str(secret or "").strip()
if not value:
return ""
if len(value) <= 8:
return "*" * len(value)
return f"{value[:4]}...{value[-4:]}"
def _safe_json(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
try:
# 返回深拷贝,避免原地修改导致 SQLAlchemy 不感知 JSON 字段变更
return json.loads(json.dumps(value))
except Exception:
return dict(value)
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return str(raw).strip().lower() in BOOL_TRUE_VALUES
def _parse_csv_items(raw: str) -> tuple[str, ...]:
rows = [item.strip().lower() for item in str(raw or "").split(",") if item.strip()]
return tuple(rows)
def _cn_now_text() -> str:
cn_tz = timezone(timedelta(hours=8))
return datetime.now(cn_tz).strftime("%Y-%m-%d %H:%M:%S")
def _cn_now_text_ms() -> str:
cn_tz = timezone(timedelta(hours=8))
return datetime.now(cn_tz).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
def _runtime_log(scope: str, message: str) -> None:
print(f"[{_cn_now_text()}][{scope}] {message}")
def _looks_like_openclaw_timeout_message(text: str) -> bool:
lowered = str(text or "").strip().lower()
if not lowered:
return False
timeout_signals = (
"request timed out before a response was generated",
"agents.defaults.timeoutseconds",
"timeoutseconds",
"before a response was generated",
)
return any(signal in lowered for signal in timeout_signals)
@dataclass
class ChatCompletionResult:
text: str
raw_response: Any = None
@dataclass
class StructuredExtractionResult:
payload: Optional[dict[str, Any]]
source_field: str
provider: str
model: str
error_code: Optional[str] = None
raw_text: str = ""
validation: str = "invalid"
from_cache: bool = False
class LLMService:
_provider_circuit_state: dict[str, dict[str, Any]] = {}
def __init__(self) -> None:
self.request_timeout = float(os.getenv("LLM_REQUEST_TIMEOUT", "45"))
self.redis_url = os.getenv("REDIS_URL")
self._redis: Optional[Any] = None
self._prompt_profile_cache: dict[int, tuple[float, dict[str, str]]] = {}
self._openclaw_routing_cache: tuple[float, dict[str, Any]] | None = None
async def _get_redis(self):
if not REDIS_AVAILABLE or not self.redis_url:
return None
if self._redis is None:
self._redis = redis_async.from_url(self.redis_url, decode_responses=True)
return self._redis
@staticmethod
def _normalize_prompt_for_hash(prompt: str) -> str:
return re.sub(r"\s+", " ", str(prompt or "")).strip()
@classmethod
def _structured_cache_key(cls, *, function_name: str, model: str, prompt: str) -> str:
normalized_prompt = cls._normalize_prompt_for_hash(prompt)
digest = hashlib.sha256(normalized_prompt.encode("utf-8")).hexdigest()
return f"pmflow:structured:{function_name}:{model}:{digest}"
async def _structured_cache_get(self, *, key: str) -> Optional[dict[str, Any]]:
redis = await self._get_redis()
if redis is None:
return None
try:
raw = await redis.get(key)
except Exception:
return None
if not raw:
return None
try:
parsed = json.loads(raw)
except Exception:
return None
return parsed if isinstance(parsed, dict) else None
async def _structured_cache_set(self, *, key: str, value: dict[str, Any], ttl: int = STRUCTURED_CACHE_TTL_SECONDS) -> None:
redis = await self._get_redis()
if redis is None:
return
try:
await redis.setex(key, ttl, json.dumps(value, ensure_ascii=False))
except Exception:
return
@staticmethod
def _provider_circuit_key(*, provider: str, base_url: str, model: str) -> str:
return f"{str(provider or '').strip().lower()}|{str(base_url or '').strip().rstrip('/')}|{str(model or '').strip()}"
@staticmethod
def _http_status_from_exception(exc: Exception) -> Optional[int]:
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None:
try:
return int(exc.response.status_code)
except Exception:
return None
return None
@classmethod
def _is_fail_fast_exception(cls, exc: Exception) -> bool:
status_code = cls._http_status_from_exception(exc)
if status_code in {401, 403}:
return True
text = str(exc or "").strip().lower()
return any(marker in text for marker in FAIL_FAST_ERROR_MARKERS)
@classmethod
def _is_circuit_breaker_worthy_exception(cls, exc: Exception) -> bool:
if cls._is_fail_fast_exception(exc):
return True
if isinstance(exc, httpx.RequestError):
return True
status_code = cls._http_status_from_exception(exc)
return status_code in {408, 409, 429, 500, 502, 503, 504}
@classmethod
def _is_provider_circuit_open(cls, circuit_key: str) -> bool:
state = cls._provider_circuit_state.get(str(circuit_key))
if not state:
return False
try:
open_until = float(state.get("open_until") or 0.0)
except Exception:
open_until = 0.0
now = time.monotonic()
if open_until <= now:
if open_until:
cls._provider_circuit_state.pop(str(circuit_key), None)
return False
return True
@classmethod
def _record_provider_success(cls, circuit_key: str) -> None:
cls._provider_circuit_state.pop(str(circuit_key), None)
@classmethod
def _record_provider_failure(cls, circuit_key: str, exc: Exception) -> None:
if not cls._is_circuit_breaker_worthy_exception(exc):
return
now = time.monotonic()
state = dict(cls._provider_circuit_state.get(str(circuit_key)) or {})
consecutive = int(state.get("consecutive_failures") or 0) + 1
open_until = float(state.get("open_until") or 0.0)
if consecutive >= CIRCUIT_BREAKER_FAILURE_THRESHOLD:
open_until = now + CIRCUIT_BREAKER_COOLDOWN_SECONDS
cls._provider_circuit_state[str(circuit_key)] = {
"consecutive_failures": consecutive,
"open_until": open_until,
"last_error": str(exc)[:240],
"updated_at_monotonic": now,
}
# ---------------------------------------------------------------------
# Engine config management
# ---------------------------------------------------------------------
async def list_engines(self, db: AsyncSession, tenant_id: int = 1) -> dict[str, Any]:
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
engines = bundle.get("engines", [])
return {
"active_engine_id": bundle.get("active_engine_id"),
"engines": [self._sanitize_engine_for_response(item) for item in engines],
}
async def create_engine(
self,
db: AsyncSession,
*,
tenant_id: int,
name: str,
provider: str,
api_key: Optional[str],
model: Optional[str] = None,
base_url: Optional[str] = None,
set_active: bool = True,
) -> dict[str, Any]:
provider = _normalize_provider(provider)
api_key = str(api_key or "").strip()
if provider != "ollama" and not api_key:
raise ValueError("api_key 不能为空")
integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
now = now_cn_aware().isoformat()
engine_id = str(uuid4())
defaults = PROVIDER_DEFAULTS[provider]
engine = {
"id": engine_id,
"name": (name or defaults["name"]).strip()[:80] or defaults["name"],
"provider": provider,
"api_key": api_key,
"model": (model or defaults["model"]).strip() or defaults["model"],
"base_url": (base_url or defaults["base_url"]).strip().rstrip("/") or defaults["base_url"],
"created_at": now,
"updated_at": now,
}
engines = bundle.get("engines", [])
engines.append(engine)
bundle["engines"] = engines
if set_active or not bundle.get("active_engine_id"):
bundle["active_engine_id"] = engine_id
integration.config = json.loads(json.dumps(bundle))
integration.is_active = True
await db.commit()
return self._sanitize_engine_for_response(engine)
async def update_engine(
self,
db: AsyncSession,
*,
tenant_id: int,
engine_id: str,
name: Optional[str] = None,
provider: Optional[str] = None,
api_key: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
) -> dict[str, Any]:
integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
engines = bundle.get("engines", [])
target: Optional[dict[str, Any]] = None
for item in engines:
if str(item.get("id")) == str(engine_id):
target = item
break
if not target:
raise ValueError("engine 不存在")
if provider is not None:
target["provider"] = _normalize_provider(provider)
active_provider = _normalize_provider(target.get("provider"))
defaults = PROVIDER_DEFAULTS[active_provider]
if name is not None:
target["name"] = (name or "").strip()[:80] or target.get("name") or defaults["name"]
if api_key is not None:
api_key_val = str(api_key).strip()
if api_key_val:
target["api_key"] = api_key_val
if model is not None:
target["model"] = (model or "").strip() or defaults["model"]
if base_url is not None:
target["base_url"] = (base_url or "").strip().rstrip("/") or defaults["base_url"]
target["updated_at"] = now_cn_aware().isoformat()
integration.config = json.loads(json.dumps(bundle))
await db.commit()
return self._sanitize_engine_for_response(target)
async def delete_engine(
self,
db: AsyncSession,
*,
tenant_id: int,
engine_id: str,
) -> dict[str, Any]:
integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
engines = bundle.get("engines", [])
kept = [item for item in engines if str(item.get("id")) != str(engine_id)]
if len(kept) == len(engines):
raise ValueError("engine 不存在")
bundle["engines"] = kept
active_engine_id = str(bundle.get("active_engine_id") or "")
if active_engine_id == str(engine_id):
bundle["active_engine_id"] = kept[0]["id"] if kept else None
integration.config = json.loads(json.dumps(bundle))
await db.commit()
return {
"deleted_engine_id": str(engine_id),
"active_engine_id": bundle.get("active_engine_id"),
"remaining_count": len(kept),
}
async def activate_engine(
self,
db: AsyncSession,
*,
tenant_id: int,
engine_id: str,
) -> dict[str, Any]:
integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
engines = bundle.get("engines", [])
exists = any(str(item.get("id")) == str(engine_id) for item in engines)
if not exists:
raise ValueError("engine 不存在")
bundle["active_engine_id"] = str(engine_id)
integration.config = json.loads(json.dumps(bundle))
await db.commit()
return {"active_engine_id": str(engine_id)}
async def test_engine_connection(
self,
db: AsyncSession,
*,
tenant_id: int,
engine_id: Optional[str] = None,
prompt: Optional[str] = None,
max_tokens: int = 64,
) -> dict[str, Any]:
test_prompt = (prompt or "你是连通性测试助手。请只返回: OK").strip()
if not test_prompt:
raise ValueError("测试 prompt 不能为空")
engine = await self._resolve_engine_for_test(
db,
tenant_id=tenant_id,
engine_id=engine_id,
)
response_text = ""
status = "success"
started = time.perf_counter()
try:
response_text = await self._chat_completion(
provider=str(engine.get("provider") or ""),
prompt=test_prompt,
system_prompt="",
api_key=str(engine.get("api_key") or ""),
base_url=str(engine.get("base_url") or ""),
model=str(engine.get("model") or ""),
temperature=0.0,
max_tokens=max_tokens,
)
if not response_text.strip():
raise ValueError("测试响应为空")
latency_ms = int((time.perf_counter() - started) * 1000)
return {
"ok": True,
"engine_id": str(engine.get("id") or ""),
"name": str(engine.get("name") or ""),
"provider": str(engine.get("provider") or ""),
"model": str(engine.get("model") or ""),
"base_url": str(engine.get("base_url") or ""),
"api_key_masked": _mask_secret(str(engine.get("api_key") or "")),
"latency_ms": latency_ms,
"response_preview": response_text[:300],
}
except Exception as exc:
status = "failed"
response_text = f"[error] {exc}"
raise ValueError(f"引擎连通测试失败: {exc}") from exc
finally:
try:
await self._log_ai_call(
tenant_id=tenant_id,
function_name="test_engine_connection",
prompt=test_prompt,
response=response_text,
status=status,
provider=str(engine.get("provider") or ""),
model=str(engine.get("model") or ""),
)
except Exception as log_exc:
_runtime_log("LLM", f"测试调用日志写入失败:{log_exc}")
# ---------------------------------------------------------------------
# LLM calls
# ---------------------------------------------------------------------
async def generate(self, prompt: str, **kwargs: Any) -> str:
call_kwargs = dict(kwargs)
db = call_kwargs.pop("db", None) or call_kwargs.pop("db_session", None)
if db is None:
async with AsyncSessionLocal() as session:
return await self._generate_with_db(session, prompt, **call_kwargs)
return await self._generate_with_db(db, prompt, **call_kwargs)
async def generate_structured_object(self, prompt: str, **kwargs: Any) -> StructuredExtractionResult:
call_kwargs = dict(kwargs)
db = call_kwargs.pop("db", None) or call_kwargs.pop("db_session", None)
if db is None:
async with AsyncSessionLocal() as session:
return await self._generate_structured_with_db(session, prompt, **call_kwargs)
return await self._generate_structured_with_db(db, prompt, **call_kwargs)
async def _generate_with_db(self, db: AsyncSession, prompt: str, **kwargs: Any) -> str:
started_at = time.perf_counter()
tenant_id = int(kwargs.get("tenant_id") or 1)
function_name = str(kwargs.get("function_name") or "llm_generate")
temperature = float(kwargs.get("temperature") or 0.2)
max_tokens = int(kwargs.get("max_tokens") or 1024)
use_prompt_profile = bool(kwargs.get("use_prompt_profile", True))
response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete"
response_text = ""
status = "success"
routing_decision = "primary_engine"
engine_meta: dict[str, Any] = {}
prompt_profile = {"role_name": "", "system_prompt": ""}
merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile)
safety_attempted = False
def _log_stub_failure(error_obj: Exception) -> None:
elapsed_ms = (time.perf_counter() - started_at) * 1000
provider = str(engine_meta.get("provider") or "")
model = str(engine_meta.get("model") or "")
logger.warning(
"[LLM_FAIL] 生成失败 → 兜底 stub function=%s provider=%s model=%s error=%r duration_ms=%.0f",
function_name,
provider,
model,
error_obj,
elapsed_ms,
)
try:
if use_prompt_profile:
prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id)
merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile)
engine = await self._resolve_engine_for_call(
db,
tenant_id=tenant_id,
function_name=function_name,
)
engine_meta = engine
provider = str(engine.get("provider") or "")
model = str(engine.get("model") or "")
response_text = await self._chat_completion(
provider=provider,
prompt=prompt,
system_prompt=prompt_profile.get("system_prompt") or "",
api_key=str(engine.get("api_key") or ""),
base_url=str(engine.get("base_url") or ""),
model=model,
temperature=temperature,
max_tokens=max_tokens,
function_name=function_name,
response_mode=response_mode,
)
if not response_text.strip():
raise ValueError("primary_engine_empty_response")
invalid_structured_output = self._is_invalid_structured_output(
function_name=function_name,
response_text=response_text,
)
should_upgrade_to_high = False
if not self._prefer_fast_local_fallback(function_name):
should_upgrade_to_high = self._should_escalate_low_to_high(
function_name=function_name,
response_text=response_text,
) or (invalid_structured_output and self._is_low_tier_function(function_name))
if should_upgrade_to_high:
high_engine = await self._resolve_high_tier_engine_for_escalation(
db,
tenant_id=tenant_id,
primary_engine=engine,
)
if high_engine is None:
raise ValueError("high_tier_engine_unavailable")
high_provider = str(high_engine.get("provider") or "")
high_model = str(high_engine.get("model") or "")
_runtime_log(
"LLM升级",
(
"低阶引擎触发升级:"
f"function={function_name}, from={provider}/{model}, to={high_provider}/{high_model}"
),
)
try:
escalated_text = await self._chat_completion(
provider=high_provider,
prompt=prompt,
system_prompt=prompt_profile.get("system_prompt") or "",
api_key=str(high_engine.get("api_key") or ""),
base_url=str(high_engine.get("base_url") or ""),
model=high_model,
temperature=temperature,
max_tokens=max_tokens,
function_name=function_name,
response_mode=response_mode,
)
if not escalated_text.strip():
raise ValueError("high_tier_empty_response")
if self._is_invalid_structured_output(function_name=function_name, response_text=escalated_text):
raise ValueError("high_tier_invalid_structured_output")
response_text = escalated_text
engine_meta = high_engine
except Exception as escalation_exc:
safety_attempted = True
safety_text, safety_engine = await self._try_safety_engine(
db,
tenant_id=tenant_id,
function_name=function_name,
prompt=prompt,
system_prompt=prompt_profile.get("system_prompt") or "",
temperature=temperature,
max_tokens=max_tokens,
primary_engine=high_engine,
reason=f"high_tier_retry_failed:{escalation_exc}",
)
if safety_engine is not None and safety_text.strip():
routing_decision = "fallback_safety_engine"
status = "fallback_safety_engine"
engine_meta = safety_engine
response_text = safety_text
return response_text
raise ValueError(f"high_tier_retry_failed:{escalation_exc}") from escalation_exc
if self._is_invalid_structured_output(function_name=function_name, response_text=response_text):
raise ValueError("invalid_structured_output")
return response_text
except Exception as exc:
upgraded_exc: Exception = exc
if self._is_fail_fast_exception(exc):
status = "failed_fast"
response_text = self._stub_response(prompt)
_runtime_log("LLM", f"生成快速失败,已跳过升级重试:{exc}")
_log_stub_failure(exc)
return response_text
if self._is_low_tier_function(function_name):
if self._prefer_fast_local_fallback(function_name):
raise upgraded_exc
high_engine = await self._resolve_high_tier_engine_for_escalation(
db,
tenant_id=tenant_id,
primary_engine=engine_meta,
)
if high_engine is not None:
high_provider = str(high_engine.get("provider") or "")
high_model = str(high_engine.get("model") or "")
_runtime_log(
"LLM升级",
(
"低阶引擎异常,转高阶兜底:"
f"function={function_name}, from={engine_meta.get('provider')}/{engine_meta.get('model')}, "
f"to={high_provider}/{high_model}, error={exc}"
),
)
try:
escalated_text = await self._chat_completion(
provider=high_provider,
prompt=prompt,
system_prompt=prompt_profile.get("system_prompt") or "",
api_key=str(high_engine.get("api_key") or ""),
base_url=str(high_engine.get("base_url") or ""),
model=high_model,
temperature=temperature,
max_tokens=max_tokens,
function_name=function_name,
response_mode=response_mode,
)
if not escalated_text.strip():
raise ValueError("high_tier_empty_response")
if self._is_invalid_structured_output(function_name=function_name, response_text=escalated_text):
raise ValueError("high_tier_invalid_structured_output")
routing_decision = "fallback_high_tier_engine"
status = "fallback_high_tier_engine"
engine_meta = high_engine
response_text = escalated_text
return response_text
except Exception as high_exc:
upgraded_exc = ValueError(f"high_tier_retry_failed:{high_exc}")
engine_meta = high_engine
if not safety_attempted:
safety_attempted = True
safety_text, safety_engine = await self._try_safety_engine(
db,
tenant_id=tenant_id,
function_name=function_name,
prompt=prompt,
system_prompt=prompt_profile.get("system_prompt") or "",
temperature=temperature,
max_tokens=max_tokens,
primary_engine=engine_meta,
reason=str(upgraded_exc),
)
if safety_engine is not None and safety_text.strip():
routing_decision = "fallback_safety_engine"
status = "fallback_safety_engine"
engine_meta = safety_engine
response_text = safety_text
return response_text
retry_text = ""
if self._direct_emergency_enabled():
fallback_engines = self._build_fallback_engines(engine_meta)
for fallback_engine in fallback_engines:
try:
fb_provider = str(fallback_engine.get("provider") or "")
fb_model = str(fallback_engine.get("model") or "")
_runtime_log(
"LLM",
f"应急直连引擎重试:provider={fb_provider}, model={fb_model}, error={exc}",
)
retry_text = await self._chat_completion(
provider=fb_provider,
prompt=prompt,
system_prompt=prompt_profile.get("system_prompt") or "",
api_key=str(fallback_engine.get("api_key") or ""),
base_url=str(fallback_engine.get("base_url") or ""),
model=fb_model,
temperature=temperature,
max_tokens=max_tokens,
function_name=function_name,
response_mode=response_mode,
)
if retry_text.strip():
routing_decision = "fallback_emergency_direct_engine"
status = "fallback_emergency_direct_engine"
engine_meta = fallback_engine
response_text = retry_text
return response_text
except Exception as retry_exc:
_runtime_log(
"LLM",
(
"应急直连重试失败:"
f"provider={fallback_engine.get('provider')}, "
f"model={fallback_engine.get('model')}, error={retry_exc}"
),
)
status = "failed"
response_text = self._stub_response(prompt)
_log_stub_failure(upgraded_exc)
return response_text
finally:
try:
merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile)
await self._log_ai_call(
tenant_id=tenant_id,
function_name=function_name,
prompt=merged_prompt,
response=response_text,
status=status,
provider=str(engine_meta.get("provider") or ""),
model=str(engine_meta.get("model") or ""),
)
if routing_decision != "primary_engine":
_runtime_log(
"LLM路由",
(
f"routing_decision={routing_decision}, "
f"function={function_name}, provider={engine_meta.get('provider')}, model={engine_meta.get('model')}"
),
)
except Exception as log_exc:
_runtime_log("LLM", f"调用日志写入失败:{log_exc}")
async def _generate_structured_with_db(
self,
db: AsyncSession,
prompt: str,
**kwargs: Any,
) -> StructuredExtractionResult:
tenant_id = int(kwargs.get("tenant_id") or 1)
function_name = str(kwargs.get("function_name") or "llm_generate").strip().lower() or "llm_generate"
temperature = float(kwargs.get("temperature") or 0.0)
max_tokens = int(kwargs.get("max_tokens") or 1024)
use_prompt_profile = bool(kwargs.get("use_prompt_profile", True))
response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete"
prompt_profile = {"role_name": "", "system_prompt": ""}
merged_prompt = prompt
provider = ""
model = ""
status = "success"
response_text = ""
try:
if use_prompt_profile:
prompt_profile = await self._resolve_active_prompt_profile(db, tenant_id=tenant_id)
merged_prompt = self._merge_prompt_with_profile(prompt=prompt, prompt_profile=prompt_profile)
engine = await self._resolve_engine_for_call(
db,
tenant_id=tenant_id,
function_name=function_name,
)
provider = str(engine.get("provider") or "")
model = str(engine.get("model") or "")
cache_key = ""
if self._structured_cache_enabled(function_name):
cache_key = self._structured_cache_key(
function_name=function_name,
model=model or "unknown",
prompt=merged_prompt,
)
cached = await self._structured_cache_get(key=cache_key)
if cached is not None:
status = "success_cache_hit"
result = StructuredExtractionResult(
payload=cached,
source_field="cache",
provider=provider,
model=model,
raw_text=json.dumps(cached, ensure_ascii=False),
validation="ok",
from_cache=True,
)
_runtime_log(
"LLM结构化",
f"function={function_name}, structured_source=cache, validation=ok, fallback=false",
)
return result
completion = await self._chat_completion_raw(
provider=provider,
prompt=prompt,
system_prompt=prompt_profile.get("system_prompt") or "",
api_key=str(engine.get("api_key") or ""),
base_url=str(engine.get("base_url") or ""),
model=model,
temperature=temperature,
max_tokens=max_tokens,
function_name=function_name,
response_mode=response_mode,
)
response_text = completion.text
extraction = self._extract_structured_object_from_response(
function_name=function_name,
response=completion.raw_response,
fallback_text=completion.text,
provider=provider,
model=model,
)
is_fallback = extraction.error_code is not None
_runtime_log(
"LLM结构化",
(
f"function={function_name}, structured_source={extraction.source_field or 'none'}, "
f"validation={extraction.validation}, fallback={'true' if is_fallback else 'false'}"
),
)
if extraction.payload is not None and cache_key:
await self._structured_cache_set(key=cache_key, value=extraction.payload)
status = extraction.error_code or "success"
return extraction
except Exception as exc:
error_code = self._classify_structured_exception(exc)
status = error_code
result = StructuredExtractionResult(
payload=None,
source_field="",
provider=provider,
model=model,
error_code=error_code,
raw_text=str(exc)[:1000],
validation="invalid",
)
_runtime_log(
"LLM结构化",
f"function={function_name}, structured_source=none, validation=invalid, fallback=true",
)
return result
finally:
try:
log_payload = response_text or status
await self._log_ai_call(
tenant_id=tenant_id,
function_name=function_name,
prompt=merged_prompt,
response=log_payload,
status=status,
provider=provider,
model=model,
)
except Exception as log_exc:
_runtime_log("LLM", f"结构化调用日志写入失败:{log_exc}")
async def _chat_completion(
self,
*,
provider: str,
prompt: str,
system_prompt: str,
api_key: str,
base_url: str,
model: str,
temperature: float,
max_tokens: int,
function_name: str = "",
response_mode: str = "complete",
) -> str:
result = await self._chat_completion_raw(
provider=provider,
prompt=prompt,
system_prompt=system_prompt,
api_key=api_key,
base_url=base_url,
model=model,
temperature=temperature,
max_tokens=max_tokens,
function_name=function_name,
response_mode=response_mode,
)
return result.text
async def _chat_completion_raw(
self,
*,
provider: str,
prompt: str,
system_prompt: str,
api_key: str,
base_url: str,
model: str,
temperature: float,
max_tokens: int,
function_name: str = "",
response_mode: str = "complete",
) -> ChatCompletionResult:
provider_name = _normalize_provider(provider)
structured_output = self._structured_output_required(function_name)
request_timeout = self._request_timeout_for_function(function_name, response_mode=response_mode)
is_openclaw_gateway_request = self._is_openclaw_gateway_request(
provider_name=provider_name,
base_url=base_url,
)
openclaw_agents_timeout_seconds = self._openclaw_agents_timeout_seconds()
if is_openclaw_gateway_request:
# 网关内部 agent 超时需小于客户端超时,避免本地请求先被中止。
request_timeout = max(float(request_timeout), float(openclaw_agents_timeout_seconds) + 5.0)
use_provider_circuit_breaker = self._use_provider_circuit_breaker(function_name)
circuit_key = self._provider_circuit_key(
provider=provider_name,
base_url=base_url,
model=model,
)
if use_provider_circuit_breaker and self._is_provider_circuit_open(circuit_key):
raise ValueError(f"provider_circuit_open:{provider_name}:{model}")
key = api_key.strip()
messages: list[dict[str, str]] = []
cleaned_system_prompt = str(system_prompt or "").strip()
if cleaned_system_prompt:
messages.append({"role": "system", "content": cleaned_system_prompt})
messages.append({"role": "user", "content": prompt})
request_trace_id = uuid4().hex[:12]
request_started_at_perf = time.perf_counter()
function_label = str(function_name or "llm_generate")
openclaw_agent_timeout = int(openclaw_agents_timeout_seconds) if is_openclaw_gateway_request else None
if is_openclaw_gateway_request:
_runtime_log(
"OpenClaw调用",
(
f"start ts={_cn_now_text_ms()} trace_id={request_trace_id} "
f"function={function_label} provider={provider_name} model={model} "
f"request_timeout={float(request_timeout):.2f}s "
f"agent_timeout={openclaw_agent_timeout if openclaw_agent_timeout is not None else 'N/A'}"
),
)
try:
if provider_name == "ollama":
endpoint = f"{base_url.rstrip('/')}/api/chat"
payload = {
"model": model,
"messages": messages,
"stream": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client:
resp = await client.post(endpoint, json=payload)
resp.raise_for_status()
data = resp.json()
message = data.get("message") if isinstance(data, dict) else {}
content = message.get("content") if isinstance(message, dict) else None
text = self._sanitize_visible_answer(self._extract_text_from_value(content))
if not text:
text = self._sanitize_visible_answer(self._extract_text_from_value(data.get("response")))
if not text and not isinstance(data, dict):
raise ValueError("Ollama 返回空内容")
if use_provider_circuit_breaker:
self._record_provider_success(circuit_key)
return ChatCompletionResult(text=text, raw_response=data)
if not key:
raise ValueError("未配置 LLM API Key")
endpoint = f"{base_url.rstrip('/')}/chat/completions"
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if is_openclaw_gateway_request:
payload["agents"] = {
"defaults": {
"timeoutSeconds": int(openclaw_agents_timeout_seconds),
}
}
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client:
resp = await client.post(endpoint, headers=headers, json=payload)
if is_openclaw_gateway_request:
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
_runtime_log(
"OpenClaw调用",
(
f"http ts={_cn_now_text_ms()} trace_id={request_trace_id} "
f"function={function_label} status={resp.status_code} elapsed_ms={elapsed_ms:.0f}"
),
)
if resp.status_code >= 400:
body_preview = str(resp.text or "").replace("\n", " ").strip()[:260]
if is_openclaw_gateway_request:
_runtime_log(
"OpenClaw调用",
(
f"http_error ts={_cn_now_text_ms()} trace_id={request_trace_id} "
f"function={function_label} status={resp.status_code} body={body_preview}"
),
)
resp.raise_for_status()
data = resp.json()
choices = data.get("choices") or []
if not choices:
raise ValueError("LLM 响应无 choices")
message = choices[0].get("message") if isinstance(choices[0], dict) else {}
text = self._extract_text_from_chat_response(
message=message,
choice=choices[0] if isinstance(choices[0], dict) else {},
response=data,
structured_output=structured_output,
)
if not text and not structured_output:
msg_keys = list(message.keys()) if isinstance(message, dict) else []
choice_keys = list(choices[0].keys()) if isinstance(choices[0], dict) else []
choice_preview = ""
try:
choice_preview = json.dumps(choices[0], ensure_ascii=False)[:300]
except Exception:
choice_preview = str(choices[0])[:300]
raise ValueError(
"LLM 返回空内容: "
f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}"
)
if _looks_like_openclaw_timeout_message(text):
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
_runtime_log(
"OpenClaw超时" if is_openclaw_gateway_request else "LLM超时文本",
(
f"detected ts={_cn_now_text_ms()} trace_id={request_trace_id} "
f"function={function_label} elapsed_ms={elapsed_ms:.0f} "
f"text={text.replace(chr(10), ' ')[:260]}"
),
)
logger.warning(
"llm_timeout_payload_detected",
trace_id=request_trace_id,
function_name=function_label,
provider=provider_name,
model=model,
base_url=str(base_url or "")[:120],
request_timeout_seconds=float(request_timeout),
agent_timeout_seconds=openclaw_agent_timeout,
text_preview=text.replace("\n", " ")[:260],
)
if is_openclaw_gateway_request:
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
_runtime_log(
"OpenClaw调用",
(
f"end ts={_cn_now_text_ms()} trace_id={request_trace_id} "
f"function={function_label} elapsed_ms={elapsed_ms:.0f} text_len={len(text)}"
),
)
if use_provider_circuit_breaker:
self._record_provider_success(circuit_key)
return ChatCompletionResult(text=text, raw_response=data)
except Exception as exc:
if _looks_like_openclaw_timeout_message(str(exc)):
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
_runtime_log(
"LLM超时异常",
(
f"detected ts={_cn_now_text_ms()} trace_id={request_trace_id} "
f"function={function_label} elapsed_ms={elapsed_ms:.0f} "
f"error={type(exc).__name__}:{str(exc)[:260]}"
),
)
logger.warning(
"llm_timeout_exception_detected",
trace_id=request_trace_id,
function_name=function_label,
provider=provider_name,
model=model,
base_url=str(base_url or "")[:120],
request_timeout_seconds=float(request_timeout),
agent_timeout_seconds=openclaw_agent_timeout,
error_type=type(exc).__name__,
error=str(exc)[:260],
)
if is_openclaw_gateway_request:
elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0
_runtime_log(
"OpenClaw调用",
(
f"exception ts={_cn_now_text_ms()} trace_id={request_trace_id} "
f"function={function_label} elapsed_ms={elapsed_ms:.0f} "
f"error={type(exc).__name__}:{str(exc)[:260]}"
),
)
if use_provider_circuit_breaker:
self._record_provider_failure(circuit_key, exc)
raise
async def generate_task_breakdown_async(self, description: str, **kwargs: Any) -> list[dict]:
if not description.strip():
return []
prompt = (
"请将以下需求拆解为 5-8 条执行任务,返回 JSON 数组,"
"每项格式: {\"task_name\":\"\",\"type\":\"backend|frontend|database|ai|devops\","
"\"estimated_hours\":4,\"priority\":\"high|medium|low\"}。\n"
f"需求:{description[:1200]}"
)
text = await self.generate(prompt, function_name="generate_task_breakdown", **kwargs)
rows = self._parse_json_array(text)
if rows:
return rows
return [
{
"task_name": "需求分析",
"type": "ai",
"priority": "medium",
"estimated_hours": 4,
"description": description[:200],
}
]
async def get_embedding_async(self, text: str, **kwargs: Any) -> list[float]:
db = kwargs.get("db") or kwargs.get("db_session")
tenant_id = int(kwargs.get("tenant_id") or 1)
if db is None:
async with AsyncSessionLocal() as session:
return await self.get_embedding_async(text, db=session, tenant_id=tenant_id)
try:
engine = await self._resolve_active_engine(db, tenant_id=tenant_id)
provider = str(engine.get("provider") or "")
if provider == "openai":
endpoint = f"{str(engine.get('base_url') or '').rstrip('/')}/embeddings"
headers = {
"Authorization": f"Bearer {str(engine.get('api_key') or '').strip()}",
"Content-Type": "application/json",
}
payload = {
"model": str(engine.get("model") or "text-embedding-3-small"),
"input": text,
}
async with httpx.AsyncClient(timeout=self.request_timeout, trust_env=False) as client:
resp = await client.post(endpoint, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
return list(data.get("data", [{}])[0].get("embedding") or [])
except Exception:
pass
# DeepSeek/GLM 及失败场景:回退伪向量,保持功能可用。
seed = hashlib.md5(text.encode("utf-8")).digest()
return [((seed[i % 16] / 255.0) * 2 - 1) for i in range(64)]
async def extract_context_insights_async(self, text: str, **kwargs: Any) -> dict:
prompt = (
"请提炼以下技术文档上下文的关键结论,返回 JSON:"
"{\"summary\":\"\",\"keywords\":[\"\",\"\"],\"risks\":[\"\",\"\"]}\n"
f"上下文:{text[:2000]}"
)
raw = await self.generate(prompt, function_name="extract_context_insights", **kwargs)
parsed = self._parse_json_object(raw)
if parsed:
return parsed
return {"summary": text[:120], "keywords": []}
async def find_relevant_context(self, **_: Any) -> list[dict]:
return []
async def evaluate_with_voting_async(self, signal_content: str, impact_score: float, **_: Any) -> dict:
is_strategic = impact_score >= 0.8
return {
"is_strategic": is_strategic,
"confidence": min(1.0, max(0.2, impact_score)),
"decision_proposal": {
"summary": signal_content[:120],
"action": "follow_up" if is_strategic else "observe",
},
"tasks": [],
"_voting": {
"enabled": False,
"models_used": [],
"strategic_votes": 0,
"total_votes": 0,
"unanimous": True,
},
}
async def audit_code_change_async(
self,
task_name: str,
commit_message: str,
code_diff: str,
**_: Any,
) -> dict:
passed = bool(task_name and commit_message and code_diff is not None)
return {
"passed": passed,
"reason": "basic_stub_check_passed" if passed else "invalid_input",
}
# ---------------------------------------------------------------------
# internals
# ---------------------------------------------------------------------
def _stub_response(self, prompt: str) -> str:
_ = prompt # 保留签名,避免调用方改动
return (
"我暂时无法生成可靠回答(模型服务异常)。"
"为避免误导,本次不直接给结论;请稍后重试,"
"或补充更具体的目标对象后继续提问。"
)
def _sanitize_engine_for_response(self, item: dict[str, Any]) -> dict[str, Any]:
return {
"id": str(item.get("id") or ""),
"name": str(item.get("name") or ""),
"provider": str(item.get("provider") or ""),
"model": str(item.get("model") or ""),
"base_url": str(item.get("base_url") or ""),
"api_key_masked": _mask_secret(str(item.get("api_key") or "")),
"updated_at": item.get("updated_at"),
"created_at": item.get("created_at"),
}
async def _get_or_init_bundle(
self,
db: AsyncSession,
*,
tenant_id: int,
) -> tuple[Integration, dict[str, Any]]:
result = await db.execute(
select(Integration).where(
and_(
Integration.tenant_id == tenant_id,
Integration.type == "llm_engine",
)
)
)
integration = result.scalar_one_or_none()
if not integration:
integration = Integration(
type="llm_engine",
config={"active_engine_id": None, "engines": []},
is_active=True,
tenant_id=tenant_id,
)
db.add(integration)
await db.flush()
bundle = _safe_json(integration.config)
engines = bundle.get("engines")
if not isinstance(engines, list):
engines = []
bundle["engines"] = engines
if "active_engine_id" not in bundle:
bundle["active_engine_id"] = None
return integration, bundle
async def _resolve_active_engine(
self,
db: AsyncSession,
*,
tenant_id: int,
) -> dict[str, Any]:
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
return self._resolve_active_engine_from_bundle(bundle)
async def _resolve_engine_for_call(
self,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
) -> dict[str, Any]:
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
active_engine = self._resolve_active_engine_from_bundle(bundle)
active_engine = self._ensure_openclaw_control_plane_engine(active_engine)
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
openclaw_routed = self._resolve_openclaw_engine(
function_name=function_name,
engines=engines,
active_engine=active_engine,
)
if openclaw_routed is not None:
openclaw_routed = self._ensure_openclaw_control_plane_engine(openclaw_routed)
provider = str(openclaw_routed.get("provider") or "")
api_key = str(openclaw_routed.get("api_key") or "").strip()
if provider != "ollama" and not api_key:
_runtime_log(
"OpenClaw路由",
(
"命中 OpenClaw 路由但目标引擎缺少 API Key,已回退 active:"
f"function={function_name}, provider={provider}, model={openclaw_routed.get('model')}"
),
)
else:
return openclaw_routed
if not self._mixed_routing_enabled():
return active_engine
tier = self._resolve_function_tier(function_name)
if tier not in {"high", "low"}:
return active_engine
routed = self._resolve_tier_engine(
tier=tier,
engines=engines,
active_engine=active_engine,
)
if not routed:
return active_engine
provider = str(routed.get("provider") or "")
api_key = str(routed.get("api_key") or "").strip()
if provider != "ollama" and not api_key:
_runtime_log(
"LLM路由",
(
"命中分层路由但目标引擎缺少 API Key,回退可用引擎:"
f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}"
),
)
fallback_engine = self._pick_tier_fallback_engine(
tier=tier,
engines=engines,
active_engine=active_engine,
)
if fallback_engine:
return fallback_engine
return active_engine
_runtime_log(
"LLM路由",
(
"函数路由生效:"
f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}"
),
)
return self._ensure_openclaw_control_plane_engine(routed)
def _resolve_active_engine_from_bundle(self, bundle: dict[str, Any]) -> dict[str, Any]:
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
active_engine_id = str(bundle.get("active_engine_id") or "")
if active_engine_id:
for engine in engines:
if str(engine.get("id") or "") == active_engine_id:
return self._normalize_engine_runtime(engine)
if engines:
return self._normalize_engine_runtime(engines[0])
return self._env_engine_runtime()
def _mixed_routing_enabled(self) -> bool:
return _env_bool("LLM_MIXED_ROUTING_ENABLED", False)
def _openclaw_routing_enabled(self) -> bool:
return _env_bool("OPENCLAW_ROUTING_ENABLED", True)
def _openclaw_control_plane_only(self) -> bool:
return self._openclaw_routing_enabled() and not self._direct_emergency_enabled()
def _ensure_openclaw_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]:
runtime = dict(engine or {})
if not self._openclaw_control_plane_only():
return runtime
openai_default = self._env_provider_runtime("openai")
runtime["provider"] = "openai"
runtime["base_url"] = (
str(os.getenv("OPENAI_BASE_URL", openai_default.get("base_url") or OPENCLAW_PRIMARY_GATEWAY_URL) or "")
.strip()
.rstrip("/")
or OPENCLAW_PRIMARY_GATEWAY_URL
)
runtime["api_key"] = (
str(os.getenv("OPENAI_API_KEY", "") or "").strip()
or str(runtime.get("api_key") or "").strip()
)
runtime["model"] = (
str(runtime.get("model") or "").strip()
or str(openai_default.get("model") or OPENCLAW_DEFAULT_LOW_MODEL).strip()
)
runtime["name"] = str(runtime.get("name") or "OpenClaw Gateway").strip() or "OpenClaw Gateway"
runtime["id"] = str(runtime.get("id") or "openclaw-gateway")
return runtime
def _openclaw_rules_path(self) -> str:
custom = str(
os.getenv(
"OPENCLAW_ROUTING_RULES_PATH",
OPENCLAW_DEFAULT_RULES_RELATIVE_PATH,
)
or ""
).strip()
raw_path = custom or OPENCLAW_DEFAULT_RULES_RELATIVE_PATH
candidate = Path(raw_path)
if not candidate.is_absolute():
candidate = (Path(__file__).resolve().parents[2] / candidate).resolve()
return str(candidate)
def _load_openclaw_routing_bundle(self) -> dict[str, Any]:
now = time.time()
if self._openclaw_routing_cache and self._openclaw_routing_cache[0] > now:
return dict(self._openclaw_routing_cache[1])
path = Path(self._openclaw_rules_path())
bundle: dict[str, Any] = {}
if path.exists():
try:
parsed = json.loads(path.read_text(encoding="utf-8"))
if isinstance(parsed, dict):
bundle = parsed
except Exception as exc:
_runtime_log("OpenClaw路由", f"规则文件解析失败:path={path} error={exc}")
bundle = {}
self._openclaw_routing_cache = (now + OPENCLAW_ROUTING_CACHE_TTL_SECONDS, dict(bundle))
return dict(bundle)
@staticmethod
def _rule_matches_function(rule: dict[str, Any], function_name: str) -> bool:
if bool(rule.get("match_all", False)):
return True
fn = str(function_name or "").strip().lower()
if not fn:
return False
exact = str(rule.get("function_exact") or "").strip().lower()
if exact and fn == exact:
return True
prefix = str(rule.get("function_prefix") or "").strip().lower()
if prefix == "*" or prefix == "all":
return True
if prefix and fn.startswith(prefix):
return True
match_any = rule.get("match_any")
if isinstance(match_any, list):
for hint in match_any:
token = str(hint or "").strip().lower()
if token and token in fn:
return True
return False
def _match_openclaw_rule(
self,
function_name: str,
*,
tier_filter: Optional[set[str]] = None,
) -> Optional[dict[str, Any]]:
if not self._openclaw_routing_enabled():
return None
bundle = self._load_openclaw_routing_bundle()
rules = bundle.get("rules") if isinstance(bundle.get("rules"), list) else []
if not rules:
return None
indexed: list[tuple[int, int, dict[str, Any]]] = []
for idx, row in enumerate(rules):
if not isinstance(row, dict):
continue
if not bool(row.get("enabled", True)):
continue
if tier_filter:
tier_name = str(row.get("tier") or "").strip().lower()
if tier_name not in tier_filter:
continue
if not self._rule_matches_function(row, function_name):
continue
priority = int(row.get("priority") or 0)
indexed.append((priority, -idx, row))
if not indexed:
return None
indexed.sort(reverse=True)
return dict(indexed[0][2])
def _resolve_openclaw_engine(
self,
*,
function_name: str,
engines: list[dict[str, Any]],
active_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
rule = self._match_openclaw_rule(function_name, tier_filter={"low", "high"})
if not rule:
return None
routed = self._resolve_engine_from_rule(
rule=rule,
engines=engines,
active_engine=active_engine,
)
if routed is None:
return None
_runtime_log(
"OpenClaw路由",
(
"规则命中:"
f"function={function_name}, provider={routed.get('provider')}, "
f"model={routed.get('model')}, rule={rule.get('name') or 'unnamed'}"
),
)
return routed
def _resolve_engine_from_rule(
self,
*,
rule: dict[str, Any],
engines: list[dict[str, Any]],
active_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
tier = str(rule.get("tier") or "").strip().lower()
provider_raw = str(rule.get("provider") or "").strip().lower()
engine_id = str(rule.get("engine_id") or "").strip()
model_override = str(rule.get("model") or "").strip()
base_url_override = str(rule.get("base_url") or "").strip().rstrip("/")
api_env_override = str(rule.get("api_env") or "").strip()
routed: Optional[dict[str, Any]] = None
if engine_id:
for item in engines:
if str(item.get("id") or "").strip() == engine_id:
routed = self._normalize_engine_runtime(item)
break
if routed is None and provider_raw:
try:
provider = _normalize_provider(provider_raw)
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:
try:
routed = self._env_provider_runtime(provider)
except Exception:
routed = None
if routed is None and tier in {"high", "low", "safety"}:
routed = self._resolve_tier_engine(
tier=tier,
engines=engines,
active_engine=active_engine,
)
if routed is None:
return None
if model_override:
routed["model"] = model_override
if base_url_override:
routed["base_url"] = base_url_override
if api_env_override:
routed["api_key"] = str(os.getenv(api_env_override, "") or "").strip()
routed["api_env"] = api_env_override
return routed
def _resolve_function_tier(self, function_name: str) -> str:
fn = str(function_name or "").strip().lower()
if not fn:
return str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower()
high_hints = _parse_csv_items(os.getenv("LLM_ROUTING_HIGH_FUNCTIONS", ""))
low_hints = _parse_csv_items(os.getenv("LLM_ROUTING_LOW_FUNCTIONS", ""))
high_pool = high_hints or DEFAULT_HIGH_TIER_HINTS
low_pool = low_hints or DEFAULT_LOW_TIER_HINTS
if any(hint and hint in fn for hint in high_pool):
return "high"
if any(hint and hint in fn for hint in low_pool):
return "low"
default_tier = str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "active") or "active").strip().lower()
if default_tier != "active":
return default_tier
# mixed routing 开启时,未知函数默认走 high,避免被 active 引擎“全吞”。
return "high" if self._mixed_routing_enabled() else "active"
def _low_to_high_escalation_enabled(self) -> bool:
return _env_bool("LLM_LOW_TO_HIGH_ESCALATION_ENABLED", True)
def _is_low_tier_function(self, function_name: str) -> bool:
rule = self._match_openclaw_rule(function_name)
if rule is not None:
tier = str(rule.get("tier") or "").strip().lower()
if tier:
return tier == "low"
return self._resolve_function_tier(function_name) == "low"
@staticmethod
def _looks_like_unanswerable_response(text: str) -> bool:
cleaned = str(text or "").strip()
lowered = cleaned.lower()
if not lowered:
return True
if len(cleaned) <= 8:
return True
return any(marker in lowered for marker in LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS)
def _should_escalate_low_to_high(self, *, function_name: str, response_text: str) -> bool:
if not self._low_to_high_escalation_enabled():
return False
if not self._is_low_tier_function(function_name):
return False
return self._looks_like_unanswerable_response(response_text)
@staticmethod
def _merge_prompt_with_profile(*, prompt: str, prompt_profile: dict[str, str]) -> str:
system_prompt = str(prompt_profile.get("system_prompt") or "").strip()
if not system_prompt:
return prompt
role_name = str(prompt_profile.get("role_name") or "unknown").strip() or "unknown"
return f"[system_role:{role_name}]\n{system_prompt}\n\n{prompt}"
def _direct_emergency_enabled(self) -> bool:
return _env_bool("LLM_DIRECT_EMERGENCY_ENABLED", False)
@staticmethod
def _prefer_fast_local_fallback(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
if not fn:
return False
return any(hint and hint in fn for hint in FAST_LOCAL_FALLBACK_FUNCTION_HINTS)
@staticmethod
def _is_fast_local_planner_function(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
return any(hint and hint in fn for hint in FAST_LOCAL_PLANNER_FUNCTION_HINTS)
@staticmethod
def _is_fast_local_worker_function(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
return any(hint and hint in fn for hint in FAST_LOCAL_WORKER_FUNCTION_HINTS)
@staticmethod
def _structured_cache_enabled(function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
return fn in {
"signal_source_onboarding_plan",
"openclaw_command_dispatch",
}
def _request_timeout_for_function(self, function_name: str, *, response_mode: str = "complete") -> float:
if not self._prefer_fast_local_fallback(function_name):
return self.request_timeout
env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_COMPLETE"
default_timeout = FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS
if self._is_fast_local_planner_function(function_name) and str(response_mode or "").strip().lower() == "fast":
env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_FAST"
default_timeout = FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS
try:
configured = float(os.getenv(env_key, str(default_timeout)))
except Exception:
configured = default_timeout
if configured <= 0:
return self.request_timeout
return min(self.request_timeout, configured)
@staticmethod
def _is_openclaw_gateway_request(*, provider_name: str, base_url: str) -> bool:
if str(provider_name or "").strip().lower() != "openai":
return False
normalized_base_url = str(base_url or "").strip().lower()
if not normalized_base_url:
return False
return "openclaw" in normalized_base_url
@staticmethod
def _openclaw_agents_timeout_seconds() -> int:
raw = os.getenv(
"OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS",
str(OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS),
)
try:
configured = int(str(raw).strip())
except Exception:
configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS
if configured <= 0:
configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS
return min(configured, OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX)
def _use_provider_circuit_breaker(self, function_name: str) -> bool:
return not self._prefer_fast_local_fallback(function_name)
def _structured_output_required(self, function_name: str) -> bool:
fn = str(function_name or "").strip().lower()
if not fn:
return False
custom_hints = _parse_csv_items(os.getenv("LLM_STRUCTURED_OUTPUT_FUNCTIONS", ""))
hint_pool = custom_hints or STRUCTURED_OUTPUT_FUNCTION_HINTS
return any(hint and hint in fn for hint in hint_pool)
@staticmethod
def _extract_json_candidate(text: str) -> str:
raw = str(text or "").strip()
if not raw:
return ""
if raw.startswith("```"):
chunks = raw.split("```")
if len(chunks) >= 2:
raw = chunks[1].strip()
if raw.startswith("json"):
raw = raw[4:].strip()
if raw.startswith("{") or raw.startswith("["):
return raw
object_candidate = LLMService._extract_balanced_json_segment(raw, "{", "}")
array_candidate = LLMService._extract_balanced_json_segment(raw, "[", "]")
if object_candidate and array_candidate:
return object_candidate if raw.find("{") <= raw.find("[") else array_candidate
if object_candidate:
return object_candidate
if array_candidate:
return array_candidate
return raw
@staticmethod
def _looks_like_json_container(text: str) -> bool:
raw = str(text or "").strip()
if not raw:
return False
if raw.startswith("{") or raw.startswith("["):
return True
return "{" in raw or "[" in raw
@staticmethod
def _extract_balanced_json_segment(text: str, open_char: str, close_char: str) -> str:
raw = str(text or "")
start = raw.find(open_char)
if start < 0:
return ""
depth = 0
in_str = False
escaped = False
for idx in range(start, len(raw)):
ch = raw[idx]
if in_str:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
continue
if ch == open_char:
depth += 1
elif ch == close_char:
depth -= 1
if depth == 0:
return raw[start : idx + 1].strip()
return ""
def _is_invalid_structured_output(self, *, function_name: str, response_text: str) -> bool:
if not self._structured_output_required(function_name):
return False
candidate = self._extract_json_candidate(response_text)
if not candidate:
return True
try:
parsed = json.loads(candidate)
except Exception:
return True
parsed = self._unwrap_structured_payload_once(parsed)
validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed)
if validation_error is not None:
return True
return not isinstance(parsed, (dict, list))
@staticmethod
def _strip_code_fence(text: str) -> str:
raw = str(text or "").strip()
if not raw.startswith("```"):
return raw
chunks = raw.split("```")
if len(chunks) >= 2:
raw = chunks[1].strip()
if raw.lower().startswith("json"):
raw = raw[4:].strip()
return raw.strip()
def _extract_text_from_chat_response(
self,
*,
message: Any,
choice: Any,
response: Any,
structured_output: bool,
) -> str:
if structured_output:
for _, raw_value in self._iter_structured_candidates(message=message, choice=choice, response=response):
text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value))
normalized = self._strip_code_fence(text)
if not self._looks_like_json_container(normalized):
continue
candidate = self._extract_json_candidate(normalized)
if candidate:
return candidate
return ""
if isinstance(message, dict):
text = self._sanitize_visible_answer(self._extract_text_from_value(message.get("content")))
if text:
return text
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
first = tool_calls[0] if isinstance(tool_calls[0], dict) else {}
fn = first.get("function") if isinstance(first, dict) else {}
if isinstance(fn, dict):
text = self._sanitize_visible_answer(self._extract_text_from_value(fn.get("arguments")))
if text:
return text
if isinstance(choice, dict):
text = self._sanitize_visible_answer(self._extract_text_from_value(choice.get("text")))
if text:
return text
if isinstance(response, dict):
text = self._sanitize_visible_answer(self._extract_text_from_value(response.get("output_text")))
if text:
return text
return ""
def _iter_structured_candidates(
self,
*,
message: Any,
choice: Any,
response: Any,
) -> list[tuple[str, Any]]:
candidates: list[tuple[str, Any]] = []
if isinstance(message, dict):
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
first = tool_calls[0] if isinstance(tool_calls[0], dict) else {}
fn = first.get("function") if isinstance(first, dict) else {}
candidates.append(("tool_calls.function.arguments", fn.get("arguments") if isinstance(fn, dict) else None))
candidates.append(("message.content", message.get("content")))
if isinstance(choice, dict):
candidates.append(("choice.text", choice.get("text")))
if isinstance(response, dict):
candidates.append(("response.output_text", response.get("output_text")))
if isinstance(message, dict):
candidates.append(("message.reasoning_content", message.get("reasoning_content")))
candidates.append(("message.reasoning_content", message.get("reasoning")))
if isinstance(choice, dict):
candidates.append(("choice.reasoning_content", choice.get("reasoning_content")))
candidates.append(("choice.reasoning_content", choice.get("reasoning")))
if isinstance(response, dict):
candidates.append(("response.reasoning_content", response.get("reasoning_content")))
candidates.append(("response.reasoning_content", response.get("reasoning")))
return candidates
@classmethod
def _unwrap_structured_payload_once(cls, payload: Any) -> Any:
if not isinstance(payload, dict):
return payload
for key in ("json", "data", "result", "arguments"):
if key not in payload:
continue
inner = payload.get(key)
if isinstance(inner, str):
candidate = cls._extract_json_candidate(cls._strip_code_fence(inner))
if not candidate:
continue
try:
return json.loads(candidate)
except Exception:
return inner
if isinstance(inner, (dict, list)):
return inner
return payload
@staticmethod
def _matches_expected_type(value: Any, expected: str) -> bool:
if expected == "bool":
return isinstance(value, bool)
if expected == "str":
return isinstance(value, str)
if expected == "list":
return isinstance(value, list)
if expected == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if expected == "number_or_none":
return value is None or (isinstance(value, (int, float)) and not isinstance(value, bool))
if expected == "dict_or_none":
return value is None or isinstance(value, dict)
return True
def _validate_structured_payload(self, *, function_name: str, payload: Any) -> Optional[str]:
fn = str(function_name or "").strip().lower()
schema = STRUCTURED_OBJECT_SCHEMAS.get(fn)
if schema is None:
return None if isinstance(payload, (dict, list)) else "structured_json_invalid"
if not isinstance(payload, dict):
return "structured_schema_invalid"
required_keys = schema.get("required_keys") or ()
for key in required_keys:
if key not in payload:
return "structured_schema_invalid"
type_checks = schema.get("type_checks") or {}
for key, expected in type_checks.items():
if not self._matches_expected_type(payload.get(key), str(expected)):
return "structured_schema_invalid"
optional_type_checks = schema.get("optional_type_checks") or {}
for key, expected_values in optional_type_checks.items():
if key not in payload:
continue
if not any(self._matches_expected_type(payload.get(key), str(expected)) for expected in expected_values):
return "structured_schema_invalid"
return None
def _extract_structured_object_from_response(
self,
*,
function_name: str,
response: Any,
fallback_text: str,
provider: str,
model: str,
) -> StructuredExtractionResult:
message = {}
choice: dict[str, Any] = {}
if isinstance(response, dict):
choices = response.get("choices") if isinstance(response.get("choices"), list) else []
choice = choices[0] if choices and isinstance(choices[0], dict) else {}
message = choice.get("message") if isinstance(choice.get("message"), dict) else {}
last_nonempty_field = ""
last_nonempty_text = ""
for field, raw_value in self._iter_structured_candidates(message=message, choice=choice, response=response):
text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value))
normalized = self._strip_code_fence(text)
if normalized:
last_nonempty_field = field
last_nonempty_text = normalized[:4000]
if not self._looks_like_json_container(normalized):
continue
candidate = self._extract_json_candidate(normalized)
if not candidate:
continue
try:
parsed = json.loads(candidate)
except Exception:
return StructuredExtractionResult(
payload=None,
source_field=field,
provider=provider,
model=model,
error_code="structured_json_invalid",
raw_text=candidate[:4000],
validation="invalid",
)
parsed = self._unwrap_structured_payload_once(parsed)
validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed)
if validation_error is not None:
return StructuredExtractionResult(
payload=None,
source_field=field,
provider=provider,
model=model,
error_code=validation_error,
raw_text=candidate[:4000],
validation="invalid",
)
return StructuredExtractionResult(
payload=parsed if isinstance(parsed, dict) else None,
source_field=field,
provider=provider,
model=model,
raw_text=candidate[:4000],
validation="ok",
)
fallback_normalized = self._strip_code_fence(fallback_text)
fallback_candidate = (
self._extract_json_candidate(fallback_normalized)
if self._looks_like_json_container(fallback_normalized)
else ""
)
if fallback_candidate:
try:
parsed = json.loads(fallback_candidate)
except Exception:
return StructuredExtractionResult(
payload=None,
source_field="generated_text",
provider=provider,
model=model,
error_code="structured_json_invalid",
raw_text=fallback_candidate[:4000],
validation="invalid",
)
parsed = self._unwrap_structured_payload_once(parsed)
validation_error = self._validate_structured_payload(function_name=function_name, payload=parsed)
if validation_error is None and isinstance(parsed, dict):
return StructuredExtractionResult(
payload=parsed,
source_field="generated_text",
provider=provider,
model=model,
raw_text=fallback_candidate[:4000],
validation="ok",
)
if not last_nonempty_field and str(fallback_text or "").strip():
last_nonempty_field = "generated_text"
last_nonempty_text = str(fallback_text or "").strip()[:4000]
error_code = "structured_json_not_found" if last_nonempty_field else "structured_empty"
return StructuredExtractionResult(
payload=None,
source_field=last_nonempty_field,
provider=provider,
model=model,
error_code=error_code,
raw_text=last_nonempty_text,
validation="invalid",
)
@staticmethod
def _classify_structured_exception(exc: Exception) -> str:
if isinstance(exc, (asyncio.TimeoutError, httpx.TimeoutException)):
return "structured_timeout"
if isinstance(exc, (httpx.HTTPError, httpx.RequestError)):
return "structured_http_error"
lowered = str(exc or "").strip().lower()
if "timeout" in lowered:
return "structured_timeout"
if "status" in lowered or "http" in lowered:
return "structured_http_error"
return "structured_empty"
@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)]
routed = self._resolve_tier_engine(
tier="high",
engines=engines,
active_engine=active_engine,
)
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
async def _resolve_safety_tier_engine(
self,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
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)]
safety_rule = self._match_openclaw_rule(function_name, tier_filter={"safety"})
routed: Optional[dict[str, Any]] = None
if safety_rule is not None:
routed = self._resolve_engine_from_rule(
rule=safety_rule,
engines=engines,
active_engine=active_engine,
)
if routed is None:
routed = self._resolve_tier_engine(
tier="safety",
engines=engines,
active_engine=active_engine,
)
if not routed:
return None
routed = self._ensure_openclaw_control_plane_engine(routed)
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
async def _try_safety_engine(
self,
db: AsyncSession,
*,
tenant_id: int,
function_name: str,
prompt: str,
system_prompt: str,
temperature: float,
max_tokens: int,
primary_engine: dict[str, Any],
reason: str,
) -> tuple[str, Optional[dict[str, Any]]]:
safety_engine = await self._resolve_safety_tier_engine(
db,
tenant_id=tenant_id,
function_name=function_name,
primary_engine=primary_engine,
)
if safety_engine is None:
_runtime_log(
"LLM路由",
f"Safety 引擎不可用,无法触发兜底:function={function_name}, reason={reason}",
)
return "", None
safety_provider = str(safety_engine.get("provider") or "")
safety_model = str(safety_engine.get("model") or "")
_runtime_log(
"LLM路由",
(
"触发 Safety 兜底:"
f"function={function_name}, provider={safety_provider}, model={safety_model}, reason={reason}"
),
)
try:
text = await self._chat_completion(
provider=safety_provider,
prompt=prompt,
system_prompt=system_prompt,
api_key=str(safety_engine.get("api_key") or ""),
base_url=str(safety_engine.get("base_url") or ""),
model=safety_model,
temperature=temperature,
max_tokens=max_tokens,
function_name=function_name,
)
if not text.strip():
raise ValueError("safety_engine_empty_response")
if self._is_invalid_structured_output(function_name=function_name, response_text=text):
raise ValueError("safety_engine_invalid_structured_output")
return text, safety_engine
except Exception as exc:
_runtime_log("LLM路由", f"Safety 兜底失败:function={function_name}, error={exc}")
return "", None
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}, provider={provider}, model={candidate.get('model')}"
),
)
return candidate
return None
def _resolve_tier_engine(
self,
*,
tier: str,
engines: list[dict[str, Any]],
active_engine: dict[str, Any],
) -> Optional[dict[str, Any]]:
tier = str(tier or "").strip().lower()
upper = tier.upper()
default_provider_by_tier = {
"low": "openai",
"high": "openai",
"safety": "openai",
}
provider_env = str(
os.getenv(
f"LLM_ROUTING_{upper}_PROVIDER",
default_provider_by_tier.get(
tier,
os.getenv("LLM_PROVIDER", "openai"),
),
)
or ""
).strip()
engine_id = str(os.getenv(f"LLM_ROUTING_{upper}_ENGINE_ID", "") or "").strip()
model_override = str(os.getenv(f"LLM_ROUTING_{upper}_MODEL", "") or "").strip()
base_url_override = str(os.getenv(f"LLM_ROUTING_{upper}_BASE_URL", "") or "").strip().rstrip("/")
routed: Optional[dict[str, Any]] = None
if engine_id:
for item in engines:
if str(item.get("id") or "") == engine_id:
routed = self._normalize_engine_runtime(item)
break
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:
try:
routed = self._env_provider_runtime(provider)
except Exception:
routed = None
if routed is None:
return None
provider = str(routed.get("provider") or "").strip().lower()
if model_override:
routed["model"] = model_override
elif self._openclaw_routing_enabled() and provider == "openai":
default_model = DEFAULT_TIER_MODEL_OVERRIDES.get(tier)
if default_model:
routed["model"] = default_model
if base_url_override:
routed["base_url"] = base_url_override
elif self._openclaw_routing_enabled() and provider == "openai":
routed["base_url"] = OPENCLAW_PRIMARY_GATEWAY_URL
if str(routed.get("id") or "") != str(active_engine.get("id") or ""):
_runtime_log(
"LLM路由",
(
"混合路由已命中:"
f"层级={tier}, provider={routed.get('provider')}, model={routed.get('model')}"
),
)
return self._ensure_openclaw_control_plane_engine(routed)
async def _resolve_engine_for_test(
self,
db: AsyncSession,
*,
tenant_id: int,
engine_id: Optional[str],
) -> dict[str, Any]:
if not engine_id:
engine = await self._resolve_active_engine(db, tenant_id=tenant_id)
if str(engine.get("provider") or "") != "ollama" and not str(engine.get("api_key") or "").strip():
raise ValueError("当前激活引擎未配置 API Key")
return engine
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
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)
if str(engine.get("provider") or "") != "ollama" and not str(engine.get("api_key") or "").strip():
raise ValueError("目标引擎未配置 API Key")
return engine
raise ValueError("engine 不存在")
async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]:
cached = self._prompt_profile_cache.get(int(tenant_id))
now = time.time()
if cached and cached[0] > now:
return dict(cached[1])
role_name = ""
system_prompt = ""
role_key = ""
try:
row = (
await db.execute(
select(Integration).where(
and_(
Integration.tenant_id == int(tenant_id),
Integration.type == "ai_prompt_profiles",
)
)
)
).scalar_one_or_none()
bundle = _safe_json(row.config if row else {})
active_key = str(bundle.get("active_role_key") or "").strip()
roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else []
selected = None
for item in roles:
if not isinstance(item, dict):
continue
item_key = str(item.get("role_key") or "").strip()
if item_key == active_key:
selected = item
break
if selected is None:
for item in roles:
if isinstance(item, dict) and bool(item.get("enabled", True)):
selected = item
break
if isinstance(selected, dict):
role_name = str(selected.get("role_name") or "").strip()
system_prompt = str(selected.get("system_prompt") or "").strip()
role_key = str(selected.get("role_key") or "").strip()
except Exception:
role_name = ""
system_prompt = ""
role_key = ""
if len(system_prompt) < 20:
fallback = build_default_role_prompt_bundle()
fallback_key = str(fallback.get("active_role_key") or "").strip()
roles = fallback.get("roles") if isinstance(fallback.get("roles"), list) else []
role = None
for item in roles:
if not isinstance(item, dict):
continue
if str(item.get("role_key") or "").strip() == fallback_key:
role = item
break
if role is None and roles:
role = roles[0] if isinstance(roles[0], dict) else None
if isinstance(role, dict):
role_name = str(role.get("role_name") or role_name or "默认角色").strip()
system_prompt = str(role.get("system_prompt") or "").strip()
role_key = str(role.get("role_key") or role_key or fallback_key).strip()
result = {
"role_key": role_key,
"role_name": role_name,
"system_prompt": system_prompt,
}
self._prompt_profile_cache[int(tenant_id)] = (now + PROMPT_PROFILE_CACHE_TTL_SECONDS, result)
return dict(result)
def _build_fallback_engines(self, primary_engine: dict[str, Any]) -> list[dict[str, Any]]:
candidates: list[dict[str, Any]] = []
provider = _normalize_provider(primary_engine.get("provider"))
current_model = str(primary_engine.get("model") or "").strip()
provider_runtime = self._env_provider_runtime(provider)
default_model = str(provider_runtime.get("model") or PROVIDER_DEFAULTS.get(provider, {}).get("model") or "").strip()
if default_model and default_model != current_model:
same_provider = dict(primary_engine)
same_provider["model"] = default_model
candidates.append(self._normalize_engine_runtime(same_provider))
fallback_provider_raw = str(os.getenv("LLM_FALLBACK_PROVIDER", "ollama")).strip()
if fallback_provider_raw:
fallback_provider = _normalize_provider(fallback_provider_raw)
if fallback_provider != provider:
candidates.append(self._env_provider_runtime(fallback_provider))
return candidates
def _env_provider_runtime(self, provider: str) -> dict[str, Any]:
provider_name = _normalize_provider(provider)
defaults = PROVIDER_DEFAULTS[provider_name]
api_env = str(defaults.get("api_env") or "").strip()
api_key = os.getenv(api_env, "") if api_env else ""
if provider_name == "glm":
api_key = os.getenv("GLM_API_KEY", os.getenv("ZHIPUAI_API_KEY", api_key))
base_url = os.getenv(f"{provider_name.upper()}_BASE_URL", defaults["base_url"])
model = os.getenv(f"{provider_name.upper()}_MODEL", defaults["model"])
if provider_name == "ollama":
base_url = os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", defaults["base_url"]))
model = os.getenv("OLLAMA_MODEL", os.getenv("OLLAMA_CHAT_MODEL", defaults["model"]))
return {
"id": f"env-{provider_name}",
"name": f"{defaults['name']} (ENV)",
"provider": provider_name,
"api_key": str(api_key or "").strip(),
"base_url": str(base_url or defaults["base_url"]).strip().rstrip("/"),
"model": str(model or defaults["model"]).strip(),
}
def _env_engine_runtime(self) -> dict[str, Any]:
provider = _normalize_provider(os.getenv("LLM_PROVIDER", "deepseek"))
runtime = self._env_provider_runtime(provider)
runtime["id"] = "env-default"
return runtime
def _normalize_engine_runtime(self, engine: dict[str, Any]) -> dict[str, Any]:
provider = _normalize_provider(engine.get("provider"))
defaults = PROVIDER_DEFAULTS[provider]
runtime = dict(engine)
runtime["provider"] = provider
runtime["base_url"] = str(runtime.get("base_url") or defaults["base_url"]).strip().rstrip("/")
runtime["model"] = str(runtime.get("model") or defaults["model"]).strip()
runtime["api_key"] = str(runtime.get("api_key") or "").strip()
runtime["name"] = str(runtime.get("name") or defaults["name"]).strip()
if not runtime.get("id"):
runtime["id"] = str(uuid4())
return runtime
async def _log_ai_call(
self,
*,
tenant_id: int,
function_name: str,
prompt: str,
response: str,
status: str,
provider: str = "",
model: str = "",
) -> None:
tokens_used = max(len(prompt) // 4, 1) + max(len(response) // 4, 0)
cost = self._estimate_cost(provider=provider, model=model, tokens=tokens_used)
row_payload = {
"tenant_id": tenant_id,
"user_id": None,
"function_name": function_name,
"prompt": prompt[:8000],
"response": (response or "")[:8000],
"tokens_used": tokens_used,
"cost": cost,
"status": status,
}
async with AsyncSessionLocal() as session:
for attempt in range(2):
try:
session.add(AICallLog(**row_payload))
await session.commit()
return
except OperationalError as exc:
await session.rollback()
if "database is locked" not in str(exc).lower() or attempt >= 1:
return
await asyncio.sleep(0.05 * (attempt + 1))
except Exception:
await session.rollback()
return
@staticmethod
def _estimate_cost(provider: str, model: str, tokens: int) -> float:
_ = model
per_1k = {
"deepseek": 0.0003,
"glm": 0.0006,
"openai": 0.0012,
"ollama": 0.0,
}.get(provider, 0.0005)
return round((tokens / 1000.0) * per_1k, 6)
@staticmethod
def _parse_json_object(text: str) -> dict[str, Any]:
raw = (text or "").strip()
if raw.startswith("```"):
parts = raw.split("```")
if len(parts) >= 2:
raw = parts[1].strip()
if raw.startswith("json"):
raw = raw[4:].strip()
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
@staticmethod
def _parse_json_array(text: str) -> list[dict]:
raw = (text or "").strip()
if raw.startswith("```"):
parts = raw.split("```")
if len(parts) >= 2:
raw = parts[1].strip()
if raw.startswith("json"):
raw = raw[4:].strip()
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [item for item in parsed if isinstance(item, dict)]
except Exception:
return []
return []
@staticmethod
def _extract_text_from_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (int, float, bool)):
return str(value)
if isinstance(value, dict):
parts: list[str] = []
for key in ("text", "content", "value", "output_text", "input_text", "arguments", "json"):
if key in value:
parts.append(LLMService._extract_text_from_value(value.get(key)))
return "".join(parts)
if isinstance(value, list):
return "".join(LLMService._extract_text_from_value(item) for item in value)
return str(value)
@staticmethod
def _sanitize_visible_answer(text: str) -> str:
cleaned = str(text or "")
if not cleaned:
return ""
cleaned = re.sub(r"<think>.*?</think>", "", cleaned, flags=re.IGNORECASE | re.DOTALL)
return cleaned.strip()
llm_service = LLMService()