PMFlow/backend/app/services/llm.py

3305 lines
131 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""统一 LLM 服务(DeepSeek / GLM / OpenAI / Ollama)。
能力:
1. 多引擎配置持久化(Integration.type=llm_engine)
2. 动态选择激活引擎
3. OpenAI-Compatible chat/completions 调用
4. 基础 AI 调用日志写入(ai_call_logs)
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import hashlib
import json
import logging
import os
import re
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional
from uuid import uuid4
import httpx
from sqlalchemy import and_, select
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from ..config.ai_prompts import build_default_role_prompt_bundle
from ..database import AsyncSessionLocal
from ..models import AICallLog, Integration
from .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()