openai_reasoning_proxy.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. #!/usr/bin/env python3
  2. import argparse
  3. import json
  4. import time
  5. import urllib.error
  6. import urllib.request
  7. from typing import Any
  8. from fastapi import FastAPI, HTTPException, Request, Response
  9. from fastapi.responses import JSONResponse
  10. import uvicorn
  11. DEFAULT_UPSTREAM = "http://127.0.0.1:7860"
  12. PASS_THROUGH_FIELDS = {
  13. "temperature",
  14. "top_p",
  15. "presence_penalty",
  16. "frequency_penalty",
  17. "stop",
  18. "seed",
  19. }
  20. app = FastAPI(title="LM Studio OpenAI Reasoning Proxy")
  21. app.state.upstream_base = DEFAULT_UPSTREAM
  22. app.state.request_timeout = 1800
  23. def post_json(url: str, payload: dict[str, Any], timeout: int) -> tuple[int, str]:
  24. body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
  25. request = urllib.request.Request(
  26. url,
  27. data=body,
  28. headers={"Content-Type": "application/json"},
  29. method="POST",
  30. )
  31. try:
  32. with urllib.request.urlopen(request, timeout=timeout) as response:
  33. return response.status, response.read().decode("utf-8", errors="replace")
  34. except urllib.error.HTTPError as exc:
  35. return exc.code, exc.read().decode("utf-8", errors="replace")
  36. def get_json(url: str, timeout: int) -> tuple[int, str]:
  37. request = urllib.request.Request(url, method="GET")
  38. try:
  39. with urllib.request.urlopen(request, timeout=timeout) as response:
  40. return response.status, response.read().decode("utf-8", errors="replace")
  41. except urllib.error.HTTPError as exc:
  42. return exc.code, exc.read().decode("utf-8", errors="replace")
  43. def parse_json_maybe(raw_text: str) -> dict[str, Any] | None:
  44. try:
  45. return json.loads(raw_text)
  46. except json.JSONDecodeError:
  47. return None
  48. def normalize_reasoning_value(value: Any) -> str | None:
  49. if isinstance(value, str):
  50. lowered = value.strip().lower()
  51. if lowered in {"on", "off"}:
  52. return lowered
  53. if isinstance(value, bool):
  54. return "on" if value else "off"
  55. return None
  56. def resolve_reasoning_mode(request_payload: dict[str, Any]) -> str:
  57. normalized = normalize_reasoning_value(request_payload.get("reasoning"))
  58. if normalized is not None:
  59. return normalized
  60. if "enable_thinking" in request_payload:
  61. enable_thinking = request_payload.get("enable_thinking")
  62. if isinstance(enable_thinking, bool):
  63. return "on" if enable_thinking else "off"
  64. return "on"
  65. def extract_text_content(content: Any) -> str:
  66. if isinstance(content, str):
  67. return content
  68. if isinstance(content, list):
  69. text_parts: list[str] = []
  70. for item in content:
  71. if isinstance(item, dict) and item.get("type") == "text":
  72. text = item.get("text")
  73. if isinstance(text, str):
  74. text_parts.append(text)
  75. return "\n".join(part for part in text_parts if part)
  76. return ""
  77. def messages_to_native_input(messages: list[dict[str, Any]]) -> tuple[str, str | None]:
  78. transcript_parts: list[str] = []
  79. system_parts: list[str] = []
  80. for message in messages:
  81. if not isinstance(message, dict):
  82. continue
  83. role = str(message.get("role") or "").strip().lower()
  84. content = extract_text_content(message.get("content"))
  85. if not content:
  86. continue
  87. if role == "system":
  88. system_parts.append(content)
  89. continue
  90. if role == "user":
  91. transcript_parts.append(f"User: {content}")
  92. continue
  93. if role == "assistant":
  94. transcript_parts.append(f"Assistant: {content}")
  95. continue
  96. transcript_parts.append(f"{role.title() or 'Message'}: {content}")
  97. transcript = "\n\n".join(transcript_parts)
  98. system_prompt = "\n\n".join(system_parts) if system_parts else None
  99. return transcript, system_prompt
  100. def build_native_payload(request_payload: dict[str, Any]) -> dict[str, Any]:
  101. messages = request_payload.get("messages")
  102. if not isinstance(messages, list) or not messages:
  103. raise ValueError("messages must be a non-empty list")
  104. input_text, system_prompt = messages_to_native_input(messages)
  105. if not input_text:
  106. raise ValueError("messages must include at least one non-system text message")
  107. native_payload: dict[str, Any] = {
  108. "model": request_payload.get("model"),
  109. "input": input_text,
  110. "reasoning": resolve_reasoning_mode(request_payload),
  111. "store": False,
  112. }
  113. if system_prompt:
  114. native_payload["system_prompt"] = system_prompt
  115. if "max_tokens" in request_payload:
  116. native_payload["max_output_tokens"] = request_payload["max_tokens"]
  117. for field in PASS_THROUGH_FIELDS:
  118. if field in request_payload:
  119. native_payload[field] = request_payload[field]
  120. return native_payload
  121. def _collect_output_text(native_response: dict[str, Any], output_type: str) -> list[str]:
  122. texts: list[str] = []
  123. for item in native_response.get("output") or []:
  124. if item.get("type") == output_type:
  125. content = item.get("content")
  126. if isinstance(content, str):
  127. texts.append(content)
  128. return texts
  129. def translate_native_response(native_response: dict[str, Any]) -> dict[str, Any]:
  130. message_parts = _collect_output_text(native_response, "message")
  131. reasoning_parts = _collect_output_text(native_response, "reasoning")
  132. stats = native_response.get("stats") or {}
  133. content = "\n".join(part for part in message_parts if part)
  134. reasoning_content = "\n".join(part for part in reasoning_parts if part)
  135. choice_message = {
  136. "role": "assistant",
  137. "content": content,
  138. "tool_calls": [],
  139. }
  140. if reasoning_content:
  141. choice_message["reasoning_content"] = reasoning_content
  142. return {
  143. "id": native_response.get("id", f"chatcmpl-proxy-{int(time.time() * 1000)}"),
  144. "object": "chat.completion",
  145. "created": int(time.time()),
  146. "model": native_response.get("model"),
  147. "choices": [
  148. {
  149. "index": 0,
  150. "message": choice_message,
  151. "logprobs": None,
  152. "finish_reason": "stop",
  153. }
  154. ],
  155. "usage": {
  156. "prompt_tokens": stats.get("input_tokens", 0),
  157. "completion_tokens": stats.get("total_output_tokens", 0),
  158. "total_tokens": stats.get("input_tokens", 0) + stats.get("total_output_tokens", 0),
  159. "completion_tokens_details": {
  160. "reasoning_tokens": stats.get("reasoning_output_tokens", 0),
  161. },
  162. },
  163. "stats": stats,
  164. "system_fingerprint": native_response.get("model"),
  165. }
  166. def json_error(status_code: int, message: str, error_type: str = "invalid_request_error") -> JSONResponse:
  167. return JSONResponse(
  168. status_code=status_code,
  169. content={
  170. "error": {
  171. "message": message,
  172. "type": error_type,
  173. }
  174. },
  175. )
  176. @app.get("/healthz")
  177. async def healthz() -> dict[str, str]:
  178. return {"status": "ok"}
  179. @app.get("/v1/models")
  180. async def list_models() -> Response:
  181. status_code, raw = get_json(
  182. f"{app.state.upstream_base}/v1/models",
  183. timeout=app.state.request_timeout,
  184. )
  185. return Response(content=raw, status_code=status_code, media_type="application/json")
  186. @app.post("/v1/chat/completions")
  187. async def chat_completions(request: Request) -> Response:
  188. try:
  189. request_payload = await request.json()
  190. except Exception as exc: # pragma: no cover
  191. raise HTTPException(status_code=400, detail=f"invalid JSON: {exc}") from exc
  192. if request_payload.get("stream") is True:
  193. return json_error(400, "stream=true is not supported by this proxy")
  194. try:
  195. native_payload = build_native_payload(request_payload)
  196. except ValueError as exc:
  197. return json_error(400, str(exc))
  198. status_code, raw = post_json(
  199. f"{app.state.upstream_base}/api/v1/chat",
  200. native_payload,
  201. timeout=app.state.request_timeout,
  202. )
  203. parsed = parse_json_maybe(raw)
  204. if status_code >= 400:
  205. if parsed is not None:
  206. return JSONResponse(status_code=status_code, content=parsed)
  207. return json_error(status_code, "upstream returned a non-JSON error", "upstream_error")
  208. if parsed is None:
  209. return json_error(502, "upstream returned non-JSON content", "bad_gateway")
  210. translated = translate_native_response(parsed)
  211. return JSONResponse(status_code=200, content=translated)
  212. def main() -> int:
  213. parser = argparse.ArgumentParser()
  214. parser.add_argument("--host", default="127.0.0.1")
  215. parser.add_argument("--port", type=int, default=8001)
  216. parser.add_argument("--upstream", default=DEFAULT_UPSTREAM)
  217. parser.add_argument("--timeout", type=int, default=1800)
  218. args = parser.parse_args()
  219. app.state.upstream_base = args.upstream.rstrip("/")
  220. app.state.request_timeout = args.timeout
  221. uvicorn.run(app, host=args.host, port=args.port)
  222. return 0
  223. if __name__ == "__main__":
  224. raise SystemExit(main())