claude-smart 0.2.48 → 0.2.50

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 (193) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.md +11 -40
  3. package/bin/claude-smart.js +393 -55
  4. package/package.json +3 -2
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/.coverage +0 -0
  8. package/plugin/README.md +4 -3
  9. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +4 -2
  10. package/plugin/dashboard/app/dashboard/page.tsx +6 -1
  11. package/plugin/dashboard/app/preferences/[id]/page.tsx +18 -6
  12. package/plugin/dashboard/app/preferences/page.tsx +32 -35
  13. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +16 -1
  14. package/plugin/dashboard/app/sessions/page.tsx +2 -0
  15. package/plugin/dashboard/app/skills/page.tsx +65 -50
  16. package/plugin/dashboard/app/skills/project/[id]/page.tsx +17 -8
  17. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +1 -6
  18. package/plugin/dashboard/components/common/host-badge.tsx +118 -0
  19. package/plugin/dashboard/components/common/learning-application-badge.tsx +34 -0
  20. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  21. package/plugin/dashboard/components/common/page-header.tsx +3 -3
  22. package/plugin/dashboard/lib/config-file.ts +5 -1
  23. package/plugin/dashboard/lib/host-attribution.ts +62 -0
  24. package/plugin/dashboard/lib/session-reader.ts +40 -2
  25. package/plugin/dashboard/lib/types.ts +7 -1
  26. package/plugin/opencode/dist/server.mjs +60 -2
  27. package/plugin/opencode/server.mts +64 -2
  28. package/plugin/pyproject.toml +2 -2
  29. package/plugin/scripts/_lib.sh +255 -7
  30. package/plugin/scripts/backend-python-runner.py +46 -0
  31. package/plugin/scripts/backend-service.sh +766 -123
  32. package/plugin/scripts/codex-hook.js +79 -229
  33. package/plugin/scripts/dashboard-open.sh +6 -4
  34. package/plugin/scripts/dashboard-service.sh +118 -137
  35. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  36. package/plugin/scripts/hook_entry.sh +3 -0
  37. package/plugin/scripts/opencode-claude-compat.js +8 -1
  38. package/plugin/scripts/smart-install.sh +116 -21
  39. package/plugin/src/README.md +1 -1
  40. package/plugin/src/claude_smart/cli.py +93 -29
  41. package/plugin/src/claude_smart/context_inject.py +3 -0
  42. package/plugin/src/claude_smart/env_config.py +56 -11
  43. package/plugin/src/claude_smart/events/post_tool.py +2 -1
  44. package/plugin/src/claude_smart/events/session_end.py +2 -1
  45. package/plugin/src/claude_smart/events/stop.py +3 -0
  46. package/plugin/src/claude_smart/events/user_prompt.py +2 -1
  47. package/plugin/src/claude_smart/internal_call.py +5 -2
  48. package/plugin/src/claude_smart/optimizer_assistant.py +59 -13
  49. package/plugin/src/claude_smart/publish.py +59 -7
  50. package/plugin/src/claude_smart/reflexio_adapter.py +137 -14
  51. package/plugin/src/claude_smart/runtime.py +15 -6
  52. package/plugin/src/claude_smart/state.py +211 -52
  53. package/plugin/uv.lock +5 -5
  54. package/plugin/vendor/reflexio/.env.example +13 -0
  55. package/plugin/vendor/reflexio/reflexio/README.md +7 -3
  56. package/plugin/vendor/reflexio/reflexio/__init__.py +12 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  58. package/plugin/vendor/reflexio/reflexio/client/client.py +166 -9
  59. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py +10 -3
  60. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/tests/test_state.py +28 -0
  61. package/plugin/vendor/reflexio/reflexio/lib/_search.py +41 -0
  62. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +195 -25
  63. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +7 -1
  64. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +2 -1
  65. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +63 -4
  66. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +1 -0
  67. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +8 -1
  68. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  69. package/plugin/vendor/reflexio/reflexio/server/README.md +22 -4
  70. package/plugin/vendor/reflexio/reflexio/server/__init__.py +18 -8
  71. package/plugin/vendor/reflexio/reflexio/server/__main__.py +6 -0
  72. package/plugin/vendor/reflexio/reflexio/server/api.py +79 -5
  73. package/plugin/vendor/reflexio/reflexio/server/billing_meter.py +263 -3
  74. package/plugin/vendor/reflexio/reflexio/server/callback_executor.py +164 -0
  75. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +81 -81
  76. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +28 -0
  77. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +63 -6
  78. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +19 -52
  79. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1 -0
  80. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +28 -0
  81. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +9 -1
  82. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedder_warmup.py +329 -0
  83. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +85 -10
  84. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +199 -2
  85. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +77 -9
  86. package/plugin/vendor/reflexio/reflexio/server/org_fanout.py +184 -0
  87. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +1 -1
  88. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.1.0.prompt.md +32 -0
  89. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.3.prompt.md +1 -1
  90. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.4.0.prompt.md +63 -0
  91. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +1 -1
  92. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v2.0.0.prompt.md +30 -0
  93. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_impact/v1.0.0.prompt.md +51 -0
  94. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/retrieved_learning_relevance/v1.0.0.prompt.md +39 -0
  95. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +1 -1
  96. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.1.0.prompt.md +43 -0
  97. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +3 -3
  98. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +122 -28
  99. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +63 -2
  100. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +22 -3
  101. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +64 -3
  102. package/plugin/vendor/reflexio/reflexio/server/services/README.md +6 -4
  103. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/README.md +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +41 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/components/retrieved_learning_evaluator.py +554 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +35 -1
  107. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/runner.py +253 -101
  108. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/scheduler.py +5 -7
  109. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/service.py +27 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +11 -5
  111. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +4 -4
  112. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +6 -2
  113. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +255 -74
  114. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +72 -0
  115. package/plugin/vendor/reflexio/reflexio/server/services/deferred_learning_plan.py +270 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +262 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/hero_state.py +2 -11
  120. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/components/rule_attribution.py +6 -2
  121. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +17 -13
  122. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +18 -3
  123. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +12 -1
  124. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +1 -1
  125. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +2 -1
  126. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +1 -3
  127. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +66 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +640 -80
  129. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +2 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +179 -99
  131. package/plugin/vendor/reflexio/reflexio/server/services/lineage/vector_backfill_sweep.py +139 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +66 -27
  133. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_trigger.py +177 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -2
  135. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +360 -49
  136. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/extractor.py +27 -30
  137. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_edit_apply.py +8 -1
  138. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +137 -69
  139. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +20 -1
  140. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +13 -6
  141. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +40 -25
  142. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/consolidator.py +12 -39
  143. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +30 -35
  144. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +166 -66
  145. package/plugin/vendor/reflexio/reflexio/server/services/reflection/service.py +457 -107
  146. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/session_dedup.py +127 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/temporal.py +104 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/dispatcher.py +139 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +16 -6
  150. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/worker.py +137 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +12 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/lifecycle_filters.py +54 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +41 -3
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +40 -2
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +2 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +190 -2
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +11 -4
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +28 -1
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +414 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -5
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +4 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +16 -5
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +86 -2
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +104 -42
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +7 -1
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +59 -7
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +430 -5
  169. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  170. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +57 -12
  171. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +197 -12
  172. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +70 -11
  173. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +17 -0
  174. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  175. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +9 -2
  176. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +269 -0
  177. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +7 -0
  178. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_retrieval_log.py +3 -1
  179. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +4 -0
  180. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +12 -1
  181. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/evaluation_state_keys.py +78 -0
  182. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +38 -0
  183. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +171 -0
  184. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +52 -1
  185. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +82 -1
  186. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_profile_store.py +74 -2
  187. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/retrieved_learning_state.py +226 -0
  188. package/plugin/vendor/reflexio/reflexio/server/services/tagging/tagging_scheduler.py +5 -6
  189. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +209 -29
  190. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +3 -0
  191. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +61 -0
  192. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +33 -0
  193. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
@@ -197,6 +197,24 @@ def _truncate_for_embedding(
197
197
  return encoding.decode(tokens[:max_tokens])
198
198
 
199
199
 
200
+ def _embedding_error(message: str, mode: str) -> LiteLLMClientError:
201
+ """Build a ``LiteLLMClientError`` and log it tagged with the resolved mode.
202
+
203
+ Attaching ``mode`` (daemon-path vs in-process-path vs cloud) to the log makes
204
+ embedding failures attributable to a routing decision. The exception message
205
+ itself is unchanged so existing callers/tests keep matching on it.
206
+
207
+ Args:
208
+ message (str): The exception message (also logged).
209
+ mode (str): The resolved embedding provider mode.
210
+
211
+ Returns:
212
+ LiteLLMClientError: The exception to raise (``raise ... from e``).
213
+ """
214
+ _LOGGER.warning("%s (mode=%s)", message, mode, extra={"mode": mode})
215
+ return LiteLLMClientError(message)
216
+
217
+
200
218
  class EmbeddingMixin:
201
219
  """Embedding dispatch (service / Nomic / local ONNX / litellm) for ``LiteLLMClient``.
202
220
 
@@ -251,6 +269,10 @@ class EmbeddingMixin:
251
269
  """
252
270
  Get embedding vector for the given text.
253
271
 
272
+ Thin wrapper over ``get_embeddings`` — the single- and batch-text paths
273
+ share one dispatch body so their routing, truncation, and error handling
274
+ cannot drift.
275
+
254
276
  Args:
255
277
  text: The text to get embedding for.
256
278
  model: Optional embedding model. When omitted, the model is
@@ -265,73 +287,7 @@ class EmbeddingMixin:
265
287
  Raises:
266
288
  LiteLLMClientError: If embedding generation fails.
267
289
  """
268
- embedding_model = model or self._resolve_default_embedding_model()
269
- mode = embedding_provider_mode(embedding_model)
270
- if mode == "off":
271
- raise EmbeddingUnavailableError("Embedding provider is disabled")
272
- if should_use_embedding_service(embedding_model):
273
- return get_service_embeddings(
274
- [text], model=embedding_model, dimensions=dimensions
275
- )[0]
276
-
277
- # local/nomic-embed-* must stay on the Nomic provider (137M params,
278
- # 768d Matryoshka-truncated to 512). Falling through to MiniLM would
279
- # mix embedding models inside existing vector stores.
280
- if _is_nomic_model(embedding_model):
281
- _reject_cloud_mode(embedding_model, mode)
282
- try:
283
- return NomicEmbedder.get().embed([text])[0]
284
- except Exception as e:
285
- raise LiteLLMClientError(
286
- f"Nomic embedding generation failed: {str(e)}"
287
- ) from e
288
-
289
- # local/* models route through the in-process ONNX embedder — no
290
- # network call, no litellm API, no tiktoken truncation (the embedder
291
- # applies its own token cap). The dispatch is gated solely on
292
- # ``chromadb`` being importable; the env-var opt-in (claude-smart's
293
- # ``CLAUDE_SMART_USE_LOCAL_EMBEDDING``) is enforced earlier in the
294
- # auto-detection layer (see ``model_defaults._auto_detect_model``).
295
- if embedding_model.startswith("local/"):
296
- _reject_cloud_mode(embedding_model, mode)
297
- if not _is_chromadb_importable():
298
- raise LiteLLMClientError(
299
- f"Embedding model {embedding_model!r} requires chromadb. "
300
- "Run `pip install chromadb`."
301
- )
302
- try:
303
- return LocalEmbedder.get().embed([text])[0]
304
- except Exception as e:
305
- raise LiteLLMClientError(
306
- f"Local embedding generation failed: {str(e)}"
307
- ) from e
308
-
309
- text = _truncate_for_embedding(text, embedding_model)
310
-
311
- try:
312
- params = {"model": embedding_model, "input": [text]}
313
- if dimensions:
314
- params["dimensions"] = dimensions
315
-
316
- # Resolve and add API key configuration if provided (overrides env vars)
317
- api_key, api_base, api_version = self._resolve_api_key(
318
- embedding_model, for_embedding=True
319
- )
320
- if api_key:
321
- params["api_key"] = api_key
322
- if api_base:
323
- params["api_base"] = api_base
324
- if api_version:
325
- params["api_version"] = api_version
326
-
327
- response = litellm.embedding(
328
- **params,
329
- timeout=self.config.timeout,
330
- num_retries=self.config.max_retries,
331
- )
332
- return response.data[0]["embedding"]
333
- except Exception as e:
334
- raise LiteLLMClientError(f"Embedding generation failed: {str(e)}") from e
290
+ return self._embed_texts([text], model, dimensions, batch=False)[0]
335
291
 
336
292
  def get_embeddings(
337
293
  self,
@@ -358,44 +314,86 @@ class EmbeddingMixin:
358
314
  """
359
315
  if not texts:
360
316
  return []
317
+ return self._embed_texts(list(texts), model, dimensions, batch=True)
361
318
 
319
+ def _embed_texts(
320
+ self,
321
+ texts: list[str],
322
+ model: str | None,
323
+ dimensions: int | None,
324
+ *,
325
+ batch: bool,
326
+ ) -> list[list[float]]:
327
+ """Shared embedding dispatch for ``get_embedding`` and ``get_embeddings``.
328
+
329
+ ``get_embedding`` passes a single-element list and unwraps ``[0]``;
330
+ ``get_embeddings`` passes the whole list. Behavior is identical between
331
+ the two callers except the error-message wording, which ``batch``
332
+ selects ("batch" wording matches the historical batch-path messages).
333
+
334
+ Args:
335
+ texts: Non-empty list of texts to embed (caller guarantees non-empty).
336
+ model: Optional embedding model; auto-detected when ``None``.
337
+ dimensions: Optional embedding dimensions.
338
+ batch: Selects the batch vs single error-message wording.
339
+
340
+ Returns:
341
+ One embedding vector per input text, in input order.
342
+
343
+ Raises:
344
+ EmbeddingUnavailableError: If the embedding provider is disabled.
345
+ LiteLLMClientError: If embedding generation fails.
346
+ """
362
347
  embedding_model = model or self._resolve_default_embedding_model()
363
348
  mode = embedding_provider_mode(embedding_model)
364
349
  if mode == "off":
365
350
  raise EmbeddingUnavailableError("Embedding provider is disabled")
366
351
  if should_use_embedding_service(embedding_model):
367
352
  return get_service_embeddings(
368
- list(texts), model=embedding_model, dimensions=dimensions
353
+ texts, model=embedding_model, dimensions=dimensions
369
354
  )
370
355
 
371
- # See matching short-circuits in get_embedding above.
356
+ # local/nomic-embed-* must stay on the Nomic provider (137M params,
357
+ # 768d Matryoshka-truncated to 512). Falling through to MiniLM would
358
+ # mix embedding models inside existing vector stores.
372
359
  if _is_nomic_model(embedding_model):
373
360
  _reject_cloud_mode(embedding_model, mode)
374
361
  try:
375
- return NomicEmbedder.get().embed(list(texts))
362
+ return NomicEmbedder.get().embed(texts)
376
363
  except Exception as e:
377
- raise LiteLLMClientError(
378
- f"Nomic batch embedding generation failed: {str(e)}"
364
+ raise _embedding_error(
365
+ f"Nomic{' batch' if batch else ''} embedding generation "
366
+ f"failed: {str(e)}",
367
+ mode,
379
368
  ) from e
380
369
 
370
+ # local/* models route through the in-process ONNX embedder — no
371
+ # network call, no litellm API, no tiktoken truncation (the embedder
372
+ # applies its own token cap). The dispatch is gated solely on
373
+ # ``chromadb`` being importable; the env-var opt-in (claude-smart's
374
+ # ``CLAUDE_SMART_USE_LOCAL_EMBEDDING``) is enforced earlier in the
375
+ # auto-detection layer (see ``model_defaults._auto_detect_model``).
381
376
  if embedding_model.startswith("local/"):
382
377
  _reject_cloud_mode(embedding_model, mode)
383
378
  if not _is_chromadb_importable():
384
- raise LiteLLMClientError(
379
+ raise _embedding_error(
385
380
  f"Embedding model {embedding_model!r} requires chromadb. "
386
- "Run `pip install chromadb`."
381
+ "Run `pip install chromadb`.",
382
+ mode,
387
383
  )
388
384
  try:
389
- return LocalEmbedder.get().embed(list(texts))
385
+ return LocalEmbedder.get().embed(texts)
390
386
  except Exception as e:
391
- raise LiteLLMClientError(
392
- f"Local batch embedding generation failed: {str(e)}"
387
+ raise _embedding_error(
388
+ f"Local{' batch' if batch else ''} embedding generation "
389
+ f"failed: {str(e)}",
390
+ mode,
393
391
  ) from e
394
392
 
395
- texts = [_truncate_for_embedding(t, embedding_model) for t in texts]
393
+ truncated = [_truncate_for_embedding(t, embedding_model) for t in texts]
396
394
 
397
395
  try:
398
- params = {"model": embedding_model, "input": texts}
396
+ params = {"model": embedding_model, "input": truncated}
399
397
  if dimensions:
400
398
  params["dimensions"] = dimensions
401
399
 
@@ -419,6 +417,8 @@ class EmbeddingMixin:
419
417
  sorted_data = sorted(response.data, key=lambda x: x["index"])
420
418
  return [item["embedding"] for item in sorted_data]
421
419
  except Exception as e:
422
- raise LiteLLMClientError(
423
- f"Batch embedding generation failed: {str(e)}"
420
+ raise _embedding_error(
421
+ f"{'Batch embedding' if batch else 'Embedding'} generation "
422
+ f"failed: {str(e)}",
423
+ mode,
424
424
  ) from e
@@ -141,9 +141,37 @@ def _picklable_completion_result(response: Any) -> Any:
141
141
  return response
142
142
 
143
143
 
144
+ def _reset_llm_client_state_after_fork() -> None:
145
+ """Drop litellm's module-level HTTP client cache in the forked CHILD.
146
+
147
+ ``_litellm_completion_worker`` runs in a ``multiprocessing`` fork child, which
148
+ inherits a *copy* of the parent's ``litellm.in_memory_llm_clients_cache`` —
149
+ including any warm keep-alive HTTPS connection the parent held. Reusing such
150
+ an inherited socket in the child shares one TLS/HTTP connection across two
151
+ processes and corrupts the child's first completion (surfaces as
152
+ ``[SSL] record layer failure`` / ``Server disconnected`` / a garbled request
153
+ the provider rejects as missing auth). Clearing the cache here forces the
154
+ child to build a fresh client (and socket) for its one completion. Best-effort
155
+ and fully guarded: a litellm build without these attributes is a no-op, never
156
+ a crash. Only the child's copy is mutated, so the parent cache is untouched.
157
+ """
158
+ try:
159
+ cache = getattr(litellm, "in_memory_llm_clients_cache", None)
160
+ if cache is not None:
161
+ flush = getattr(cache, "flush_cache", None)
162
+ if callable(flush):
163
+ flush()
164
+ cache_dict = getattr(cache, "cache_dict", None)
165
+ if isinstance(cache_dict, dict):
166
+ cache_dict.clear()
167
+ except Exception: # noqa: BLE001, S110 — a reset failure must never break the call
168
+ pass
169
+
170
+
144
171
  def _litellm_completion_worker(
145
172
  params: dict[str, Any], result_queue: multiprocessing.Queue
146
173
  ) -> None:
174
+ _reset_llm_client_state_after_fork()
147
175
  try:
148
176
  result_queue.put(
149
177
  ("ok", _picklable_completion_result(litellm.completion(**params)))
@@ -53,7 +53,11 @@ from reflexio.server.llm.image_utils import (
53
53
  encode_image_to_base64 as _encode_image_to_base64,
54
54
  )
55
55
  from reflexio.server.llm.llm_utils import is_pydantic_model
56
- from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
56
+ from reflexio.server.llm.model_defaults import (
57
+ ModelRole,
58
+ default_max_tokens_for_model,
59
+ resolve_model_name,
60
+ )
57
61
 
58
62
  if TYPE_CHECKING:
59
63
  from reflexio.server.llm._litellm_types import LiteLLMConfig
@@ -74,6 +78,36 @@ _MODEL_TIMEOUT_FLOOR_SECONDS: dict[str, int] = {
74
78
  }
75
79
 
76
80
 
81
+ # Upstream-provider errors that are EXPECTED and transient. By the time one of
82
+ # these reaches the request handler the fallback ladder is already exhausted,
83
+ # but the caller — not the client — owns fatality, and many callers degrade
84
+ # gracefully (FTS fallback for document expansion, skip-dedup for profile
85
+ # consolidation). Log these at WARNING so a flaky provider (e.g. minimax
86
+ # timeouts / 529 overload) can't flood ERROR-level alerts for handled failures;
87
+ # genuinely-unexpected errors (bugs, auth, malformed structured output) stay
88
+ # ERROR. Classified by exception TYPE NAME to avoid importing the heavy
89
+ # ``litellm``/``openai`` exception hierarchies at module import.
90
+ _TRANSIENT_LLM_ERROR_NAMES: frozenset[str] = frozenset(
91
+ {
92
+ "Timeout",
93
+ "APITimeoutError",
94
+ "APIConnectionError",
95
+ "RateLimitError",
96
+ "InternalServerError",
97
+ "ServiceUnavailableError",
98
+ }
99
+ )
100
+
101
+
102
+ def _is_expected_transient_llm_error(exc: BaseException) -> bool:
103
+ """True for expected transient upstream failures (timeout / connection /
104
+ rate-limit / overload), including our own ``LLMHardTimeoutError`` (a
105
+ ``TimeoutError`` subclass raised when a provider hang is killed)."""
106
+ if isinstance(exc, TimeoutError): # incl. LLMHardTimeoutError
107
+ return True
108
+ return type(exc).__name__ in _TRANSIENT_LLM_ERROR_NAMES
109
+
110
+
77
111
  class TextGenerationMixin:
78
112
  """Chat/response generation, completion-param build, hard-timeout, cost, multimodal.
79
113
 
@@ -293,7 +327,10 @@ class TextGenerationMixin:
293
327
  tool_choice = kwargs.pop("tool_choice", None)
294
328
  model_role: ModelRole | None = kwargs.pop("model_role", None)
295
329
 
296
- actual_model = kwargs.pop("model", self.config.model)
330
+ # An explicit ``model=None`` means "use the config default" — callers
331
+ # like the eval judges forward an optional model straight through, and
332
+ # a literal None would crash on ``.lower()`` during key resolution.
333
+ actual_model = kwargs.pop("model", None) or self.config.model
297
334
 
298
335
  # model_role takes priority over the default model but falls through
299
336
  # to the custom_endpoint override below (highest priority).
@@ -322,8 +359,16 @@ class TextGenerationMixin:
322
359
  }
323
360
 
324
361
  # Drop any fallback entry that points back at the primary — sending the
325
- # same broken endpoint twice never helps.
326
- fallback_models = [m for m in fallback_models_raw if m != actual_model]
362
+ # same broken endpoint twice never helps. Also drop in-process ``local/*``
363
+ # embedding models: they have no litellm completion route (they are served
364
+ # in-process by ``_litellm_embedding.py``), so litellm would raise
365
+ # ``BadRequestError: LLM Provider NOT provided`` deep inside its fallback
366
+ # ladder if one ever landed in this list (Sentry PYTHON-FASTAPI-CV).
367
+ fallback_models = [
368
+ m
369
+ for m in fallback_models_raw
370
+ if m != actual_model and not m.startswith("local/")
371
+ ]
327
372
 
328
373
  temperature = kwargs.pop("temperature", self.config.temperature)
329
374
  if self._is_temperature_restricted_model(actual_model):
@@ -357,6 +402,10 @@ class TextGenerationMixin:
357
402
  params["temperature"] = 0.0
358
403
 
359
404
  max_tokens = kwargs.pop("max_tokens", self.config.max_tokens)
405
+ if max_tokens is None:
406
+ # Provider-level guard: some providers (MiniMax-M3) stall into the
407
+ # request timeout when max_tokens is omitted. See model_defaults.
408
+ max_tokens = default_max_tokens_for_model(actual_model)
360
409
  if max_tokens:
361
410
  params["max_tokens"] = max_tokens
362
411
  if self.config.top_p != 1.0:
@@ -523,7 +572,7 @@ class TextGenerationMixin:
523
572
 
524
573
  # The loop only exits with a drained result (every no-result path
525
574
  # above raises); the assert makes that invariant explicit for pyright.
526
- assert result is not None
575
+ assert result is not None # noqa: S101
527
576
  status, payload = result
528
577
  # The payload is drained, so the feeder is unblocked and the child can
529
578
  # exit. Reap it (terminating if it lingers) to avoid a zombie.
@@ -815,7 +864,15 @@ class TextGenerationMixin:
815
864
  )
816
865
  return _call_and_parse()
817
866
  except Exception as e:
818
- self.logger.error(
867
+ # Expected transient upstream failures (provider timeout / connection
868
+ # / rate-limit / overload) log at WARNING — callers own fatality and
869
+ # most degrade gracefully; only genuinely-unexpected errors are ERROR.
870
+ log = (
871
+ self.logger.warning
872
+ if _is_expected_transient_llm_error(e)
873
+ else self.logger.error
874
+ )
875
+ log(
819
876
  "event=llm_request_end model=%s elapsed_seconds=%.3f success=False error_type=%s error=%s",
820
877
  params.get("model"),
821
878
  time.perf_counter() - request_start,
@@ -34,29 +34,19 @@ _SUPPORTED_MODELS = {
34
34
  _ACTIVE_MODEL: str | None = None
35
35
  _ACTIVE_MODEL_LOCK = threading.Lock()
36
36
 
37
- # The underlying sentence-transformers / ONNX embedders share internal buffers
38
- # and are NOT thread-safe: concurrent ``.embed()`` calls interleave and corrupt
39
- # each other's padding/attention tensors (observed under concurrent publishes as
40
- # ``RuntimeError: The size of tensor a (62) must match the size of tensor b (61)
41
- # at non-singleton dimension 1``). The ``_ENCODE_SEMAPHORE`` below only caps how
42
- # many requests run at once (for memory), it does NOT serialize them, so this
43
- # lock guards the actual model inference. Throughput comes from micro-batch
44
- # coalescing (many texts per encode), not from parallel encodes on one model.
45
- _MODEL_ENCODE_LOCK = threading.Lock()
46
-
47
37
  DEFAULT_OSS_EMBEDDING_MODEL = MINILM_MODEL
48
38
 
49
- # Bound how many embed/encode calls run at once. The endpoint is a sync ``def``
50
- # served from Starlette's threadpool, so without a guard a burst of requests
51
- # would run that many model.encode() calls in parallel, stacking their
52
- # activation memory and OOM-killing the daemon. The semaphore caps simultaneous
53
- # encodes; excess requests block on acquire() and are picked up when a slot
54
- # frees — they queue, they are never rejected. Pair with a small encode
55
- # batch_size so each in-flight encode stays cheap.
39
+ # Bound how many micro-batch processor threads run at once. The endpoint is a
40
+ # sync ``def`` served from Starlette's threadpool, so without a cap a burst of
41
+ # requests would spawn a processor per request and stack their activation
42
+ # memory, OOM-killing the daemon. This caps concurrent processors; excess
43
+ # requests queue on the micro-batch condition and are picked up when a slot
44
+ # frees — they are never rejected. The embedder singletons (NomicEmbedder /
45
+ # LocalEmbedder) serialize the actual model.encode() internally — the shared
46
+ # models are not thread-safe — so no encode lock or semaphore is needed here.
47
+ # Pair with a small encode batch_size so each in-flight encode stays cheap.
56
48
  _DEFAULT_MAX_CONCURRENCY = 4
57
49
  _ENV_MAX_CONCURRENCY = "REFLEXIO_EMBED_MAX_CONCURRENCY"
58
- _ENCODE_SEMAPHORE: threading.BoundedSemaphore | None = None
59
- _ENCODE_SEMAPHORE_LOCK = threading.Lock()
60
50
 
61
51
  # Opportunistically coalesce concurrent small requests before calling
62
52
  # model.encode(). This keeps request concurrency thread-based while giving the
@@ -111,16 +101,6 @@ def _micro_batch_max_texts() -> int:
111
101
  )
112
102
 
113
103
 
114
- def _encode_semaphore() -> threading.BoundedSemaphore:
115
- """Return the process-wide encode semaphore, building it on first use."""
116
- global _ENCODE_SEMAPHORE
117
- if _ENCODE_SEMAPHORE is None:
118
- with _ENCODE_SEMAPHORE_LOCK:
119
- if _ENCODE_SEMAPHORE is None:
120
- _ENCODE_SEMAPHORE = threading.BoundedSemaphore(_max_concurrency())
121
- return _ENCODE_SEMAPHORE
122
-
123
-
124
104
  class EmbeddingRequest(BaseModel):
125
105
  model: str
126
106
  input: str | list[str]
@@ -313,29 +293,16 @@ def _process_micro_batch(jobs: list[_EmbeddingJob]) -> None:
313
293
 
314
294
 
315
295
  def _encode_texts_now(model: str, texts: list[str]) -> list[list[float]]:
316
- semaphore = _encode_semaphore()
317
- # Non-blocking probe purely for observability: if no slot is free, this
318
- # request will have to wait. The real acquire below blocks until a slot
319
- # opens, so the request queues and is picked up later — never rejected.
320
- if not semaphore.acquire(blocking=False):
321
- logger.info(
322
- "Embedding request queued; all %d encode slots busy",
323
- _max_concurrency(),
324
- )
325
- semaphore.acquire()
326
- try:
327
- # Serialize the actual inference: the shared embedder models are not
328
- # thread-safe, so concurrent encodes race and produce tensor-shape
329
- # errors. The semaphore (above) bounds memory; this lock bounds the
330
- # model to one in-flight encode at a time.
331
- with _MODEL_ENCODE_LOCK:
332
- if is_nomic_model(model):
333
- return NomicEmbedder.get().embed(texts)
334
- if model == MINILM_MODEL:
335
- return LocalEmbedder.get().embed(texts)
336
- raise HTTPException(status_code=400, detail=f"Unsupported model: {model}")
337
- finally:
338
- semaphore.release()
296
+ # The embedder singletons serialize their own model.encode() internally
297
+ # (the shared sentence-transformers / ONNX models are not thread-safe see
298
+ # NomicEmbedder / LocalEmbedder). Concurrency is already bounded by the
299
+ # micro-batch processor cap (``_max_concurrency``), so no extra encode
300
+ # lock/semaphore is needed here.
301
+ if is_nomic_model(model):
302
+ return NomicEmbedder.get().embed(texts)
303
+ if model == MINILM_MODEL:
304
+ return LocalEmbedder.get().embed(texts)
305
+ raise HTTPException(status_code=400, detail=f"Unsupported model: {model}")
339
306
 
340
307
 
341
308
  def _activate_model(model: str) -> None:
@@ -110,6 +110,7 @@ __all__ = [
110
110
  "create_litellm_client",
111
111
  ]
112
112
 
113
+
113
114
  class LiteLLMClient(TextGenerationMixin, EmbeddingMixin, StructuredOutputMixin):
114
115
  """
115
116
  Unified LLM client using LiteLLM for multi-provider support.
@@ -267,6 +267,34 @@ _PROVIDER_DEFAULTS: dict[str, ProviderDefaults] = {
267
267
  }
268
268
 
269
269
 
270
+ # Output-token cap applied when neither the call site nor the client config
271
+ # sets max_tokens. MiniMax-M3 misbehaves with unbounded output: omitting
272
+ # max_tokens (especially combined with a strict json_schema response_format)
273
+ # deterministically stalls generation into litellm's 120s timeout (reproduced
274
+ # 2026-07; observed in prod as consolidator/document-expansion timeouts).
275
+ # Sizing (measured in prod, 2026-07-14): M3's reasoning tokens count against
276
+ # this budget, so too small a cap starves the visible output — at 4096 the
277
+ # model regularly spent the whole budget thinking and returned empty/truncated
278
+ # content (structured-output parse failures ran ~10-20x the 8192-era rate,
279
+ # breaking extraction). 8192 was the healthiest measured setting. The 120s
280
+ # provider stalls occur at every cap value (provider-side; mitigate with
281
+ # fallback models, not here). Providers absent from this map stay unbounded.
282
+ _PROVIDER_DEFAULT_MAX_TOKENS: dict[str, int] = {"minimax": 8192}
283
+
284
+
285
+ def default_max_tokens_for_model(model: str) -> int | None:
286
+ """Return the provider-level default output-token cap for ``model``.
287
+
288
+ Args:
289
+ model (str): Full model name, e.g. ``"minimax/MiniMax-M3"``.
290
+
291
+ Returns:
292
+ int | None: Cap to apply when the caller set none, or None (no cap).
293
+ """
294
+ provider = model.split("/", 1)[0] if "/" in model else ""
295
+ return _PROVIDER_DEFAULT_MAX_TOKENS.get(provider)
296
+
297
+
270
298
  EMBEDDING_CAPABLE_PROVIDERS: frozenset[str] = frozenset(
271
299
  p for p, d in _PROVIDER_DEFAULTS.items() if d.embedding is not None
272
300
  )
@@ -89,6 +89,12 @@ class ClaudeCodeCLIError(RuntimeError):
89
89
  """Raised when the claude CLI subprocess fails in a way we cannot recover from."""
90
90
 
91
91
 
92
+ def _diagnostic_excerpt(text: str, limit: int = 500) -> str:
93
+ """Return a bounded single-line excerpt for local CLI diagnostics."""
94
+ compact = " ".join((text or "").split())
95
+ return compact[:limit]
96
+
97
+
92
98
  def _env_enabled() -> bool:
93
99
  """Return True when ``CLAUDE_SMART_USE_LOCAL_CLI`` is set to a truthy value.
94
100
 
@@ -1037,7 +1043,9 @@ class ClaudeCodeLLM(CustomLLM):
1037
1043
  self._record_stall_safely(result)
1038
1044
  raise ClaudeCodeCLIError(
1039
1045
  f"claude -p stream failed; retry_errors={result.retry_errors}; "
1040
- f"stderr={result.stderr_text[:200]!r}"
1046
+ f"stderr={_diagnostic_excerpt(result.stderr_text)!r}; "
1047
+ f"stdout={_diagnostic_excerpt(result.terminal_text)!r}; "
1048
+ f"parsed={result.raw_lines_parsed}; failed={result.raw_lines_failed}"
1041
1049
  )
1042
1050
 
1043
1051
  def _record_stall_safely(self, result: ParseResult) -> None: