claude-smart 0.2.42 → 0.2.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (402) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.md +1 -1
  3. package/bin/claude-smart.js +2 -2
  4. package/package.json +9 -3
  5. package/plugin/.claude-plugin/plugin.json +9 -3
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +23 -3
  8. package/plugin/pyproject.toml +3 -3
  9. package/plugin/scripts/_lib.sh +91 -0
  10. package/plugin/scripts/backend-service.sh +51 -4
  11. package/plugin/scripts/cli.sh +3 -1
  12. package/plugin/scripts/codex-hook.js +72 -4
  13. package/plugin/scripts/dashboard-build.sh +1 -0
  14. package/plugin/scripts/dashboard-service.sh +1 -0
  15. package/plugin/scripts/ensure-plugin-root.sh +1 -0
  16. package/plugin/scripts/hook_entry.sh +6 -3
  17. package/plugin/scripts/smart-install.sh +3 -2
  18. package/plugin/src/README.md +57 -0
  19. package/plugin/src/claude_smart/context_format.py +11 -12
  20. package/plugin/src/claude_smart/cs_cite.py +26 -12
  21. package/plugin/src/claude_smart/ids.py +13 -5
  22. package/plugin/uv.lock +126 -5
  23. package/plugin/vendor/reflexio/.env.example +62 -0
  24. package/plugin/vendor/reflexio/LICENSE +201 -0
  25. package/plugin/vendor/reflexio/README.md +338 -0
  26. package/plugin/vendor/reflexio/pyproject.toml +274 -0
  27. package/plugin/vendor/reflexio/reflexio/README.md +184 -0
  28. package/plugin/vendor/reflexio/reflexio/__init__.py +166 -0
  29. package/plugin/vendor/reflexio/reflexio/benchmarks/__init__.py +1 -0
  30. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/README.md +109 -0
  31. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/__init__.py +1 -0
  32. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/backends.py +175 -0
  33. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/bench.py +642 -0
  34. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/embed_cache.py +330 -0
  35. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/report.py +317 -0
  36. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/report.md +43 -0
  37. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/results.json +4478 -0
  38. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/scenarios.py +134 -0
  39. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/seed.py +255 -0
  40. package/plugin/vendor/reflexio/reflexio/cli/README.md +287 -0
  41. package/plugin/vendor/reflexio/reflexio/cli/__init__.py +0 -0
  42. package/plugin/vendor/reflexio/reflexio/cli/__main__.py +56 -0
  43. package/plugin/vendor/reflexio/reflexio/cli/_client.py +86 -0
  44. package/plugin/vendor/reflexio/reflexio/cli/app.py +127 -0
  45. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +265 -0
  46. package/plugin/vendor/reflexio/reflexio/cli/codex_auth.py +503 -0
  47. package/plugin/vendor/reflexio/reflexio/cli/commands/__init__.py +0 -0
  48. package/plugin/vendor/reflexio/reflexio/cli/commands/admin_cmd.py +65 -0
  49. package/plugin/vendor/reflexio/reflexio/cli/commands/agent_playbooks.py +503 -0
  50. package/plugin/vendor/reflexio/reflexio/cli/commands/api.py +114 -0
  51. package/plugin/vendor/reflexio/reflexio/cli/commands/auth.py +109 -0
  52. package/plugin/vendor/reflexio/reflexio/cli/commands/config_cmd.py +511 -0
  53. package/plugin/vendor/reflexio/reflexio/cli/commands/doctor.py +127 -0
  54. package/plugin/vendor/reflexio/reflexio/cli/commands/embeddings.py +53 -0
  55. package/plugin/vendor/reflexio/reflexio/cli/commands/interactions.py +478 -0
  56. package/plugin/vendor/reflexio/reflexio/cli/commands/profiles.py +303 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/commands/services.py +289 -0
  58. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +964 -0
  59. package/plugin/vendor/reflexio/reflexio/cli/commands/shortcuts.py +285 -0
  60. package/plugin/vendor/reflexio/reflexio/cli/commands/status_cmd.py +143 -0
  61. package/plugin/vendor/reflexio/reflexio/cli/commands/user_playbooks.py +373 -0
  62. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +284 -0
  63. package/plugin/vendor/reflexio/reflexio/cli/errors.py +217 -0
  64. package/plugin/vendor/reflexio/reflexio/cli/log_format.py +247 -0
  65. package/plugin/vendor/reflexio/reflexio/cli/output.py +867 -0
  66. package/plugin/vendor/reflexio/reflexio/cli/paths.py +41 -0
  67. package/plugin/vendor/reflexio/reflexio/cli/run_services.py +391 -0
  68. package/plugin/vendor/reflexio/reflexio/cli/state.py +204 -0
  69. package/plugin/vendor/reflexio/reflexio/cli/stop_services.py +96 -0
  70. package/plugin/vendor/reflexio/reflexio/cli/utils.py +329 -0
  71. package/plugin/vendor/reflexio/reflexio/client/__init__.py +3 -0
  72. package/plugin/vendor/reflexio/reflexio/client/cache.py +150 -0
  73. package/plugin/vendor/reflexio/reflexio/client/client.py +2613 -0
  74. package/plugin/vendor/reflexio/reflexio/defaults.py +23 -0
  75. package/plugin/vendor/reflexio/reflexio/integrations/__init__.py +0 -0
  76. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/.clawhubignore +7 -0
  77. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/README.md +274 -0
  78. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/TESTING.md +517 -0
  79. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/hook/handler.js +473 -0
  80. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package-lock.json +2156 -0
  81. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package.json +18 -0
  82. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/handler.ts +241 -0
  83. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/setup.ts +140 -0
  84. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/index.ts +130 -0
  85. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/publish.ts +113 -0
  86. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/search.ts +52 -0
  87. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/server.ts +103 -0
  88. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/sqlite-buffer.ts +156 -0
  89. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/user-id.ts +134 -0
  90. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/openclaw.plugin.json +41 -0
  91. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/package.json +17 -0
  92. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/rules/reflexio.md +24 -0
  93. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/reflexio/SKILL.md +48 -0
  94. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/publish_clawhub.sh +278 -0
  95. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/references/HOOK.md +164 -0
  96. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/install.sh +36 -0
  97. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/uninstall.sh +35 -0
  98. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/publish.test.ts +27 -0
  99. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/search.test.ts +31 -0
  100. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/server.test.ts +42 -0
  101. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/setup.test.ts +49 -0
  102. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/sqlite-buffer.test.ts +91 -0
  103. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/user-id.test.ts +50 -0
  104. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tsconfig.json +16 -0
  105. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/types/openclaw.d.ts +230 -0
  106. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/vitest.config.ts +13 -0
  107. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/README.md +120 -0
  108. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/TESTING.md +168 -0
  109. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package-lock.json +1657 -0
  110. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package.json +16 -0
  111. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/HEARTBEAT.md +6 -0
  112. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/README.md +84 -0
  113. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/SKILL.md +194 -0
  114. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/_meta.json +6 -0
  115. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/agents/reflexio-extractor.md +45 -0
  116. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/handler.ts +214 -0
  117. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/setup.ts +55 -0
  118. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/index.ts +327 -0
  119. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/consolidate.ts +233 -0
  120. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/dedup.ts +80 -0
  121. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/io.ts +155 -0
  122. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/openclaw-cli.ts +67 -0
  123. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/search.ts +33 -0
  124. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-playbook.ts +76 -0
  125. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-profile.ts +79 -0
  126. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/openclaw.plugin.json +46 -0
  127. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/package.json +18 -0
  128. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/README.md +36 -0
  129. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/full_consolidation.md +56 -0
  130. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/playbook_extraction.md +217 -0
  131. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/profile_extraction.md +132 -0
  132. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-consolidate/SKILL.md +33 -0
  133. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-embedded/SKILL.md +194 -0
  134. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/HOOK.md +18 -0
  135. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/architecture.md +49 -0
  136. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/comparison.md +31 -0
  137. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/future-work.md +47 -0
  138. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/porting-notes.md +52 -0
  139. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/install.sh +52 -0
  140. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/uninstall.sh +36 -0
  141. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/consolidate.test.ts +135 -0
  142. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/dedup.test.ts +104 -0
  143. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/io.test.ts +175 -0
  144. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/search.test.ts +66 -0
  145. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/smoke-test.ts +140 -0
  146. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-playbook.test.ts +93 -0
  147. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-profile.test.ts +174 -0
  148. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tsconfig.json +16 -0
  149. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/types/openclaw.d.ts +230 -0
  150. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/vitest.config.ts +7 -0
  151. package/plugin/vendor/reflexio/reflexio/lib/__init__.py +23 -0
  152. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +310 -0
  153. package/plugin/vendor/reflexio/reflexio/lib/_base.py +225 -0
  154. package/plugin/vendor/reflexio/reflexio/lib/_config.py +83 -0
  155. package/plugin/vendor/reflexio/reflexio/lib/_dashboard.py +266 -0
  156. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +176 -0
  157. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +334 -0
  158. package/plugin/vendor/reflexio/reflexio/lib/_operations.py +153 -0
  159. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +545 -0
  160. package/plugin/vendor/reflexio/reflexio/lib/_reflection.py +52 -0
  161. package/plugin/vendor/reflexio/reflexio/lib/_search.py +167 -0
  162. package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +103 -0
  163. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +288 -0
  164. package/plugin/vendor/reflexio/reflexio/lib/reflexio_lib.py +27 -0
  165. package/plugin/vendor/reflexio/reflexio/models/__init__.py +0 -0
  166. package/plugin/vendor/reflexio/reflexio/models/api_schema/__init__.py +0 -0
  167. package/plugin/vendor/reflexio/reflexio/models/api_schema/braintrust_schema.py +141 -0
  168. package/plugin/vendor/reflexio/reflexio/models/api_schema/common.py +41 -0
  169. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +3 -0
  170. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +1112 -0
  171. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +63 -0
  172. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +487 -0
  173. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +28 -0
  174. package/plugin/vendor/reflexio/reflexio/models/api_schema/pending_tool_call_schema.py +83 -0
  175. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +768 -0
  176. package/plugin/vendor/reflexio/reflexio/models/api_schema/service_schemas.py +9 -0
  177. package/plugin/vendor/reflexio/reflexio/models/api_schema/stall_state_schema.py +32 -0
  178. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/__init__.py +3 -0
  179. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +177 -0
  180. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +129 -0
  181. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/enums.py +25 -0
  182. package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +333 -0
  183. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +908 -0
  184. package/plugin/vendor/reflexio/reflexio/models/py.typed +0 -0
  185. package/plugin/vendor/reflexio/reflexio/server/OVERVIEW.md +90 -0
  186. package/plugin/vendor/reflexio/reflexio/server/README.md +622 -0
  187. package/plugin/vendor/reflexio/reflexio/server/__init__.py +210 -0
  188. package/plugin/vendor/reflexio/reflexio/server/__main__.py +132 -0
  189. package/plugin/vendor/reflexio/reflexio/server/_auth.py +25 -0
  190. package/plugin/vendor/reflexio/reflexio/server/api.py +2868 -0
  191. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +34 -0
  192. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/account_api.py +143 -0
  193. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/health_api.py +91 -0
  194. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/pending_tool_call_api.py +572 -0
  195. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/precondition_checks.py +66 -0
  196. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +562 -0
  197. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +50 -0
  198. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/stall_state_api.py +100 -0
  199. package/plugin/vendor/reflexio/reflexio/server/cache/__init__.py +15 -0
  200. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +208 -0
  201. package/plugin/vendor/reflexio/reflexio/server/correlation.py +46 -0
  202. package/plugin/vendor/reflexio/reflexio/server/llm/__init__.py +30 -0
  203. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +359 -0
  204. package/plugin/vendor/reflexio/reflexio/server/llm/image_utils.py +55 -0
  205. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1871 -0
  206. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +140 -0
  207. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +479 -0
  208. package/plugin/vendor/reflexio/reflexio/server/llm/providers/__init__.py +1 -0
  209. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +1122 -0
  210. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_stream_parser.py +197 -0
  211. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +338 -0
  212. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +213 -0
  213. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +288 -0
  214. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/__init__.py +6 -0
  215. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +187 -0
  216. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/llm_reranker.py +148 -0
  217. package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +716 -0
  218. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +179 -0
  219. package/plugin/vendor/reflexio/reflexio/server/prompt/__init__.py +0 -0
  220. package/plugin/vendor/reflexio/reflexio/server/prompt/_dispatchers.py +54 -0
  221. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/README.md +121 -0
  222. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/agent_success_evaluation/v1.0.0.prompt.md +58 -0
  223. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/agent_success_evaluation_with_comparison/v1.0.0.prompt.md +76 -0
  224. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/answer_synthesis/v1.5.2.prompt.md +88 -0
  225. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/compress_session_for_query/v1.3.0.prompt.md +31 -0
  226. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +20 -0
  227. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.0.0.prompt.md +53 -0
  228. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.1.0.prompt.md +57 -0
  229. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.2.0.prompt.md +68 -0
  230. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.3.0.prompt.md +70 -0
  231. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.4.0.prompt.md +77 -0
  232. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.5.0.prompt.md +82 -0
  233. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.6.0.prompt.md +83 -0
  234. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.1.0.prompt.md +193 -0
  235. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.2.0.prompt.md +206 -0
  236. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.0.0-deprecated.prompt.md +66 -0
  237. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.0.0.prompt.md +43 -0
  238. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.1.0.prompt.md +46 -0
  239. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.0.0-deprecated.prompt.md +64 -0
  240. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.0.0.prompt.md +39 -0
  241. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.1.0.prompt.md +39 -0
  242. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.2.0.prompt.md +47 -0
  243. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +58 -0
  244. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.1.prompt.md +69 -0
  245. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +71 -0
  246. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.0.2.prompt.md +254 -0
  247. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.1.0.prompt.md +274 -0
  248. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +283 -0
  249. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.2.prompt.md +234 -0
  250. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +244 -0
  251. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v1.0.0.prompt.md +73 -0
  252. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v2.0.0.prompt.md +86 -0
  253. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.0.0.prompt.md +97 -0
  254. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.1.0.prompt.md +119 -0
  255. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.2.0.prompt.md +123 -0
  256. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +137 -0
  257. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.0.0.prompt.md +14 -0
  258. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.1.0.prompt.md +24 -0
  259. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.2.0.prompt.md +29 -0
  260. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.0.0.prompt.md +11 -0
  261. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.1.0.prompt.md +21 -0
  262. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.2.0.prompt.md +25 -0
  263. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.0.0.prompt.md +37 -0
  264. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.1.0.prompt.md +40 -0
  265. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.2.0.prompt.md +36 -0
  266. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v1.0.0.prompt.md +45 -0
  267. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v2.0.0.prompt.md +81 -0
  268. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v3.0.0.prompt.md +80 -0
  269. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate_expert/v1.0.0.prompt.md +34 -0
  270. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_deduplication/v1.0.0.prompt.md +116 -0
  271. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate/v1.0.0.prompt.md +33 -0
  272. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate_override/v1.0.0.prompt.md +16 -0
  273. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.0.0.prompt.md +140 -0
  274. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.1.0.prompt.md +160 -0
  275. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_main/v1.0.0.prompt.md +14 -0
  276. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +19 -0
  277. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/rerank_relevance/v1.1.0.prompt.md +44 -0
  278. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +43 -0
  279. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_content_evaluation/v1.0.0.prompt.md +33 -0
  280. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/feedback_extraction_main_v1.jsonl +10 -0
  281. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/profile_update_main_v1.jsonl +10 -0
  282. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_manager.py +280 -0
  283. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_schema.py +11 -0
  284. package/plugin/vendor/reflexio/reflexio/server/services/README.md +58 -0
  285. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +131 -0
  286. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_constants.py +60 -0
  287. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_service.py +228 -0
  288. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_utils.py +87 -0
  289. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluator.py +372 -0
  290. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/delayed_group_evaluator.py +156 -0
  291. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +336 -0
  292. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +471 -0
  293. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +1668 -0
  294. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/__init__.py +0 -0
  295. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_cron.py +196 -0
  296. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_encryption.py +101 -0
  297. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/client.py +167 -0
  298. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/service.py +281 -0
  299. package/plugin/vendor/reflexio/reflexio/server/services/configurator/base_configurator.py +179 -0
  300. package/plugin/vendor/reflexio/reflexio/server/services/configurator/config_storage.py +62 -0
  301. package/plugin/vendor/reflexio/reflexio/server/services/configurator/configurator.py +87 -0
  302. package/plugin/vendor/reflexio/reflexio/server/services/configurator/local_file_config_storage.py +187 -0
  303. package/plugin/vendor/reflexio/reflexio/server/services/configurator/test_config_storage.py +162 -0
  304. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +112 -0
  305. package/plugin/vendor/reflexio/reflexio/server/services/embedding_text.py +62 -0
  306. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/__init__.py +0 -0
  307. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/distribution.py +33 -0
  308. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/eval_sampler.py +126 -0
  309. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/group_aggregation.py +192 -0
  310. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/hero_state.py +75 -0
  311. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/rule_attribution.py +97 -0
  312. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +515 -0
  313. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/shadow_aggregation.py +90 -0
  314. package/plugin/vendor/reflexio/reflexio/server/services/extraction/__init__.py +0 -0
  315. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +91 -0
  316. package/plugin/vendor/reflexio/reflexio/server/services/extraction/invariants.py +303 -0
  317. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +25 -0
  318. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +358 -0
  319. package/plugin/vendor/reflexio/reflexio/server/services/extraction/plan.py +138 -0
  320. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +217 -0
  321. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +535 -0
  322. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +171 -0
  323. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +779 -0
  324. package/plugin/vendor/reflexio/reflexio/server/services/extraction/tools.py +1125 -0
  325. package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +94 -0
  326. package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +251 -0
  327. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +702 -0
  328. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +835 -0
  329. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +89 -0
  330. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_aggregator.py +1388 -0
  331. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +1045 -0
  332. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_extractor.py +436 -0
  333. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_generation_service.py +808 -0
  334. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_constants.py +28 -0
  335. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_utils.py +362 -0
  336. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/__init__.py +24 -0
  337. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/assistant_webhook.py +246 -0
  338. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/gepa_adapter.py +291 -0
  339. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/judge.py +97 -0
  340. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/models.py +96 -0
  341. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +645 -0
  342. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/rollout.py +35 -0
  343. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scenario_resolver.py +93 -0
  344. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +174 -0
  345. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/__init__.py +26 -0
  346. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_document_expander.py +179 -0
  347. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +297 -0
  348. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +772 -0
  349. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_extractor.py +462 -0
  350. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +737 -0
  351. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service_utils.py +290 -0
  352. package/plugin/vendor/reflexio/reflexio/server/services/reflection/__init__.py +17 -0
  353. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_extractor.py +247 -0
  354. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +803 -0
  355. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service_utils.py +146 -0
  356. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/__init__.py +0 -0
  357. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +80 -0
  358. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
  359. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +756 -0
  360. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/__init__.py +1 -0
  361. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +184 -0
  362. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/outcome.py +81 -0
  363. package/plugin/vendor/reflexio/reflexio/server/services/storage/constants.py +2 -0
  364. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +11 -0
  365. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +154 -0
  366. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +155 -0
  367. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +59 -0
  368. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +1298 -0
  369. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +1945 -0
  370. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +600 -0
  371. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_operations.py +346 -0
  372. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1378 -0
  373. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +747 -0
  374. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +263 -0
  375. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +193 -0
  376. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +166 -0
  377. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_stall_state.py +217 -0
  378. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +153 -0
  379. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +384 -0
  380. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_base.py +71 -0
  381. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +235 -0
  382. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +170 -0
  383. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +677 -0
  384. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +250 -0
  385. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +154 -0
  386. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +130 -0
  387. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +93 -0
  388. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_stall_state.py +76 -0
  389. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +572 -0
  390. package/plugin/vendor/reflexio/reflexio/server/site_var/README.md +77 -0
  391. package/plugin/vendor/reflexio/reflexio/server/site_var/feature_flags.py +116 -0
  392. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_manager.py +263 -0
  393. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/feature_flags.json +13 -0
  394. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/llm_model_setting.json +7 -0
  395. package/plugin/vendor/reflexio/reflexio/server/tracing.py +158 -0
  396. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +113 -0
  397. package/plugin/vendor/reflexio/reflexio/server/uvicorn_logging.py +76 -0
  398. package/plugin/vendor/reflexio/reflexio/test_support/__init__.py +1 -0
  399. package/plugin/vendor/reflexio/reflexio/test_support/llm_fixtures.py +62 -0
  400. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +242 -0
  401. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +129 -0
  402. package/plugin/vendor/reflexio/reflexio/test_support/skip_decorators.py +43 -0
@@ -0,0 +1,1871 @@
1
+ """
2
+ LiteLLM-based unified LLM client.
3
+
4
+ This module provides a unified interface to multiple LLM providers (OpenAI, Claude, Azure OpenAI)
5
+ using LiteLLM. It maintains the same interface as the existing LLMClient for easy replacement.
6
+ """
7
+
8
+ import base64
9
+ import json
10
+ import logging
11
+ import multiprocessing
12
+ import os
13
+ import pickle
14
+ import queue
15
+ import re
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from functools import lru_cache
19
+ from typing import Any
20
+
21
+ import litellm
22
+ import tiktoken
23
+ from pydantic import BaseModel
24
+
25
+ from reflexio.models.config_schema import APIKeyConfig
26
+ from reflexio.server.llm.image_utils import (
27
+ SUPPORTED_IMAGE_MIME_TYPES,
28
+ ImageEncodingError,
29
+ )
30
+ from reflexio.server.llm.image_utils import (
31
+ encode_image_to_base64 as _encode_image_to_base64,
32
+ )
33
+ from reflexio.server.llm.llm_utils import (
34
+ is_pydantic_model,
35
+ strict_response_format_for_model,
36
+ )
37
+ from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
38
+ from reflexio.server.llm.providers.claude_code_provider import (
39
+ register_if_enabled as _register_claude_code,
40
+ )
41
+ from reflexio.server.llm.providers.embedding_service_provider import (
42
+ EmbeddingUnavailableError,
43
+ embedding_provider_mode,
44
+ get_service_embeddings,
45
+ should_use_embedding_service,
46
+ )
47
+ from reflexio.server.llm.providers.local_embedding_provider import (
48
+ LocalEmbedder,
49
+ )
50
+ from reflexio.server.llm.providers.local_embedding_provider import (
51
+ is_chromadb_importable as _is_chromadb_importable,
52
+ )
53
+ from reflexio.server.llm.providers.local_embedding_provider import (
54
+ register_if_chromadb_available as _register_local_embedder,
55
+ )
56
+ from reflexio.server.llm.providers.nomic_embedding_provider import (
57
+ NomicEmbedder,
58
+ )
59
+ from reflexio.server.llm.providers.nomic_embedding_provider import (
60
+ is_nomic_model as _is_nomic_model,
61
+ )
62
+ from reflexio.server.llm.providers.nomic_embedding_provider import (
63
+ register_if_enabled as _register_nomic_embedder,
64
+ )
65
+
66
+ # Suppress LiteLLM's verbose logging
67
+ litellm.suppress_debug_info = True
68
+
69
+ # Opt-in registration of claude-smart's local providers. All no-ops
70
+ # unless the matching env var is set. Safe to call at import.
71
+ _register_claude_code()
72
+ _register_local_embedder()
73
+ _register_nomic_embedder()
74
+
75
+ _LOGGER = logging.getLogger(__name__)
76
+
77
+ # OpenAI's documented max input length for text-embedding-3-* and ada-002 is
78
+ # 8191 tokens. Used as the fallback limit only when a model's name looks
79
+ # OpenAI-family but litellm's registry has no entry for it.
80
+ _OPENAI_EMBEDDING_FALLBACK_MAX_TOKENS = 8191
81
+
82
+ # Models whose truncation warning has already been emitted this process. Keeps
83
+ # batch backfills of millions of long docs from flooding logs — the first hit
84
+ # per model goes to WARNING, everything after to DEBUG.
85
+ _TRUNCATION_WARNED_MODELS: set[str] = set()
86
+
87
+ # Model-name prefixes that route through OpenAI's embedding API (and therefore
88
+ # share the 8191-token cap). Anything that does not start with one of these is
89
+ # treated as "unknown provider" when litellm has no registry entry.
90
+ _OPENAI_EMBEDDING_FAMILY_PREFIXES = ("text-embedding-", "openai/", "azure/")
91
+
92
+ # Python-to-JSON keyword replacements used by _sanitize_json_string.
93
+ _PYTHON_TO_JSON_REPLACEMENTS = {"True": "true", "False": "false", "None": "null"}
94
+
95
+
96
+ @lru_cache(maxsize=32)
97
+ def _get_embedding_limit(model: str) -> int | None:
98
+ """
99
+ Resolve the maximum input token count for an embedding model.
100
+
101
+ Consults ``litellm.get_model_info`` first so provider-specific caps are
102
+ respected (OpenAI ~8191, Cohere 512, Voyage 32000, etc.). When litellm has
103
+ no entry for the model, falls back to the OpenAI 8191 cap only when the
104
+ model name looks OpenAI-family; otherwise returns ``None`` to disable
105
+ truncation for unknown providers (safer than over-truncating their input).
106
+
107
+ Args:
108
+ model (str): Embedding model name (e.g. 'text-embedding-3-small',
109
+ 'cohere/embed-english-v3.0').
110
+
111
+ Returns:
112
+ int | None: Maximum input tokens, or ``None`` when the limit is unknown
113
+ and no safe fallback applies.
114
+ """
115
+ try:
116
+ info = litellm.get_model_info(model)
117
+ except Exception:
118
+ info = None
119
+ if info and info.get("mode") == "embedding":
120
+ max_tokens = info.get("max_input_tokens")
121
+ if isinstance(max_tokens, int) and max_tokens > 0:
122
+ return max_tokens
123
+ if model.startswith(_OPENAI_EMBEDDING_FAMILY_PREFIXES):
124
+ return _OPENAI_EMBEDDING_FALLBACK_MAX_TOKENS
125
+ return None
126
+
127
+
128
+ @lru_cache(maxsize=16)
129
+ def _get_embedding_encoding(model: str) -> tiktoken.Encoding:
130
+ """
131
+ Return the tiktoken encoding for an embedding model, falling back to cl100k_base.
132
+
133
+ For non-OpenAI providers tiktoken does not know the real tokenizer, so the
134
+ cl100k_base fallback is an approximate proxy for token counting. That is
135
+ acceptable here because we truncate toward the provider's cap with the
136
+ proxy, which tends to over-truncate by a small fraction rather than under-
137
+ truncate and cause upstream 400s.
138
+
139
+ Args:
140
+ model (str): Embedding model name (e.g. 'text-embedding-3-small').
141
+
142
+ Returns:
143
+ tiktoken.Encoding: Encoder to use for token counting and truncation.
144
+ """
145
+ try:
146
+ return tiktoken.encoding_for_model(model)
147
+ except KeyError:
148
+ return tiktoken.get_encoding("cl100k_base")
149
+
150
+
151
+ def _reject_cloud_mode(embedding_model: str, mode: str) -> None:
152
+ """
153
+ Raise when a local-only embedding model is configured for cloud mode.
154
+
155
+ Args:
156
+ embedding_model (str): The resolved embedding model name.
157
+ mode (str): The resolved embedding provider mode.
158
+
159
+ Raises:
160
+ EmbeddingUnavailableError: If ``mode`` is ``"cloud"``.
161
+ """
162
+ if mode == "cloud":
163
+ raise EmbeddingUnavailableError(
164
+ f"Local embedding model {embedding_model!r} cannot use cloud mode"
165
+ )
166
+
167
+
168
+ def _truncate_for_embedding(
169
+ text: str, model: str, max_tokens: int | None = None
170
+ ) -> str:
171
+ """
172
+ Truncate a string so its token count fits within an embedding model's input limit.
173
+
174
+ The token budget is auto-resolved from ``_get_embedding_limit`` by default.
175
+ When the model has no known limit (unknown provider not in litellm's
176
+ registry and not OpenAI-family), returns the text unchanged — over-
177
+ truncating an unknown provider's input is worse than passing it through
178
+ and letting the provider's own error surface.
179
+
180
+ Args:
181
+ text (str): Raw input text.
182
+ model (str): Embedding model name, used to pick the tokenizer and the
183
+ per-provider token cap.
184
+ max_tokens (int | None): Override for the resolved budget. Primarily
185
+ used by tests to exercise the truncation path on short strings;
186
+ leave as ``None`` in production callers.
187
+
188
+ Returns:
189
+ str: Original text if it already fits (or the model has no known
190
+ limit), otherwise a token-bounded prefix.
191
+ """
192
+ if not text:
193
+ return text
194
+ if max_tokens is None:
195
+ max_tokens = _get_embedding_limit(model)
196
+ if max_tokens is None:
197
+ return text
198
+ encoding = _get_embedding_encoding(model)
199
+ tokens = encoding.encode(text, disallowed_special=())
200
+ if len(tokens) <= max_tokens:
201
+ return text
202
+ if model in _TRUNCATION_WARNED_MODELS:
203
+ _LOGGER.debug(
204
+ "Truncating embedding input from %d to %d tokens for model %s",
205
+ len(tokens),
206
+ max_tokens,
207
+ model,
208
+ )
209
+ else:
210
+ _TRUNCATION_WARNED_MODELS.add(model)
211
+ _LOGGER.warning(
212
+ "Truncating embedding input from %d to %d tokens for model %s "
213
+ "(further occurrences will be logged at DEBUG)",
214
+ len(tokens),
215
+ max_tokens,
216
+ model,
217
+ )
218
+ return encoding.decode(tokens[:max_tokens])
219
+
220
+
221
+ @dataclass
222
+ class LiteLLMConfig:
223
+ """
224
+ Configuration for LiteLLM client.
225
+
226
+ Args:
227
+ model: Model name to use (e.g., 'gpt-4o', 'claude-3-5-sonnet-20241022').
228
+ temperature: Temperature for response generation (0.0 to 2.0).
229
+ max_tokens: Maximum tokens to generate.
230
+ timeout: Request timeout in seconds.
231
+ max_retries: Maximum retry attempts on the primary model. Passed
232
+ directly to litellm's num_retries. Default 3.
233
+ retry_delay: Currently unused — LiteLLM owns retry backoff. Kept for
234
+ backward compatibility; remove in a follow-up sweep.
235
+ top_p: Top-p sampling parameter.
236
+ api_key_config: Optional API key configuration from Config (overrides env vars).
237
+ fallback_models: Models LiteLLM tries in order after the primary
238
+ exhausts num_retries. Passed directly to litellm's fallbacks param.
239
+ Default is an empty list (no fallback) so local reflexio and the
240
+ claude-smart integration are never silently routed to an unintended
241
+ provider. Production opts in via the env var
242
+ REFLEXIO_LLM_FALLBACK_MODELS (comma-separated, e.g. "gpt-5.4-mini").
243
+ Self-references are deduped at request time.
244
+ """
245
+
246
+ model: str
247
+ temperature: float = 0.7
248
+ max_tokens: int | None = None
249
+ timeout: int = 120
250
+ max_retries: int = 3
251
+ retry_delay: float = 1.0
252
+ top_p: float = 1.0
253
+ api_key_config: APIKeyConfig | None = None
254
+ fallback_models: list[str] = field(
255
+ default_factory=lambda: [
256
+ m.strip()
257
+ for m in os.environ.get("REFLEXIO_LLM_FALLBACK_MODELS", "").split(",")
258
+ if m.strip()
259
+ ]
260
+ )
261
+
262
+
263
+ # Reasoning models that routinely exceed the default 120s provider timeout on
264
+ # large extraction contexts. Values are floors, not overrides: the effective
265
+ # timeout is max(configured, floor), and an explicit per-call timeout kwarg
266
+ # always wins.
267
+ _MODEL_TIMEOUT_FLOOR_SECONDS: dict[str, int] = {
268
+ "minimax/MiniMax-M3": 240,
269
+ }
270
+
271
+
272
+ @dataclass
273
+ class ToolCallingChatResponse:
274
+ """Response from a chat call that was routed in tool-calling mode.
275
+
276
+ Returned instead of ``str | BaseModel`` whenever the caller passes
277
+ ``tools=...`` to ``generate_chat_response``. Callers inspect
278
+ ``tool_calls`` to drive a tool loop; ``content`` is set on the
279
+ terminal (non-tool) turn.
280
+
281
+ Args:
282
+ content: Text content from the model, or None when the model emitted tool calls.
283
+ tool_calls: List of tool call objects from the model, or None on the terminal turn.
284
+ finish_reason: The stop reason reported by the provider (e.g. "tool_calls", "stop").
285
+ usage: Raw usage object from the LLM response (provider-dependent shape), or None.
286
+ cost_usd: Estimated cost in USD for this call via litellm price table, or None when
287
+ the provider is not in the table (local ONNX, claude-code CLI, etc.).
288
+ """
289
+
290
+ content: str | None
291
+ tool_calls: list[Any] | None
292
+ finish_reason: str | None
293
+ usage: Any | None = None
294
+ cost_usd: float | None = None
295
+
296
+
297
+ class LiteLLMClientError(Exception):
298
+ """Custom exception for LiteLLM client errors."""
299
+
300
+
301
+ class StructuredOutputParseError(Exception):
302
+ """Raised when a structured-output LLM call returns content that cannot be parsed.
303
+
304
+ Caught by the retry loop in ``_make_request`` so a malformed response
305
+ burns a retry attempt rather than silently returning unparsed content.
306
+ """
307
+
308
+
309
+ class LLMHardTimeoutError(TimeoutError):
310
+ """Raised when an LLM call exceeds the client-side wall-clock timeout."""
311
+
312
+
313
+ @dataclass
314
+ class _CompletionMessageSnapshot:
315
+ content: str | None = None
316
+ tool_calls: Any | None = None
317
+
318
+
319
+ @dataclass
320
+ class _CompletionChoiceSnapshot:
321
+ message: _CompletionMessageSnapshot
322
+ finish_reason: str | None = None
323
+
324
+
325
+ @dataclass
326
+ class _PromptTokenDetailsSnapshot:
327
+ cached_tokens: int = 0
328
+
329
+
330
+ @dataclass
331
+ class _CompletionUsageSnapshot:
332
+ prompt_tokens: int | None = None
333
+ completion_tokens: int | None = None
334
+ total_tokens: int | None = None
335
+ prompt_tokens_details: _PromptTokenDetailsSnapshot | None = None
336
+ cache_creation_input_tokens: int | None = None
337
+ cache_read_input_tokens: int | None = None
338
+
339
+
340
+ @dataclass
341
+ class _CompletionResponseSnapshot:
342
+ choices: list[_CompletionChoiceSnapshot]
343
+ usage: _CompletionUsageSnapshot | None = None
344
+ model: str | None = None
345
+ _hidden_params: dict[str, Any] = field(default_factory=dict)
346
+
347
+
348
+ @dataclass
349
+ class _CompletionErrorSnapshot:
350
+ type_name: str
351
+ message: str
352
+
353
+
354
+ def _ensure_picklable(value: Any) -> Any:
355
+ try:
356
+ pickle.dumps(value)
357
+ except Exception:
358
+ return repr(value)
359
+ return value
360
+
361
+
362
+ def _snapshot_completion_response(response: Any) -> _CompletionResponseSnapshot:
363
+ choices: list[_CompletionChoiceSnapshot] = []
364
+ for choice in getattr(response, "choices", []) or []:
365
+ message = getattr(choice, "message", None)
366
+ choices.append(
367
+ _CompletionChoiceSnapshot(
368
+ message=_CompletionMessageSnapshot(
369
+ content=getattr(message, "content", None),
370
+ tool_calls=_ensure_picklable(getattr(message, "tool_calls", None)),
371
+ ),
372
+ finish_reason=getattr(choice, "finish_reason", None),
373
+ )
374
+ )
375
+
376
+ usage = getattr(response, "usage", None)
377
+ usage_snapshot = None
378
+ if usage is not None:
379
+ prompt_details = getattr(usage, "prompt_tokens_details", None)
380
+ prompt_details_snapshot = None
381
+ if prompt_details is not None:
382
+ prompt_details_snapshot = _PromptTokenDetailsSnapshot(
383
+ cached_tokens=int(getattr(prompt_details, "cached_tokens", 0) or 0)
384
+ )
385
+ usage_snapshot = _CompletionUsageSnapshot(
386
+ prompt_tokens=getattr(usage, "prompt_tokens", None),
387
+ completion_tokens=getattr(usage, "completion_tokens", None),
388
+ total_tokens=getattr(usage, "total_tokens", None),
389
+ prompt_tokens_details=prompt_details_snapshot,
390
+ cache_creation_input_tokens=getattr(
391
+ usage, "cache_creation_input_tokens", None
392
+ ),
393
+ cache_read_input_tokens=getattr(usage, "cache_read_input_tokens", None),
394
+ )
395
+
396
+ hidden_params = getattr(response, "_hidden_params", {}) or {}
397
+ if not isinstance(hidden_params, dict):
398
+ hidden_params = {}
399
+
400
+ return _CompletionResponseSnapshot(
401
+ choices=choices,
402
+ usage=usage_snapshot,
403
+ model=getattr(response, "model", None),
404
+ _hidden_params={str(k): _ensure_picklable(v) for k, v in hidden_params.items()},
405
+ )
406
+
407
+
408
+ def _picklable_completion_result(response: Any) -> Any:
409
+ try:
410
+ pickle.dumps(response)
411
+ except Exception:
412
+ return _snapshot_completion_response(response)
413
+ return response
414
+
415
+
416
+ def _litellm_completion_worker(
417
+ params: dict[str, Any], result_queue: multiprocessing.Queue
418
+ ) -> None:
419
+ try:
420
+ result_queue.put(
421
+ ("ok", _picklable_completion_result(litellm.completion(**params)))
422
+ )
423
+ except BaseException as exc:
424
+ try:
425
+ pickle.dumps(exc)
426
+ except Exception:
427
+ result_queue.put(
428
+ ("error", _CompletionErrorSnapshot(type(exc).__name__, str(exc)))
429
+ )
430
+ else:
431
+ result_queue.put(("error", exc))
432
+
433
+
434
+ class LiteLLMClient:
435
+ """
436
+ Unified LLM client using LiteLLM for multi-provider support.
437
+
438
+ Supports OpenAI, Claude, and Azure OpenAI models through a consistent interface.
439
+ Provides structured output support, multi-modal (image) input, and embeddings.
440
+ """
441
+
442
+ SUPPORTED_IMAGE_FORMATS: set[str] = set(SUPPORTED_IMAGE_MIME_TYPES.keys())
443
+
444
+ # Providers that use a simple "prefix/" -> api_key mapping
445
+ _SIMPLE_PROVIDER_PREFIXES: dict[str, str] = {
446
+ "gemini/": "gemini",
447
+ "openrouter/": "openrouter",
448
+ "minimax/": "minimax",
449
+ "deepseek/": "deepseek",
450
+ "zai/": "zai",
451
+ "moonshot/": "moonshot",
452
+ "xai/": "xai",
453
+ }
454
+
455
+ # Models that only support temperature=1.0 (custom values cause errors or degraded performance)
456
+ TEMPERATURE_RESTRICTED_MODELS = {
457
+ "gpt-5",
458
+ "gpt-5.4-mini",
459
+ "gpt-5-nano",
460
+ "gpt-5-codex",
461
+ "gemini-3-flash-preview",
462
+ "gemini-3-pro-preview",
463
+ }
464
+
465
+ def __init__(self, config: LiteLLMConfig):
466
+ """
467
+ Initialize the LiteLLM client.
468
+
469
+ Args:
470
+ config: LiteLLM configuration containing model and provider settings.
471
+
472
+ Raises:
473
+ LiteLLMClientError: If initialization fails.
474
+ """
475
+ self.config = config
476
+ self.logger = logging.getLogger(__name__)
477
+ self.logger.info("LiteLLM client initialized with model: %s", config.model)
478
+
479
+ # Pre-resolve API key configuration for the main model
480
+ self._api_key, self._api_base, self._api_version = self._resolve_api_key()
481
+
482
+ # Lazily-resolved default embedding model. Populated on first call to
483
+ # _resolve_default_embedding_model so a client built with no embedding
484
+ # use case never pays the auto-detection cost.
485
+ self._default_embedding_model: str | None = None
486
+
487
+ # Enable Braintrust observability when API key is configured
488
+ if os.environ.get("BRAINTRUST_API_KEY") and "braintrust" not in (
489
+ litellm.callbacks or []
490
+ ):
491
+ litellm.callbacks = litellm.callbacks or []
492
+ litellm.callbacks.append("braintrust")
493
+ self.logger.info("Braintrust observability enabled")
494
+
495
+ def _resolve_api_key(
496
+ self, model: str | None = None, for_embedding: bool = False
497
+ ) -> tuple[str | None, str | None, str | None]:
498
+ """
499
+ Resolve API key, base URL, and version from api_key_config based on model name.
500
+
501
+ Args:
502
+ model: Optional model name to resolve keys for. Defaults to self.config.model.
503
+ for_embedding: If True, skip custom endpoint override (embeddings use their own provider).
504
+
505
+ Returns:
506
+ tuple[Optional[str], Optional[str], Optional[str]]: (api_key, api_base, api_version)
507
+ """
508
+ if not self.config.api_key_config:
509
+ return None, None, None
510
+
511
+ # Custom endpoint takes priority for non-embedding calls
512
+ if not for_embedding:
513
+ ce = self.config.api_key_config.custom_endpoint
514
+ if ce and ce.api_key and ce.api_base:
515
+ return ce.api_key, str(ce.api_base), None
516
+
517
+ model_to_check = model or self.config.model
518
+ model_lower = model_to_check.lower()
519
+
520
+ return self._resolve_by_prefix(model_lower)
521
+
522
+ def _resolve_by_prefix(
523
+ self, model_lower: str
524
+ ) -> tuple[str | None, str | None, str | None]:
525
+ """Resolve API credentials by matching the model prefix to a provider.
526
+
527
+ Args:
528
+ model_lower: Lowercased model name string.
529
+
530
+ Returns:
531
+ tuple[Optional[str], Optional[str], Optional[str]]: (api_key, api_base, api_version)
532
+ """
533
+ akc = self.config.api_key_config
534
+ if not akc:
535
+ return None, None, None
536
+
537
+ # claude-code/* routes through the Claude Code CLI (custom provider);
538
+ # it has no API key config — auth comes from the CLI itself.
539
+ if model_lower.startswith("claude-code/"):
540
+ return None, None, None
541
+
542
+ for prefix, attr in self._SIMPLE_PROVIDER_PREFIXES.items():
543
+ if model_lower.startswith(prefix):
544
+ provider_cfg = getattr(akc, attr, None)
545
+ if provider_cfg:
546
+ return provider_cfg.api_key, None, None
547
+ return None, None, None
548
+
549
+ # DashScope (Qwen) — has an optional api_base
550
+ if model_lower.startswith("dashscope/"):
551
+ if akc.dashscope:
552
+ return akc.dashscope.api_key, akc.dashscope.api_base, None
553
+ return None, None, None
554
+
555
+ # Azure OpenAI
556
+ if model_lower.startswith("azure/"):
557
+ if akc.openai and akc.openai.azure_config:
558
+ azure = akc.openai.azure_config
559
+ return azure.api_key, str(azure.endpoint), azure.api_version
560
+ return None, None, None
561
+
562
+ # Anthropic/Claude models
563
+ if "claude" in model_lower or "anthropic" in model_lower:
564
+ if akc.anthropic:
565
+ return akc.anthropic.api_key, None, None
566
+ return None, None, None
567
+
568
+ # OpenAI models (default fallback)
569
+ if akc.openai and akc.openai.api_key:
570
+ return akc.openai.api_key, None, None
571
+
572
+ return None, None, None
573
+
574
+ def generate_response(
575
+ self,
576
+ prompt: str,
577
+ system_message: str | None = None,
578
+ images: list[str | bytes | dict] | None = None,
579
+ image_media_type: str | None = None,
580
+ **kwargs: Any,
581
+ ) -> str | BaseModel | ToolCallingChatResponse:
582
+ """
583
+ Generate a response using the configured LLM.
584
+
585
+ Args:
586
+ prompt: The user prompt/message.
587
+ system_message: Optional system message to set context.
588
+ images: Optional list of images (file paths, bytes, or pre-formatted content blocks).
589
+ image_media_type: Media type for images if passing bytes (e.g., 'image/png').
590
+ **kwargs: Additional parameters including:
591
+ - response_format: Pydantic BaseModel class for structured output
592
+ - parse_structured_output: Whether to parse structured output (default True)
593
+ - temperature: Override config temperature
594
+ - max_tokens: Override config max_tokens
595
+
596
+ Returns:
597
+ Generated response content. Returns string for text responses,
598
+ or BaseModel instance for Pydantic model responses.
599
+
600
+ Raises:
601
+ LiteLLMClientError: If the API call fails after all retries,
602
+ or if response_format is not a Pydantic BaseModel class.
603
+ """
604
+ # Validate response_format if provided
605
+ response_format = kwargs.get("response_format")
606
+ if response_format is not None and not is_pydantic_model(response_format):
607
+ raise LiteLLMClientError(
608
+ "response_format must be a Pydantic BaseModel class, "
609
+ f"got {type(response_format).__name__}"
610
+ )
611
+
612
+ # Build user message content
613
+ user_content = self._build_user_content(prompt, images, image_media_type)
614
+
615
+ # Build messages list
616
+ messages = []
617
+ if system_message:
618
+ messages.append({"role": "system", "content": system_message})
619
+ messages.append({"role": "user", "content": user_content})
620
+
621
+ return self._make_request(messages, **kwargs)
622
+
623
+ def generate_chat_response(
624
+ self,
625
+ messages: list[dict[str, Any]],
626
+ system_message: str | None = None,
627
+ *,
628
+ tools: list[Any] | None = None,
629
+ tool_choice: str | dict[str, Any] | None = None,
630
+ model_role: ModelRole | None = None,
631
+ max_retries: int | None = None,
632
+ fallback_models: list[str] | None = None,
633
+ **kwargs: Any,
634
+ ) -> str | BaseModel | ToolCallingChatResponse:
635
+ """
636
+ Generate a response from a list of chat messages.
637
+
638
+ Args:
639
+ messages: List of messages in chat format [{"role": "...", "content": "..."}].
640
+ system_message: Optional system message to prepend.
641
+ tools: Optional list of tool definitions for tool-calling mode.
642
+ When provided, the return type is ``ToolCallingChatResponse``.
643
+ tool_choice: Optional tool choice control ("auto", "none", "required",
644
+ or a dict specifying a particular tool). Forwarded to the provider.
645
+ model_role: Optional ``ModelRole`` to override the model selected for
646
+ this request. The role is resolved via ``resolve_model_name`` using
647
+ the client's ``api_key_config``.
648
+ max_retries (int | None): Optional per-call override for the number of
649
+ retry attempts. When ``None`` (the default), the value falls back to
650
+ ``LiteLLMConfig.max_retries``.
651
+ fallback_models (list[str] | None): Optional per-call override for the
652
+ fallback model chain. When ``None`` (the default), the value falls
653
+ back to ``LiteLLMConfig.fallback_models``.
654
+ **kwargs: Additional parameters including:
655
+ - response_format: Pydantic BaseModel class for structured output
656
+ - parse_structured_output: Whether to parse structured output (default True)
657
+ - temperature: Override config temperature
658
+ - max_tokens: Override config max_tokens
659
+
660
+ Returns:
661
+ Generated response content. Returns string for text responses,
662
+ ``BaseModel`` instance for Pydantic model responses, or
663
+ ``ToolCallingChatResponse`` when ``tools`` is provided.
664
+
665
+ Raises:
666
+ LiteLLMClientError: If the API call fails after all retries,
667
+ or if response_format is not a Pydantic BaseModel class.
668
+ """
669
+ # Validate response_format if provided
670
+ response_format = kwargs.get("response_format")
671
+ if response_format is not None and not is_pydantic_model(response_format):
672
+ raise LiteLLMClientError(
673
+ "response_format must be a Pydantic BaseModel class, "
674
+ f"got {type(response_format).__name__}"
675
+ )
676
+
677
+ # Prepend system message if provided
678
+ final_messages = list(messages)
679
+ if system_message:
680
+ # Check if first message is already a system message
681
+ if final_messages and final_messages[0].get("role") == "system":
682
+ # Merge with existing system message
683
+ final_messages[0]["content"] = (
684
+ f"{system_message}\n\n{final_messages[0]['content']}"
685
+ )
686
+ else:
687
+ final_messages.insert(0, {"role": "system", "content": system_message})
688
+
689
+ # Forward tool-calling and model-role kwargs into _make_request
690
+ if tools is not None:
691
+ kwargs["tools"] = tools
692
+ if tool_choice is not None:
693
+ kwargs["tool_choice"] = tool_choice
694
+ if model_role is not None:
695
+ kwargs["model_role"] = model_role
696
+ if max_retries is not None:
697
+ kwargs["max_retries"] = max_retries
698
+ if fallback_models is not None:
699
+ kwargs["fallback_models"] = fallback_models
700
+
701
+ return self._make_request(final_messages, **kwargs)
702
+
703
+ def _resolve_default_embedding_model(self) -> str:
704
+ """
705
+ Resolve the embedding model to use when callers do not specify one.
706
+
707
+ Routes through the same auto-detection chain as the rest of reflexio
708
+ (``resolve_model_name`` for ``ModelRole.EMBEDDING``) so a session that
709
+ has the local ONNX embedder enabled — or any non-OpenAI provider —
710
+ does not silently fall back to ``text-embedding-3-small`` and produce
711
+ OpenAI 401s. Higher-precedence org config and site-var overrides are
712
+ the caller's responsibility to resolve and pass via ``model=``; this
713
+ helper handles only the auto-detect tier.
714
+
715
+ Returns:
716
+ str: The auto-detected embedding model name (cached after first call).
717
+
718
+ Raises:
719
+ RuntimeError: Propagated from ``resolve_model_name`` when no
720
+ embedding-capable provider is available.
721
+ """
722
+ if self._default_embedding_model is None:
723
+ self._default_embedding_model = resolve_model_name(
724
+ ModelRole.EMBEDDING,
725
+ api_key_config=self.config.api_key_config,
726
+ )
727
+ return self._default_embedding_model
728
+
729
+ def get_embedding(
730
+ self, text: str, model: str | None = None, dimensions: int | None = None
731
+ ) -> list[float]:
732
+ """
733
+ Get embedding vector for the given text.
734
+
735
+ Args:
736
+ text: The text to get embedding for.
737
+ model: Optional embedding model. When omitted, the model is
738
+ auto-detected via ``resolve_model_name(ModelRole.EMBEDDING)``
739
+ so callers inherit the local-embedder gate and any non-OpenAI
740
+ provider configured for this client.
741
+ dimensions: Optional number of dimensions for the embedding vector.
742
+
743
+ Returns:
744
+ List of floats representing the embedding vector.
745
+
746
+ Raises:
747
+ LiteLLMClientError: If embedding generation fails.
748
+ """
749
+ embedding_model = model or self._resolve_default_embedding_model()
750
+ mode = embedding_provider_mode(embedding_model)
751
+ if mode == "off":
752
+ raise EmbeddingUnavailableError("Embedding provider is disabled")
753
+ if should_use_embedding_service(embedding_model):
754
+ return get_service_embeddings(
755
+ [text], model=embedding_model, dimensions=dimensions
756
+ )[0]
757
+
758
+ # local/nomic-embed-* must stay on the Nomic provider (137M params,
759
+ # 768d Matryoshka-truncated to 512). Falling through to MiniLM would
760
+ # mix embedding models inside existing vector stores.
761
+ if _is_nomic_model(embedding_model):
762
+ _reject_cloud_mode(embedding_model, mode)
763
+ try:
764
+ return NomicEmbedder.get().embed([text])[0]
765
+ except Exception as e:
766
+ raise LiteLLMClientError(
767
+ f"Nomic embedding generation failed: {str(e)}"
768
+ ) from e
769
+
770
+ # local/* models route through the in-process ONNX embedder — no
771
+ # network call, no litellm API, no tiktoken truncation (the embedder
772
+ # applies its own token cap). The dispatch is gated solely on
773
+ # ``chromadb`` being importable; the env-var opt-in (claude-smart's
774
+ # ``CLAUDE_SMART_USE_LOCAL_EMBEDDING``) is enforced earlier in the
775
+ # auto-detection layer (see ``model_defaults._auto_detect_model``).
776
+ if embedding_model.startswith("local/"):
777
+ _reject_cloud_mode(embedding_model, mode)
778
+ if not _is_chromadb_importable():
779
+ raise LiteLLMClientError(
780
+ f"Embedding model {embedding_model!r} requires chromadb. "
781
+ "Run `pip install chromadb`."
782
+ )
783
+ try:
784
+ return LocalEmbedder.get().embed([text])[0]
785
+ except Exception as e:
786
+ raise LiteLLMClientError(
787
+ f"Local embedding generation failed: {str(e)}"
788
+ ) from e
789
+
790
+ text = _truncate_for_embedding(text, embedding_model)
791
+
792
+ try:
793
+ params = {"model": embedding_model, "input": [text]}
794
+ if dimensions:
795
+ params["dimensions"] = dimensions
796
+
797
+ # Resolve and add API key configuration if provided (overrides env vars)
798
+ api_key, api_base, api_version = self._resolve_api_key(
799
+ embedding_model, for_embedding=True
800
+ )
801
+ if api_key:
802
+ params["api_key"] = api_key
803
+ if api_base:
804
+ params["api_base"] = api_base
805
+ if api_version:
806
+ params["api_version"] = api_version
807
+
808
+ response = litellm.embedding(
809
+ **params,
810
+ timeout=self.config.timeout,
811
+ num_retries=self.config.max_retries,
812
+ )
813
+ return response.data[0]["embedding"]
814
+ except Exception as e:
815
+ raise LiteLLMClientError(f"Embedding generation failed: {str(e)}") from e
816
+
817
+ def get_embeddings(
818
+ self,
819
+ texts: list[str],
820
+ model: str | None = None,
821
+ dimensions: int | None = None,
822
+ ) -> list[list[float]]:
823
+ """
824
+ Get embedding vectors for multiple texts in a single API call.
825
+
826
+ Args:
827
+ texts: List of texts to get embeddings for.
828
+ model: Optional embedding model. When omitted, the model is
829
+ auto-detected via ``resolve_model_name(ModelRole.EMBEDDING)``
830
+ so callers inherit the local-embedder gate and any non-OpenAI
831
+ provider configured for this client.
832
+ dimensions: Optional number of dimensions for the embedding vectors.
833
+
834
+ Returns:
835
+ List of embedding vectors, one per input text, in the same order as input.
836
+
837
+ Raises:
838
+ LiteLLMClientError: If embedding generation fails.
839
+ """
840
+ if not texts:
841
+ return []
842
+
843
+ embedding_model = model or self._resolve_default_embedding_model()
844
+ mode = embedding_provider_mode(embedding_model)
845
+ if mode == "off":
846
+ raise EmbeddingUnavailableError("Embedding provider is disabled")
847
+ if should_use_embedding_service(embedding_model):
848
+ return get_service_embeddings(
849
+ list(texts), model=embedding_model, dimensions=dimensions
850
+ )
851
+
852
+ # See matching short-circuits in get_embedding above.
853
+ if _is_nomic_model(embedding_model):
854
+ _reject_cloud_mode(embedding_model, mode)
855
+ try:
856
+ return NomicEmbedder.get().embed(list(texts))
857
+ except Exception as e:
858
+ raise LiteLLMClientError(
859
+ f"Nomic batch embedding generation failed: {str(e)}"
860
+ ) from e
861
+
862
+ if embedding_model.startswith("local/"):
863
+ _reject_cloud_mode(embedding_model, mode)
864
+ if not _is_chromadb_importable():
865
+ raise LiteLLMClientError(
866
+ f"Embedding model {embedding_model!r} requires chromadb. "
867
+ "Run `pip install chromadb`."
868
+ )
869
+ try:
870
+ return LocalEmbedder.get().embed(list(texts))
871
+ except Exception as e:
872
+ raise LiteLLMClientError(
873
+ f"Local batch embedding generation failed: {str(e)}"
874
+ ) from e
875
+
876
+ texts = [_truncate_for_embedding(t, embedding_model) for t in texts]
877
+
878
+ try:
879
+ params = {"model": embedding_model, "input": texts}
880
+ if dimensions:
881
+ params["dimensions"] = dimensions
882
+
883
+ # Resolve and add API key configuration if provided (overrides env vars)
884
+ api_key, api_base, api_version = self._resolve_api_key(
885
+ embedding_model, for_embedding=True
886
+ )
887
+ if api_key:
888
+ params["api_key"] = api_key
889
+ if api_base:
890
+ params["api_base"] = api_base
891
+ if api_version:
892
+ params["api_version"] = api_version
893
+
894
+ response = litellm.embedding(
895
+ **params,
896
+ timeout=self.config.timeout,
897
+ num_retries=self.config.max_retries,
898
+ )
899
+ # Response data may not be in order, sort by index to ensure correct ordering
900
+ sorted_data = sorted(response.data, key=lambda x: x["index"])
901
+ return [item["embedding"] for item in sorted_data]
902
+ except Exception as e:
903
+ raise LiteLLMClientError(
904
+ f"Batch embedding generation failed: {str(e)}"
905
+ ) from e
906
+
907
+ def _build_completion_params(
908
+ self, messages: list[dict[str, Any]], **kwargs: Any
909
+ ) -> tuple[dict[str, Any], Any, bool, int, list[str]]:
910
+ """Build completion request parameters from messages and kwargs.
911
+
912
+ Args:
913
+ messages: List of messages to send
914
+ **kwargs: Additional parameters (response_format, max_retries, model, etc.)
915
+
916
+ Returns:
917
+ Tuple of (params dict, response_format, parse_structured_output,
918
+ max_retries, fallback_models). ``fallback_models`` already has any
919
+ entry equal to the primary model removed.
920
+ """
921
+ response_format = kwargs.pop("response_format", None)
922
+ strict_response_format = kwargs.pop("strict_response_format", True)
923
+ parse_structured_output = kwargs.pop("parse_structured_output", True)
924
+ max_retries_arg = kwargs.pop("max_retries", self.config.max_retries)
925
+ try:
926
+ max_retries = max(1, int(max_retries_arg))
927
+ except (TypeError, ValueError):
928
+ max_retries = max(1, int(self.config.max_retries))
929
+
930
+ # Per-call fallback_models wins over config when explicitly provided.
931
+ # Use sentinel-style check so an explicit empty list disables fallback
932
+ # for the call even when the config has fallbacks set.
933
+ if "fallback_models" in kwargs:
934
+ fallback_models_raw = kwargs.pop("fallback_models") or []
935
+ else:
936
+ fallback_models_raw = list(self.config.fallback_models)
937
+
938
+ # Pop tool-calling kwargs before the final params.update(kwargs) so they
939
+ # don't leak into the params dict twice.
940
+ tools = kwargs.pop("tools", None)
941
+ tool_choice = kwargs.pop("tool_choice", None)
942
+ model_role: ModelRole | None = kwargs.pop("model_role", None)
943
+
944
+ actual_model = kwargs.pop("model", self.config.model)
945
+
946
+ # model_role takes priority over the default model but falls through
947
+ # to the custom_endpoint override below (highest priority).
948
+ if model_role is not None:
949
+ actual_model = resolve_model_name(
950
+ role=model_role,
951
+ site_var_value=None,
952
+ config_override=None,
953
+ api_key_config=self.config.api_key_config,
954
+ )
955
+
956
+ ce = (
957
+ self.config.api_key_config.custom_endpoint
958
+ if self.config.api_key_config
959
+ else None
960
+ )
961
+ if ce and ce.api_key and ce.api_base:
962
+ actual_model = ce.model
963
+
964
+ params: dict[str, Any] = {
965
+ "model": actual_model,
966
+ "messages": messages,
967
+ "timeout": kwargs.pop(
968
+ "timeout", self._effective_timeout_for_model(actual_model)
969
+ ),
970
+ }
971
+
972
+ # Drop any fallback entry that points back at the primary — sending the
973
+ # same broken endpoint twice never helps.
974
+ fallback_models = [m for m in fallback_models_raw if m != actual_model]
975
+
976
+ temperature = kwargs.pop("temperature", self.config.temperature)
977
+ if self._is_temperature_restricted_model(actual_model):
978
+ params["temperature"] = 1.0
979
+ else:
980
+ params["temperature"] = temperature
981
+
982
+ # Determinism knob: `seed` is always injected (defaulting to 42) on
983
+ # providers that honor it, since seed alone is cheap and harmless.
984
+ # The companion temperature=0 override is opt-in via an explicit
985
+ # REFLEXIO_LLM_SEED env var so that caller-configured temperature
986
+ # flows through by default — silently clobbering a user's configured
987
+ # temperature was surprising. Current-gen reasoning models (gpt-5-*)
988
+ # ignore both knobs; the seed is best-effort.
989
+ default_seed = 42
990
+ seed_explicit = "REFLEXIO_LLM_SEED" in os.environ
991
+ seed_raw = os.environ.get("REFLEXIO_LLM_SEED", str(default_seed))
992
+ try:
993
+ params["seed"] = int(seed_raw)
994
+ except ValueError:
995
+ self.logger.warning(
996
+ "REFLEXIO_LLM_SEED=%r is not an int; falling back to default seed=%d",
997
+ seed_raw,
998
+ default_seed,
999
+ )
1000
+ params["seed"] = default_seed
1001
+ # Keep seed best-effort without mutating LiteLLM's process-wide
1002
+ # drop_params setting. Providers that do not support seed can ignore it.
1003
+ params["drop_params"] = True
1004
+ if seed_explicit and not self._is_temperature_restricted_model(actual_model):
1005
+ params["temperature"] = 0.0
1006
+
1007
+ max_tokens = kwargs.pop("max_tokens", self.config.max_tokens)
1008
+ if max_tokens:
1009
+ params["max_tokens"] = max_tokens
1010
+ if self.config.top_p != 1.0:
1011
+ params["top_p"] = self.config.top_p
1012
+ if response_format:
1013
+ params["response_format"] = self._provider_response_format(
1014
+ response_format=response_format,
1015
+ model=actual_model,
1016
+ strict_response_format=strict_response_format,
1017
+ )
1018
+ if tools is not None:
1019
+ params["tools"] = tools
1020
+ if tool_choice is not None:
1021
+ params["tool_choice"] = tool_choice
1022
+
1023
+ if actual_model != self.config.model:
1024
+ api_key, api_base, api_version = self._resolve_api_key(actual_model)
1025
+ else:
1026
+ api_key, api_base, api_version = (
1027
+ self._api_key,
1028
+ self._api_base,
1029
+ self._api_version,
1030
+ )
1031
+ if api_key:
1032
+ params["api_key"] = api_key
1033
+ if api_base:
1034
+ params["api_base"] = api_base
1035
+ if api_version:
1036
+ params["api_version"] = api_version
1037
+
1038
+ params.update(kwargs)
1039
+
1040
+ # Braintrust metadata for observability (no-op if callback not registered)
1041
+ if os.environ.get("BRAINTRUST_API_KEY"):
1042
+ params["metadata"] = {
1043
+ **params.get("metadata", {}),
1044
+ "project_name": os.environ.get("BRAINTRUST_PROJECT_NAME", "reflexio"),
1045
+ }
1046
+ params["messages"] = self._apply_prompt_caching(
1047
+ params["messages"], params["model"]
1048
+ )
1049
+
1050
+ return (
1051
+ params,
1052
+ response_format,
1053
+ parse_structured_output,
1054
+ max_retries,
1055
+ fallback_models,
1056
+ )
1057
+
1058
+ @staticmethod
1059
+ @lru_cache(maxsize=256)
1060
+ def _supports_response_schema(model: str) -> bool:
1061
+ try:
1062
+ return bool(litellm.supports_response_schema(model=model))
1063
+ except Exception:
1064
+ return False
1065
+
1066
+ def _provider_response_format(
1067
+ self,
1068
+ *,
1069
+ response_format: Any,
1070
+ model: str,
1071
+ strict_response_format: bool,
1072
+ ) -> Any:
1073
+ """Return the provider-facing response_format while preserving parser schema.
1074
+
1075
+ Callers pass a Pydantic model so local parsing stays type-safe. When
1076
+ LiteLLM says the target model supports JSON Schema response formats, we
1077
+ send an explicit strict schema to constrain generation. Unsupported
1078
+ providers keep the existing Pydantic response_format behavior.
1079
+ """
1080
+
1081
+ if (
1082
+ strict_response_format
1083
+ and is_pydantic_model(response_format)
1084
+ and self._supports_response_schema(model)
1085
+ ):
1086
+ return strict_response_format_for_model(response_format)
1087
+ return response_format
1088
+
1089
+ def _compute_cost_usd(self, response: Any, model: str | None) -> float | None:
1090
+ """Compute call cost in USD via the litellm price table.
1091
+
1092
+ Falls back to None when the provider is not mapped (local ONNX,
1093
+ claude-code CLI, etc.) rather than failing the request.
1094
+
1095
+ Args:
1096
+ response: Raw LLM response object.
1097
+ model: Fully-qualified model name used for the call.
1098
+
1099
+ Returns:
1100
+ float | None: Cost in USD, or None when unavailable.
1101
+ """
1102
+ try:
1103
+ import litellm
1104
+
1105
+ cost = litellm.completion_cost(completion_response=response, model=model)
1106
+ return float(cost) if cost else None
1107
+ except Exception:
1108
+ return None
1109
+
1110
+ def _completion_with_hard_timeout(self, params: dict[str, Any]) -> Any:
1111
+ """Run ``litellm.completion`` with a client-side wall-clock bound.
1112
+
1113
+ Some providers can exceed LiteLLM's ``timeout`` kwarg. Run the blocking
1114
+ call in a child process so the caller can fail, release locks, and
1115
+ terminate the in-flight provider request instead of waiting indefinitely.
1116
+ """
1117
+ provider_timeout = params.get("timeout", self.config.timeout)
1118
+ try:
1119
+ timeout_seconds = float(provider_timeout)
1120
+ except (TypeError, ValueError):
1121
+ timeout_seconds = float(self.config.timeout)
1122
+ grace_seconds = self._hard_timeout_grace_seconds()
1123
+ hard_timeout = max(0.001, timeout_seconds) + max(0.0, grace_seconds)
1124
+
1125
+ if not self._should_process_isolate_completion(timeout_seconds, grace_seconds):
1126
+ return litellm.completion(**params)
1127
+
1128
+ process_context = multiprocessing.get_context()
1129
+ result_queue = process_context.Queue(maxsize=1)
1130
+ process = process_context.Process(
1131
+ target=_litellm_completion_worker,
1132
+ args=(params, result_queue),
1133
+ daemon=True,
1134
+ )
1135
+ process.start()
1136
+ try:
1137
+ process.join(timeout=hard_timeout)
1138
+ if process.is_alive():
1139
+ process.terminate()
1140
+ process.join(timeout=1.0)
1141
+ if process.is_alive():
1142
+ process.kill()
1143
+ process.join(timeout=1.0)
1144
+ raise LLMHardTimeoutError(
1145
+ f"LLM request exceeded hard timeout of {hard_timeout:.3f}s "
1146
+ f"(provider timeout={provider_timeout!r})"
1147
+ )
1148
+
1149
+ try:
1150
+ status, payload = result_queue.get(timeout=1.0)
1151
+ except queue.Empty as exc:
1152
+ raise LiteLLMClientError(
1153
+ "LLM request process exited without returning a result "
1154
+ f"(exitcode={process.exitcode})"
1155
+ ) from exc
1156
+
1157
+ if status == "ok":
1158
+ return payload
1159
+ if isinstance(payload, _CompletionErrorSnapshot):
1160
+ raise LiteLLMClientError(
1161
+ f"litellm.completion raised {payload.type_name}: {payload.message}"
1162
+ )
1163
+ raise payload
1164
+ finally:
1165
+ result_queue.close()
1166
+ result_queue.join_thread()
1167
+
1168
+ def _effective_timeout_for_model(self, model: str) -> int:
1169
+ """Return the configured timeout, raised to the model's floor if one exists.
1170
+
1171
+ Args:
1172
+ model: Resolved model name (e.g. 'minimax/MiniMax-M3').
1173
+
1174
+ Returns:
1175
+ int: max(config.timeout, per-model floor). Callers that pass an
1176
+ explicit timeout kwarg bypass this entirely.
1177
+ """
1178
+ return max(self.config.timeout, _MODEL_TIMEOUT_FLOOR_SECONDS.get(model, 0))
1179
+
1180
+ def _hard_timeout_grace_seconds(self) -> float:
1181
+ raw = os.environ.get("REFLEXIO_LLM_HARD_TIMEOUT_GRACE_SECONDS", "5") or "5"
1182
+ try:
1183
+ return max(0.0, float(raw))
1184
+ except ValueError:
1185
+ self.logger.warning(
1186
+ "Invalid REFLEXIO_LLM_HARD_TIMEOUT_GRACE_SECONDS=%r; using 5",
1187
+ raw,
1188
+ )
1189
+ return 5.0
1190
+
1191
+ def _should_process_isolate_completion(
1192
+ self, timeout_seconds: float, grace_seconds: float
1193
+ ) -> bool:
1194
+ """Use process isolation for real LiteLLM calls while preserving test doubles.
1195
+
1196
+ Unit tests often monkeypatch ``litellm.completion`` with local closures
1197
+ that capture params in parent memory. Those closures cannot be observed
1198
+ through a subprocess, so only real LiteLLM functions and explicit short
1199
+ timeout tests go through the process path.
1200
+ """
1201
+ completion_module = getattr(litellm.completion, "__module__", "")
1202
+ if completion_module.startswith("litellm"):
1203
+ return True
1204
+ return timeout_seconds + grace_seconds < 1.0
1205
+
1206
+ def _log_token_usage(self, params: dict[str, Any], response: Any) -> None:
1207
+ """Log token usage with cache statistics and cost from an LLM response.
1208
+
1209
+ Args:
1210
+ params: Request parameters (for model name)
1211
+ response: LLM response object
1212
+ """
1213
+ usage = getattr(response, "usage", None)
1214
+ if not usage:
1215
+ return
1216
+
1217
+ cache_info = ""
1218
+ details = getattr(usage, "prompt_tokens_details", None)
1219
+ if details:
1220
+ cached = getattr(details, "cached_tokens", 0)
1221
+ if cached:
1222
+ cache_info = f", cached: {cached}"
1223
+ cache_creation = getattr(usage, "cache_creation_input_tokens", None)
1224
+ cache_read = getattr(usage, "cache_read_input_tokens", None)
1225
+ if cache_creation or cache_read:
1226
+ cache_info = (
1227
+ f", cache_write: {cache_creation or 0}, cache_read: {cache_read or 0}"
1228
+ )
1229
+
1230
+ cost = self._compute_cost_usd(response, params.get("model"))
1231
+ cost_suffix = f", cost: ${cost:.6f}" if cost is not None else ""
1232
+
1233
+ self.logger.info(
1234
+ "Token usage - model: %s, input: %s, output: %s, total: %s%s%s",
1235
+ params.get("model"),
1236
+ usage.prompt_tokens,
1237
+ usage.completion_tokens,
1238
+ usage.total_tokens,
1239
+ cache_info,
1240
+ cost_suffix,
1241
+ )
1242
+
1243
+ def _emit_fallback_observability(
1244
+ self, response: Any, params: dict[str, Any]
1245
+ ) -> None:
1246
+ """Surface fallback-routing info to logs and Sentry when applicable.
1247
+
1248
+ LiteLLM rewrites ``response.model`` to the model that actually served
1249
+ the call, so we detect a fallback by comparing it against the model
1250
+ we asked for. The check is best-effort: any exception inside this
1251
+ helper is swallowed so observability never breaks the request.
1252
+
1253
+ Args:
1254
+ response: The litellm completion response object.
1255
+ params: The params dict that was passed to ``litellm.completion`` —
1256
+ used to read the originally requested primary model name.
1257
+ """
1258
+ try:
1259
+ primary_model = params.get("model")
1260
+ hidden = getattr(response, "_hidden_params", {}) or {}
1261
+ served_model = (
1262
+ hidden.get("model_id")
1263
+ or hidden.get("model")
1264
+ or getattr(response, "model", None)
1265
+ )
1266
+
1267
+ if not served_model or served_model == primary_model:
1268
+ return
1269
+
1270
+ self.logger.info(
1271
+ "event=llm_fallback_used primary_model=%s served_model=%s",
1272
+ primary_model,
1273
+ served_model,
1274
+ )
1275
+
1276
+ # Local import keeps sentry out of module-init paths the tests
1277
+ # exercise without a Sentry SDK installed. sentry_sdk is an
1278
+ # enterprise-only dependency; OSS callers run without it and the
1279
+ # ImportError is intentionally absorbed by the outer except.
1280
+ import sentry_sdk # type: ignore[import-not-found]
1281
+
1282
+ sentry_sdk.set_tag("llm.fallback_used", "true")
1283
+ sentry_sdk.set_tag("llm.primary_model", str(primary_model))
1284
+ sentry_sdk.set_tag("llm.fallback_model", str(served_model))
1285
+ except Exception: # noqa: BLE001 — observability must not break the call
1286
+ return
1287
+
1288
+ def _make_request(
1289
+ self, messages: list[dict[str, Any]], **kwargs: Any
1290
+ ) -> str | BaseModel | ToolCallingChatResponse:
1291
+ """
1292
+ Make a request to the LLM, delegating retries and fallback to litellm.
1293
+
1294
+ Retry and fallback semantics are handed to ``litellm.completion`` via
1295
+ the native ``num_retries`` and ``fallbacks`` kwargs. Per the documented
1296
+ flow at https://docs.litellm.ai/docs/router_architecture, the primary
1297
+ model is tried ``num_retries+1`` times, then each fallback gets a single
1298
+ attempt. The one piece we still own at the client level is a single
1299
+ retry for ``StructuredOutputParseError``: LiteLLM cannot detect a
1300
+ post-hoc Pydantic re-validation failure because it sees a successful
1301
+ HTTP response.
1302
+
1303
+ Args:
1304
+ messages: List of messages to send.
1305
+ **kwargs: Additional parameters (response_format, max_retries,
1306
+ fallback_models, tools, etc.).
1307
+
1308
+ Returns:
1309
+ Response content as string, BaseModel instance, or
1310
+ ToolCallingChatResponse when the request was in tool-calling mode.
1311
+
1312
+ Raises:
1313
+ LiteLLMClientError: If the request fails after all retries and
1314
+ fallbacks have been exhausted by litellm.
1315
+ """
1316
+ params, response_format, parse_structured_output, max_retries, fallbacks = (
1317
+ self._build_completion_params(messages, **kwargs)
1318
+ )
1319
+
1320
+ # Hand retries + fallbacks to litellm. ``num_retries`` is the documented
1321
+ # alias for max_retries on litellm.completion.
1322
+ params["num_retries"] = max_retries
1323
+ if fallbacks:
1324
+ params["fallbacks"] = fallbacks
1325
+
1326
+ request_start = time.perf_counter()
1327
+ self.logger.info(
1328
+ "event=llm_request_start model=%s timeout=%s has_response_format=%s num_retries=%d fallbacks=%s",
1329
+ params.get("model"),
1330
+ params.get("timeout"),
1331
+ response_format is not None,
1332
+ max_retries,
1333
+ fallbacks,
1334
+ )
1335
+
1336
+ def _call_and_parse() -> str | BaseModel | ToolCallingChatResponse:
1337
+ response = self._completion_with_hard_timeout(params)
1338
+ self._emit_fallback_observability(response, params)
1339
+ message = response.choices[0].message # type: ignore[reportAttributeAccessIssue]
1340
+ content = message.content
1341
+ self._log_token_usage(params, response)
1342
+ self.logger.info(
1343
+ "event=llm_request_end model=%s timeout=%s has_response_format=%s elapsed_seconds=%.3f success=%s",
1344
+ params.get("model"),
1345
+ params.get("timeout"),
1346
+ response_format is not None,
1347
+ time.perf_counter() - request_start,
1348
+ True,
1349
+ )
1350
+
1351
+ # Tool-calling path: return a structured response instead of
1352
+ # going through _maybe_parse_structured_output.
1353
+ if "tools" in params:
1354
+ raw_usage = getattr(response, "usage", None)
1355
+ call_cost = self._compute_cost_usd(response, params.get("model"))
1356
+ return ToolCallingChatResponse(
1357
+ content=content,
1358
+ tool_calls=getattr(message, "tool_calls", None),
1359
+ finish_reason=response.choices[0].finish_reason, # type: ignore[reportAttributeAccessIssue]
1360
+ usage=raw_usage,
1361
+ cost_usd=call_cost,
1362
+ )
1363
+
1364
+ return self._maybe_parse_structured_output(
1365
+ content, # type: ignore[reportArgumentType]
1366
+ response_format,
1367
+ parse_structured_output,
1368
+ )
1369
+
1370
+ try:
1371
+ try:
1372
+ return _call_and_parse()
1373
+ except StructuredOutputParseError:
1374
+ # LiteLLM's num_retries covers API errors, but a Pydantic
1375
+ # re-validation failure happens AFTER litellm sees a
1376
+ # successful 200 — so we owe one explicit second attempt at
1377
+ # the model. PR #121 documented this as a MiniMax-M3
1378
+ # mitigation.
1379
+ self.logger.warning(
1380
+ "event=llm_parse_retry model=%s — primary returned malformed structured output, retrying once",
1381
+ params.get("model"),
1382
+ )
1383
+ return _call_and_parse()
1384
+ except LLMHardTimeoutError:
1385
+ # The hard timeout kills the litellm subprocess, so litellm's
1386
+ # num_retries never gets a chance — we owe one explicit retry
1387
+ # at this level to cover transient provider hangs.
1388
+ self.logger.warning(
1389
+ "event=llm_hard_timeout_retry model=%s — request hit hard timeout, retrying once",
1390
+ params.get("model"),
1391
+ )
1392
+ return _call_and_parse()
1393
+ except Exception as e:
1394
+ self.logger.error(
1395
+ "event=llm_request_end model=%s elapsed_seconds=%.3f success=False error_type=%s error=%s",
1396
+ params.get("model"),
1397
+ time.perf_counter() - request_start,
1398
+ type(e).__name__,
1399
+ e,
1400
+ )
1401
+ raise LiteLLMClientError(f"API call failed: {e}") from e
1402
+
1403
+ def _apply_prompt_caching(
1404
+ self, messages: list[dict[str, Any]], model: str
1405
+ ) -> list[dict[str, Any]]:
1406
+ """
1407
+ Apply prompt caching markers for supported providers.
1408
+
1409
+ For Anthropic models, transforms the system message content into content-block
1410
+ format with cache_control markers to enable prefix caching.
1411
+ For other providers, returns messages unchanged.
1412
+
1413
+ Args:
1414
+ messages: List of chat messages.
1415
+ model: Model name to determine provider.
1416
+
1417
+ Returns:
1418
+ list[dict]: Messages with cache control applied where appropriate.
1419
+ """
1420
+ model_lower = model.lower()
1421
+ # The claude-code/* custom provider routes through the Claude Code CLI,
1422
+ # which does not accept Anthropic API cache_control content blocks.
1423
+ if model_lower.startswith("claude-code/"):
1424
+ return messages
1425
+ is_anthropic = "claude" in model_lower or "anthropic" in model_lower
1426
+
1427
+ if not is_anthropic:
1428
+ return messages
1429
+
1430
+ result = []
1431
+ for msg in messages:
1432
+ if msg.get("role") == "system" and isinstance(msg.get("content"), str):
1433
+ # Transform system message to content-block format with cache_control
1434
+ result.append(
1435
+ {
1436
+ "role": "system",
1437
+ "content": [
1438
+ {
1439
+ "type": "text",
1440
+ "text": msg["content"],
1441
+ "cache_control": {"type": "ephemeral"},
1442
+ }
1443
+ ],
1444
+ }
1445
+ )
1446
+ else:
1447
+ result.append(msg)
1448
+
1449
+ return result
1450
+
1451
+ def _build_user_content(
1452
+ self,
1453
+ prompt: str,
1454
+ images: list[str | bytes | dict] | None = None,
1455
+ image_media_type: str | None = None,
1456
+ ) -> str | list[dict[str, Any]]:
1457
+ """
1458
+ Build user content with optional images.
1459
+
1460
+ Args:
1461
+ prompt: Text prompt.
1462
+ images: Optional list of images.
1463
+ image_media_type: Media type for byte images.
1464
+
1465
+ Returns:
1466
+ String for text-only, or list of content blocks for multi-modal.
1467
+ """
1468
+ if not images:
1469
+ return prompt
1470
+
1471
+ content_blocks = [{"type": "text", "text": prompt}]
1472
+
1473
+ for image in images:
1474
+ if isinstance(image, dict):
1475
+ # Already formatted content block
1476
+ content_blocks.append(image)
1477
+ elif isinstance(image, bytes):
1478
+ # Raw bytes
1479
+ media_type = image_media_type or "image/png"
1480
+ base64_data = base64.b64encode(image).decode("utf-8")
1481
+ content_blocks.append(
1482
+ self._create_image_content_block(base64_data, media_type)
1483
+ )
1484
+ elif isinstance(image, str):
1485
+ # File path or URL
1486
+ if image.startswith(("http://", "https://")):
1487
+ # URL - use directly
1488
+ content_blocks.append(
1489
+ {"type": "image_url", "image_url": {"url": image}} # type: ignore[reportArgumentType]
1490
+ )
1491
+ else:
1492
+ # File path
1493
+ base64_data, media_type = self.encode_image_to_base64(image)
1494
+ content_blocks.append(
1495
+ self._create_image_content_block(base64_data, media_type)
1496
+ )
1497
+
1498
+ return content_blocks
1499
+
1500
+ def _create_image_content_block(
1501
+ self, base64_data: str, media_type: str
1502
+ ) -> dict[str, Any]:
1503
+ """
1504
+ Create an image content block for the API.
1505
+
1506
+ Args:
1507
+ base64_data: Base64-encoded image data.
1508
+ media_type: MIME type of the image.
1509
+
1510
+ Returns:
1511
+ Image content block dictionary.
1512
+ """
1513
+ return {
1514
+ "type": "image_url",
1515
+ "image_url": {"url": f"data:{media_type};base64,{base64_data}"},
1516
+ }
1517
+
1518
+ def encode_image_to_base64(self, image_path: str) -> tuple[str, str]:
1519
+ """
1520
+ Encode an image file to base64.
1521
+
1522
+ Delegates to :func:`reflexio.server.llm.image_utils.encode_image_to_base64`
1523
+ and wraps errors as :class:`LiteLLMClientError`.
1524
+
1525
+ Args:
1526
+ image_path (str): Path to the image file.
1527
+
1528
+ Returns:
1529
+ tuple[str, str]: ``(base64_data, media_type)`` pair.
1530
+
1531
+ Raises:
1532
+ LiteLLMClientError: If the image cannot be read or format is unsupported.
1533
+ """
1534
+ try:
1535
+ return _encode_image_to_base64(image_path)
1536
+ except ImageEncodingError as exc:
1537
+ raise LiteLLMClientError(str(exc)) from exc
1538
+
1539
+ def _is_temperature_restricted_model(self, model: str) -> bool:
1540
+ """
1541
+ Check if a model has temperature restrictions (e.g., GPT-5 and Gemini 3 models only support temperature=1.0).
1542
+
1543
+ Args:
1544
+ model: Model name to check.
1545
+
1546
+ Returns:
1547
+ True if the model has temperature restrictions.
1548
+ """
1549
+ model_lower = model.lower()
1550
+ # Strip provider routing prefixes (e.g., "openrouter/openai/gpt-5-nano" -> "gpt-5-nano")
1551
+ model_name = model_lower.rsplit("/", 1)[-1]
1552
+ # Check if model starts with any of the restricted model prefixes
1553
+ return any(
1554
+ model_name.startswith(restricted) or model_name == restricted
1555
+ for restricted in self.TEMPERATURE_RESTRICTED_MODELS
1556
+ )
1557
+
1558
+ def _maybe_parse_structured_output(
1559
+ self,
1560
+ content: str,
1561
+ response_format: Any,
1562
+ parse_structured_output: bool,
1563
+ ) -> str | BaseModel:
1564
+ """
1565
+ Parse structured output if applicable.
1566
+
1567
+ Args:
1568
+ content: Raw response content.
1569
+ response_format: Expected response format (must be a Pydantic BaseModel class).
1570
+ parse_structured_output: Whether to parse the output.
1571
+
1572
+ Returns:
1573
+ String for text responses, or BaseModel instance for structured responses.
1574
+ """
1575
+ if not response_format or not parse_structured_output:
1576
+ return content
1577
+
1578
+ if content is None:
1579
+ return content
1580
+
1581
+ # If content is already a Pydantic model (some providers return parsed)
1582
+ if isinstance(content, BaseModel):
1583
+ return content
1584
+
1585
+ # Try to parse JSON and convert to Pydantic model
1586
+ # Extract JSON from markdown code blocks if present
1587
+ json_str = self._extract_json_from_string(content)
1588
+ try:
1589
+ parsed = json.loads(json_str)
1590
+
1591
+ # response_format must be a Pydantic model (validated at entry points)
1592
+ return response_format.model_validate(parsed)
1593
+ except Exception:
1594
+ # LLMs sometimes produce Python-style output (single quotes, True/False,
1595
+ # trailing commas). Try to sanitize before giving up.
1596
+ try:
1597
+ sanitized = self._sanitize_json_string(json_str)
1598
+ parsed = json.loads(sanitized)
1599
+ return response_format.model_validate(parsed)
1600
+ except Exception:
1601
+ # Last resort: json-repair can recover complete responses with
1602
+ # small syntax glitches, such as missing commas. Do not repair
1603
+ # likely truncation: the retry loop should request a fresh
1604
+ # complete response instead of accepting invented tail content.
1605
+ try:
1606
+ from json_repair import repair_json
1607
+
1608
+ if self._looks_truncated_json(json_str):
1609
+ raise StructuredOutputParseError(
1610
+ "Structured output appears truncated"
1611
+ )
1612
+
1613
+ repaired = repair_json(json_str, return_objects=True)
1614
+ return response_format.model_validate(repaired)
1615
+ except Exception as e:
1616
+ model = self.config.model
1617
+ snippet = (
1618
+ content[:200]
1619
+ if isinstance(content, str)
1620
+ else repr(content)[:200]
1621
+ )
1622
+ raise StructuredOutputParseError(
1623
+ f"Structured output parse failed for model={model!r}: {e}. "
1624
+ f"Content snippet: {snippet!r}"
1625
+ ) from e
1626
+
1627
+ def _extract_json_from_string(self, content: str) -> str:
1628
+ """
1629
+ Extract JSON from a string, handling markdown code blocks.
1630
+
1631
+ Args:
1632
+ content: String potentially containing JSON.
1633
+
1634
+ Returns:
1635
+ Extracted JSON string.
1636
+ """
1637
+ content = content.strip()
1638
+
1639
+ # Try to extract from markdown code blocks
1640
+ json_block_pattern = r"```(?:json)?\s*([\s\S]*?)```"
1641
+ matches = re.findall(json_block_pattern, content)
1642
+ if matches:
1643
+ return matches[0].strip()
1644
+
1645
+ # Try to find JSON object or array
1646
+ for start_char, end_char in [("{", "}"), ("[", "]")]:
1647
+ start_idx = content.find(start_char)
1648
+ end_idx = content.rfind(end_char)
1649
+ if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
1650
+ return content[start_idx : end_idx + 1]
1651
+
1652
+ return content
1653
+
1654
+ def _looks_truncated_json(self, json_str: str) -> bool:
1655
+ """
1656
+ Return True when a JSON-like string appears to end before it is complete.
1657
+
1658
+ This intentionally only treats content with a JSON container opener as
1659
+ truncation. Plain text that is not JSON should proceed to the normal
1660
+ parse failure path.
1661
+
1662
+ Args:
1663
+ json_str: Extracted JSON-like response text.
1664
+
1665
+ Returns:
1666
+ True if the response has unclosed containers or strings.
1667
+ """
1668
+ stripped = json_str.strip()
1669
+ start_indices = [
1670
+ idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1
1671
+ ]
1672
+ if not stripped or not start_indices:
1673
+ return False
1674
+ stripped = stripped[min(start_indices) :]
1675
+
1676
+ stack: list[str] = []
1677
+ in_str = False
1678
+ escape = False
1679
+ pairs = {"{": "}", "[": "]"}
1680
+
1681
+ for ch in stripped:
1682
+ if escape:
1683
+ escape = False
1684
+ continue
1685
+ if ch == "\\" and in_str:
1686
+ escape = True
1687
+ continue
1688
+ if ch == '"':
1689
+ in_str = not in_str
1690
+ continue
1691
+ if in_str:
1692
+ continue
1693
+ if ch in pairs:
1694
+ stack.append(pairs[ch])
1695
+ elif ch in ("}", "]") and (not stack or stack.pop() != ch):
1696
+ return False
1697
+
1698
+ return in_str or bool(stack)
1699
+
1700
+ def _sanitize_json_string(self, json_str: str) -> str:
1701
+ """
1702
+ Sanitize a JSON-like string that uses Python-style syntax into valid JSON.
1703
+
1704
+ Handles common LLM issues: single quotes, Python True/False/None,
1705
+ and trailing commas before closing braces/brackets.
1706
+
1707
+ Args:
1708
+ json_str: A JSON-like string that may contain Python-style syntax.
1709
+
1710
+ Returns:
1711
+ A sanitized string closer to valid JSON.
1712
+ """
1713
+ s = json_str
1714
+
1715
+ # Walk character-by-character to:
1716
+ # 1. Replace single-quoted strings with double-quoted strings
1717
+ # 2. Replace Python True/False/None with JSON true/false/null ONLY outside strings
1718
+ # 3. Handle escaped apostrophes inside single-quoted strings (e.g. 'didn\'t')
1719
+ # 4. Escape literal double quotes that end up inside double-quoted strings
1720
+ result = []
1721
+ in_double = False
1722
+ in_single = False
1723
+ i = 0
1724
+ while i < len(s):
1725
+ ch = s[i]
1726
+ if ch == "\\" and (in_double or in_single):
1727
+ # Escaped character inside a string
1728
+ if i + 1 < len(s):
1729
+ next_ch = s[i + 1]
1730
+ if in_single and next_ch == "'":
1731
+ # \' inside single-quoted string → literal apostrophe
1732
+ # In JSON double-quoted strings, apostrophe needs no escape
1733
+ result.append("'")
1734
+ i += 2
1735
+ continue
1736
+ result.append(ch)
1737
+ result.append(next_ch)
1738
+ i += 2
1739
+ continue
1740
+ result.append(ch)
1741
+ elif ch == '"' and not in_single:
1742
+ in_double = not in_double
1743
+ result.append(ch)
1744
+ elif ch == "'" and not in_double:
1745
+ in_single = not in_single
1746
+ result.append('"') # swap single → double
1747
+ else:
1748
+ # Escape unescaped double quotes inside single-quoted strings
1749
+ # (they become part of a double-quoted JSON string)
1750
+ if in_single and ch == '"':
1751
+ result.append('\\"')
1752
+ else:
1753
+ result.append(ch)
1754
+ i += 1
1755
+ s = "".join(result)
1756
+
1757
+ # Replace Python booleans/None with JSON equivalents only outside quoted strings.
1758
+ # We walk the already-double-quoted result so we only need to track double quotes.
1759
+ output = []
1760
+ in_str = False
1761
+ j = 0
1762
+ while j < len(s):
1763
+ if s[j] == "\\" and in_str:
1764
+ output.append(s[j : j + 2])
1765
+ j += 2
1766
+ continue
1767
+ if s[j] == '"':
1768
+ in_str = not in_str
1769
+ output.append(s[j])
1770
+ j += 1
1771
+ continue
1772
+ if not in_str:
1773
+ matched = False
1774
+ for py_val, json_val in _PYTHON_TO_JSON_REPLACEMENTS.items():
1775
+ if s[j : j + len(py_val)] == py_val:
1776
+ # Check word boundaries
1777
+ before = s[j - 1] if j > 0 else " "
1778
+ after = s[j + len(py_val)] if j + len(py_val) < len(s) else " "
1779
+ if (
1780
+ not before.isalnum()
1781
+ and before != "_"
1782
+ and not after.isalnum()
1783
+ and after != "_"
1784
+ ):
1785
+ output.append(json_val)
1786
+ j += len(py_val)
1787
+ matched = True
1788
+ break
1789
+ if not matched:
1790
+ output.append(s[j])
1791
+ j += 1
1792
+ else:
1793
+ output.append(s[j])
1794
+ j += 1
1795
+ s = "".join(output)
1796
+
1797
+ # Remove trailing commas before } or ]
1798
+ return re.sub(r",\s*([}\]])", r"\1", s)
1799
+
1800
+ def update_config(self, **kwargs) -> None:
1801
+ """
1802
+ Update client configuration.
1803
+
1804
+ Args:
1805
+ **kwargs: Configuration parameters to update (model, temperature, etc.).
1806
+ """
1807
+ for key, value in kwargs.items():
1808
+ if hasattr(self.config, key):
1809
+ setattr(self.config, key, value)
1810
+ self.logger.debug("Updated config: %s = %s", key, value)
1811
+ # Invalidate the embedding-default cache when the provider
1812
+ # surface changes — resolve_model_name(EMBEDDING) reads
1813
+ # api_key_config, so a swap must force a re-detect.
1814
+ if key == "api_key_config":
1815
+ self._default_embedding_model = None
1816
+ else:
1817
+ self.logger.warning("Unknown config parameter: %s", key)
1818
+
1819
+ def get_model(self) -> str:
1820
+ """
1821
+ Get the current model being used.
1822
+
1823
+ Returns:
1824
+ Model name string.
1825
+ """
1826
+ return self.config.model
1827
+
1828
+ def get_config(self) -> LiteLLMConfig:
1829
+ """
1830
+ Get the current configuration.
1831
+
1832
+ Returns:
1833
+ Current LiteLLM configuration.
1834
+ """
1835
+ return self.config
1836
+
1837
+
1838
+ def create_litellm_client(
1839
+ model: str,
1840
+ temperature: float = 0.7,
1841
+ max_tokens: int | None = None,
1842
+ timeout: int = 60,
1843
+ max_retries: int = 3,
1844
+ api_key_config: APIKeyConfig | None = None,
1845
+ **kwargs,
1846
+ ) -> LiteLLMClient:
1847
+ """
1848
+ Create a LiteLLM client with simplified parameters.
1849
+
1850
+ Args:
1851
+ model: Model name to use (e.g., 'gpt-4o', 'claude-3-5-sonnet-20241022').
1852
+ temperature: Temperature for response generation.
1853
+ max_tokens: Maximum tokens to generate.
1854
+ timeout: Request timeout in seconds.
1855
+ max_retries: Maximum retry attempts.
1856
+ api_key_config: Optional API key configuration from Config (overrides env vars).
1857
+ **kwargs: Additional configuration parameters.
1858
+
1859
+ Returns:
1860
+ Configured LiteLLM client.
1861
+ """
1862
+ config = LiteLLMConfig(
1863
+ model=model,
1864
+ temperature=temperature,
1865
+ max_tokens=max_tokens,
1866
+ timeout=timeout,
1867
+ max_retries=max_retries,
1868
+ api_key_config=api_key_config,
1869
+ **kwargs,
1870
+ )
1871
+ return LiteLLMClient(config)