2727 lines
109 KiB
Python
2727 lines
109 KiB
Python
"""统一 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()
|