"""统一 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 = 3.0 FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 6.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 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]] = { "signal_source_onboarding_plan": { "required_keys": ("should_onboard", "summary", "worker_roles", "execution_instruction"), "type_checks": { "should_onboard": "bool", "summary": "str", "worker_roles": "list", "execution_instruction": "str", }, "optional_type_checks": { "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_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 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: 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 = "" 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, ) 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: 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 _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: 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: raw = str(os.getenv("LLM_REASONING_CONTENT_FALLBACK", "0")).strip().lower() if raw not in {"1", "true", "yes", "on"}: return False fn = str(function_name or "").strip().lower() 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": "glm", "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".*?", "", cleaned, flags=re.IGNORECASE | re.DOTALL) return cleaned.strip() llm_service = LLMService()