feat: add openai-compatible model gateway
This commit is contained in:
162
backend/apps/modelhub/services.py
Normal file
162
backend/apps/modelhub/services.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from dataclasses import dataclass
|
||||
from time import monotonic, sleep
|
||||
|
||||
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, str]],
|
||||
*,
|
||||
temperature: float,
|
||||
max_output_tokens: int,
|
||||
) -> CompletionResult:
|
||||
provider = deployment.provider
|
||||
client = OpenAI(
|
||||
api_key=decrypt_secret(provider.api_key_encrypted),
|
||||
base_url=provider.base_url,
|
||||
timeout=min(max(provider.default_timeout_seconds, 1), 300),
|
||||
)
|
||||
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, str]],
|
||||
*,
|
||||
temperature: float | None = None,
|
||||
max_output_tokens: int | 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,
|
||||
"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):
|
||||
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,
|
||||
)
|
||||
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
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user