343 lines
14 KiB
Python
343 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from typing import Any
|
|
|
|
import requests
|
|
|
|
|
|
class DeepSeekClient:
|
|
def __init__(
|
|
self,
|
|
api_key: str,
|
|
base_url: str = "https://api.deepseek.com",
|
|
model: str = "deepseek-chat",
|
|
timeout: float = 25.0,
|
|
connect_timeout: float | None = None,
|
|
read_timeout: float | None = None,
|
|
retries: int = 2,
|
|
extra_headers: dict[str, Any] | None = None,
|
|
force_json_mode: bool = True,
|
|
):
|
|
self.api_key = api_key.strip()
|
|
self.base_url = base_url.rstrip("/")
|
|
self.model = model
|
|
self.timeout = float(timeout)
|
|
self.connect_timeout = float(connect_timeout) if connect_timeout is not None else 8.0
|
|
self.read_timeout = float(read_timeout) if read_timeout is not None else float(timeout)
|
|
self.retries = retries
|
|
self.force_json_mode = bool(force_json_mode)
|
|
self.extra_headers = self._normalize_headers(extra_headers)
|
|
|
|
def _normalize_headers(self, headers: dict[str, Any] | None) -> dict[str, str]:
|
|
if not isinstance(headers, dict):
|
|
return {}
|
|
normalized: dict[str, str] = {}
|
|
for key, value in headers.items():
|
|
safe_key = str(key or "").strip()
|
|
safe_value = str(value or "").strip()
|
|
if safe_key and safe_value:
|
|
normalized[safe_key] = safe_value
|
|
return normalized
|
|
|
|
def _chat_url(self) -> str:
|
|
if self.base_url.endswith("/chat/completions"):
|
|
return self.base_url
|
|
if self.base_url.endswith("/v1"):
|
|
return f"{self.base_url}/chat/completions"
|
|
return f"{self.base_url}/v1/chat/completions"
|
|
|
|
def _request_headers(self) -> dict[str, str]:
|
|
headers = {
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
headers.update(self.extra_headers)
|
|
if "openrouter.ai" in self.base_url:
|
|
headers.setdefault("HTTP-Referer", "http://localhost")
|
|
headers.setdefault("X-Title", "Echo Tavern")
|
|
return headers
|
|
|
|
def _payload(self, messages: list[dict[str, str]], temperature: float, max_tokens: int, use_json_mode: bool) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"model": self.model,
|
|
"messages": messages,
|
|
}
|
|
payload["temperature"] = temperature
|
|
payload["max_tokens"] = max_tokens
|
|
if use_json_mode:
|
|
payload["response_format"] = {"type": "json_object"}
|
|
return payload
|
|
|
|
def _build_payload(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
temperature: float,
|
|
max_tokens: int,
|
|
*,
|
|
use_json_mode: bool,
|
|
token_field: str,
|
|
include_temperature: bool,
|
|
) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"model": self.model,
|
|
"messages": messages,
|
|
}
|
|
if include_temperature:
|
|
payload["temperature"] = temperature
|
|
if token_field == "max_tokens":
|
|
payload["max_tokens"] = max_tokens
|
|
elif token_field == "max_completion_tokens":
|
|
payload["max_completion_tokens"] = max_tokens
|
|
if use_json_mode:
|
|
payload["response_format"] = {"type": "json_object"}
|
|
return payload
|
|
|
|
def _is_response_format_unsupported(self, status_code: int, raw_text: str) -> bool:
|
|
if status_code not in {400, 404, 415, 422}:
|
|
return False
|
|
lower = str(raw_text or "").lower()
|
|
if "response_format" in lower:
|
|
return True
|
|
if "json_object" in lower and "support" in lower:
|
|
return True
|
|
return "json schema" in lower and "support" in lower
|
|
|
|
def _extract_error_message(self, body: Any, raw_text: str) -> str:
|
|
if isinstance(body, dict):
|
|
error = body.get("error")
|
|
if isinstance(error, dict):
|
|
msg = error.get("message")
|
|
if isinstance(msg, str) and msg.strip():
|
|
return msg.strip()
|
|
if isinstance(error, str) and error.strip():
|
|
return error.strip()
|
|
msg = body.get("message")
|
|
if isinstance(msg, str) and msg.strip():
|
|
return msg.strip()
|
|
text = str(raw_text or "").strip()
|
|
if not text:
|
|
return ""
|
|
if len(text) > 1000:
|
|
return text[:1000]
|
|
return text
|
|
|
|
def _detect_token_field_switch(self, status_code: int, error_message: str, current_field: str) -> str | None:
|
|
if status_code not in {400, 404, 415, 422}:
|
|
return None
|
|
lower = str(error_message or "").lower()
|
|
if current_field == "max_tokens":
|
|
if "max_tokens" in lower and any(word in lower for word in ("unsupported", "unknown", "invalid", "not allowed", "unrecognized")):
|
|
return "max_completion_tokens"
|
|
if current_field == "max_completion_tokens":
|
|
if "max_completion_tokens" in lower and any(word in lower for word in ("unsupported", "unknown", "invalid", "not allowed", "unrecognized")):
|
|
return "max_tokens"
|
|
if current_field != "none":
|
|
if "maximum context length" in lower and "max_tokens" in lower:
|
|
return "none"
|
|
if "unexpected parameter" in lower and ("max_tokens" in lower or "max_completion_tokens" in lower):
|
|
return "none"
|
|
return None
|
|
|
|
def _is_temperature_unsupported(self, status_code: int, error_message: str) -> bool:
|
|
if status_code not in {400, 404, 415, 422}:
|
|
return False
|
|
lower = str(error_message or "").lower()
|
|
if "temperature" not in lower:
|
|
return False
|
|
return any(word in lower for word in ("unsupported", "unknown", "invalid", "not allowed", "unrecognized"))
|
|
|
|
def _content_to_text(self, value: Any) -> str:
|
|
if isinstance(value, str):
|
|
return value.strip()
|
|
if isinstance(value, dict):
|
|
for key in ("text", "content", "message"):
|
|
text = value.get(key)
|
|
if isinstance(text, str) and text.strip():
|
|
return text.strip()
|
|
return ""
|
|
if isinstance(value, list):
|
|
parts: list[str] = []
|
|
for row in value:
|
|
if isinstance(row, str):
|
|
text = row.strip()
|
|
if text:
|
|
parts.append(text)
|
|
continue
|
|
if not isinstance(row, dict):
|
|
continue
|
|
text = row.get("text")
|
|
if not isinstance(text, str):
|
|
nested = row.get("content")
|
|
if isinstance(nested, str):
|
|
text = nested
|
|
if isinstance(text, str):
|
|
clean = text.strip()
|
|
if clean:
|
|
parts.append(clean)
|
|
return "\n".join(parts).strip()
|
|
return ""
|
|
|
|
def _extract_content(self, body: dict[str, Any]) -> str:
|
|
choices = body.get("choices", [])
|
|
if isinstance(choices, list) and choices:
|
|
first = choices[0] if isinstance(choices[0], dict) else {}
|
|
message = first.get("message", {})
|
|
content = ""
|
|
if isinstance(message, dict):
|
|
content = self._content_to_text(message.get("content"))
|
|
if not content:
|
|
tool_calls = message.get("tool_calls", [])
|
|
if isinstance(tool_calls, list) and tool_calls:
|
|
call = tool_calls[0] if isinstance(tool_calls[0], dict) else {}
|
|
func = call.get("function", {}) if isinstance(call, dict) else {}
|
|
if isinstance(func, dict):
|
|
content = self._content_to_text(func.get("arguments"))
|
|
if not content:
|
|
content = self._content_to_text(message.get("reasoning"))
|
|
if not content:
|
|
content = self._content_to_text(first.get("text"))
|
|
if not content and isinstance(first.get("delta"), dict):
|
|
content = self._content_to_text(first.get("delta", {}).get("content"))
|
|
if content:
|
|
return content
|
|
|
|
output_text = self._content_to_text(body.get("output_text"))
|
|
if output_text:
|
|
return output_text
|
|
|
|
output = body.get("output", [])
|
|
if isinstance(output, list):
|
|
blocks: list[str] = []
|
|
for item in output:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
content_rows = item.get("content", [])
|
|
if not isinstance(content_rows, list):
|
|
continue
|
|
for row in content_rows:
|
|
if isinstance(row, dict):
|
|
text = self._content_to_text(row.get("text"))
|
|
if text:
|
|
blocks.append(text)
|
|
merged = "\n".join(blocks).strip()
|
|
if merged:
|
|
return merged
|
|
return ""
|
|
|
|
def chat_json(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
*,
|
|
temperature: float = 0.6,
|
|
max_tokens: int = 800,
|
|
) -> dict[str, Any]:
|
|
if not self.api_key:
|
|
return {"ok": False, "error": "missing_api_key", "content": None, "detail": "api_key_empty"}
|
|
|
|
url = self._chat_url()
|
|
headers = self._request_headers()
|
|
|
|
last_error = "request_failed"
|
|
last_detail = ""
|
|
attempt = 0
|
|
use_json_mode = self.force_json_mode
|
|
token_field = "max_tokens"
|
|
include_temperature = True
|
|
while attempt <= self.retries:
|
|
try:
|
|
payload = self._build_payload(
|
|
messages,
|
|
temperature,
|
|
max_tokens,
|
|
use_json_mode=use_json_mode,
|
|
token_field=token_field,
|
|
include_temperature=include_temperature,
|
|
)
|
|
response = requests.post(
|
|
url,
|
|
headers=headers,
|
|
json=payload,
|
|
timeout=(self.connect_timeout, self.read_timeout),
|
|
)
|
|
raw_text = response.text or ""
|
|
if response.status_code >= 500:
|
|
last_error = f"server_error_{response.status_code}"
|
|
raise requests.HTTPError(last_error)
|
|
|
|
if response.status_code >= 400:
|
|
body_error: Any = None
|
|
try:
|
|
body_error = response.json()
|
|
except (json.JSONDecodeError, ValueError):
|
|
body_error = None
|
|
error_message = self._extract_error_message(body_error, raw_text)
|
|
if use_json_mode and self._is_response_format_unsupported(response.status_code, raw_text):
|
|
use_json_mode = False
|
|
continue
|
|
next_token_field = self._detect_token_field_switch(response.status_code, error_message, token_field)
|
|
if next_token_field is not None and next_token_field != token_field:
|
|
token_field = next_token_field
|
|
continue
|
|
if include_temperature and self._is_temperature_unsupported(response.status_code, error_message):
|
|
include_temperature = False
|
|
continue
|
|
return {
|
|
"ok": False,
|
|
"error": f"http_{response.status_code}",
|
|
"content": None,
|
|
"detail": error_message,
|
|
"raw": body_error if isinstance(body_error, dict) else raw_text,
|
|
}
|
|
|
|
try:
|
|
body = response.json()
|
|
except json.JSONDecodeError:
|
|
return {
|
|
"ok": False,
|
|
"error": "invalid_json_response",
|
|
"content": None,
|
|
"detail": "",
|
|
"raw": raw_text,
|
|
}
|
|
|
|
content = self._extract_content(body)
|
|
if not content:
|
|
return {"ok": False, "error": "empty_content", "content": None, "detail": "", "raw": body}
|
|
|
|
return {
|
|
"ok": True,
|
|
"error": None,
|
|
"content": content,
|
|
"detail": "",
|
|
"raw": body,
|
|
"usage": body.get("usage"),
|
|
}
|
|
|
|
except requests.Timeout as exc:
|
|
last_error = "request_timeout"
|
|
last_detail = str(exc)
|
|
except requests.ConnectionError as exc:
|
|
detail = str(exc)
|
|
lower = detail.lower()
|
|
if "nodename nor servname provided" in lower or "name or service not known" in lower:
|
|
last_error = "dns_resolution_failed"
|
|
elif "connection refused" in lower:
|
|
last_error = "connection_refused"
|
|
elif "read timed out" in lower:
|
|
last_error = "request_timeout"
|
|
else:
|
|
last_error = "network_connection_error"
|
|
last_detail = detail
|
|
except (requests.HTTPError, ValueError):
|
|
if last_error == "request_failed":
|
|
last_error = "invalid_response"
|
|
last_detail = ""
|
|
|
|
attempt += 1
|
|
if attempt <= self.retries:
|
|
time.sleep(0.4 * attempt)
|
|
|
|
return {"ok": False, "error": last_error, "content": None, "detail": last_detail}
|