open_router_llm.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. """OpenRouter LLM 调用工具。"""
  2. from __future__ import annotations
  3. from typing import Any
  4. from openrouter import OpenRouter
  5. from openrouter.errors.openroutererror import OpenRouterError as SdkOpenRouterError
  6. from app.core.config import settings
  7. _ALLOWED_ROLES = frozenset({"system", "user", "assistant"})
  8. class OpenRouterCallError(Exception):
  9. """项目内 OpenRouter 调用失败。"""
  10. def __init__(self, message: str, *, cause: Exception | None = None):
  11. super().__init__(message)
  12. self.cause = cause
  13. def _build_client() -> OpenRouter:
  14. kwargs: dict[str, Any] = {
  15. "api_key": settings.open_router_api_key.strip(),
  16. "timeout_ms": max(settings.open_router_timeout_seconds, 1) * 1000,
  17. }
  18. if settings.open_router_http_referer.strip():
  19. kwargs["http_referer"] = settings.open_router_http_referer.strip()
  20. if settings.open_router_app_title.strip():
  21. kwargs["x_open_router_title"] = settings.open_router_app_title.strip()
  22. if settings.open_router_base_url.strip():
  23. kwargs["server_url"] = settings.open_router_base_url.strip()
  24. return OpenRouter(**kwargs)
  25. def _normalize_messages(messages: list[dict[str, str]]) -> list[dict[str, str]]:
  26. if not messages:
  27. raise OpenRouterCallError("messages must not be empty")
  28. normalized: list[dict[str, str]] = []
  29. for index, item in enumerate(messages):
  30. role = (item.get("role") or "").strip()
  31. content = item.get("content")
  32. if role not in _ALLOWED_ROLES:
  33. raise OpenRouterCallError(
  34. f"messages[{index}].role must be one of: system, user, assistant"
  35. )
  36. if content is None or not str(content).strip():
  37. raise OpenRouterCallError(f"messages[{index}].content must not be empty")
  38. normalized.append({"role": role, "content": str(content)})
  39. return normalized
  40. def _normalize_chat_response(response: Any) -> dict[str, Any]:
  41. choices = getattr(response, "choices", None) or []
  42. first_choice = choices[0] if choices else None
  43. message = getattr(first_choice, "message", None) if first_choice else None
  44. content = getattr(message, "content", None) or ""
  45. usage = getattr(response, "usage", None)
  46. if hasattr(usage, "model_dump"):
  47. usage = usage.model_dump()
  48. elif usage is not None and not isinstance(usage, dict):
  49. usage = dict(usage) if hasattr(usage, "__iter__") else usage
  50. return {
  51. "id": getattr(response, "id", None),
  52. "model": getattr(response, "model", None),
  53. "content": content,
  54. "usage": usage,
  55. "finish_reason": getattr(first_choice, "finish_reason", None) if first_choice else None,
  56. }
  57. def create_chat_completion(
  58. messages: list[dict[str, str]],
  59. *,
  60. model: str | None = None,
  61. temperature: float | None = None,
  62. max_tokens: int | None = None,
  63. ) -> dict[str, Any]:
  64. """通过 OpenRouter SDK 调用对话补全,返回结构化结果。"""
  65. if not settings.open_router_api_key.strip():
  66. raise OpenRouterCallError("OPEN_ROUTER_API_KEY or OPENROUTER_API_KEY is not configured")
  67. normalized_messages = _normalize_messages(messages)
  68. model_name = (model or settings.open_router_default_model).strip()
  69. if not model_name:
  70. raise OpenRouterCallError(
  71. "model is required (set OPEN_ROUTER_DEFAULT_MODEL or pass model=...)"
  72. )
  73. request_kwargs: dict[str, Any] = {
  74. "model": model_name,
  75. "messages": normalized_messages,
  76. }
  77. temperature_value = (
  78. temperature if temperature is not None else settings.open_router_temperature
  79. )
  80. max_tokens_value = max_tokens if max_tokens is not None else settings.open_router_max_tokens
  81. if temperature_value is not None:
  82. request_kwargs["temperature"] = temperature_value
  83. if max_tokens_value is not None:
  84. request_kwargs["max_tokens"] = max_tokens_value
  85. try:
  86. with _build_client() as client:
  87. response = client.chat.send(**request_kwargs)
  88. except SdkOpenRouterError as exc:
  89. raise OpenRouterCallError(str(exc), cause=exc) from exc
  90. except Exception as exc:
  91. raise OpenRouterCallError(f"OpenRouter SDK error: {exc}", cause=exc) from exc
  92. return _normalize_chat_response(response)
  93. def chat_text(
  94. user_prompt: str,
  95. *,
  96. system_prompt: str | None = None,
  97. model: str | None = None,
  98. temperature: float | None = None,
  99. max_tokens: int | None = None,
  100. ) -> str:
  101. """单轮对话便捷方法,直接返回模型回复文本。"""
  102. messages: list[dict[str, str]] = []
  103. if system_prompt and system_prompt.strip():
  104. messages.append({"role": "system", "content": system_prompt.strip()})
  105. messages.append({"role": "user", "content": user_prompt.strip()})
  106. result = create_chat_completion(
  107. messages,
  108. model=model,
  109. temperature=temperature,
  110. max_tokens=max_tokens,
  111. )
  112. return str(result.get("content") or "")