211 lines
8.2 KiB
Python
211 lines
8.2 KiB
Python
from __future__ import annotations
|
||
|
||
from .shared import *
|
||
|
||
class LLMGuardrailsPromptMixin:
|
||
@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 _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,
|
||
routing_meta: dict[str, Any] | None = None,
|
||
error: Exception,
|
||
) -> str:
|
||
clean_error = re.sub(r"\s+", " ", str(error or "")).strip()
|
||
if len(clean_error) > 160:
|
||
clean_error = f"{clean_error[:160]}..."
|
||
fn_text = str(function_name or "llm_generate").strip() or "llm_generate"
|
||
meta = _normalize_routing_meta(routing_meta)
|
||
if not clean_error:
|
||
clean_error = "upstream_empty_or_invalid_response"
|
||
route_bits = _routing_meta_log_text(meta)
|
||
if route_bits:
|
||
return f"模型调用失败:function={fn_text}, {route_bits}, reason={clean_error}"
|
||
return f"模型调用失败:function={fn_text}, reason={clean_error}"
|
||
|
||
@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",
|
||
routing_meta: dict[str, Any] | None = None,
|
||
) -> float:
|
||
meta = _normalize_routing_meta(routing_meta)
|
||
if meta.get("fallback_policy") != "fast_local":
|
||
return self.request_timeout
|
||
env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_COMPLETE"
|
||
default_timeout = FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS
|
||
if meta.get("execution_mode") == "local_planner" 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 _http_status_retry_budget_for_function(function_name: str) -> int:
|
||
fn = str(function_name or "").strip().lower()
|
||
is_intent_function = fn in {
|
||
"pmflow_semantic_context_decision",
|
||
"intent_structured_runtime_failover",
|
||
}
|
||
env_key = "LLM_INTENT_HTTP_RETRY_BUDGET" if is_intent_function else "LLM_HTTP_STATUS_RETRY_BUDGET"
|
||
default_budget = "0" if is_intent_function else "2"
|
||
raw = str(os.getenv(env_key, default_budget) or default_budget).strip()
|
||
try:
|
||
parsed = int(raw)
|
||
except Exception:
|
||
parsed = int(default_budget)
|
||
return max(0, min(parsed, 3))
|
||
|
||
@staticmethod
|
||
def _empty_chat_retry_budget(*, function_name: str, current_max_tokens: int) -> int:
|
||
fn = str(function_name or "").strip().lower()
|
||
current = max(int(current_max_tokens or 0), 1)
|
||
if current >= 2800:
|
||
return 0
|
||
if fn == "pmflow_semantic_context_decision":
|
||
raw_budget = str(os.getenv("LLM_INTENT_EMPTY_RETRY_BUDGET", "2") or "2").strip()
|
||
try:
|
||
parsed_budget = int(raw_budget)
|
||
except Exception:
|
||
parsed_budget = 2
|
||
return max(0, min(parsed_budget, 3))
|
||
if fn.startswith("adaptive_rag_answer_"):
|
||
# RAG 问答在 GLM 系列上经常先返回 reasoning_content,需至少升档两次才能拿到最终 content。
|
||
return 2
|
||
if any(token in fn for token in ("qa", "query", "reply")):
|
||
return 1
|
||
return 1
|
||
|
||
@staticmethod
|
||
def _should_retry_empty_chat_response(
|
||
*,
|
||
structured_output: bool,
|
||
message: Any,
|
||
choice: Any,
|
||
current_max_tokens: int,
|
||
) -> bool:
|
||
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, routing_meta: dict[str, Any] | None = None) -> bool:
|
||
fn = str(function_name or "").strip().lower()
|
||
if any(fn.startswith(prefix) for prefix in CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES):
|
||
return False
|
||
meta = _normalize_routing_meta(routing_meta)
|
||
return meta.get("fallback_policy") != "fast_local"
|
||
|
||
def _structured_output_required(
|
||
self,
|
||
function_name: str,
|
||
routing_meta: dict[str, Any] | None = None,
|
||
) -> bool:
|
||
meta = _normalize_routing_meta(routing_meta)
|
||
if meta.get("response_contract") == "structured":
|
||
return True
|
||
if meta.get("response_contract") == "freeform":
|
||
return False
|
||
fn = str(function_name or "").strip().lower()
|
||
if not fn:
|
||
return False
|
||
return fn in STRUCTURED_OBJECT_SCHEMAS
|