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