Files
LLM_Proxy/src/services/proxy_service.py
T

791 lines
33 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""上游 LLM 代理服务。"""
from __future__ import annotations
import json
from dataclasses import replace
from typing import Any, Callable, Dict, Iterator, Optional, Tuple
import requests
import websocket
from flask import Response, stream_with_context
from ..application.app_context import AppContext
from ..config.auth_group_manager import AuthGroupManager, AuthGroupSelectionError, SelectedAuthEntry
from ..executors import OpenedUpstreamResponse, build_default_executor_registry
from ..external import LLMProvider
from ..hooks import HookContext, HookErrorType
from ..proxy_core import (
DownstreamChunk,
decode_stream_events,
encode_downstream_chunk,
encode_downstream_response_body,
is_terminal_chunk,
should_emit_terminal_chunk,
)
from ..translators import Translator, build_default_translator_registry
from ..utils.compat import dataclass
from ..utils.net import build_requests_proxies
@dataclass(frozen=True, slots=True)
class ProxyErrorInfo:
"""Structured failure information for locally generated proxy errors."""
message: str
status_code: int = 502
error_type: str = "upstream_error"
error_code: Optional[str] = None
class ProxyService:
"""处理上游 LLM 代理请求。"""
def __init__(self, ctx: AppContext, auth_group_manager: Optional[AuthGroupManager] = None):
self._logger = ctx.logger
self._root_path = ctx.root_path
self._auth_group_manager = auth_group_manager
self._executor_registry = build_default_executor_registry(self._logger)
self._translator_registry = build_default_translator_registry()
def proxy_request(
self,
provider: LLMProvider,
request_data: Dict[str, Any],
request_headers: Dict[str, str],
on_complete: Optional[Callable[[Dict[str, Any]], None]] = None,
forward_stream_usage: bool = False,
) -> Tuple[Optional[Response], int, Optional[ProxyErrorInfo]]:
"""代理请求到目标 provider,并处理重试、格式转换与 guard。"""
target_url = provider.api
requested_model = request_data["model"]
upstream_model = self._get_upstream_model_name(provider.name, requested_model)
timeout_seconds = provider.timeout_seconds
max_retries = provider.max_retries
verify_ssl = provider.verify_ssl
request_proxies = build_requests_proxies(provider.proxy)
translator = self._translator_registry.get(provider.source_format, provider.target_format)
last_error: Optional[ProxyErrorInfo] = None
previous_status_code: Optional[int] = None
previous_error_type: Optional[HookErrorType] = None
self._ensure_supported_target_format(provider.target_format)
def build_request(
attempt: int,
selected_auth: Optional[SelectedAuthEntry],
) -> Tuple[Dict[str, str], Dict[str, Any], Dict[str, Any], HookContext]:
initial_stream = bool(request_data.get("stream", False))
request_ctx = HookContext(
retry=attempt,
root_path=self._root_path,
logger=self._logger,
provider_name=provider.name,
request_model=requested_model,
upstream_model=upstream_model,
provider_source_format=provider.source_format,
provider_target_format=provider.target_format,
transport=provider.transport,
stream=initial_stream,
auth_group_name=(
selected_auth.auth_group_name
if selected_auth is not None
else provider.auth_group
),
auth_entry_id=(selected_auth.entry_id if selected_auth is not None else None),
last_status_code=previous_status_code,
last_error_type=previous_error_type,
)
headers = dict(request_headers)
headers["content-type"] = "application/json"
if selected_auth is not None:
headers.update(selected_auth.headers_mapping())
elif provider.api_key:
headers["authorization"] = f"Bearer {provider.api_key}"
headers = provider.apply_header_hook(request_ctx, headers)
guarded_body = provider.apply_request_guard(request_ctx, dict(request_data))
guarded_stream = bool(guarded_body.get("stream", False))
if guarded_stream != request_ctx.stream:
request_ctx = replace(request_ctx, stream=guarded_stream)
translated_body = translator.translate_request(upstream_model, guarded_body, guarded_stream)
return headers, guarded_body, translated_body, request_ctx
for attempt in range(max_retries):
selected_auth: Optional[SelectedAuthEntry] = None
attempt_finalized = False
def finalize_attempt(
*,
status_code: Optional[int] = None,
error_type: Optional[HookErrorType] = None,
error_message: Optional[str] = None,
response_headers: Optional[Dict[str, Any]] = None,
usage: Optional[Dict[str, Any]] = None,
) -> None:
nonlocal attempt_finalized
if attempt_finalized:
return
attempt_finalized = True
if self._auth_group_manager is None:
return
self._auth_group_manager.finish(
selected_auth,
status_code=status_code,
error_type=error_type,
error_message=error_message,
response_headers=response_headers,
usage=usage,
)
try:
if self._auth_group_manager is not None and provider.auth_group:
selected_auth = self._auth_group_manager.acquire(provider.auth_group)
headers, guarded_body, translated_body, request_ctx = build_request(attempt, selected_auth)
requested_stream = request_ctx.stream
self._logger.info(
"Proxying upstream request: provider=%s transport=%s source=%s target=%s model=%s attempt=%s/%s stream=%s auth_group=%s auth_entry=%s",
provider.name,
provider.transport,
provider.source_format,
provider.target_format,
translated_body.get("model"),
attempt + 1,
max_retries,
requested_stream,
request_ctx.auth_group_name or "<none>",
request_ctx.auth_entry_id or "<none>",
)
if self._auth_group_manager is not None:
self._auth_group_manager.mark_request_dispatched(selected_auth)
opened = self._coerce_opened_response(
self._open_upstream_response(
provider,
headers,
translated_body,
requested_stream,
target_url,
request_proxies,
timeout_seconds,
verify_ssl,
)
)
if self._should_retry_status_code(opened.status_code) and attempt < max_retries - 1:
previous_status_code = opened.status_code
previous_error_type = None
raw_response_headers = dict(getattr(opened.response, "headers", {}) or {})
_, _, retry_summary = self._consume_upstream_error(provider, opened)
finalize_attempt(
status_code=opened.status_code,
error_message=retry_summary,
response_headers=raw_response_headers,
)
self._logger.warning(
"Retryable upstream status (attempt %s/%s): provider=%s status=%s stream=%s, retrying",
attempt + 1,
max_retries,
provider.name,
opened.status_code,
opened.is_stream,
)
continue
if requested_stream != opened.is_stream:
self._logger.warning(
"Stream mode mismatch: provider=%s requested_stream=%s upstream_stream=%s decoder=%s content_type=%s",
provider.name,
requested_stream,
opened.is_stream,
opened.stream_format,
opened.content_type,
)
if opened.status_code >= 400:
raw_response_headers = dict(getattr(opened.response, "headers", {}) or {})
response, error_summary = self._build_error_response(provider, opened)
finalize_attempt(
status_code=opened.status_code,
error_message=error_summary,
response_headers=raw_response_headers,
)
return response, opened.status_code, None
if opened.is_stream:
response = self._build_stream_response(
provider=provider,
translator=translator,
request_ctx=request_ctx,
original_request=guarded_body,
translated_request=translated_body,
opened=opened,
on_complete=on_complete,
forward_stream_usage=forward_stream_usage,
finalize_attempt=finalize_attempt,
)
else:
response = self._build_nonstream_response(
provider=provider,
translator=translator,
request_ctx=request_ctx,
original_request=guarded_body,
translated_request=translated_body,
opened=opened,
on_complete=on_complete,
finalize_attempt=finalize_attempt,
)
self._logger.info(
"Upstream request completed: provider=%s transport=%s source=%s target=%s status=%s stream=%s",
provider.name,
provider.transport,
provider.source_format,
provider.target_format,
opened.status_code,
opened.is_stream,
)
return response, opened.status_code, None
except AuthGroupSelectionError as exc:
last_error = ProxyErrorInfo(
message=exc.message,
status_code=exc.status_code,
error_type=exc.error_type,
error_code=exc.error_code,
)
self._logger.warning(
"Auth group unavailable: provider=%s auth_group=%s status=%s error=%s",
provider.name,
provider.auth_group or "<none>",
exc.status_code,
exc.message,
)
return None, exc.status_code, last_error
except requests.exceptions.RequestException as exc:
previous_status_code = None
previous_error_type = self._classify_request_error(exc)
last_error = self._build_transport_error_info("HTTP", exc, max_retries)
self._logger.error(
"HTTP upstream request error (attempt %s/%s): provider=%s error=%s",
attempt + 1,
max_retries,
provider.name,
exc,
)
finalize_attempt(
error_type=previous_error_type,
error_message=str(exc),
)
if attempt < max_retries - 1:
continue
except (websocket.WebSocketException, OSError) as exc:
previous_status_code = None
previous_error_type = self._classify_websocket_error(exc)
last_error = self._build_transport_error_info("WebSocket", exc, max_retries)
self._logger.error(
"WebSocket upstream request error (attempt %s/%s): provider=%s error=%s",
attempt + 1,
max_retries,
provider.name,
exc,
)
finalize_attempt(
error_type=previous_error_type,
error_message=str(exc),
)
if attempt < max_retries - 1:
continue
except Exception:
finalize_attempt()
raise
if last_error is None:
last_error = ProxyErrorInfo(
message="Upstream request failed after retries",
status_code=502,
error_type="upstream_error",
error_code="upstream_request_failed",
)
return None, last_error.status_code, last_error
def _build_stream_response(
self,
*,
provider: LLMProvider,
translator: Translator,
request_ctx: HookContext,
original_request: Dict[str, Any],
translated_request: Dict[str, Any],
opened: OpenedUpstreamResponse,
on_complete: Optional[Callable[[Dict[str, Any]], None]],
forward_stream_usage: bool,
finalize_attempt: Optional[Callable[..., None]] = None,
) -> Response:
response = opened.response
meta = self._create_empty_meta()
completed = False
terminal_sent = False
def safe_on_complete(
*,
error_type: Optional[HookErrorType] = None,
error_message: Optional[str] = None,
) -> None:
nonlocal completed
if completed:
return
completed = True
if finalize_attempt is not None:
finalize_attempt(
status_code=(opened.status_code if error_type is None else None),
error_type=error_type,
error_message=error_message,
usage=(meta if error_type is None else None),
)
if on_complete and error_type is None:
try:
on_complete(meta)
except Exception as exc:
self._logger.error("Error in on_complete callback: %s", exc)
def generate() -> Iterator[bytes]:
nonlocal terminal_sent
state: Dict[str, Any] = {}
completion_error_type: Optional[HookErrorType] = None
completion_error_message: Optional[str] = None
try:
upstream_chunks = response.iter_content(chunk_size=None)
for event in decode_stream_events(upstream_chunks, opened.stream_format):
downstream_chunks = translator.translate_stream_event(
self._get_upstream_model_name(provider.name, request_ctx.request_model),
original_request,
translated_request,
event,
state,
)
for downstream_chunk in downstream_chunks:
guarded_chunk = self._guard_stream_chunk(provider, request_ctx, downstream_chunk)
if guarded_chunk is None:
continue
if is_terminal_chunk(guarded_chunk, provider.target_format):
terminal_sent = True
if guarded_chunk.kind == "done":
encoded_terminal = encode_downstream_chunk(guarded_chunk, provider.target_format)
if encoded_terminal:
yield encoded_terminal
continue
if (
provider.target_format == "openai_chat"
and guarded_chunk.kind == "json"
and not forward_stream_usage
and self._is_usage_only_stream_chunk(guarded_chunk.payload)
):
continue
if guarded_chunk.kind == "json" and isinstance(guarded_chunk.payload, dict):
self._update_meta_from_payload(meta, guarded_chunk.payload)
encoded_chunk = encode_downstream_chunk(guarded_chunk, provider.target_format)
if encoded_chunk:
yield encoded_chunk
if should_emit_terminal_chunk(provider.target_format) and not terminal_sent:
encoded_terminal = encode_downstream_chunk(
DownstreamChunk(kind="done"),
provider.target_format,
)
if encoded_terminal:
yield encoded_terminal
except requests.exceptions.RequestException as exc:
completion_error_type = self._classify_request_error(exc)
completion_error_message = str(exc)
self._logger.error("Streamed HTTP upstream error: provider=%s error=%s", provider.name, exc)
raise
except (websocket.WebSocketException, OSError) as exc:
completion_error_type = self._classify_websocket_error(exc)
completion_error_message = str(exc)
self._logger.error("Streamed WebSocket upstream error: provider=%s error=%s", provider.name, exc)
raise
except Exception as exc:
completion_error_type = HookErrorType.TRANSPORT_ERROR
completion_error_message = str(exc)
self._logger.error("Streamed upstream processing error: provider=%s error=%s", provider.name, exc)
raise
finally:
try:
response.close()
finally:
safe_on_complete(
error_type=completion_error_type,
error_message=completion_error_message,
)
headers = self._filter_response_headers(getattr(response, "headers", {}))
headers["Content-Type"] = "text/event-stream; charset=utf-8"
headers["Cache-Control"] = "no-cache"
return Response(
stream_with_context(generate()),
status=opened.status_code,
headers=headers,
)
def _build_nonstream_response(
self,
*,
provider: LLMProvider,
translator: Translator,
request_ctx: HookContext,
original_request: Dict[str, Any],
translated_request: Dict[str, Any],
opened: OpenedUpstreamResponse,
on_complete: Optional[Callable[[Dict[str, Any]], None]],
finalize_attempt: Optional[Callable[..., None]] = None,
) -> Response:
response = opened.response
try:
raw_body = self._read_response_body(response)
parsed_body = self._parse_json_bytes(raw_body)
translated_payload = translator.translate_nonstream_response(
self._get_upstream_model_name(provider.name, request_ctx.request_model),
original_request,
translated_request,
parsed_body if parsed_body is not None else raw_body,
)
guarded_payload = provider.apply_response_guard(request_ctx, translated_payload)
body_to_send = translated_payload if guarded_payload is None else guarded_payload
meta = self._create_empty_meta()
if isinstance(body_to_send, dict):
self._update_meta_from_payload(meta, body_to_send)
if on_complete:
try:
on_complete(meta)
except Exception as exc:
self._logger.error("Error in on_complete callback: %s", exc)
if finalize_attempt is not None:
finalize_attempt(status_code=opened.status_code, usage=meta)
response_body = encode_downstream_response_body(body_to_send, provider.target_format)
headers = self._filter_response_headers(getattr(response, "headers", {}))
headers["Content-Type"] = self._resolve_nonstream_content_type(body_to_send, opened.content_type)
return Response(
response_body,
status=opened.status_code,
headers=headers,
)
finally:
response.close()
def _consume_upstream_error(
self,
provider: LLMProvider,
opened: OpenedUpstreamResponse,
) -> tuple[bytes, Dict[str, str], Optional[str]]:
response = opened.response
try:
body = self._read_response_body(response)
summary = self._summarize_upstream_error(body, opened.content_type)
log_method = self._logger.warning if opened.status_code < 500 else self._logger.error
log_method(
"Upstream returned error: provider=%s transport=%s format=%s status=%s stream=%s error=%s",
provider.name,
provider.transport,
f"{provider.source_format}->{provider.target_format}",
opened.status_code,
opened.is_stream,
summary or "<empty>",
)
headers = self._filter_response_headers(getattr(response, "headers", {}))
if opened.content_type:
headers["Content-Type"] = opened.content_type
return body, headers, summary
finally:
response.close()
def _build_error_response(
self,
provider: LLMProvider,
opened: OpenedUpstreamResponse,
) -> tuple[Response, Optional[str]]:
body, headers, summary = self._consume_upstream_error(provider, opened)
return Response(body, status=opened.status_code, headers=headers), summary
def _guard_stream_chunk(
self,
provider: LLMProvider,
request_ctx: HookContext,
chunk: DownstreamChunk,
) -> Optional[DownstreamChunk]:
if chunk.kind == "done":
return chunk
guarded_payload = provider.apply_response_guard(request_ctx, chunk.payload)
payload = chunk.payload if guarded_payload is None else guarded_payload
if isinstance(payload, DownstreamChunk):
return payload
if isinstance(payload, (dict, list)):
return DownstreamChunk(kind="json", payload=payload, event=chunk.event)
return DownstreamChunk(kind="text", payload=payload, event=chunk.event)
def _open_upstream_response(
self,
provider: LLMProvider,
headers: Dict[str, str],
body: Dict[str, Any],
requested_stream: bool,
target_url: str,
request_proxies: Optional[Dict[str, str]],
timeout_seconds: int,
verify_ssl: bool,
) -> OpenedUpstreamResponse:
del target_url
executor = self._executor_registry.get(provider.transport)
return executor.execute(
provider=provider,
headers=headers,
body=body,
requested_stream=requested_stream,
timeout_seconds=timeout_seconds,
verify_ssl=verify_ssl,
request_proxies=request_proxies,
)
@staticmethod
def _coerce_opened_response(result: Any) -> OpenedUpstreamResponse:
if isinstance(result, OpenedUpstreamResponse):
return result
if isinstance(result, tuple) and len(result) == 3:
response, is_stream, status_code = result
headers = getattr(response, "headers", {}) or {}
content_type = (headers.get("Content-Type") or "").lower()
return OpenedUpstreamResponse(
response=response,
status_code=int(status_code),
content_type=content_type,
is_stream=bool(is_stream),
stream_format="sse_json" if is_stream else "nonstream",
)
raise TypeError(f"Unsupported upstream response result: {type(result)!r}")
@staticmethod
def _build_transport_error_info(
transport: str,
exc: Exception,
max_retries: int,
) -> ProxyErrorInfo:
attempt_label = "attempt" if max_retries == 1 else "attempts"
return ProxyErrorInfo(
message=f"{transport} upstream request failed after {max_retries} {attempt_label}: {exc}",
status_code=502,
error_type="upstream_error",
error_code="upstream_request_failed",
)
@classmethod
def _summarize_upstream_error(cls, raw_body: bytes, content_type: str) -> Optional[str]:
del content_type
if not raw_body:
return None
body_text = raw_body.decode("utf-8", errors="ignore").strip()
if not body_text:
return None
try:
payload = json.loads(body_text)
except json.JSONDecodeError:
return cls._truncate_error_text(body_text)
message = cls._extract_error_message(payload)
if message:
return cls._truncate_error_text(message)
return cls._truncate_error_text(json.dumps(payload, ensure_ascii=False))
@classmethod
def _extract_error_message(cls, payload: Any) -> Optional[str]:
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
return cls._join_error_parts(
error.get("message"),
error_type=error.get("type"),
error_code=error.get("code"),
)
if error not in (None, ""):
return str(error)
if payload.get("message") not in (None, ""):
return str(payload.get("message"))
return None
@staticmethod
def _join_error_parts(
message: Any,
*,
error_type: Any = None,
error_code: Any = None,
) -> Optional[str]:
parts = []
if message not in (None, ""):
parts.append(str(message).strip())
tags = []
if error_type not in (None, ""):
tags.append(f"type={error_type}")
if error_code not in (None, ""):
tags.append(f"code={error_code}")
if tags:
suffix = ", ".join(tags)
if parts:
parts[0] = f"{parts[0]} ({suffix})"
else:
parts.append(suffix)
if not parts:
return None
return parts[0]
@staticmethod
def _truncate_error_text(text: str, limit: int = 1000) -> str:
if len(text) <= limit:
return text
return text[: limit - 3] + "..."
@staticmethod
def _create_empty_meta() -> Dict[str, Any]:
return {
"response_model": None,
"total_tokens": 0,
"prompt_tokens": 0,
"completion_tokens": 0,
}
@staticmethod
def _update_meta_from_payload(meta: Dict[str, Any], payload: Dict[str, Any]) -> None:
model = payload.get("model")
if model:
meta["response_model"] = model
if payload.get("modelVersion") is not None:
meta["response_model"] = payload.get("modelVersion")
usage = payload.get("usage")
usage_metadata = payload.get("usageMetadata")
response = payload.get("response")
if isinstance(response, dict):
if response.get("model") is not None:
meta["response_model"] = response.get("model")
if response.get("modelVersion") is not None:
meta["response_model"] = response.get("modelVersion")
if isinstance(response.get("usage"), dict):
usage = response.get("usage")
if isinstance(response.get("usageMetadata"), dict):
usage_metadata = response.get("usageMetadata")
if isinstance(usage, dict):
if usage.get("total_tokens") is not None:
meta["total_tokens"] = int(usage.get("total_tokens") or 0)
elif usage.get("input_tokens") is not None or usage.get("output_tokens") is not None:
meta["total_tokens"] = int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0)
if usage.get("prompt_tokens") is not None:
meta["prompt_tokens"] = int(usage.get("prompt_tokens") or 0)
elif usage.get("input_tokens") is not None:
meta["prompt_tokens"] = int(usage.get("input_tokens") or 0)
if usage.get("completion_tokens") is not None:
meta["completion_tokens"] = int(usage.get("completion_tokens") or 0)
elif usage.get("output_tokens") is not None:
meta["completion_tokens"] = int(usage.get("output_tokens") or 0)
return
if not isinstance(usage_metadata, dict):
return
meta["prompt_tokens"] = int(usage_metadata.get("promptTokenCount") or 0)
meta["completion_tokens"] = int(usage_metadata.get("candidatesTokenCount") or 0)
meta["total_tokens"] = int(
usage_metadata.get("totalTokenCount") or (meta["prompt_tokens"] + meta["completion_tokens"])
)
@staticmethod
def _is_usage_only_stream_chunk(payload: Any) -> bool:
if not isinstance(payload, dict):
return False
if not isinstance(payload.get("usage"), dict):
return False
choices = payload.get("choices")
return choices is None or (isinstance(choices, list) and len(choices) == 0)
@staticmethod
def _get_upstream_model_name(provider_name: str, requested_model_name: str) -> str:
prefix = f"{provider_name}/"
if requested_model_name.startswith(prefix):
return requested_model_name[len(prefix):]
return requested_model_name
@staticmethod
def _ensure_supported_target_format(target_format: str) -> None:
if str(target_format or "").strip().lower() not in {
"openai_chat",
"openai_responses",
"claude_chat",
"codex",
}:
raise ValueError(f"Unsupported downstream target_format: {target_format}")
@staticmethod
def _read_response_body(response: Any) -> bytes:
content = getattr(response, "content", None)
if isinstance(content, bytes):
return content
if isinstance(content, str):
return content.encode("utf-8")
return b"".join(response.iter_content(chunk_size=None))
@staticmethod
def _parse_json_bytes(raw_body: bytes) -> Optional[Any]:
if not raw_body:
return None
try:
return json.loads(raw_body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
@staticmethod
def _resolve_nonstream_content_type(payload: Any, upstream_content_type: str) -> str:
if isinstance(payload, (dict, list)):
return "application/json; charset=utf-8"
return upstream_content_type or "application/octet-stream"
@staticmethod
def _should_retry_status_code(status_code: int) -> bool:
retryable_status_codes = {408, 409, 425, 429, 500, 502, 503, 504}
return status_code in retryable_status_codes
@staticmethod
def _classify_request_error(exc: requests.exceptions.RequestException) -> HookErrorType:
if isinstance(exc, requests.exceptions.Timeout):
return HookErrorType.TIMEOUT
if isinstance(exc, requests.exceptions.ConnectionError):
return HookErrorType.CONNECTION_ERROR
return HookErrorType.TRANSPORT_ERROR
@staticmethod
def _classify_websocket_error(exc: Exception) -> HookErrorType:
if isinstance(exc, websocket.WebSocketException):
return HookErrorType.WEBSOCKET_ERROR
return HookErrorType.TRANSPORT_ERROR
@staticmethod
def _filter_response_headers(headers: Any) -> Dict[str, str]:
excluded = {
"transfer-encoding",
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"upgrade",
"set-cookie",
"content-length",
"content-encoding",
}
return {key: value for key, value in headers.items() if key.lower() not in excluded}