3305 lines
131 KiB
Python
3305 lines
131 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 .model_context_bundle_service import ModelContextBundleService
|
||
|
||
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_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": "https://api.openai.com/v1",
|
||
"model": "gpt-4o-mini",
|
||
"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",
|
||
"signal_planner_analyze",
|
||
)
|
||
DEFAULT_LOW_TIER_HINTS = (
|
||
"feishu_level_classification",
|
||
"feishu_signal_extraction",
|
||
"feishu_humanized_reply",
|
||
"signal_source_onboarding_plan",
|
||
"signal_source_ai_worker_execution",
|
||
"extract_context_insights",
|
||
"project_draft_",
|
||
"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",
|
||
"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",
|
||
"signal_planner_analyze",
|
||
)
|
||
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",
|
||
"signal_source_ai_worker_execution",
|
||
)
|
||
FAST_LOCAL_PLANNER_FUNCTION_HINTS = (
|
||
"signal_source_onboarding_plan",
|
||
)
|
||
FAST_LOCAL_WORKER_FUNCTION_HINTS = ("signal_source_ai_worker_execution",)
|
||
FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS = 20.0
|
||
FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 35.0
|
||
STRUCTURED_CACHE_TTL_SECONDS = 300
|
||
LLM_ENGINE_SETTINGS_DEFAULTS = {
|
||
"enable_local_kb_qa": False,
|
||
"local_kb_qa_scope": "kb_qa_only",
|
||
}
|
||
LOCAL_KB_QA_FUNCTION_PREFIXES = (
|
||
"adaptive_rag_answer_",
|
||
)
|
||
LOCAL_KB_QA_FUNCTION_EXACT = {
|
||
"rag_intent_classification",
|
||
"rag_db_project_semantic_retrieval",
|
||
}
|
||
STRICT_MODEL_ERROR_FUNCTION_PREFIXES = (
|
||
"adaptive_rag_answer_",
|
||
)
|
||
STRICT_MODEL_ERROR_FUNCTION_EXACT = {
|
||
"rag_db_project_semantic_retrieval",
|
||
}
|
||
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
|
||
CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES = (
|
||
"project_from_text_",
|
||
"project_draft_",
|
||
)
|
||
DDL_API_GUARDRAIL_FUNCTION_EXACT = {
|
||
"classify_intent_ai_pmo_kernel",
|
||
"classify_intent_complex",
|
||
}
|
||
DDL_API_GUARDRAIL_SYSTEM_PROMPT = """
|
||
硬性约束(最高优先级):
|
||
1. 禁止直接执行或建议执行数据库 DDL SQL(CREATE/ALTER/DROP/TRUNCATE/RENAME/COMMENT/GRANT/REVOKE)。
|
||
2. 所有数据库 DDL 变更必须通过受控接口或迁移流程(例如 Alembic migration)。
|
||
3. 若输入要求直接改库执行 DDL:
|
||
- `classify_intent_ai_pmo_kernel` / `classify_intent_complex` 必须返回 `query.read_only`,并在 reasoning 包含 `ddl_api_required`。
|
||
""".strip()
|
||
STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
|
||
"ai_pmo_signal_title_refine": {
|
||
"required_keys": ("title",),
|
||
"type_checks": {
|
||
"title": "str",
|
||
},
|
||
},
|
||
"signal_source_onboarding_plan": {
|
||
"required_keys": ("should_onboard", "summary"),
|
||
"type_checks": {
|
||
"should_onboard": "bool",
|
||
"summary": "str",
|
||
},
|
||
"optional_type_checks": {
|
||
"worker_roles": ("list",),
|
||
"execution_instruction": ("str_or_none",),
|
||
"source": ("dict_or_none",),
|
||
},
|
||
},
|
||
"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": ("confidence", "reasoning"),
|
||
"type_checks": {
|
||
"confidence": "number",
|
||
"reasoning": "str",
|
||
},
|
||
"optional_type_checks": {
|
||
"intent": ("str_or_none",),
|
||
"family": ("str_or_none",),
|
||
"action": ("str_or_none",),
|
||
"project_id": ("number_or_none",),
|
||
"requested_count": ("number_or_none",),
|
||
"requires_confirmation": ("bool",),
|
||
},
|
||
},
|
||
"classify_intent_complex": {
|
||
"required_keys": ("confidence", "reasoning"),
|
||
"type_checks": {
|
||
"confidence": "number",
|
||
"reasoning": "str",
|
||
},
|
||
"optional_type_checks": {
|
||
"intent": ("str_or_none",),
|
||
"family": ("str_or_none",),
|
||
"action": ("str_or_none",),
|
||
"project_id": ("number_or_none",),
|
||
"requested_count": ("number_or_none",),
|
||
"requires_confirmation": ("bool",),
|
||
},
|
||
},
|
||
"signal_planner_analyze": {
|
||
"required_keys": ("summary", "priority", "execution_plan", "risks", "confidence"),
|
||
"type_checks": {
|
||
"summary": "str",
|
||
"priority": "str",
|
||
"execution_plan": "list",
|
||
"risks": "list",
|
||
"confidence": "number",
|
||
},
|
||
"optional_type_checks": {
|
||
"assignee_user_id": ("number_or_none",),
|
||
"assignee_reason": ("str_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
|
||
|
||
@staticmethod
|
||
def _is_retryable_http_status(status_code: Optional[int]) -> bool:
|
||
if status_code is None:
|
||
return False
|
||
return int(status_code) in {408, 409, 429, 500, 502, 503, 504}
|
||
|
||
@staticmethod
|
||
def _parse_retry_after_seconds(raw_value: Optional[str]) -> Optional[float]:
|
||
value = str(raw_value or "").strip()
|
||
if not value:
|
||
return None
|
||
try:
|
||
seconds = float(value)
|
||
except Exception:
|
||
return None
|
||
if seconds <= 0:
|
||
return None
|
||
return min(seconds, 10.0)
|
||
|
||
@classmethod
|
||
def _http_retry_delay_seconds(
|
||
cls,
|
||
*,
|
||
status_code: Optional[int],
|
||
attempt: int,
|
||
retry_after_header: Optional[str],
|
||
) -> float:
|
||
retry_after = cls._parse_retry_after_seconds(retry_after_header)
|
||
if retry_after is not None:
|
||
return retry_after
|
||
base = 0.8
|
||
if int(status_code or 0) == 429:
|
||
base = 1.2
|
||
capped_attempt = max(0, min(int(attempt), 6))
|
||
return min(base * (2 ** capped_attempt), 6.0)
|
||
|
||
@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_provider_circuit_open_exception(cls, exc: Exception) -> bool:
|
||
return "provider_circuit_open" in str(exc or "").strip().lower()
|
||
|
||
@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 cls._is_retryable_http_status(status_code)
|
||
|
||
@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 cls._is_provider_circuit_open_exception(exc):
|
||
return
|
||
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],
|
||
"settings": self._normalize_engine_settings(bundle.get("settings")),
|
||
}
|
||
|
||
async def update_engine_settings(
|
||
self,
|
||
db: AsyncSession,
|
||
*,
|
||
tenant_id: int,
|
||
enable_local_kb_qa: Optional[bool] = None,
|
||
) -> dict[str, Any]:
|
||
integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
||
settings = self._normalize_engine_settings(bundle.get("settings"))
|
||
if enable_local_kb_qa is not None:
|
||
settings["enable_local_kb_qa"] = bool(enable_local_kb_qa)
|
||
bundle["settings"] = settings
|
||
integration.config = json.loads(json.dumps(bundle))
|
||
await db.commit()
|
||
return settings
|
||
|
||
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:
|
||
transient_retry_budget = 2
|
||
transient_retry_attempt = 0
|
||
while True:
|
||
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,
|
||
)
|
||
break
|
||
except Exception as transient_exc:
|
||
status_code = self._http_status_from_exception(transient_exc)
|
||
can_retry_transient = (
|
||
transient_retry_budget > 0
|
||
and (
|
||
self._is_retryable_http_status(status_code)
|
||
or isinstance(transient_exc, httpx.RequestError)
|
||
)
|
||
)
|
||
if not can_retry_transient:
|
||
raise
|
||
retry_after_header = None
|
||
if isinstance(transient_exc, httpx.HTTPStatusError) and transient_exc.response is not None:
|
||
retry_after_header = transient_exc.response.headers.get("Retry-After")
|
||
retry_delay = self._http_retry_delay_seconds(
|
||
status_code=status_code,
|
||
attempt=transient_retry_attempt,
|
||
retry_after_header=retry_after_header,
|
||
)
|
||
_runtime_log(
|
||
"LLM",
|
||
(
|
||
"连通测试重试:"
|
||
f"engine={engine.get('provider')}/{engine.get('model')}, "
|
||
f"status={status_code or 'request_error'}, "
|
||
f"retry_in={retry_delay:.2f}s, left={transient_retry_budget - 1}"
|
||
),
|
||
)
|
||
transient_retry_budget -= 1
|
||
transient_retry_attempt += 1
|
||
await asyncio.sleep(retry_delay)
|
||
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}"
|
||
status_code = self._http_status_from_exception(exc)
|
||
if int(status_code or 0) == 429:
|
||
raise ValueError("引擎连通测试失败: 上游限流(429),请稍后重试或更换 API Key。") from 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 _inject_model_context_bundle_prompt(
|
||
self,
|
||
*,
|
||
db: Any,
|
||
tenant_id: int,
|
||
function_name: str,
|
||
prompt: str,
|
||
) -> str:
|
||
raw_prompt = str(prompt or "")
|
||
normalized_function = str(function_name or "").strip().lower()
|
||
if not ModelContextBundleService.should_inject_context(normalized_function):
|
||
return raw_prompt
|
||
# 单测里可能传入 object()/mock 作为 db 占位,此时直接跳过注入。
|
||
if not hasattr(db, "execute"):
|
||
return raw_prompt
|
||
try:
|
||
return await ModelContextBundleService.inject_prompt_with_bundle(
|
||
db,
|
||
tenant_id=int(tenant_id),
|
||
function_name=normalized_function,
|
||
prompt=raw_prompt,
|
||
)
|
||
except Exception as exc:
|
||
_runtime_log("LLM上下文", f"上下文注入失败,已降级原始 prompt:function={normalized_function}, error={exc}")
|
||
return raw_prompt
|
||
|
||
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": ""}
|
||
prompt_with_context = await self._inject_model_context_bundle_prompt(
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
function_name=function_name,
|
||
prompt=prompt,
|
||
)
|
||
merged_prompt = self._merge_prompt_with_profile(
|
||
prompt=prompt_with_context,
|
||
prompt_profile=prompt_profile,
|
||
function_name=function_name,
|
||
)
|
||
|
||
def _log_stub_failure(error_obj: Exception, *, used_stub: bool = False) -> None:
|
||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||
provider = str(engine_meta.get("provider") or "")
|
||
model = str(engine_meta.get("model") or "")
|
||
outcome = "兜底 stub" if used_stub else "显式报错"
|
||
logger.warning(
|
||
"[LLM_FAIL] 生成失败 → %s function=%s provider=%s model=%s error=%r duration_ms=%.0f",
|
||
outcome,
|
||
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_with_context,
|
||
prompt_profile=prompt_profile,
|
||
function_name=function_name,
|
||
)
|
||
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_with_context,
|
||
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")
|
||
|
||
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:
|
||
failover_response, failover_engine = await self._retry_with_high_tier_after_low_model_failure(
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
function_name=function_name,
|
||
failed_engine=engine_meta,
|
||
prompt=prompt_with_context,
|
||
system_prompt=str(prompt_profile.get("system_prompt") or ""),
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
response_mode=response_mode,
|
||
error=exc,
|
||
)
|
||
if failover_response and isinstance(failover_engine, dict):
|
||
routing_decision = "glm47_error_escalate_to_high"
|
||
status = "success_failover"
|
||
response_text = failover_response
|
||
engine_meta = failover_engine
|
||
return failover_response
|
||
status = "failed"
|
||
reason_text = self._build_model_error_message(
|
||
function_name=function_name,
|
||
provider=str(engine_meta.get("provider") or ""),
|
||
model=str(engine_meta.get("model") or ""),
|
||
error=exc,
|
||
)
|
||
response_text = reason_text
|
||
_log_stub_failure(exc, used_stub=False)
|
||
raise RuntimeError(reason_text) from exc
|
||
finally:
|
||
try:
|
||
merged_prompt = self._merge_prompt_with_profile(
|
||
prompt=prompt_with_context,
|
||
prompt_profile=prompt_profile,
|
||
function_name=function_name,
|
||
)
|
||
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": ""}
|
||
prompt_with_context = await self._inject_model_context_bundle_prompt(
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
function_name=function_name,
|
||
prompt=prompt,
|
||
)
|
||
merged_prompt = prompt_with_context
|
||
provider = ""
|
||
model = ""
|
||
engine_meta: dict[str, Any] = {}
|
||
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_with_context,
|
||
prompt_profile=prompt_profile,
|
||
function_name=function_name,
|
||
)
|
||
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 "")
|
||
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_with_context,
|
||
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,
|
||
)
|
||
_runtime_log(
|
||
"LLM结构化",
|
||
(
|
||
f"function={function_name}, structured_source={extraction.source_field or 'none'}, "
|
||
f"validation={extraction.validation}, fallback=false"
|
||
),
|
||
)
|
||
if extraction.payload is None:
|
||
raise ValueError(str(extraction.error_code or "structured_schema_invalid"))
|
||
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:
|
||
failover_result, failover_engine = await self._retry_structured_with_high_tier_after_low_model_failure(
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
function_name=function_name,
|
||
failed_engine=engine_meta,
|
||
prompt=prompt_with_context,
|
||
system_prompt=str(prompt_profile.get("system_prompt") or ""),
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
response_mode=response_mode,
|
||
error=exc,
|
||
)
|
||
if isinstance(failover_result, StructuredExtractionResult) and isinstance(failover_engine, dict):
|
||
provider = str(failover_engine.get("provider") or "")
|
||
model = str(failover_engine.get("model") or "")
|
||
response_text = failover_result.raw_text or json.dumps(failover_result.payload or {}, ensure_ascii=False)
|
||
status = failover_result.error_code or "success_failover"
|
||
return failover_result
|
||
error_code = self._classify_structured_exception(exc)
|
||
status = error_code
|
||
reason_text = self._build_model_error_message(
|
||
function_name=function_name,
|
||
provider=provider,
|
||
model=model,
|
||
error=exc,
|
||
)
|
||
response_text = reason_text
|
||
_runtime_log(
|
||
"LLM结构化",
|
||
f"function={function_name}, structured_source=none, validation=invalid, fallback=false",
|
||
)
|
||
raise RuntimeError(reason_text) from exc
|
||
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 = self._inject_function_guardrails(
|
||
system_prompt=system_prompt,
|
||
function_name=function_name,
|
||
)
|
||
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"
|
||
headers = {
|
||
"Authorization": f"Bearer {key}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
current_max_tokens = max(int(max_tokens or 0), 1)
|
||
empty_content_retry_budget = 1
|
||
http_status_retry_budget = 2
|
||
http_status_retry_attempt = 0
|
||
while True:
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"temperature": temperature,
|
||
"max_tokens": current_max_tokens,
|
||
}
|
||
if is_openclaw_gateway_request:
|
||
payload["agents"] = {
|
||
"defaults": {
|
||
"timeoutSeconds": int(openclaw_agents_timeout_seconds),
|
||
}
|
||
}
|
||
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}"
|
||
),
|
||
)
|
||
status_code = int(resp.status_code)
|
||
can_retry_http_status = (
|
||
http_status_retry_budget > 0
|
||
and self._is_retryable_http_status(status_code)
|
||
)
|
||
if can_retry_http_status:
|
||
retry_delay = self._http_retry_delay_seconds(
|
||
status_code=status_code,
|
||
attempt=http_status_retry_attempt,
|
||
retry_after_header=resp.headers.get("Retry-After"),
|
||
)
|
||
_runtime_log(
|
||
"LLM",
|
||
(
|
||
"HTTP状态重试:"
|
||
f"function={function_label}, provider={provider_name}, model={model}, "
|
||
f"status={status_code}, retry_in={retry_delay:.2f}s, "
|
||
f"left={http_status_retry_budget - 1}"
|
||
),
|
||
)
|
||
http_status_retry_budget -= 1
|
||
http_status_retry_attempt += 1
|
||
await asyncio.sleep(retry_delay)
|
||
continue
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
choices = data.get("choices") or []
|
||
if not choices:
|
||
raise ValueError("LLM 响应无 choices")
|
||
first_choice = choices[0] if isinstance(choices[0], dict) else {}
|
||
message = first_choice.get("message") if isinstance(first_choice, dict) else {}
|
||
text = self._extract_text_from_chat_response(
|
||
message=message,
|
||
choice=first_choice,
|
||
response=data,
|
||
structured_output=structured_output,
|
||
)
|
||
if (
|
||
not text
|
||
and not structured_output
|
||
and self._reasoning_content_fallback_enabled(function_name=function_label)
|
||
):
|
||
fallback_text = self._extract_reasoning_fallback_text(
|
||
message=message,
|
||
choice=first_choice,
|
||
response=data,
|
||
)
|
||
if fallback_text:
|
||
text = fallback_text
|
||
_runtime_log(
|
||
"LLM",
|
||
(
|
||
"空content回退reasoning_content:"
|
||
f"function={function_label}, provider={provider_name}, model={model}, "
|
||
f"text_len={len(text)}"
|
||
),
|
||
)
|
||
if text or structured_output:
|
||
break
|
||
|
||
msg_keys = list(message.keys()) if isinstance(message, dict) else []
|
||
choice_keys = list(first_choice.keys()) if isinstance(first_choice, dict) else []
|
||
choice_preview = ""
|
||
try:
|
||
choice_preview = json.dumps(first_choice, ensure_ascii=False)[:300]
|
||
except Exception:
|
||
choice_preview = str(first_choice)[:300]
|
||
empty_error = ValueError(
|
||
"LLM 返回空内容: "
|
||
f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}"
|
||
)
|
||
can_retry_empty = (
|
||
empty_content_retry_budget > 0
|
||
and self._should_retry_empty_chat_response(
|
||
structured_output=structured_output,
|
||
message=message,
|
||
choice=first_choice,
|
||
current_max_tokens=current_max_tokens,
|
||
)
|
||
)
|
||
if not can_retry_empty:
|
||
raise empty_error
|
||
|
||
empty_content_retry_budget -= 1
|
||
next_max_tokens = self._next_retry_max_tokens_for_empty_chat(current_max_tokens)
|
||
_runtime_log(
|
||
"LLM",
|
||
(
|
||
"空内容提额重试:"
|
||
f"function={function_label}, provider={provider_name}, model={model}, "
|
||
f"max_tokens={current_max_tokens}->{next_max_tokens}"
|
||
),
|
||
)
|
||
current_max_tokens = next_max_tokens
|
||
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"),
|
||
}
|
||
|
||
@staticmethod
|
||
def _normalize_engine_settings(raw: Any) -> dict[str, Any]:
|
||
settings = dict(LLM_ENGINE_SETTINGS_DEFAULTS)
|
||
if isinstance(raw, dict):
|
||
settings["enable_local_kb_qa"] = bool(raw.get("enable_local_kb_qa", settings["enable_local_kb_qa"]))
|
||
return settings
|
||
|
||
@staticmethod
|
||
def _is_local_kb_qa_function(function_name: str) -> bool:
|
||
fn = str(function_name or "").strip().lower()
|
||
if not fn:
|
||
return False
|
||
if fn in LOCAL_KB_QA_FUNCTION_EXACT:
|
||
return True
|
||
return any(prefix and fn.startswith(prefix) for prefix in LOCAL_KB_QA_FUNCTION_PREFIXES)
|
||
|
||
def _ensure_engine_allowed_for_function(
|
||
self,
|
||
*,
|
||
engine: dict[str, Any],
|
||
function_name: str,
|
||
settings: dict[str, Any],
|
||
engine_label: str,
|
||
) -> dict[str, Any]:
|
||
runtime = self._normalize_engine_runtime(engine)
|
||
provider = str(runtime.get("source_provider") or runtime.get("provider") or "")
|
||
if provider == "ollama":
|
||
if not bool(settings.get("enable_local_kb_qa")):
|
||
raise ValueError("本地 LLM 未启用,请先在引擎配置中打开“本地 LLM(仅知识库问答)”开关")
|
||
if not self._is_local_kb_qa_function(function_name):
|
||
raise ValueError(f"本地 LLM 仅允许用于知识库问答,当前 function={function_name}")
|
||
return runtime
|
||
|
||
if not str(runtime.get("api_key") or "").strip():
|
||
raise ValueError(f"{engine_label} 未配置 API Key")
|
||
return runtime
|
||
|
||
def _ensure_engine_allowed_for_test(
|
||
self,
|
||
*,
|
||
engine: dict[str, Any],
|
||
settings: dict[str, Any],
|
||
engine_label: str,
|
||
) -> dict[str, Any]:
|
||
runtime = self._normalize_engine_runtime(engine)
|
||
provider = str(runtime.get("source_provider") or runtime.get("provider") or "")
|
||
if provider == "ollama":
|
||
if not bool(settings.get("enable_local_kb_qa")):
|
||
raise ValueError("本地 LLM 未启用,请先在引擎配置中打开“本地 LLM(仅知识库问答)”开关")
|
||
raise ValueError("本地 LLM 仅允许用于知识库问答,连通性测试请通过知识库问答链路验证")
|
||
if not str(runtime.get("api_key") or "").strip():
|
||
raise ValueError(f"{engine_label} 未配置 API Key")
|
||
return runtime
|
||
|
||
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": [], "settings": self._normalize_engine_settings(None)},
|
||
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
|
||
bundle["settings"] = self._normalize_engine_settings(bundle.get("settings"))
|
||
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)
|
||
settings = self._normalize_engine_settings(bundle.get("settings"))
|
||
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)]
|
||
|
||
override_engine = await self._resolve_function_override_engine(
|
||
db,
|
||
tenant_id=tenant_id,
|
||
function_name=function_name,
|
||
engines=engines,
|
||
active_engine=active_engine,
|
||
)
|
||
if override_engine is not None:
|
||
return self._ensure_engine_allowed_for_function(
|
||
engine=override_engine,
|
||
function_name=function_name,
|
||
settings=settings,
|
||
engine_label="override_engine",
|
||
)
|
||
|
||
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)
|
||
return self._ensure_engine_allowed_for_function(
|
||
engine=openclaw_routed,
|
||
function_name=function_name,
|
||
settings=settings,
|
||
engine_label="openclaw_routed_engine",
|
||
)
|
||
|
||
if not self._mixed_routing_enabled():
|
||
return self._ensure_engine_allowed_for_function(
|
||
engine=active_engine,
|
||
function_name=function_name,
|
||
settings=settings,
|
||
engine_label="active_engine",
|
||
)
|
||
|
||
tier = self._resolve_function_tier(function_name)
|
||
if tier not in {"high", "low"}:
|
||
return self._ensure_engine_allowed_for_function(
|
||
engine=active_engine,
|
||
function_name=function_name,
|
||
settings=settings,
|
||
engine_label="active_engine",
|
||
)
|
||
|
||
routed = self._resolve_tier_engine(
|
||
tier=tier,
|
||
engines=engines,
|
||
active_engine=active_engine,
|
||
)
|
||
if not routed:
|
||
return self._ensure_engine_allowed_for_function(
|
||
engine=active_engine,
|
||
function_name=function_name,
|
||
settings=settings,
|
||
engine_label="active_engine",
|
||
)
|
||
provider = str(routed.get("provider") or "")
|
||
_runtime_log(
|
||
"LLM路由",
|
||
(
|
||
"函数路由生效:"
|
||
f"function={function_name}, tier={tier}, provider={provider}, model={routed.get('model')}"
|
||
),
|
||
)
|
||
return self._ensure_engine_allowed_for_function(
|
||
engine=self._ensure_openclaw_control_plane_engine(routed),
|
||
function_name=function_name,
|
||
settings=settings,
|
||
engine_label=f"{tier}_tier_engine",
|
||
)
|
||
|
||
async def _resolve_function_override_engine(
|
||
self,
|
||
db: AsyncSession,
|
||
*,
|
||
tenant_id: int,
|
||
function_name: str,
|
||
engines: list[dict[str, Any]],
|
||
active_engine: dict[str, Any],
|
||
) -> Optional[dict[str, Any]]:
|
||
fn = str(function_name or "").strip().lower()
|
||
if not fn:
|
||
return None
|
||
try:
|
||
row = (
|
||
await db.execute(
|
||
select(Integration).where(
|
||
and_(
|
||
Integration.tenant_id == int(tenant_id),
|
||
Integration.type == "model_routing",
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
bundle = _safe_json(row.config if row else {})
|
||
mappings = bundle.get("mappings") if isinstance(bundle.get("mappings"), dict) else {}
|
||
override = mappings.get(fn) if isinstance(mappings.get(fn), dict) else None
|
||
if not override or not bool(override.get("enabled", True)):
|
||
return None
|
||
rule = {
|
||
"tier": str(override.get("tier") or "high").strip().lower() or "high",
|
||
"provider": str(override.get("provider") or "").strip().lower(),
|
||
"model": str(override.get("model") or "").strip(),
|
||
"api_env": str(override.get("api_env") or "").strip(),
|
||
"base_url": str(override.get("base_url") or "").strip(),
|
||
}
|
||
routed = self._resolve_engine_from_rule(
|
||
rule=rule,
|
||
engines=engines,
|
||
active_engine=active_engine,
|
||
)
|
||
if routed is None:
|
||
return None
|
||
provider = str(routed.get("provider") or "")
|
||
if provider != "ollama" and not str(routed.get("api_key") or "").strip():
|
||
raise ValueError(
|
||
"函数覆盖路由命中但目标引擎缺少 API Key:"
|
||
f"function={fn}, provider={provider}, model={routed.get('model')}"
|
||
)
|
||
_runtime_log(
|
||
"LLM路由",
|
||
(
|
||
"函数覆盖路由命中:"
|
||
f"function={fn}, provider={routed.get('provider')}, model={routed.get('model')}"
|
||
),
|
||
)
|
||
return self._ensure_openclaw_control_plane_engine(routed)
|
||
except ValueError:
|
||
raise
|
||
except Exception as exc:
|
||
_runtime_log("LLM路由", f"函数覆盖路由读取失败:function={fn}, error={exc}")
|
||
return None
|
||
|
||
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 False
|
||
|
||
def _openclaw_control_plane_only(self) -> bool:
|
||
return False
|
||
|
||
def _ensure_openclaw_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]:
|
||
runtime = dict(engine or {})
|
||
runtime["source_provider"] = str(runtime.get("source_provider") or runtime.get("provider") or "").strip().lower()
|
||
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 _is_glm_47_model(model_name: str) -> bool:
|
||
normalized = re.sub(r"[\s_]+", "", str(model_name or "")).lower()
|
||
if not normalized:
|
||
return False
|
||
return "glm" in normalized and "4.7" in normalized
|
||
|
||
def _should_retry_glm47_with_high_tier(
|
||
self,
|
||
*,
|
||
function_name: str,
|
||
failed_engine: dict[str, Any],
|
||
) -> bool:
|
||
if not self._low_to_high_escalation_enabled():
|
||
return False
|
||
failed_model = str(failed_engine.get("model") or "").strip()
|
||
if self._is_glm_47_model(failed_model):
|
||
return True
|
||
# 兜底兼容:低阶函数遇到 4.x GLM 也允许升级高阶重试。
|
||
if self._is_low_tier_function(function_name):
|
||
lowered = failed_model.lower()
|
||
return "glm" in lowered and "4." in lowered
|
||
return False
|
||
|
||
async def _retry_with_high_tier_after_low_model_failure(
|
||
self,
|
||
*,
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
function_name: str,
|
||
failed_engine: dict[str, Any],
|
||
prompt: str,
|
||
system_prompt: str,
|
||
temperature: float,
|
||
max_tokens: int,
|
||
response_mode: str,
|
||
error: Exception,
|
||
) -> tuple[Optional[str], Optional[dict[str, Any]]]:
|
||
if not self._should_retry_glm47_with_high_tier(function_name=function_name, failed_engine=failed_engine):
|
||
return None, None
|
||
try:
|
||
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
||
engines = [item for item in bundle.get("engines", []) if isinstance(item, dict)]
|
||
active_engine = self._resolve_active_engine_from_bundle(bundle)
|
||
high_engine = self._resolve_tier_engine(
|
||
tier="high",
|
||
engines=engines,
|
||
active_engine=active_engine,
|
||
)
|
||
if not isinstance(high_engine, dict):
|
||
return None, None
|
||
high_engine = self._ensure_engine_allowed_for_function(
|
||
engine=high_engine,
|
||
function_name=function_name,
|
||
settings=self._normalize_engine_settings(bundle.get("settings")),
|
||
engine_label="high_tier_failover",
|
||
)
|
||
failed_provider = str(failed_engine.get("provider") or "").strip().lower()
|
||
failed_model = str(failed_engine.get("model") or "").strip()
|
||
high_provider = str(high_engine.get("provider") or "").strip().lower()
|
||
high_model = str(high_engine.get("model") or "").strip()
|
||
if failed_provider == high_provider and failed_model == high_model:
|
||
return None, None
|
||
_runtime_log(
|
||
"LLM路由",
|
||
(
|
||
"低阶模型失败自动升级:"
|
||
f"function={function_name}, from={failed_provider}:{failed_model}, "
|
||
f"to={high_provider}:{high_model}, reason={str(error)[:120]}"
|
||
),
|
||
)
|
||
text = await self._chat_completion(
|
||
provider=high_provider,
|
||
prompt=prompt,
|
||
system_prompt=system_prompt,
|
||
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 text.strip():
|
||
raise ValueError("high_tier_empty_response")
|
||
if self._is_invalid_structured_output(function_name=function_name, response_text=text):
|
||
raise ValueError("high_tier_invalid_structured_output")
|
||
return text, high_engine
|
||
except Exception as retry_exc:
|
||
_runtime_log(
|
||
"LLM路由",
|
||
(
|
||
"低阶模型失败升级高阶也失败:"
|
||
f"function={function_name}, error={type(retry_exc).__name__}:{str(retry_exc)[:180]}"
|
||
),
|
||
)
|
||
return None, None
|
||
|
||
async def _retry_structured_with_high_tier_after_low_model_failure(
|
||
self,
|
||
*,
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
function_name: str,
|
||
failed_engine: dict[str, Any],
|
||
prompt: str,
|
||
system_prompt: str,
|
||
temperature: float,
|
||
max_tokens: int,
|
||
response_mode: str,
|
||
error: Exception,
|
||
) -> tuple[Optional[StructuredExtractionResult], Optional[dict[str, Any]]]:
|
||
if not self._should_retry_glm47_with_high_tier(function_name=function_name, failed_engine=failed_engine):
|
||
return None, None
|
||
try:
|
||
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
||
engines = [item for item in bundle.get("engines", []) if isinstance(item, dict)]
|
||
active_engine = self._resolve_active_engine_from_bundle(bundle)
|
||
high_engine = self._resolve_tier_engine(
|
||
tier="high",
|
||
engines=engines,
|
||
active_engine=active_engine,
|
||
)
|
||
if not isinstance(high_engine, dict):
|
||
return None, None
|
||
high_engine = self._ensure_engine_allowed_for_function(
|
||
engine=high_engine,
|
||
function_name=function_name,
|
||
settings=self._normalize_engine_settings(bundle.get("settings")),
|
||
engine_label="high_tier_structured_failover",
|
||
)
|
||
failed_provider = str(failed_engine.get("provider") or "").strip().lower()
|
||
failed_model = str(failed_engine.get("model") or "").strip()
|
||
high_provider = str(high_engine.get("provider") or "").strip().lower()
|
||
high_model = str(high_engine.get("model") or "").strip()
|
||
if failed_provider == high_provider and failed_model == high_model:
|
||
return None, None
|
||
_runtime_log(
|
||
"LLM路由",
|
||
(
|
||
"低阶结构化失败自动升级:"
|
||
f"function={function_name}, from={failed_provider}:{failed_model}, "
|
||
f"to={high_provider}:{high_model}, reason={str(error)[:120]}"
|
||
),
|
||
)
|
||
completion = await self._chat_completion_raw(
|
||
provider=high_provider,
|
||
prompt=prompt,
|
||
system_prompt=system_prompt,
|
||
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,
|
||
)
|
||
extraction = self._extract_structured_object_from_response(
|
||
function_name=function_name,
|
||
response=completion.raw_response,
|
||
fallback_text=completion.text,
|
||
provider=high_provider,
|
||
model=high_model,
|
||
)
|
||
if extraction.payload is None:
|
||
raise ValueError(str(extraction.error_code or "structured_schema_invalid"))
|
||
_runtime_log(
|
||
"LLM结构化",
|
||
(
|
||
f"function={function_name}, structured_source={extraction.source_field or 'none'}, "
|
||
f"validation={extraction.validation}, fallback=true"
|
||
),
|
||
)
|
||
return extraction, high_engine
|
||
except Exception as retry_exc:
|
||
_runtime_log(
|
||
"LLM路由",
|
||
(
|
||
"低阶结构化失败升级高阶也失败:"
|
||
f"function={function_name}, error={type(retry_exc).__name__}:{str(retry_exc)[:180]}"
|
||
),
|
||
)
|
||
return None, None
|
||
|
||
@staticmethod
|
||
def _requires_ddl_api_guardrail(function_name: str) -> bool:
|
||
fn = str(function_name or "").strip().lower()
|
||
if not fn:
|
||
return False
|
||
return fn in DDL_API_GUARDRAIL_FUNCTION_EXACT
|
||
|
||
@classmethod
|
||
def _inject_function_guardrails(cls, *, system_prompt: str, function_name: str) -> str:
|
||
base_prompt = str(system_prompt or "").strip()
|
||
if not cls._requires_ddl_api_guardrail(function_name):
|
||
return base_prompt
|
||
extra = str(DDL_API_GUARDRAIL_SYSTEM_PROMPT or "").strip()
|
||
if not extra:
|
||
return base_prompt
|
||
if extra in base_prompt:
|
||
return base_prompt
|
||
if not base_prompt:
|
||
return extra
|
||
return f"{base_prompt}\n\n{extra}"
|
||
|
||
@classmethod
|
||
def _merge_prompt_with_profile(
|
||
cls,
|
||
*,
|
||
prompt: str,
|
||
prompt_profile: dict[str, str],
|
||
function_name: str = "",
|
||
) -> str:
|
||
system_prompt = cls._inject_function_guardrails(
|
||
system_prompt=str(prompt_profile.get("system_prompt") or "").strip(),
|
||
function_name=function_name,
|
||
)
|
||
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 _should_raise_model_error(function_name: str) -> bool:
|
||
fn = str(function_name or "").strip().lower()
|
||
if not fn:
|
||
return False
|
||
if fn in STRICT_MODEL_ERROR_FUNCTION_EXACT:
|
||
return True
|
||
return any(prefix and fn.startswith(prefix) for prefix in STRICT_MODEL_ERROR_FUNCTION_PREFIXES)
|
||
|
||
@staticmethod
|
||
def _build_model_error_message(
|
||
*,
|
||
function_name: str,
|
||
provider: str,
|
||
model: str,
|
||
error: Exception,
|
||
) -> str:
|
||
clean_error = re.sub(r"\s+", " ", str(error or "")).strip()
|
||
if len(clean_error) > 160:
|
||
clean_error = f"{clean_error[:160]}..."
|
||
provider_text = str(provider or "unknown").strip() or "unknown"
|
||
model_text = str(model or "unknown").strip() or "unknown"
|
||
fn_text = str(function_name or "llm_generate").strip() or "llm_generate"
|
||
if not clean_error:
|
||
clean_error = "upstream_empty_or_invalid_response"
|
||
return (
|
||
f"模型调用失败:function={fn_text}, provider={provider_text}, model={model_text}, "
|
||
f"reason={clean_error}"
|
||
)
|
||
|
||
@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",
|
||
}
|
||
|
||
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)
|
||
|
||
@staticmethod
|
||
def _should_retry_empty_chat_response(
|
||
*,
|
||
structured_output: bool,
|
||
message: Any,
|
||
choice: Any,
|
||
current_max_tokens: int,
|
||
) -> bool:
|
||
if structured_output:
|
||
return False
|
||
if int(current_max_tokens or 0) >= 2800:
|
||
return False
|
||
finish_reason = str(choice.get("finish_reason") or "").strip().lower() if isinstance(choice, dict) else ""
|
||
if finish_reason not in {"length", "max_tokens"}:
|
||
return False
|
||
reasoning_text = ""
|
||
if isinstance(message, dict):
|
||
reasoning_text = str(message.get("reasoning_content") or message.get("reasoning") or "").strip()
|
||
if not reasoning_text and isinstance(choice, dict):
|
||
reasoning_text = str(choice.get("reasoning_content") or choice.get("reasoning") or "").strip()
|
||
return bool(reasoning_text)
|
||
|
||
@staticmethod
|
||
def _next_retry_max_tokens_for_empty_chat(current_max_tokens: int) -> int:
|
||
current = max(int(current_max_tokens or 0), 1)
|
||
grown = max(current + 512, int(current * 1.8))
|
||
return min(grown, 3000)
|
||
|
||
def _use_provider_circuit_breaker(self, function_name: str) -> bool:
|
||
fn = str(function_name or "").strip().lower()
|
||
if any(fn.startswith(prefix) for prefix in CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES):
|
||
return False
|
||
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 _extract_reasoning_fallback_text(
|
||
self,
|
||
*,
|
||
message: Any,
|
||
choice: Any,
|
||
response: Any,
|
||
) -> str:
|
||
candidates: list[Any] = []
|
||
if isinstance(message, dict):
|
||
candidates.append(message.get("reasoning_content"))
|
||
candidates.append(message.get("reasoning"))
|
||
if isinstance(choice, dict):
|
||
candidates.append(choice.get("reasoning_content"))
|
||
candidates.append(choice.get("reasoning"))
|
||
if isinstance(response, dict):
|
||
candidates.append(response.get("reasoning_content"))
|
||
candidates.append(response.get("reasoning"))
|
||
for raw in candidates:
|
||
text = self._sanitize_visible_answer(self._extract_text_from_value(raw))
|
||
if text:
|
||
return text
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _reasoning_content_fallback_enabled(*, function_name: str) -> bool:
|
||
fn = str(function_name or "").strip().lower()
|
||
if fn and ("title_refine" in fn or "project_draft_analysis_glm47_fallback" in fn):
|
||
return True
|
||
raw = str(os.getenv("LLM_REASONING_CONTENT_FALLBACK", "0")).strip().lower()
|
||
if raw not in {"1", "true", "yes", "on"}:
|
||
return False
|
||
if not fn:
|
||
return True
|
||
if fn.startswith("generate_structured") or fn.startswith("llm_generate_structured"):
|
||
return False
|
||
if "signal_batch_analysis" in fn:
|
||
return False
|
||
if any(token in fn for token in ("answer", "reply", "qa")):
|
||
return True
|
||
return False
|
||
|
||
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 == "str_or_none":
|
||
return value is None or isinstance(value, str)
|
||
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": "glm",
|
||
"high": "openai",
|
||
"safety": "glm",
|
||
}
|
||
provider_env = str(
|
||
os.getenv(
|
||
f"LLM_ROUTING_{upper}_PROVIDER",
|
||
default_provider_by_tier.get(
|
||
tier,
|
||
os.getenv("LLM_PROVIDER", "glm"),
|
||
),
|
||
)
|
||
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":
|
||
configured_openai_base = str(os.getenv("OPENAI_BASE_URL", "") or "").strip().rstrip("/")
|
||
routed["base_url"] = configured_openai_base or "https://api.openai.com/v1"
|
||
|
||
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]:
|
||
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
||
settings = self._normalize_engine_settings(bundle.get("settings"))
|
||
if not engine_id:
|
||
engine = self._resolve_active_engine_from_bundle(bundle)
|
||
return self._ensure_engine_allowed_for_test(
|
||
engine=engine,
|
||
settings=settings,
|
||
engine_label="当前激活引擎",
|
||
)
|
||
|
||
engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)]
|
||
for item in engines:
|
||
if str(item.get("id") or "") == str(engine_id):
|
||
engine = self._normalize_engine_runtime(item)
|
||
return self._ensure_engine_allowed_for_test(
|
||
engine=engine,
|
||
settings=settings,
|
||
engine_label="目标引擎",
|
||
)
|
||
raise ValueError("engine 不存在")
|
||
|
||
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", "glm"))
|
||
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()
|