litellm_lmstudio_adapter.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. #!/usr/bin/env python3
  2. import argparse
  3. import json
  4. import time
  5. from typing import Any
  6. import httpx
  7. from fastapi import FastAPI, Request
  8. from fastapi.responses import JSONResponse, Response, StreamingResponse
  9. import uvicorn
  10. DEFAULT_UPSTREAM = "http://127.0.0.1:7860"
  11. PASS_THROUGH_CHAT_FIELDS = {
  12. "temperature",
  13. "top_p",
  14. "presence_penalty",
  15. "frequency_penalty",
  16. "stop",
  17. "seed",
  18. }
  19. STUB_ENDPOINTS = {
  20. "/v1/audio/speech": "audio.speech",
  21. "/v1/audio/transcriptions": "audio.transcriptions",
  22. "/v1/audio/translations": "audio.translations",
  23. "/v1/images/generations": "images.generations",
  24. "/v1/images/edits": "images.edits",
  25. "/v1/images/variations": "images.variations",
  26. "/v1/moderations": "moderations",
  27. "/v1/files": "files",
  28. }
  29. app = FastAPI(title="LiteLLM LM Studio Adapter")
  30. app.state.upstream_base = DEFAULT_UPSTREAM
  31. app.state.request_timeout = 1800
  32. def normalize_reasoning_value(value: Any) -> str | None:
  33. if isinstance(value, str):
  34. lowered = value.strip().lower()
  35. if lowered in {"on", "off"}:
  36. return lowered
  37. if isinstance(value, bool):
  38. return "on" if value else "off"
  39. return None
  40. def resolve_reasoning_mode(request_payload: dict[str, Any]) -> str:
  41. normalized = normalize_reasoning_value(request_payload.get("reasoning"))
  42. if normalized is not None:
  43. return normalized
  44. enable_thinking = request_payload.get("enable_thinking")
  45. if isinstance(enable_thinking, bool):
  46. return "on" if enable_thinking else "off"
  47. return "on"
  48. def extract_text_content(content: Any) -> str:
  49. if isinstance(content, str):
  50. return content
  51. if isinstance(content, list):
  52. text_parts: list[str] = []
  53. for item in content:
  54. if isinstance(item, dict) and item.get("type") == "text":
  55. text = item.get("text")
  56. if isinstance(text, str):
  57. text_parts.append(text)
  58. return "\n".join(part for part in text_parts if part)
  59. return ""
  60. def messages_to_native_input(messages: list[dict[str, Any]]) -> tuple[str, str | None]:
  61. transcript_parts: list[str] = []
  62. system_parts: list[str] = []
  63. for message in messages:
  64. if not isinstance(message, dict):
  65. continue
  66. role = str(message.get("role") or "").strip().lower()
  67. content = extract_text_content(message.get("content"))
  68. if not content:
  69. continue
  70. if role == "system":
  71. system_parts.append(content)
  72. elif role == "user":
  73. transcript_parts.append(f"User: {content}")
  74. elif role == "assistant":
  75. transcript_parts.append(f"Assistant: {content}")
  76. else:
  77. transcript_parts.append(f"{role.title() or 'Message'}: {content}")
  78. return "\n\n".join(transcript_parts), "\n\n".join(system_parts) or None
  79. def build_chat_native_payload(request_payload: dict[str, Any]) -> dict[str, Any]:
  80. messages = request_payload.get("messages")
  81. if not isinstance(messages, list) or not messages:
  82. raise ValueError("messages must be a non-empty list")
  83. input_text, system_prompt = messages_to_native_input(messages)
  84. if not input_text:
  85. raise ValueError("messages must include at least one non-system text message")
  86. native_payload: dict[str, Any] = {
  87. "model": request_payload.get("model"),
  88. "input": input_text,
  89. "reasoning": resolve_reasoning_mode(request_payload),
  90. "store": False,
  91. }
  92. if system_prompt:
  93. native_payload["system_prompt"] = system_prompt
  94. if "max_tokens" in request_payload:
  95. native_payload["max_output_tokens"] = request_payload["max_tokens"]
  96. for field in PASS_THROUGH_CHAT_FIELDS:
  97. if field in request_payload:
  98. native_payload[field] = request_payload[field]
  99. if request_payload.get("stream") is True:
  100. native_payload["stream"] = True
  101. return native_payload
  102. def _collect_output_text(native_response: dict[str, Any], output_type: str) -> list[str]:
  103. texts: list[str] = []
  104. for item in native_response.get("output") or []:
  105. if item.get("type") == output_type:
  106. content = item.get("content")
  107. if isinstance(content, str):
  108. texts.append(content)
  109. return texts
  110. def translate_chat_response(native_response: dict[str, Any]) -> dict[str, Any]:
  111. model = native_response.get("model") or native_response.get("model_instance_id")
  112. message_parts = _collect_output_text(native_response, "message")
  113. reasoning_parts = _collect_output_text(native_response, "reasoning")
  114. stats = native_response.get("stats") or {}
  115. content = "\n".join(part for part in message_parts if part)
  116. reasoning_content = "\n".join(part for part in reasoning_parts if part)
  117. message = {"role": "assistant", "content": content, "tool_calls": []}
  118. if reasoning_content:
  119. message["reasoning_content"] = reasoning_content
  120. return {
  121. "id": native_response.get("id", f"chatcmpl-{int(time.time() * 1000)}"),
  122. "object": "chat.completion",
  123. "created": int(time.time()),
  124. "model": model,
  125. "choices": [
  126. {
  127. "index": 0,
  128. "message": message,
  129. "logprobs": None,
  130. "finish_reason": "stop",
  131. }
  132. ],
  133. "usage": {
  134. "prompt_tokens": stats.get("input_tokens", 0),
  135. "completion_tokens": stats.get("total_output_tokens", 0),
  136. "total_tokens": stats.get("input_tokens", 0) + stats.get("total_output_tokens", 0),
  137. "completion_tokens_details": {
  138. "reasoning_tokens": stats.get("reasoning_output_tokens", 0),
  139. },
  140. },
  141. "stats": stats,
  142. "system_fingerprint": model,
  143. }
  144. def build_responses_response(native_response: dict[str, Any]) -> dict[str, Any]:
  145. chat_response = translate_chat_response(native_response)
  146. message = chat_response["choices"][0]["message"]
  147. output = []
  148. if message.get("reasoning_content"):
  149. output.append(
  150. {
  151. "id": "rs_reasoning_0",
  152. "type": "reasoning",
  153. "summary": [],
  154. "content": [{"type": "output_text", "text": message["reasoning_content"]}],
  155. }
  156. )
  157. output.append(
  158. {
  159. "id": "msg_0",
  160. "type": "message",
  161. "role": "assistant",
  162. "content": [{"type": "output_text", "text": message.get("content", "")}],
  163. }
  164. )
  165. return {
  166. "id": f"resp_{int(time.time() * 1000)}",
  167. "object": "response",
  168. "created_at": int(time.time()),
  169. "model": chat_response["model"],
  170. "output": output,
  171. "usage": chat_response["usage"],
  172. "status": "completed",
  173. }
  174. def sse_frame(data: dict[str, Any]) -> str:
  175. return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
  176. def parse_sse_event_blocks(raw_text: str):
  177. for block in raw_text.split("\n\n"):
  178. block = block.strip()
  179. if not block:
  180. continue
  181. event_name = None
  182. data_lines: list[str] = []
  183. for line in block.splitlines():
  184. if line.startswith("event:"):
  185. event_name = line[6:].strip()
  186. elif line.startswith("data:"):
  187. data_lines.append(line[5:].strip())
  188. data_raw = "\n".join(data_lines)
  189. parsed_data = None
  190. if data_raw and data_raw != "[DONE]":
  191. try:
  192. parsed_data = json.loads(data_raw)
  193. except json.JSONDecodeError:
  194. parsed_data = None
  195. yield {
  196. "event": event_name,
  197. "data": parsed_data,
  198. "data_raw": data_raw,
  199. }
  200. def translate_chat_stream_event(event: dict[str, Any], model: str, chunk_id: str) -> str | None:
  201. event_type = event.get("type")
  202. content = event.get("content")
  203. if not isinstance(content, str):
  204. return None
  205. delta: dict[str, Any] = {}
  206. if event_type in {"reasoning", "reasoning.delta"}:
  207. delta["reasoning_content"] = content
  208. elif event_type in {"message", "message.delta"}:
  209. delta["content"] = content
  210. else:
  211. return None
  212. payload = {
  213. "id": chunk_id,
  214. "object": "chat.completion.chunk",
  215. "created": int(time.time()),
  216. "model": model,
  217. "choices": [{"index": 0, "delta": delta, "finish_reason": None}],
  218. }
  219. return sse_frame(payload)
  220. def translate_responses_stream_event(event: dict[str, Any], model: str, response_id: str) -> list[str]:
  221. event_type = event.get("type")
  222. content = event.get("content")
  223. if not isinstance(content, str):
  224. return []
  225. if event_type in {"reasoning", "reasoning.delta"}:
  226. return [
  227. sse_frame(
  228. {
  229. "type": "response.reasoning.delta",
  230. "response_id": response_id,
  231. "model": model,
  232. "delta": content,
  233. }
  234. )
  235. ]
  236. if event_type in {"message", "message.delta"}:
  237. return [
  238. sse_frame(
  239. {
  240. "type": "response.output_text.delta",
  241. "response_id": response_id,
  242. "model": model,
  243. "delta": content,
  244. }
  245. )
  246. ]
  247. return []
  248. def build_stub_error(feature_name: str) -> JSONResponse:
  249. return JSONResponse(
  250. status_code=501,
  251. content={
  252. "error": {
  253. "message": f"{feature_name} is not implemented by this adapter yet",
  254. "type": "not_implemented",
  255. "param": None,
  256. "code": "not_implemented",
  257. }
  258. },
  259. )
  260. async def get_async_client() -> httpx.AsyncClient:
  261. return httpx.AsyncClient(timeout=app.state.request_timeout)
  262. async def proxy_request(method: str, path: str, body: bytes | None = None, headers: dict[str, str] | None = None) -> Response:
  263. async with await get_async_client() as client:
  264. response = await client.request(
  265. method,
  266. f"{app.state.upstream_base}{path}",
  267. content=body,
  268. headers=headers,
  269. )
  270. response_headers = {
  271. key: value
  272. for key, value in response.headers.items()
  273. if key.lower() not in {"content-length", "transfer-encoding", "connection", "content-encoding"}
  274. }
  275. return Response(
  276. content=response.content,
  277. status_code=response.status_code,
  278. headers=response_headers,
  279. media_type=response.headers.get("content-type"),
  280. )
  281. async def stream_lmstudio_events(native_payload: dict[str, Any], translator, model: str, final_frame: str):
  282. async with httpx.AsyncClient(timeout=app.state.request_timeout) as client:
  283. async with client.stream(
  284. "POST",
  285. f"{app.state.upstream_base}/api/v1/chat",
  286. json=native_payload,
  287. headers={"Accept": "text/event-stream"},
  288. ) as response:
  289. if response.status_code >= 400:
  290. raw = await response.aread()
  291. payload = {
  292. "error": {
  293. "message": raw.decode("utf-8", errors="replace"),
  294. "type": "upstream_error",
  295. }
  296. }
  297. yield sse_frame(payload)
  298. yield "data: [DONE]\n\n"
  299. return
  300. buffer = ""
  301. async for text in response.aiter_text():
  302. buffer += text
  303. while "\n\n" in buffer:
  304. block, buffer = buffer.split("\n\n", 1)
  305. for parsed_block in parse_sse_event_blocks(block + "\n\n"):
  306. if parsed_block["data_raw"] == "[DONE]":
  307. continue
  308. event = parsed_block["data"]
  309. if not isinstance(event, dict):
  310. continue
  311. translated = translator(event, model)
  312. if translated is None:
  313. continue
  314. if isinstance(translated, str):
  315. if translated:
  316. yield translated
  317. else:
  318. for frame in translated:
  319. yield frame
  320. if final_frame:
  321. yield final_frame
  322. yield "data: [DONE]\n\n"
  323. @app.get("/healthz")
  324. async def healthz() -> dict[str, str]:
  325. return {"status": "ok"}
  326. @app.get("/v1/models")
  327. async def list_models() -> Response:
  328. return await proxy_request("GET", "/v1/models")
  329. @app.post("/v1/chat/completions")
  330. async def chat_completions(request: Request) -> Response:
  331. payload = await request.json()
  332. native_payload = build_chat_native_payload(payload)
  333. model = str(payload.get("model") or "")
  334. if payload.get("stream") is True:
  335. chunk_id = f"chatcmpl-{int(time.time() * 1000)}"
  336. def translator(event: dict[str, Any], event_model: str):
  337. return translate_chat_stream_event(event, event_model, chunk_id)
  338. final_payload = {
  339. "id": chunk_id,
  340. "object": "chat.completion.chunk",
  341. "created": int(time.time()),
  342. "model": model,
  343. "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
  344. }
  345. return StreamingResponse(
  346. stream_lmstudio_events(native_payload, translator, model, sse_frame(final_payload)),
  347. media_type="text/event-stream",
  348. )
  349. async with await get_async_client() as client:
  350. response = await client.post(f"{app.state.upstream_base}/api/v1/chat", json=native_payload)
  351. return JSONResponse(status_code=response.status_code, content=translate_chat_response(response.json()))
  352. @app.post("/v1/responses")
  353. async def responses_api(request: Request) -> Response:
  354. payload = await request.json()
  355. messages = payload.get("input")
  356. if isinstance(messages, str):
  357. payload = {
  358. "model": payload.get("model"),
  359. "messages": [{"role": "user", "content": messages}],
  360. "reasoning": payload.get("reasoning"),
  361. "enable_thinking": payload.get("enable_thinking"),
  362. "stream": payload.get("stream"),
  363. "max_tokens": payload.get("max_output_tokens") or payload.get("max_tokens"),
  364. "temperature": payload.get("temperature"),
  365. }
  366. elif isinstance(messages, list):
  367. payload = {
  368. "model": payload.get("model"),
  369. "messages": messages,
  370. "reasoning": payload.get("reasoning"),
  371. "enable_thinking": payload.get("enable_thinking"),
  372. "stream": payload.get("stream"),
  373. "max_tokens": payload.get("max_output_tokens") or payload.get("max_tokens"),
  374. "temperature": payload.get("temperature"),
  375. }
  376. else:
  377. raise ValueError("responses input must be a string or a message list")
  378. native_payload = build_chat_native_payload(payload)
  379. model = str(payload.get("model") or "")
  380. response_id = f"resp_{int(time.time() * 1000)}"
  381. if payload.get("stream") is True:
  382. def translator(event: dict[str, Any], event_model: str):
  383. return translate_responses_stream_event(event, event_model, response_id)
  384. final_frame = sse_frame(
  385. {
  386. "type": "response.completed",
  387. "response": {
  388. "id": response_id,
  389. "model": model,
  390. "status": "completed",
  391. },
  392. }
  393. )
  394. initial_frame = sse_frame(
  395. {
  396. "type": "response.created",
  397. "response": {
  398. "id": response_id,
  399. "model": model,
  400. "status": "in_progress",
  401. },
  402. }
  403. )
  404. async def generator():
  405. yield initial_frame
  406. async for frame in stream_lmstudio_events(native_payload, translator, model, final_frame):
  407. yield frame
  408. return StreamingResponse(generator(), media_type="text/event-stream")
  409. async with await get_async_client() as client:
  410. response = await client.post(f"{app.state.upstream_base}/api/v1/chat", json=native_payload)
  411. return JSONResponse(status_code=response.status_code, content=build_responses_response(response.json()))
  412. @app.post("/v1/embeddings")
  413. async def embeddings(request: Request) -> Response:
  414. body = await request.body()
  415. return await proxy_request("POST", "/v1/embeddings", body=body, headers={"Content-Type": "application/json"})
  416. @app.post("/v1/completions")
  417. async def completions(request: Request) -> Response:
  418. body = await request.body()
  419. return await proxy_request("POST", "/v1/completions", body=body, headers={"Content-Type": "application/json"})
  420. @app.api_route("/api/v1/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  421. async def native_v1_passthrough(path: str, request: Request) -> Response:
  422. body = await request.body()
  423. headers = {"Content-Type": request.headers.get("content-type", "application/json")}
  424. query = f"?{request.url.query}" if request.url.query else ""
  425. return await proxy_request(request.method, f"/api/v1/{path}{query}", body=body, headers=headers)
  426. @app.api_route("/api/v0/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
  427. async def native_v0_passthrough(path: str, request: Request) -> Response:
  428. body = await request.body()
  429. headers = {"Content-Type": request.headers.get("content-type", "application/json")}
  430. query = f"?{request.url.query}" if request.url.query else ""
  431. return await proxy_request(request.method, f"/api/v0/{path}{query}", body=body, headers=headers)
  432. for route_path, feature_name in STUB_ENDPOINTS.items():
  433. async def _stub(feature=feature_name):
  434. return build_stub_error(feature)
  435. app.add_api_route(route_path, _stub, methods=["POST", "GET", "DELETE"])
  436. def main() -> int:
  437. parser = argparse.ArgumentParser()
  438. parser.add_argument("--host", default="127.0.0.1")
  439. parser.add_argument("--port", type=int, default=8010)
  440. parser.add_argument("--upstream", default=DEFAULT_UPSTREAM)
  441. parser.add_argument("--timeout", type=int, default=1800)
  442. args = parser.parse_args()
  443. app.state.upstream_base = args.upstream.rstrip("/")
  444. app.state.request_timeout = args.timeout
  445. uvicorn.run(app, host=args.host, port=args.port)
  446. return 0
  447. if __name__ == "__main__":
  448. raise SystemExit(main())