provider.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. import json
  2. from collections.abc import Iterator
  3. import httpx
  4. from core_domain import (
  5. ChatCompletionRequestContract,
  6. ChatCompletionResponseContract,
  7. EmbeddingDataItem,
  8. EmbeddingRequestContract,
  9. EmbeddingResponseContract,
  10. )
  11. from core_shared import JSONValue
  12. from app.bootstrap.settings import ModelGatewayServiceSettings
  13. class ModelProviderClientError(Exception):
  14. pass
  15. class ModelProviderClient:
  16. def __init__(self, *, settings: ModelGatewayServiceSettings) -> None:
  17. self.settings = settings
  18. def create_chat_completion(
  19. self,
  20. payload: ChatCompletionRequestContract,
  21. *,
  22. provider_type: str | None = None,
  23. provider_base_url: str | None = None,
  24. provider_api_key: str | None = None,
  25. timeout_seconds: float = 60.0,
  26. ) -> ChatCompletionResponseContract:
  27. if payload.model is None:
  28. raise ModelProviderClientError("model is required for chat completion")
  29. resolved_provider_type = provider_type or self.settings.provider_type
  30. if resolved_provider_type == "anthropic":
  31. return self._create_anthropic_message(
  32. payload,
  33. provider_base_url=provider_base_url,
  34. provider_api_key=provider_api_key,
  35. timeout_seconds=timeout_seconds)
  36. return self._create_openai_compatible_chat_completion(
  37. payload,
  38. provider_base_url=provider_base_url,
  39. provider_api_key=provider_api_key,
  40. timeout_seconds=timeout_seconds)
  41. def stream_chat_completion(
  42. self,
  43. payload: ChatCompletionRequestContract,
  44. *,
  45. provider_type: str | None = None,
  46. provider_base_url: str | None = None,
  47. provider_api_key: str | None = None,
  48. timeout_seconds: float = 60.0,
  49. ) -> Iterator[str]:
  50. if payload.model is None:
  51. raise ModelProviderClientError("model is required for chat completion")
  52. resolved_provider_type = provider_type or self.settings.provider_type
  53. if resolved_provider_type == "anthropic":
  54. yield from self._stream_anthropic_message(
  55. payload,
  56. provider_base_url=provider_base_url,
  57. provider_api_key=provider_api_key,
  58. timeout_seconds=timeout_seconds)
  59. return
  60. yield from self._stream_openai_compatible_chat_completion(
  61. payload,
  62. provider_base_url=provider_base_url,
  63. provider_api_key=provider_api_key,
  64. timeout_seconds=timeout_seconds)
  65. def create_embedding(
  66. self,
  67. payload: EmbeddingRequestContract,
  68. *,
  69. provider_type: str | None = None,
  70. provider_base_url: str | None = None,
  71. provider_api_key: str | None = None,
  72. timeout_seconds: float = 60.0,
  73. ) -> EmbeddingResponseContract:
  74. resolved_provider_type = provider_type or self.settings.provider_type
  75. return self._create_openai_compatible_embedding(
  76. payload,
  77. provider_base_url=provider_base_url,
  78. provider_api_key=provider_api_key,
  79. timeout_seconds=timeout_seconds)
  80. def _create_openai_compatible_embedding(
  81. self,
  82. payload: EmbeddingRequestContract,
  83. *,
  84. provider_base_url: str | None,
  85. provider_api_key: str | None,
  86. timeout_seconds: float) -> EmbeddingResponseContract:
  87. request_payload: dict[str, JSONValue] = {
  88. "model": payload.model or "",
  89. "input": payload.input,
  90. }
  91. if payload.dimensions is not None:
  92. request_payload["dimensions"] = payload.dimensions
  93. request_headers: dict[str, str] = {"content-type": "application/json"}
  94. api_key = (
  95. provider_api_key
  96. if provider_api_key is not None
  97. else self.settings.provider_api_key
  98. )
  99. if api_key:
  100. request_headers["authorization"] = f"Bearer {api_key}"
  101. try:
  102. base_url = provider_base_url or self.settings.provider_base_url
  103. with httpx.Client(timeout=timeout_seconds) as client:
  104. response = client.post(
  105. _join_url(base_url, "embeddings"),
  106. json=request_payload,
  107. headers=request_headers)
  108. response.raise_for_status()
  109. except httpx.HTTPStatusError as exc:
  110. detail = exc.response.text[:1000]
  111. raise ModelProviderClientError(
  112. f"embedding request failed: {exc.response.status_code} {detail}") from exc
  113. except httpx.HTTPError as exc:
  114. raise ModelProviderClientError(f"embedding request failed: {exc}") from exc
  115. response_json = _coerce_json_dict(response.json())
  116. return _parse_embedding_response(response_json)
  117. def list_models(
  118. self,
  119. *,
  120. provider_type: str | None = None,
  121. provider_base_url: str | None = None,
  122. provider_api_key: str | None = None,
  123. timeout_seconds: float = 30.0,
  124. ) -> list[dict[str, JSONValue]]:
  125. resolved_provider_type = provider_type or self.settings.provider_type
  126. if resolved_provider_type == "anthropic":
  127. return self._list_anthropic_models(
  128. provider_base_url=provider_base_url,
  129. provider_api_key=provider_api_key,
  130. timeout_seconds=timeout_seconds)
  131. return self._list_openai_compatible_models(
  132. provider_base_url=provider_base_url,
  133. provider_api_key=provider_api_key,
  134. timeout_seconds=timeout_seconds)
  135. def _list_openai_compatible_models(
  136. self,
  137. *,
  138. provider_base_url: str | None,
  139. provider_api_key: str | None,
  140. timeout_seconds: float) -> list[dict[str, JSONValue]]:
  141. request_headers: dict[str, str] = {"content-type": "application/json"}
  142. api_key = (
  143. provider_api_key
  144. if provider_api_key is not None
  145. else self.settings.provider_api_key
  146. )
  147. if api_key:
  148. request_headers["authorization"] = f"Bearer {api_key}"
  149. try:
  150. base_url = provider_base_url or self.settings.provider_base_url
  151. with httpx.Client(timeout=timeout_seconds) as client:
  152. response = client.get(_join_url(base_url, "models"), headers=request_headers)
  153. response.raise_for_status()
  154. except httpx.HTTPStatusError as exc:
  155. detail = exc.response.text[:1000]
  156. raise ModelProviderClientError(
  157. f"model provider list models failed: {exc.response.status_code} {detail}") from exc
  158. except httpx.HTTPError as exc:
  159. raise ModelProviderClientError(f"model provider list models failed: {exc}") from exc
  160. return _extract_model_items(_coerce_json_dict(response.json()))
  161. def _list_anthropic_models(
  162. self,
  163. *,
  164. provider_base_url: str | None,
  165. provider_api_key: str | None,
  166. timeout_seconds: float) -> list[dict[str, JSONValue]]:
  167. api_key = (
  168. provider_api_key
  169. if provider_api_key is not None
  170. else self.settings.provider_api_key
  171. )
  172. if not api_key:
  173. raise ModelProviderClientError("anthropic api key is required")
  174. request_headers = {
  175. "content-type": "application/json",
  176. "x-api-key": api_key,
  177. "anthropic-version": "2023-06-01",
  178. }
  179. try:
  180. base_url = provider_base_url or self.settings.provider_base_url
  181. with httpx.Client(timeout=timeout_seconds) as client:
  182. response = client.get(_join_url(base_url, "v1/models"), headers=request_headers)
  183. response.raise_for_status()
  184. except httpx.HTTPStatusError as exc:
  185. detail = exc.response.text[:1000]
  186. raise ModelProviderClientError(
  187. f"anthropic list models failed: {exc.response.status_code} {detail}") from exc
  188. except httpx.HTTPError as exc:
  189. raise ModelProviderClientError(f"anthropic list models failed: {exc}") from exc
  190. return _extract_model_items(_coerce_json_dict(response.json()))
  191. def _create_openai_compatible_chat_completion(
  192. self,
  193. payload: ChatCompletionRequestContract,
  194. *,
  195. provider_base_url: str | None,
  196. provider_api_key: str | None,
  197. timeout_seconds: float) -> ChatCompletionResponseContract:
  198. request_payload: dict[str, JSONValue] = {
  199. "model": payload.model or "",
  200. "messages": [item.model_dump(mode="json") for item in payload.messages],
  201. }
  202. if payload.temperature is not None:
  203. request_payload["temperature"] = payload.temperature
  204. if payload.max_tokens is not None:
  205. request_payload["max_tokens"] = payload.max_tokens
  206. if payload.tools_json:
  207. request_payload["tools"] = payload.tools_json
  208. if payload.tool_choice is not None:
  209. request_payload["tool_choice"] = payload.tool_choice
  210. request_headers: dict[str, str] = {"content-type": "application/json"}
  211. api_key = (
  212. provider_api_key
  213. if provider_api_key is not None
  214. else self.settings.provider_api_key
  215. )
  216. if api_key:
  217. request_headers["authorization"] = f"Bearer {api_key}"
  218. try:
  219. base_url = provider_base_url or self.settings.provider_base_url
  220. with httpx.Client(timeout=timeout_seconds) as client:
  221. response = client.post(
  222. _join_url(base_url, "chat/completions"),
  223. json=request_payload,
  224. headers=request_headers)
  225. response.raise_for_status()
  226. except httpx.HTTPStatusError as exc:
  227. detail = exc.response.text[:1000]
  228. raise ModelProviderClientError(
  229. f"model provider request failed: {exc.response.status_code} {detail}") from exc
  230. except httpx.HTTPError as exc:
  231. raise ModelProviderClientError(f"model provider request failed: {exc}") from exc
  232. response_json = _coerce_json_dict(response.json())
  233. content = _extract_response_content(response_json)
  234. finish_reason = _extract_finish_reason(response_json)
  235. tool_calls_json = _extract_tool_calls_json(response_json)
  236. usage_json = _extract_usage_json(response_json)
  237. return ChatCompletionResponseContract(
  238. model=payload.model,
  239. content=content,
  240. finish_reason=finish_reason,
  241. tool_calls_json=tool_calls_json,
  242. usage_json=usage_json,
  243. raw_response_json=response_json)
  244. def _stream_openai_compatible_chat_completion(
  245. self,
  246. payload: ChatCompletionRequestContract,
  247. *,
  248. provider_base_url: str | None,
  249. provider_api_key: str | None,
  250. timeout_seconds: float) -> Iterator[str]:
  251. request_payload = _build_openai_request_payload(payload)
  252. request_payload["stream"] = True
  253. request_headers = _build_openai_headers(
  254. settings=self.settings,
  255. provider_api_key=provider_api_key)
  256. try:
  257. base_url = provider_base_url or self.settings.provider_base_url
  258. with httpx.Client(timeout=timeout_seconds) as client:
  259. with client.stream(
  260. "POST",
  261. _join_url(base_url, "chat/completions"),
  262. json=request_payload,
  263. headers=request_headers) as response:
  264. response.raise_for_status()
  265. for line in response.iter_lines():
  266. if not line.startswith("data:"):
  267. continue
  268. data = line.removeprefix("data:").strip()
  269. if data == "[DONE]":
  270. break
  271. try:
  272. payload_json = _coerce_json_dict(json.loads(data))
  273. except json.JSONDecodeError:
  274. continue
  275. delta = _extract_openai_stream_delta(payload_json)
  276. if delta:
  277. yield delta
  278. except httpx.HTTPStatusError as exc:
  279. detail = exc.response.text[:1000]
  280. raise ModelProviderClientError(
  281. f"model provider stream failed: {exc.response.status_code} {detail}") from exc
  282. except httpx.HTTPError as exc:
  283. raise ModelProviderClientError(f"model provider stream failed: {exc}") from exc
  284. def _create_anthropic_message(
  285. self,
  286. payload: ChatCompletionRequestContract,
  287. *,
  288. provider_base_url: str | None,
  289. provider_api_key: str | None,
  290. timeout_seconds: float) -> ChatCompletionResponseContract:
  291. api_key = (
  292. provider_api_key
  293. if provider_api_key is not None
  294. else self.settings.provider_api_key
  295. )
  296. if not api_key:
  297. raise ModelProviderClientError("anthropic api key is required")
  298. system_prompt, messages = _to_anthropic_messages(payload)
  299. request_payload: dict[str, JSONValue] = {
  300. "model": payload.model or "",
  301. "max_tokens": payload.max_tokens or 1024,
  302. "messages": messages,
  303. }
  304. if system_prompt:
  305. request_payload["system"] = system_prompt
  306. if payload.temperature is not None:
  307. request_payload["temperature"] = payload.temperature
  308. request_headers = {
  309. "content-type": "application/json",
  310. "x-api-key": api_key,
  311. "anthropic-version": "2023-06-01",
  312. }
  313. try:
  314. base_url = provider_base_url or self.settings.provider_base_url
  315. with httpx.Client(timeout=timeout_seconds) as client:
  316. response = client.post(
  317. _join_url(base_url, "v1/messages"),
  318. json=request_payload,
  319. headers=request_headers)
  320. response.raise_for_status()
  321. except httpx.HTTPStatusError as exc:
  322. detail = exc.response.text[:1000]
  323. raise ModelProviderClientError(
  324. f"anthropic request failed: {exc.response.status_code} {detail}") from exc
  325. except httpx.HTTPError as exc:
  326. raise ModelProviderClientError(f"anthropic request failed: {exc}") from exc
  327. response_json = _coerce_json_dict(response.json())
  328. return ChatCompletionResponseContract(
  329. model=_read_string(response_json, "model") or payload.model,
  330. content=_extract_anthropic_content(response_json),
  331. finish_reason=_read_string(response_json, "stop_reason"),
  332. tool_calls_json=[],
  333. usage_json=_extract_usage_json(response_json),
  334. raw_response_json=response_json)
  335. def _stream_anthropic_message(
  336. self,
  337. payload: ChatCompletionRequestContract,
  338. *,
  339. provider_base_url: str | None,
  340. provider_api_key: str | None,
  341. timeout_seconds: float) -> Iterator[str]:
  342. api_key = (
  343. provider_api_key
  344. if provider_api_key is not None
  345. else self.settings.provider_api_key
  346. )
  347. if not api_key:
  348. raise ModelProviderClientError("anthropic api key is required")
  349. system_prompt, messages = _to_anthropic_messages(payload)
  350. request_payload: dict[str, JSONValue] = {
  351. "model": payload.model or "",
  352. "max_tokens": payload.max_tokens or 1024,
  353. "messages": messages,
  354. "stream": True,
  355. }
  356. if system_prompt:
  357. request_payload["system"] = system_prompt
  358. if payload.temperature is not None:
  359. request_payload["temperature"] = payload.temperature
  360. request_headers = {
  361. "content-type": "application/json",
  362. "x-api-key": api_key,
  363. "anthropic-version": "2023-06-01",
  364. }
  365. try:
  366. base_url = provider_base_url or self.settings.provider_base_url
  367. with httpx.Client(timeout=timeout_seconds) as client:
  368. with client.stream(
  369. "POST",
  370. _join_url(base_url, "v1/messages"),
  371. json=request_payload,
  372. headers=request_headers) as response:
  373. response.raise_for_status()
  374. for line in response.iter_lines():
  375. if not line.startswith("data:"):
  376. continue
  377. data = line.removeprefix("data:").strip()
  378. try:
  379. payload_json = _coerce_json_dict(json.loads(data))
  380. except json.JSONDecodeError:
  381. continue
  382. delta = _extract_anthropic_stream_delta(payload_json)
  383. if delta:
  384. yield delta
  385. except httpx.HTTPStatusError as exc:
  386. detail = exc.response.text[:1000]
  387. raise ModelProviderClientError(
  388. f"anthropic stream failed: {exc.response.status_code} {detail}") from exc
  389. except httpx.HTTPError as exc:
  390. raise ModelProviderClientError(f"anthropic stream failed: {exc}") from exc
  391. def _coerce_json_dict(payload: JSONValue) -> dict[str, JSONValue]:
  392. if isinstance(payload, dict):
  393. return {str(key): value for key, value in payload.items()}
  394. return {}
  395. def _build_openai_request_payload(
  396. payload: ChatCompletionRequestContract) -> dict[str, JSONValue]:
  397. request_payload: dict[str, JSONValue] = {
  398. "model": payload.model or "",
  399. "messages": [item.model_dump(mode="json") for item in payload.messages],
  400. }
  401. if payload.temperature is not None:
  402. request_payload["temperature"] = payload.temperature
  403. if payload.max_tokens is not None:
  404. request_payload["max_tokens"] = payload.max_tokens
  405. if payload.tools_json:
  406. request_payload["tools"] = payload.tools_json
  407. if payload.tool_choice is not None:
  408. request_payload["tool_choice"] = payload.tool_choice
  409. return request_payload
  410. def _build_openai_headers(
  411. *,
  412. settings: ModelGatewayServiceSettings,
  413. provider_api_key: str | None) -> dict[str, str]:
  414. request_headers: dict[str, str] = {"content-type": "application/json"}
  415. api_key = (
  416. provider_api_key
  417. if provider_api_key is not None
  418. else settings.provider_api_key
  419. )
  420. if api_key:
  421. request_headers["authorization"] = f"Bearer {api_key}"
  422. return request_headers
  423. def _join_url(base_url: str, path: str) -> str:
  424. normalized_base = base_url.rstrip("/")
  425. normalized_path = path.strip("/")
  426. if normalized_path.startswith("v1/") and normalized_base.endswith("/v1"):
  427. normalized_path = normalized_path.removeprefix("v1/")
  428. return f"{normalized_base}/{normalized_path}"
  429. def _to_anthropic_messages(
  430. payload: ChatCompletionRequestContract) -> tuple[str | None, list[dict[str, JSONValue]]]:
  431. system_parts: list[str] = []
  432. messages: list[dict[str, JSONValue]] = []
  433. for message in payload.messages:
  434. if message.role == "system":
  435. system_parts.append(message.content)
  436. continue
  437. role = "assistant" if message.role == "assistant" else "user"
  438. if messages and messages[-1].get("role") == role:
  439. previous = messages[-1].get("content")
  440. messages[-1]["content"] = f"{previous}\n\n{message.content}" if isinstance(previous, str) else message.content
  441. else:
  442. messages.append({"role": role, "content": message.content})
  443. if not messages:
  444. messages.append({"role": "user", "content": ""})
  445. return ("\n\n".join(system_parts) if system_parts else None), messages
  446. def _extract_anthropic_content(payload: dict[str, JSONValue]) -> str:
  447. content = payload.get("content")
  448. if isinstance(content, str):
  449. return content
  450. if not isinstance(content, list):
  451. return ""
  452. parts: list[str] = []
  453. for item in content:
  454. if not isinstance(item, dict):
  455. continue
  456. text = item.get("text")
  457. if isinstance(text, str):
  458. parts.append(text)
  459. return "\n".join(parts)
  460. def _read_string(payload: dict[str, JSONValue], key: str) -> str | None:
  461. value = payload.get(key)
  462. return value if isinstance(value, str) else None
  463. def _extract_model_items(payload: dict[str, JSONValue]) -> list[dict[str, JSONValue]]:
  464. data = payload.get("data")
  465. if not isinstance(data, list):
  466. data = payload.get("models")
  467. if not isinstance(data, list):
  468. return []
  469. items: list[dict[str, JSONValue]] = []
  470. for item in data:
  471. if isinstance(item, str):
  472. model_id = item
  473. display_name = item
  474. owned_by = None
  475. elif isinstance(item, dict):
  476. model_id = _read_string(item, "id") or _read_string(item, "model") or _read_string(item, "name")
  477. if model_id is None:
  478. continue
  479. display_name = _read_string(item, "display_name") or _read_string(item, "displayName") or model_id
  480. owned_by = _read_string(item, "owned_by") or _read_string(item, "ownedBy")
  481. else:
  482. continue
  483. model_item: dict[str, JSONValue] = {
  484. "modelId": model_id,
  485. "displayName": display_name,
  486. "modelType": _infer_model_type(model_id),
  487. }
  488. if owned_by:
  489. model_item["ownedBy"] = owned_by
  490. items.append(model_item)
  491. return items
  492. def _infer_model_type(model_id: str) -> str:
  493. normalized = model_id.lower()
  494. if "embed" in normalized or "embedding" in normalized:
  495. return "embedding"
  496. if "rerank" in normalized or "ranker" in normalized:
  497. return "rerank"
  498. if "moderation" in normalized:
  499. return "moderation"
  500. if "image" in normalized or "vision" in normalized:
  501. return "image"
  502. if "audio" in normalized or "whisper" in normalized or "tts" in normalized:
  503. return "audio"
  504. if "reason" in normalized or "thinking" in normalized or normalized.endswith("-r1") or "-r1-" in normalized:
  505. return "reasoning"
  506. return "chat"
  507. def _extract_response_content(payload: dict[str, JSONValue]) -> str:
  508. choices = payload.get("choices")
  509. if isinstance(choices, list) and choices:
  510. first_choice = choices[0]
  511. if isinstance(first_choice, dict):
  512. message = first_choice.get("message")
  513. if isinstance(message, dict):
  514. content = message.get("content")
  515. if isinstance(content, str):
  516. return content
  517. text = first_choice.get("text")
  518. if isinstance(text, str):
  519. return text
  520. return ""
  521. def _extract_openai_stream_delta(payload: dict[str, JSONValue]) -> str:
  522. choices = payload.get("choices")
  523. if not isinstance(choices, list) or not choices:
  524. return ""
  525. first_choice = choices[0]
  526. if not isinstance(first_choice, dict):
  527. return ""
  528. delta = first_choice.get("delta")
  529. if isinstance(delta, dict):
  530. content = delta.get("content")
  531. if isinstance(content, str):
  532. return content
  533. text = first_choice.get("text")
  534. return text if isinstance(text, str) else ""
  535. def _extract_anthropic_stream_delta(payload: dict[str, JSONValue]) -> str:
  536. if payload.get("type") != "content_block_delta":
  537. return ""
  538. delta = payload.get("delta")
  539. if not isinstance(delta, dict):
  540. return ""
  541. text = delta.get("text")
  542. return text if isinstance(text, str) else ""
  543. def _extract_finish_reason(payload: dict[str, JSONValue]) -> str | None:
  544. choices = payload.get("choices")
  545. if isinstance(choices, list) and choices:
  546. first_choice = choices[0]
  547. if isinstance(first_choice, dict):
  548. finish_reason = first_choice.get("finish_reason")
  549. if isinstance(finish_reason, str):
  550. return finish_reason
  551. return None
  552. def _extract_tool_calls_json(payload: dict[str, JSONValue]) -> list[dict[str, JSONValue]]:
  553. choices = payload.get("choices")
  554. if isinstance(choices, list) and choices:
  555. first_choice = choices[0]
  556. if isinstance(first_choice, dict):
  557. message = first_choice.get("message")
  558. if isinstance(message, dict):
  559. tool_calls = message.get("tool_calls")
  560. if isinstance(tool_calls, list):
  561. return [
  562. {str(item_key): item_value for item_key, item_value in item.items()}
  563. for item in tool_calls
  564. if isinstance(item, dict)
  565. ]
  566. return []
  567. def _extract_usage_json(payload: dict[str, JSONValue]) -> dict[str, JSONValue]:
  568. usage = payload.get("usage")
  569. if isinstance(usage, dict):
  570. return {str(key): value for key, value in usage.items()}
  571. return {}
  572. def _parse_embedding_response(payload: dict[str, JSONValue]) -> EmbeddingResponseContract:
  573. model = _read_string(payload, "model")
  574. usage = payload.get("usage")
  575. usage_json = {str(k): v for k, v in usage.items()} if isinstance(usage, dict) else {}
  576. data_items: list[EmbeddingDataItem] = []
  577. data = payload.get("data")
  578. if isinstance(data, list):
  579. for idx, item in enumerate(data):
  580. if not isinstance(item, dict):
  581. continue
  582. embedding_raw = item.get("embedding")
  583. if not isinstance(embedding_raw, list):
  584. continue
  585. embedding = [float(v) for v in embedding_raw if isinstance(v, (int, float)) and not isinstance(v, bool)]
  586. index = item.get("index")
  587. data_items.append(EmbeddingDataItem(embedding=embedding, index=index if isinstance(index, int) else idx))
  588. return EmbeddingResponseContract(model=model, data=data_items, usage_json=usage_json)