"""模型调用执行器。 统一处理超时预算、可重试错误、凭据脱敏、调用快照和 Token/耗时记录。 """ from collections.abc import Callable from dataclasses import dataclass from time import monotonic, sleep from typing import Any from django.utils import timezone from openai import ( APIConnectionError, APIStatusError, APITimeoutError, AuthenticationError, BadRequestError, OpenAI, RateLimitError, ) from apps.modelhub.crypto import CredentialEncryptionError, decrypt_secret from apps.modelhub.models import ModelDeployment, ModelInvocation TRANSIENT_ERRORS = (APIConnectionError, APITimeoutError, RateLimitError) @dataclass(frozen=True) class CompletionResult: content: str input_tokens: int | None output_tokens: int | None class ModelGatewayError(RuntimeError): def __init__(self, code: str, message: str): super().__init__(message) self.code = code def _request_completion( deployment: ModelDeployment, messages: list[dict[str, Any]], *, temperature: float, max_output_tokens: int, timeout_seconds: float | None = None, ) -> CompletionResult: provider = deployment.provider request_timeout = min(max(provider.default_timeout_seconds, 1), 300) if timeout_seconds is not None: request_timeout = min(request_timeout, max(timeout_seconds, 1)) client = OpenAI( api_key=decrypt_secret(provider.api_key_encrypted), base_url=provider.base_url, timeout=request_timeout, # Retry here so the platform can expose it instead of waiting on opaque SDK retries. max_retries=0, ) response = client.chat.completions.create( model=deployment.model_name, messages=messages, temperature=temperature, max_tokens=max_output_tokens, ) content = response.choices[0].message.content or "" usage = response.usage return CompletionResult( content=content, input_tokens=getattr(usage, "prompt_tokens", None) if usage else None, output_tokens=getattr(usage, "completion_tokens", None) if usage else None, ) def _error_details(error: Exception) -> tuple[str, str, bool]: if isinstance(error, AuthenticationError): return "authentication_failed", "模型渠道鉴权失败", False if isinstance(error, BadRequestError): return "bad_request", "模型请求参数不被渠道接受", False if isinstance(error, RateLimitError): return "rate_limited", "模型渠道当前限流", True if isinstance(error, APITimeoutError): return "timeout", "模型请求超时", True if isinstance(error, APIConnectionError): return "connection_failed", "无法连接模型渠道", True if isinstance(error, APIStatusError) and error.status_code >= 500: return "provider_unavailable", "模型渠道暂时不可用", True if isinstance(error, CredentialEncryptionError): return "credential_unavailable", str(error), False return "unexpected_error", "模型调用发生未预期错误", False def execute_invocation( invocation: ModelInvocation, messages: list[dict[str, Any]], *, temperature: float | None = None, max_output_tokens: int | None = None, on_retry: Callable[[int, str], None] | None = None, total_timeout_seconds: float | None = None, ) -> ModelInvocation: """在总时间预算内调用模型,分类错误、限制重试并记录脱敏终态。""" deployment = invocation.deployment if deployment is None or not deployment.enabled or not deployment.provider.enabled: invocation.status = "failed" invocation.error_code = "deployment_unavailable" invocation.error_message = "模型配置或渠道未启用" invocation.finished_at = timezone.now() invocation.save(update_fields=["status", "error_code", "error_message", "finished_at"]) return invocation invocation.status = "running" invocation.started_at = timezone.now() invocation.model_snapshot = { "provider_id": deployment.provider_id, "provider_name": deployment.provider.name, "base_url": deployment.provider.base_url, "deployment_id": deployment.id, "model_name": deployment.model_name, "capability": deployment.capability, "temperature": temperature if temperature is not None else deployment.default_temperature, "max_output_tokens": max_output_tokens or deployment.max_output_tokens, } invocation.save(update_fields=["status", "started_at", "model_snapshot"]) started = monotonic() result: CompletionResult | None = None last_error: Exception | None = None for attempt in range(2): elapsed = monotonic() - started remaining = total_timeout_seconds - elapsed if total_timeout_seconds is not None else None if remaining is not None and remaining <= 0: invocation.error_code = "timeout" invocation.error_message = "模型请求超过 Agent 时间预算" break try: result = _request_completion( deployment, messages, temperature=temperature if temperature is not None else deployment.default_temperature, max_output_tokens=max_output_tokens or deployment.max_output_tokens, timeout_seconds=remaining, ) invocation.retry_count = attempt break except Exception as error: # provider libraries expose several subclasses code, message, transient = _error_details(error) last_error = error invocation.retry_count = attempt if not transient or attempt == 1: invocation.error_code = code invocation.error_message = message break if total_timeout_seconds is not None: remaining_after_error = total_timeout_seconds - (monotonic() - started) if remaining_after_error <= 1: invocation.error_code = code invocation.error_message = message break invocation.retry_count = attempt + 1 invocation.error_code = code invocation.error_message = message invocation.save(update_fields=["retry_count", "error_code", "error_message"]) if on_retry: on_retry(attempt + 1, code) sleep(1) invocation.duration_ms = max(int((monotonic() - started) * 1000), 0) invocation.finished_at = timezone.now() if result is not None: invocation.status = "succeeded" invocation.output_text = result.content invocation.input_tokens = result.input_tokens invocation.output_tokens = result.output_tokens invocation.error_code = "" invocation.error_message = "" else: invocation.status = "failed" if not invocation.error_code: code, message, _ = _error_details(last_error or RuntimeError()) invocation.error_code = code invocation.error_message = message invocation.save() return invocation def create_model_test( deployment: ModelDeployment, prompt: str, user=None, ) -> ModelInvocation: """创建可审计的异步模型连通测试,不绕过正式模型执行器。""" return ModelInvocation.objects.create( deployment=deployment, invocation_type="model_test", input_snapshot={"messages": [{"role": "user", "content": prompt}]}, created_by=user, )