275 lines
10 KiB
Python
275 lines
10 KiB
Python
"""模型调用执行器。
|
|
|
|
统一处理超时预算、可重试错误、凭据脱敏、调用快照和 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
|
|
from apps.modelhub.structured_output import (
|
|
STRUCTURED_OUTPUT_TEST_PROMPT,
|
|
STRUCTURED_OUTPUT_TEST_SCHEMA,
|
|
StructuredOutputError,
|
|
build_structured_request,
|
|
)
|
|
|
|
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,
|
|
response_format: dict[str, Any] | None = None,
|
|
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,
|
|
)
|
|
request_options: dict[str, Any] = {
|
|
"model": deployment.model_name,
|
|
"messages": messages,
|
|
"temperature": temperature,
|
|
"max_tokens": max_output_tokens,
|
|
}
|
|
if response_format is not None:
|
|
request_options["response_format"] = response_format
|
|
response = client.chat.completions.create(
|
|
**request_options,
|
|
)
|
|
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,
|
|
output_schema: dict[str, Any] | None = None,
|
|
schema_name: str | 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 deployment.archived_at is not None
|
|
or deployment.provider.archived_at is not 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
|
|
|
|
request_messages = messages
|
|
response_format = None
|
|
structured_metadata = None
|
|
if output_schema is not None:
|
|
try:
|
|
request_messages, response_format, structured_metadata = build_structured_request(
|
|
messages,
|
|
mode=deployment.structured_output_mode,
|
|
output_schema=output_schema,
|
|
schema_name=schema_name or f"invocation_{invocation.public_id.hex}",
|
|
)
|
|
except StructuredOutputError as error:
|
|
invocation.status = "failed"
|
|
invocation.error_code = error.code
|
|
invocation.error_message = str(error)
|
|
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,
|
|
"structured_output": structured_metadata,
|
|
}
|
|
if structured_metadata is not None:
|
|
invocation.input_snapshot = {
|
|
**invocation.input_snapshot,
|
|
"structured_output": structured_metadata,
|
|
"output_schema": output_schema,
|
|
"response_format": response_format,
|
|
}
|
|
invocation.save(
|
|
update_fields=["status", "started_at", "model_snapshot", "input_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,
|
|
request_messages,
|
|
temperature=temperature
|
|
if temperature is not None
|
|
else deployment.default_temperature,
|
|
max_output_tokens=max_output_tokens or deployment.max_output_tokens,
|
|
response_format=response_format,
|
|
timeout_seconds=remaining,
|
|
)
|
|
invocation.retry_count = attempt
|
|
break
|
|
except Exception as error: # provider libraries expose several subclasses
|
|
code, message, transient = _error_details(error)
|
|
if response_format is not None and code == "bad_request":
|
|
code = "structured_output_unsupported"
|
|
message = "模型渠道拒绝当前结构化输出配置"
|
|
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,
|
|
)
|
|
|
|
|
|
def create_structured_output_test(
|
|
deployment: ModelDeployment,
|
|
user=None,
|
|
) -> ModelInvocation:
|
|
"""创建单次、不可修复的结构化输出能力测试。"""
|
|
|
|
return ModelInvocation.objects.create(
|
|
deployment=deployment,
|
|
invocation_type="structured_output_test",
|
|
input_snapshot={
|
|
"messages": [{"role": "user", "content": STRUCTURED_OUTPUT_TEST_PROMPT}],
|
|
"output_schema": STRUCTURED_OUTPUT_TEST_SCHEMA,
|
|
"schema_name": f"deployment_{deployment.id}_structured_output_test",
|
|
},
|
|
created_by=user,
|
|
)
|