claude-smart 0.2.43 → 0.2.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/bin/claude-smart.js +2 -2
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +21 -1
  8. package/plugin/pyproject.toml +2 -2
  9. package/plugin/scripts/backend-service.sh +50 -4
  10. package/plugin/scripts/cli.sh +2 -1
  11. package/plugin/scripts/ensure-plugin-root.sh +1 -0
  12. package/plugin/scripts/hook_entry.sh +5 -3
  13. package/plugin/scripts/smart-install.sh +2 -2
  14. package/plugin/src/README.md +57 -0
  15. package/plugin/uv.lock +126 -5
  16. package/plugin/vendor/reflexio/.env.example +9 -0
  17. package/plugin/vendor/reflexio/pyproject.toml +5 -2
  18. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +0 -1
  19. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +7 -4
  20. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +4 -4
  21. package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +2 -2
  22. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +13 -4
  23. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +3 -1
  24. package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +59 -6
  25. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +1 -1
  26. package/plugin/vendor/reflexio/reflexio/server/README.md +7 -1
  27. package/plugin/vendor/reflexio/reflexio/server/api.py +188 -34
  28. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +34 -0
  29. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +33 -11
  30. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +278 -29
  31. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +457 -181
  32. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +28 -0
  33. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +22 -12
  34. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +137 -9
  35. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +1 -1
  36. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +36 -3
  37. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +13 -3
  38. package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +17 -0
  39. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +1 -1
  40. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.1.prompt.md +69 -0
  41. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.2.prompt.md +71 -0
  42. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +27 -23
  43. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.2.prompt.md +234 -0
  44. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.3.prompt.md +244 -0
  45. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +19 -9
  46. package/plugin/vendor/reflexio/reflexio/server/services/README.md +58 -0
  47. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +1 -5
  48. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +44 -2
  49. package/plugin/vendor/reflexio/reflexio/server/services/embedding_text.py +62 -0
  50. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +8 -1
  51. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +91 -24
  52. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +3 -1
  53. package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +6 -3
  54. package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +1 -1
  55. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +18 -5
  56. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +2 -2
  57. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +88 -3
  58. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +36 -5
  59. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +6 -3
  60. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +6 -3
  61. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +32 -22
  62. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +89 -4
  63. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +46 -1
  64. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +12 -0
  65. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +1 -1
  66. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +8 -4
@@ -8,10 +8,13 @@ using LiteLLM. It maintains the same interface as the existing LLMClient for eas
8
8
  import base64
9
9
  import json
10
10
  import logging
11
+ import multiprocessing
11
12
  import os
13
+ import pickle
14
+ import queue
12
15
  import re
13
16
  import time
14
- from dataclasses import dataclass
17
+ from dataclasses import dataclass, field
15
18
  from functools import lru_cache
16
19
  from typing import Any
17
20
 
@@ -53,9 +56,6 @@ from reflexio.server.llm.providers.local_embedding_provider import (
53
56
  from reflexio.server.llm.providers.nomic_embedding_provider import (
54
57
  NomicEmbedder,
55
58
  )
56
- from reflexio.server.llm.providers.nomic_embedding_provider import (
57
- is_enabled as _nomic_embedder_enabled,
58
- )
59
59
  from reflexio.server.llm.providers.nomic_embedding_provider import (
60
60
  is_nomic_model as _is_nomic_model,
61
61
  )
@@ -148,6 +148,23 @@ def _get_embedding_encoding(model: str) -> tiktoken.Encoding:
148
148
  return tiktoken.get_encoding("cl100k_base")
149
149
 
150
150
 
151
+ def _reject_cloud_mode(embedding_model: str, mode: str) -> None:
152
+ """
153
+ Raise when a local-only embedding model is configured for cloud mode.
154
+
155
+ Args:
156
+ embedding_model (str): The resolved embedding model name.
157
+ mode (str): The resolved embedding provider mode.
158
+
159
+ Raises:
160
+ EmbeddingUnavailableError: If ``mode`` is ``"cloud"``.
161
+ """
162
+ if mode == "cloud":
163
+ raise EmbeddingUnavailableError(
164
+ f"Local embedding model {embedding_model!r} cannot use cloud mode"
165
+ )
166
+
167
+
151
168
  def _truncate_for_embedding(
152
169
  text: str, model: str, max_tokens: int | None = None
153
170
  ) -> str:
@@ -207,24 +224,49 @@ class LiteLLMConfig:
207
224
  Configuration for LiteLLM client.
208
225
 
209
226
  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)
227
+ model: Model name to use (e.g., 'gpt-4o', 'claude-3-5-sonnet-20241022').
228
+ temperature: Temperature for response generation (0.0 to 2.0).
229
+ max_tokens: Maximum tokens to generate.
230
+ timeout: Request timeout in seconds.
231
+ max_retries: Maximum retry attempts on the primary model. Passed
232
+ directly to litellm's num_retries. Default 3.
233
+ retry_delay: Currently unused — LiteLLM owns retry backoff. Kept for
234
+ backward compatibility; remove in a follow-up sweep.
235
+ top_p: Top-p sampling parameter.
236
+ api_key_config: Optional API key configuration from Config (overrides env vars).
237
+ fallback_models: Models LiteLLM tries in order after the primary
238
+ exhausts num_retries. Passed directly to litellm's fallbacks param.
239
+ Default is an empty list (no fallback) so local reflexio and the
240
+ claude-smart integration are never silently routed to an unintended
241
+ provider. Production opts in via the env var
242
+ REFLEXIO_LLM_FALLBACK_MODELS (comma-separated, e.g. "gpt-5.4-mini").
243
+ Self-references are deduped at request time.
218
244
  """
219
245
 
220
246
  model: str
221
247
  temperature: float = 0.7
222
248
  max_tokens: int | None = None
223
249
  timeout: int = 120
224
- max_retries: int = 1
250
+ max_retries: int = 3
225
251
  retry_delay: float = 1.0
226
252
  top_p: float = 1.0
227
253
  api_key_config: APIKeyConfig | None = None
254
+ fallback_models: list[str] = field(
255
+ default_factory=lambda: [
256
+ m.strip()
257
+ for m in os.environ.get("REFLEXIO_LLM_FALLBACK_MODELS", "").split(",")
258
+ if m.strip()
259
+ ]
260
+ )
261
+
262
+
263
+ # Reasoning models that routinely exceed the default 120s provider timeout on
264
+ # large extraction contexts. Values are floors, not overrides: the effective
265
+ # timeout is max(configured, floor), and an explicit per-call timeout kwarg
266
+ # always wins.
267
+ _MODEL_TIMEOUT_FLOOR_SECONDS: dict[str, int] = {
268
+ "minimax/MiniMax-M3": 240,
269
+ }
228
270
 
229
271
 
230
272
  @dataclass
@@ -264,6 +306,131 @@ class StructuredOutputParseError(Exception):
264
306
  """
265
307
 
266
308
 
309
+ class LLMHardTimeoutError(TimeoutError):
310
+ """Raised when an LLM call exceeds the client-side wall-clock timeout."""
311
+
312
+
313
+ @dataclass
314
+ class _CompletionMessageSnapshot:
315
+ content: str | None = None
316
+ tool_calls: Any | None = None
317
+
318
+
319
+ @dataclass
320
+ class _CompletionChoiceSnapshot:
321
+ message: _CompletionMessageSnapshot
322
+ finish_reason: str | None = None
323
+
324
+
325
+ @dataclass
326
+ class _PromptTokenDetailsSnapshot:
327
+ cached_tokens: int = 0
328
+
329
+
330
+ @dataclass
331
+ class _CompletionUsageSnapshot:
332
+ prompt_tokens: int | None = None
333
+ completion_tokens: int | None = None
334
+ total_tokens: int | None = None
335
+ prompt_tokens_details: _PromptTokenDetailsSnapshot | None = None
336
+ cache_creation_input_tokens: int | None = None
337
+ cache_read_input_tokens: int | None = None
338
+
339
+
340
+ @dataclass
341
+ class _CompletionResponseSnapshot:
342
+ choices: list[_CompletionChoiceSnapshot]
343
+ usage: _CompletionUsageSnapshot | None = None
344
+ model: str | None = None
345
+ _hidden_params: dict[str, Any] = field(default_factory=dict)
346
+
347
+
348
+ @dataclass
349
+ class _CompletionErrorSnapshot:
350
+ type_name: str
351
+ message: str
352
+
353
+
354
+ def _ensure_picklable(value: Any) -> Any:
355
+ try:
356
+ pickle.dumps(value)
357
+ except Exception:
358
+ return repr(value)
359
+ return value
360
+
361
+
362
+ def _snapshot_completion_response(response: Any) -> _CompletionResponseSnapshot:
363
+ choices: list[_CompletionChoiceSnapshot] = []
364
+ for choice in getattr(response, "choices", []) or []:
365
+ message = getattr(choice, "message", None)
366
+ choices.append(
367
+ _CompletionChoiceSnapshot(
368
+ message=_CompletionMessageSnapshot(
369
+ content=getattr(message, "content", None),
370
+ tool_calls=_ensure_picklable(getattr(message, "tool_calls", None)),
371
+ ),
372
+ finish_reason=getattr(choice, "finish_reason", None),
373
+ )
374
+ )
375
+
376
+ usage = getattr(response, "usage", None)
377
+ usage_snapshot = None
378
+ if usage is not None:
379
+ prompt_details = getattr(usage, "prompt_tokens_details", None)
380
+ prompt_details_snapshot = None
381
+ if prompt_details is not None:
382
+ prompt_details_snapshot = _PromptTokenDetailsSnapshot(
383
+ cached_tokens=int(getattr(prompt_details, "cached_tokens", 0) or 0)
384
+ )
385
+ usage_snapshot = _CompletionUsageSnapshot(
386
+ prompt_tokens=getattr(usage, "prompt_tokens", None),
387
+ completion_tokens=getattr(usage, "completion_tokens", None),
388
+ total_tokens=getattr(usage, "total_tokens", None),
389
+ prompt_tokens_details=prompt_details_snapshot,
390
+ cache_creation_input_tokens=getattr(
391
+ usage, "cache_creation_input_tokens", None
392
+ ),
393
+ cache_read_input_tokens=getattr(usage, "cache_read_input_tokens", None),
394
+ )
395
+
396
+ hidden_params = getattr(response, "_hidden_params", {}) or {}
397
+ if not isinstance(hidden_params, dict):
398
+ hidden_params = {}
399
+
400
+ return _CompletionResponseSnapshot(
401
+ choices=choices,
402
+ usage=usage_snapshot,
403
+ model=getattr(response, "model", None),
404
+ _hidden_params={str(k): _ensure_picklable(v) for k, v in hidden_params.items()},
405
+ )
406
+
407
+
408
+ def _picklable_completion_result(response: Any) -> Any:
409
+ try:
410
+ pickle.dumps(response)
411
+ except Exception:
412
+ return _snapshot_completion_response(response)
413
+ return response
414
+
415
+
416
+ def _litellm_completion_worker(
417
+ params: dict[str, Any], result_queue: multiprocessing.Queue
418
+ ) -> None:
419
+ try:
420
+ result_queue.put(
421
+ ("ok", _picklable_completion_result(litellm.completion(**params)))
422
+ )
423
+ except BaseException as exc:
424
+ try:
425
+ pickle.dumps(exc)
426
+ except Exception:
427
+ result_queue.put(
428
+ ("error", _CompletionErrorSnapshot(type(exc).__name__, str(exc)))
429
+ )
430
+ else:
431
+ result_queue.put(("error", exc))
432
+
433
+
267
434
  class LiteLLMClient:
268
435
  """
269
436
  Unified LLM client using LiteLLM for multi-provider support.
@@ -285,23 +452,10 @@ class LiteLLMClient:
285
452
  "xai/": "xai",
286
453
  }
287
454
 
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
455
  # Models that only support temperature=1.0 (custom values cause errors or degraded performance)
302
456
  TEMPERATURE_RESTRICTED_MODELS = {
303
457
  "gpt-5",
304
- "gpt-5-mini",
458
+ "gpt-5.4-mini",
305
459
  "gpt-5-nano",
306
460
  "gpt-5-codex",
307
461
  "gemini-3-flash-preview",
@@ -474,6 +628,8 @@ class LiteLLMClient:
474
628
  tools: list[Any] | None = None,
475
629
  tool_choice: str | dict[str, Any] | None = None,
476
630
  model_role: ModelRole | None = None,
631
+ max_retries: int | None = None,
632
+ fallback_models: list[str] | None = None,
477
633
  **kwargs: Any,
478
634
  ) -> str | BaseModel | ToolCallingChatResponse:
479
635
  """
@@ -489,6 +645,12 @@ class LiteLLMClient:
489
645
  model_role: Optional ``ModelRole`` to override the model selected for
490
646
  this request. The role is resolved via ``resolve_model_name`` using
491
647
  the client's ``api_key_config``.
648
+ max_retries (int | None): Optional per-call override for the number of
649
+ retry attempts. When ``None`` (the default), the value falls back to
650
+ ``LiteLLMConfig.max_retries``.
651
+ fallback_models (list[str] | None): Optional per-call override for the
652
+ fallback model chain. When ``None`` (the default), the value falls
653
+ back to ``LiteLLMConfig.fallback_models``.
492
654
  **kwargs: Additional parameters including:
493
655
  - response_format: Pydantic BaseModel class for structured output
494
656
  - parse_structured_output: Whether to parse structured output (default True)
@@ -531,6 +693,10 @@ class LiteLLMClient:
531
693
  kwargs["tool_choice"] = tool_choice
532
694
  if model_role is not None:
533
695
  kwargs["model_role"] = model_role
696
+ if max_retries is not None:
697
+ kwargs["max_retries"] = max_retries
698
+ if fallback_models is not None:
699
+ kwargs["fallback_models"] = fallback_models
534
700
 
535
701
  return self._make_request(final_messages, **kwargs)
536
702
 
@@ -589,11 +755,11 @@ class LiteLLMClient:
589
755
  [text], model=embedding_model, dimensions=dimensions
590
756
  )[0]
591
757
 
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():
758
+ # local/nomic-embed-* must stay on the Nomic provider (137M params,
759
+ # 768d Matryoshka-truncated to 512). Falling through to MiniLM would
760
+ # mix embedding models inside existing vector stores.
761
+ if _is_nomic_model(embedding_model):
762
+ _reject_cloud_mode(embedding_model, mode)
597
763
  try:
598
764
  return NomicEmbedder.get().embed([text])[0]
599
765
  except Exception as e:
@@ -608,10 +774,7 @@ class LiteLLMClient:
608
774
  # ``CLAUDE_SMART_USE_LOCAL_EMBEDDING``) is enforced earlier in the
609
775
  # auto-detection layer (see ``model_defaults._auto_detect_model``).
610
776
  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
- )
777
+ _reject_cloud_mode(embedding_model, mode)
615
778
  if not _is_chromadb_importable():
616
779
  raise LiteLLMClientError(
617
780
  f"Embedding model {embedding_model!r} requires chromadb. "
@@ -642,7 +805,11 @@ class LiteLLMClient:
642
805
  if api_version:
643
806
  params["api_version"] = api_version
644
807
 
645
- response = litellm.embedding(**params, timeout=self.config.timeout)
808
+ response = litellm.embedding(
809
+ **params,
810
+ timeout=self.config.timeout,
811
+ num_retries=self.config.max_retries,
812
+ )
646
813
  return response.data[0]["embedding"]
647
814
  except Exception as e:
648
815
  raise LiteLLMClientError(f"Embedding generation failed: {str(e)}") from e
@@ -683,7 +850,8 @@ class LiteLLMClient:
683
850
  )
684
851
 
685
852
  # See matching short-circuits in get_embedding above.
686
- if _is_nomic_model(embedding_model) and _nomic_embedder_enabled():
853
+ if _is_nomic_model(embedding_model):
854
+ _reject_cloud_mode(embedding_model, mode)
687
855
  try:
688
856
  return NomicEmbedder.get().embed(list(texts))
689
857
  except Exception as e:
@@ -692,10 +860,7 @@ class LiteLLMClient:
692
860
  ) from e
693
861
 
694
862
  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
- )
863
+ _reject_cloud_mode(embedding_model, mode)
699
864
  if not _is_chromadb_importable():
700
865
  raise LiteLLMClientError(
701
866
  f"Embedding model {embedding_model!r} requires chromadb. "
@@ -726,7 +891,11 @@ class LiteLLMClient:
726
891
  if api_version:
727
892
  params["api_version"] = api_version
728
893
 
729
- response = litellm.embedding(**params, timeout=self.config.timeout)
894
+ response = litellm.embedding(
895
+ **params,
896
+ timeout=self.config.timeout,
897
+ num_retries=self.config.max_retries,
898
+ )
730
899
  # Response data may not be in order, sort by index to ensure correct ordering
731
900
  sorted_data = sorted(response.data, key=lambda x: x["index"])
732
901
  return [item["embedding"] for item in sorted_data]
@@ -737,7 +906,7 @@ class LiteLLMClient:
737
906
 
738
907
  def _build_completion_params(
739
908
  self, messages: list[dict[str, Any]], **kwargs: Any
740
- ) -> tuple[dict[str, Any], Any, bool, int]:
909
+ ) -> tuple[dict[str, Any], Any, bool, int, list[str]]:
741
910
  """Build completion request parameters from messages and kwargs.
742
911
 
743
912
  Args:
@@ -745,7 +914,9 @@ class LiteLLMClient:
745
914
  **kwargs: Additional parameters (response_format, max_retries, model, etc.)
746
915
 
747
916
  Returns:
748
- Tuple of (params dict, response_format, parse_structured_output, max_retries)
917
+ Tuple of (params dict, response_format, parse_structured_output,
918
+ max_retries, fallback_models). ``fallback_models`` already has any
919
+ entry equal to the primary model removed.
749
920
  """
750
921
  response_format = kwargs.pop("response_format", None)
751
922
  strict_response_format = kwargs.pop("strict_response_format", True)
@@ -756,6 +927,14 @@ class LiteLLMClient:
756
927
  except (TypeError, ValueError):
757
928
  max_retries = max(1, int(self.config.max_retries))
758
929
 
930
+ # Per-call fallback_models wins over config when explicitly provided.
931
+ # Use sentinel-style check so an explicit empty list disables fallback
932
+ # for the call even when the config has fallbacks set.
933
+ if "fallback_models" in kwargs:
934
+ fallback_models_raw = kwargs.pop("fallback_models") or []
935
+ else:
936
+ fallback_models_raw = list(self.config.fallback_models)
937
+
759
938
  # Pop tool-calling kwargs before the final params.update(kwargs) so they
760
939
  # don't leak into the params dict twice.
761
940
  tools = kwargs.pop("tools", None)
@@ -785,10 +964,15 @@ class LiteLLMClient:
785
964
  params: dict[str, Any] = {
786
965
  "model": actual_model,
787
966
  "messages": messages,
788
- "timeout": kwargs.pop("timeout", self.config.timeout),
789
- "num_retries": 0,
967
+ "timeout": kwargs.pop(
968
+ "timeout", self._effective_timeout_for_model(actual_model)
969
+ ),
790
970
  }
791
971
 
972
+ # Drop any fallback entry that points back at the primary — sending the
973
+ # same broken endpoint twice never helps.
974
+ fallback_models = [m for m in fallback_models_raw if m != actual_model]
975
+
792
976
  temperature = kwargs.pop("temperature", self.config.temperature)
793
977
  if self._is_temperature_restricted_model(actual_model):
794
978
  params["temperature"] = 1.0
@@ -863,7 +1047,13 @@ class LiteLLMClient:
863
1047
  params["messages"], params["model"]
864
1048
  )
865
1049
 
866
- return params, response_format, parse_structured_output, max_retries
1050
+ return (
1051
+ params,
1052
+ response_format,
1053
+ parse_structured_output,
1054
+ max_retries,
1055
+ fallback_models,
1056
+ )
867
1057
 
868
1058
  @staticmethod
869
1059
  @lru_cache(maxsize=256)
@@ -917,6 +1107,102 @@ class LiteLLMClient:
917
1107
  except Exception:
918
1108
  return None
919
1109
 
1110
+ def _completion_with_hard_timeout(self, params: dict[str, Any]) -> Any:
1111
+ """Run ``litellm.completion`` with a client-side wall-clock bound.
1112
+
1113
+ Some providers can exceed LiteLLM's ``timeout`` kwarg. Run the blocking
1114
+ call in a child process so the caller can fail, release locks, and
1115
+ terminate the in-flight provider request instead of waiting indefinitely.
1116
+ """
1117
+ provider_timeout = params.get("timeout", self.config.timeout)
1118
+ try:
1119
+ timeout_seconds = float(provider_timeout)
1120
+ except (TypeError, ValueError):
1121
+ timeout_seconds = float(self.config.timeout)
1122
+ grace_seconds = self._hard_timeout_grace_seconds()
1123
+ hard_timeout = max(0.001, timeout_seconds) + max(0.0, grace_seconds)
1124
+
1125
+ if not self._should_process_isolate_completion(timeout_seconds, grace_seconds):
1126
+ return litellm.completion(**params)
1127
+
1128
+ process_context = multiprocessing.get_context()
1129
+ result_queue = process_context.Queue(maxsize=1)
1130
+ process = process_context.Process(
1131
+ target=_litellm_completion_worker,
1132
+ args=(params, result_queue),
1133
+ daemon=True,
1134
+ )
1135
+ process.start()
1136
+ try:
1137
+ process.join(timeout=hard_timeout)
1138
+ if process.is_alive():
1139
+ process.terminate()
1140
+ process.join(timeout=1.0)
1141
+ if process.is_alive():
1142
+ process.kill()
1143
+ process.join(timeout=1.0)
1144
+ raise LLMHardTimeoutError(
1145
+ f"LLM request exceeded hard timeout of {hard_timeout:.3f}s "
1146
+ f"(provider timeout={provider_timeout!r})"
1147
+ )
1148
+
1149
+ try:
1150
+ status, payload = result_queue.get(timeout=1.0)
1151
+ except queue.Empty as exc:
1152
+ raise LiteLLMClientError(
1153
+ "LLM request process exited without returning a result "
1154
+ f"(exitcode={process.exitcode})"
1155
+ ) from exc
1156
+
1157
+ if status == "ok":
1158
+ return payload
1159
+ if isinstance(payload, _CompletionErrorSnapshot):
1160
+ raise LiteLLMClientError(
1161
+ f"litellm.completion raised {payload.type_name}: {payload.message}"
1162
+ )
1163
+ raise payload
1164
+ finally:
1165
+ result_queue.close()
1166
+ result_queue.join_thread()
1167
+
1168
+ def _effective_timeout_for_model(self, model: str) -> int:
1169
+ """Return the configured timeout, raised to the model's floor if one exists.
1170
+
1171
+ Args:
1172
+ model: Resolved model name (e.g. 'minimax/MiniMax-M3').
1173
+
1174
+ Returns:
1175
+ int: max(config.timeout, per-model floor). Callers that pass an
1176
+ explicit timeout kwarg bypass this entirely.
1177
+ """
1178
+ return max(self.config.timeout, _MODEL_TIMEOUT_FLOOR_SECONDS.get(model, 0))
1179
+
1180
+ def _hard_timeout_grace_seconds(self) -> float:
1181
+ raw = os.environ.get("REFLEXIO_LLM_HARD_TIMEOUT_GRACE_SECONDS", "5") or "5"
1182
+ try:
1183
+ return max(0.0, float(raw))
1184
+ except ValueError:
1185
+ self.logger.warning(
1186
+ "Invalid REFLEXIO_LLM_HARD_TIMEOUT_GRACE_SECONDS=%r; using 5",
1187
+ raw,
1188
+ )
1189
+ return 5.0
1190
+
1191
+ def _should_process_isolate_completion(
1192
+ self, timeout_seconds: float, grace_seconds: float
1193
+ ) -> bool:
1194
+ """Use process isolation for real LiteLLM calls while preserving test doubles.
1195
+
1196
+ Unit tests often monkeypatch ``litellm.completion`` with local closures
1197
+ that capture params in parent memory. Those closures cannot be observed
1198
+ through a subprocess, so only real LiteLLM functions and explicit short
1199
+ timeout tests go through the process path.
1200
+ """
1201
+ completion_module = getattr(litellm.completion, "__module__", "")
1202
+ if completion_module.startswith("litellm"):
1203
+ return True
1204
+ return timeout_seconds + grace_seconds < 1.0
1205
+
920
1206
  def _log_token_usage(self, params: dict[str, Any], response: Any) -> None:
921
1207
  """Log token usage with cache statistics and cost from an LLM response.
922
1208
 
@@ -954,163 +1240,165 @@ class LiteLLMClient:
954
1240
  cost_suffix,
955
1241
  )
956
1242
 
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,
1243
+ def _emit_fallback_observability(
1244
+ self, response: Any, params: dict[str, Any]
965
1245
  ) -> None:
966
- """Handle retry logic or raise on non-retryable/final errors.
1246
+ """Surface fallback-routing info to logs and Sentry when applicable.
967
1247
 
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
1248
+ LiteLLM rewrites ``response.model`` to the model that actually served
1249
+ the call, so we detect a fallback by comparing it against the model
1250
+ we asked for. The check is best-effort: any exception inside this
1251
+ helper is swallowed so observability never breaks the request.
975
1252
 
976
- Raises:
977
- LiteLLMClientError: If the error is non-retryable or this was the last attempt
1253
+ Args:
1254
+ response: The litellm completion response object.
1255
+ params: The params dict that was passed to ``litellm.completion`` —
1256
+ used to read the originally requested primary model name.
978
1257
  """
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
- )
1258
+ try:
1259
+ primary_model = params.get("model")
1260
+ hidden = getattr(response, "_hidden_params", {}) or {}
1261
+ served_model = (
1262
+ hidden.get("model_id")
1263
+ or hidden.get("model")
1264
+ or getattr(response, "model", None)
1265
+ )
993
1266
 
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
1267
+ if not served_model or served_model == primary_model:
1268
+ return
997
1269
 
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,
1270
+ self.logger.info(
1271
+ "event=llm_fallback_used primary_model=%s served_model=%s",
1272
+ primary_model,
1273
+ served_model,
1014
1274
  )
1015
1275
 
1276
+ # Local import keeps sentry out of module-init paths the tests
1277
+ # exercise without a Sentry SDK installed. sentry_sdk is an
1278
+ # enterprise-only dependency; OSS callers run without it and the
1279
+ # ImportError is intentionally absorbed by the outer except.
1280
+ import sentry_sdk # type: ignore[import-not-found]
1281
+
1282
+ sentry_sdk.set_tag("llm.fallback_used", "true")
1283
+ sentry_sdk.set_tag("llm.primary_model", str(primary_model))
1284
+ sentry_sdk.set_tag("llm.fallback_model", str(served_model))
1285
+ except Exception: # noqa: BLE001 — observability must not break the call
1286
+ return
1287
+
1016
1288
  def _make_request(
1017
1289
  self, messages: list[dict[str, Any]], **kwargs: Any
1018
1290
  ) -> str | BaseModel | ToolCallingChatResponse:
1019
1291
  """
1020
- Make a request to the LLM with retry logic.
1292
+ Make a request to the LLM, delegating retries and fallback to litellm.
1293
+
1294
+ Retry and fallback semantics are handed to ``litellm.completion`` via
1295
+ the native ``num_retries`` and ``fallbacks`` kwargs. Per the documented
1296
+ flow at https://docs.litellm.ai/docs/router_architecture, the primary
1297
+ model is tried ``num_retries+1`` times, then each fallback gets a single
1298
+ attempt. The one piece we still own at the client level is a single
1299
+ retry for ``StructuredOutputParseError``: LiteLLM cannot detect a
1300
+ post-hoc Pydantic re-validation failure because it sees a successful
1301
+ HTTP response.
1021
1302
 
1022
1303
  Args:
1023
1304
  messages: List of messages to send.
1024
- **kwargs: Additional parameters.
1305
+ **kwargs: Additional parameters (response_format, max_retries,
1306
+ fallback_models, tools, etc.).
1025
1307
 
1026
1308
  Returns:
1027
1309
  Response content as string, BaseModel instance, or
1028
1310
  ToolCallingChatResponse when the request was in tool-calling mode.
1029
1311
 
1030
1312
  Raises:
1031
- LiteLLMClientError: If the request fails after all retries.
1313
+ LiteLLMClientError: If the request fails after all retries and
1314
+ fallbacks have been exhausted by litellm.
1032
1315
  """
1033
- params, response_format, parse_structured_output, max_retries = (
1316
+ params, response_format, parse_structured_output, max_retries, fallbacks = (
1034
1317
  self._build_completion_params(messages, **kwargs)
1035
1318
  )
1036
1319
 
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()
1320
+ # Hand retries + fallbacks to litellm. ``num_retries`` is the documented
1321
+ # alias for max_retries on litellm.completion.
1322
+ params["num_retries"] = max_retries
1323
+ if fallbacks:
1324
+ params["fallbacks"] = fallbacks
1325
+
1326
+ request_start = time.perf_counter()
1327
+ self.logger.info(
1328
+ "event=llm_request_start model=%s timeout=%s has_response_format=%s num_retries=%d fallbacks=%s",
1329
+ params.get("model"),
1330
+ params.get("timeout"),
1331
+ response_format is not None,
1332
+ max_retries,
1333
+ fallbacks,
1334
+ )
1335
+
1336
+ def _call_and_parse() -> str | BaseModel | ToolCallingChatResponse:
1337
+ response = self._completion_with_hard_timeout(params)
1338
+ self._emit_fallback_observability(response, params)
1339
+ message = response.choices[0].message # type: ignore[reportAttributeAccessIssue]
1340
+ content = message.content
1341
+ self._log_token_usage(params, response)
1046
1342
  self.logger.info(
1047
- "event=llm_request_start model=%s timeout=%s has_response_format=%s attempt=%d/%d",
1343
+ "event=llm_request_end model=%s timeout=%s has_response_format=%s elapsed_seconds=%.3f success=%s",
1048
1344
  params.get("model"),
1049
1345
  params.get("timeout"),
1050
1346
  response_format is not None,
1051
- attempt + 1,
1052
- effective_max_retries,
1347
+ time.perf_counter() - request_start,
1348
+ True,
1053
1349
  )
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
1350
 
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,
1351
+ # Tool-calling path: return a structured response instead of
1352
+ # going through _maybe_parse_structured_output.
1353
+ if "tools" in params:
1354
+ raw_usage = getattr(response, "usage", None)
1355
+ call_cost = self._compute_cost_usd(response, params.get("model"))
1356
+ return ToolCallingChatResponse(
1357
+ content=content,
1358
+ tool_calls=getattr(message, "tool_calls", None),
1359
+ finish_reason=response.choices[0].finish_reason, # type: ignore[reportAttributeAccessIssue]
1360
+ usage=raw_usage,
1361
+ cost_usd=call_cost,
1071
1362
  )
1072
1363
 
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
- )
1364
+ return self._maybe_parse_structured_output(
1365
+ content, # type: ignore[reportArgumentType]
1366
+ response_format,
1367
+ parse_structured_output,
1368
+ )
1085
1369
 
1086
- return self._maybe_parse_structured_output(
1087
- content, # type: ignore[reportArgumentType]
1088
- response_format,
1089
- parse_structured_output, # type: ignore[reportArgumentType]
1370
+ try:
1371
+ try:
1372
+ return _call_and_parse()
1373
+ except StructuredOutputParseError:
1374
+ # LiteLLM's num_retries covers API errors, but a Pydantic
1375
+ # re-validation failure happens AFTER litellm sees a
1376
+ # successful 200 — so we owe one explicit second attempt at
1377
+ # the model. PR #121 documented this as a MiniMax-M3
1378
+ # mitigation.
1379
+ self.logger.warning(
1380
+ "event=llm_parse_retry model=%s — primary returned malformed structured output, retrying once",
1381
+ params.get("model"),
1090
1382
  )
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,
1383
+ return _call_and_parse()
1384
+ except LLMHardTimeoutError:
1385
+ # The hard timeout kills the litellm subprocess, so litellm's
1386
+ # num_retries never gets a chance — we owe one explicit retry
1387
+ # at this level to cover transient provider hangs.
1388
+ self.logger.warning(
1389
+ "event=llm_hard_timeout_retry model=%s — request hit hard timeout, retrying once",
1390
+ params.get("model"),
1108
1391
  )
1109
- attempt += 1
1110
-
1111
- raise LiteLLMClientError(
1112
- f"API call failed after {effective_max_retries} retries: {str(last_error)}"
1113
- )
1392
+ return _call_and_parse()
1393
+ except Exception as e:
1394
+ self.logger.error(
1395
+ "event=llm_request_end model=%s elapsed_seconds=%.3f success=False error_type=%s error=%s",
1396
+ params.get("model"),
1397
+ time.perf_counter() - request_start,
1398
+ type(e).__name__,
1399
+ e,
1400
+ )
1401
+ raise LiteLLMClientError(f"API call failed: {e}") from e
1114
1402
 
1115
1403
  def _apply_prompt_caching(
1116
1404
  self, messages: list[dict[str, Any]], model: str
@@ -1509,18 +1797,6 @@ class LiteLLMClient:
1509
1797
  # Remove trailing commas before } or ]
1510
1798
  return re.sub(r",\s*([}\]])", r"\1", s)
1511
1799
 
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
1800
  def update_config(self, **kwargs) -> None:
1525
1801
  """
1526
1802
  Update client configuration.