claude-smart 0.2.41 → 0.2.43

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