claude-smart 0.2.46 → 0.2.48

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 (170) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +19 -11
  3. package/bin/claude-smart.js +290 -68
  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 +11 -10
  8. package/plugin/dashboard/app/layout.tsx +20 -0
  9. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  10. package/plugin/dashboard/package-lock.json +61 -390
  11. package/plugin/dashboard/package.json +2 -2
  12. package/plugin/opencode/dist/server.mjs +76 -2
  13. package/plugin/opencode/server.mts +79 -2
  14. package/plugin/pyproject.toml +6 -2
  15. package/plugin/scripts/smart-install.sh +7 -1
  16. package/plugin/src/claude_smart/cli.py +210 -22
  17. package/plugin/src/claude_smart/context_format.py +9 -9
  18. package/plugin/src/claude_smart/cs_cite.py +66 -19
  19. package/plugin/uv.lock +5 -5
  20. package/plugin/vendor/reflexio/.env.example +7 -0
  21. package/plugin/vendor/reflexio/README.md +3 -3
  22. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  23. package/plugin/vendor/reflexio/reflexio/README.md +11 -6
  24. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
  25. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
  26. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  27. package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
  28. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
  29. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  30. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  31. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
  32. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  33. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
  34. package/plugin/vendor/reflexio/reflexio/lib/_search.py +27 -5
  35. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
  36. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  37. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  38. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  39. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
  40. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +37 -0
  41. package/plugin/vendor/reflexio/reflexio/server/README.md +38 -9
  42. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  43. package/plugin/vendor/reflexio/reflexio/server/api.py +274 -3267
  44. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  45. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  46. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
  47. package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
  48. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  49. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  50. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  51. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  52. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  53. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  54. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  55. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  56. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  57. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  58. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  59. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +57 -5
  60. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  61. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  62. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  63. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
  64. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  67. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  68. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  69. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  70. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  71. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  72. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  73. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  74. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  75. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  76. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  77. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/README.md +5 -2
  79. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  87. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  88. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  89. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  91. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +385 -78
  95. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
  96. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +121 -525
  98. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  102. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
  103. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +43 -6
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +58 -351
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +452 -0
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -4
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1126
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +955 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +844 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -9
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +57 -86
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  169. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
  170. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
@@ -0,0 +1,980 @@
1
+ """Text-generation concern for ``LiteLLMClient`` — the largest bucket (Tier-2.5).
2
+
3
+ ``TextGenerationMixin`` holds the chat/response entry points
4
+ (``generate_response``, ``generate_chat_response``), completion-param building,
5
+ the subprocess hard-timeout path, observability, the ``_make_request``
6
+ orchestrator, prompt caching, multimodal image handling, and
7
+ ``_compute_cost_usd`` (kept as a retained method here — verbatim billing
8
+ exception->None semantics; called only by two text-gen methods, so no separate
9
+ cost module).
10
+
11
+ LLM-mock: every litellm call is via the shared ``litellm`` module attr
12
+ (``litellm.completion``) so the global ``patch("litellm.completion")`` mock and
13
+ the fork-inherited subprocess worker still intercept — NEVER ``from litellm
14
+ import completion``.
15
+
16
+ SINK-1 (patch-where-used): ``resolve_model_name`` is imported HERE for the
17
+ model-role path in ``_build_completion_params``; it is also imported into
18
+ ``_litellm_embedding`` for the embedding path — the two are independent bindings.
19
+
20
+ Per-mixin TYPE_CHECKING stubs (Tier-1b idiom) self-type the foreign members these
21
+ methods read: the client-core attributes/creds resolver (``config``, ``logger``,
22
+ ``_api_key``/``_api_base``/``_api_version``, ``_resolve_api_key`` on the facade)
23
+ and the two cross-mixin edges resolved via MRO at runtime
24
+ (``_provider_response_format`` + ``_maybe_parse_structured_output`` on
25
+ ``StructuredOutputMixin`` — which is why structured-output moves first).
26
+
27
+ Bodies moved VERBATIM from the former monolithic ``litellm_client.py``.
28
+ """
29
+
30
+ import base64
31
+ import logging
32
+ import multiprocessing
33
+ import os
34
+ import queue
35
+ import time
36
+ from typing import TYPE_CHECKING, Any
37
+
38
+ import litellm
39
+ from pydantic import BaseModel
40
+
41
+ from reflexio.server.llm._litellm_subprocess import _litellm_completion_worker
42
+ from reflexio.server.llm._litellm_types import (
43
+ LiteLLMClientError,
44
+ LLMHardTimeoutError,
45
+ StructuredOutputParseError,
46
+ ToolCallingChatResponse,
47
+ )
48
+ from reflexio.server.llm.image_utils import (
49
+ SUPPORTED_IMAGE_MIME_TYPES,
50
+ ImageEncodingError,
51
+ )
52
+ from reflexio.server.llm.image_utils import (
53
+ encode_image_to_base64 as _encode_image_to_base64,
54
+ )
55
+ from reflexio.server.llm.llm_utils import is_pydantic_model
56
+ from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
57
+
58
+ if TYPE_CHECKING:
59
+ from reflexio.server.llm._litellm_types import LiteLLMConfig
60
+
61
+
62
+ # Per-model provider-timeout floors. Values are floors, not overrides: the
63
+ # effective timeout is max(configured, floor), and an explicit per-call timeout
64
+ # kwarg always wins.
65
+ #
66
+ # MiniMax-M3 was pinned to 240s when it was the sole model. That let a *hung*
67
+ # primary block ~240s before falling back, dominating the wasted time behind
68
+ # Sentry PYTHON-FASTAPI-62. It is now floored at the 120s default so a hang is
69
+ # abandoned sooner and the fallback (e.g. gpt-5-mini) is reached faster. This is
70
+ # the key post-deploy tuning knob: raise it if legitimately-slow calls start
71
+ # timing out, lower it to cut more waste.
72
+ _MODEL_TIMEOUT_FLOOR_SECONDS: dict[str, int] = {
73
+ "minimax/MiniMax-M3": 120,
74
+ }
75
+
76
+
77
+ class TextGenerationMixin:
78
+ """Chat/response generation, completion-param build, hard-timeout, cost, multimodal.
79
+
80
+ Mixed into ``LiteLLMClient`` (first in the MRO); the ``self`` members these
81
+ methods read are owned by the client-core ``__init__`` on the facade and by
82
+ ``StructuredOutputMixin``. The stubs below (Tier-1b idiom) give pyright the
83
+ foreign-member types without introducing shared class-level mutable state —
84
+ NEVER assign the annotation-only members here.
85
+ """
86
+
87
+ SUPPORTED_IMAGE_FORMATS: set[str] = set(SUPPORTED_IMAGE_MIME_TYPES.keys())
88
+
89
+ # Models that only support temperature=1.0 (custom values cause errors or degraded performance)
90
+ TEMPERATURE_RESTRICTED_MODELS = {
91
+ "gpt-5",
92
+ "gpt-5.4-mini",
93
+ "gpt-5-nano",
94
+ "gpt-5-codex",
95
+ "gemini-3-flash-preview",
96
+ "gemini-3-pro-preview",
97
+ }
98
+
99
+ # Base-owned attributes these methods read (init'd in the facade ``__init__``).
100
+ config: "LiteLLMConfig"
101
+ logger: logging.Logger
102
+ _api_key: str | None
103
+ _api_base: str | None
104
+ _api_version: str | None
105
+
106
+ if TYPE_CHECKING:
107
+ # Client-core credential resolver (stays on the facade per the split).
108
+ def _resolve_api_key(
109
+ self, model: str | None = ..., for_embedding: bool = ...
110
+ ) -> tuple[str | None, str | None, str | None]: ...
111
+
112
+ # Concrete on ``StructuredOutputMixin`` (resolved via MRO); declared
113
+ # type-only so pyright can resolve these ``self.`` calls.
114
+ def _provider_response_format(
115
+ self, *, response_format: Any, model: str, strict_response_format: bool
116
+ ) -> Any: ...
117
+
118
+ def _maybe_parse_structured_output(
119
+ self,
120
+ content: str,
121
+ response_format: Any,
122
+ parse_structured_output: bool,
123
+ ) -> "str | BaseModel": ...
124
+
125
+ def generate_response(
126
+ self,
127
+ prompt: str,
128
+ system_message: str | None = None,
129
+ images: list[str | bytes | dict] | None = None,
130
+ image_media_type: str | None = None,
131
+ **kwargs: Any,
132
+ ) -> str | BaseModel | ToolCallingChatResponse:
133
+ """
134
+ Generate a response using the configured LLM.
135
+
136
+ Args:
137
+ prompt: The user prompt/message.
138
+ system_message: Optional system message to set context.
139
+ images: Optional list of images (file paths, bytes, or pre-formatted content blocks).
140
+ image_media_type: Media type for images if passing bytes (e.g., 'image/png').
141
+ **kwargs: Additional parameters including:
142
+ - response_format: Pydantic BaseModel class for structured output
143
+ - parse_structured_output: Whether to parse structured output (default True)
144
+ - temperature: Override config temperature
145
+ - max_tokens: Override config max_tokens
146
+
147
+ Returns:
148
+ Generated response content. Returns string for text responses,
149
+ or BaseModel instance for Pydantic model responses.
150
+
151
+ Raises:
152
+ LiteLLMClientError: If the API call fails after all retries,
153
+ or if response_format is not a Pydantic BaseModel class.
154
+ """
155
+ # Validate response_format if provided
156
+ response_format = kwargs.get("response_format")
157
+ if response_format is not None and not is_pydantic_model(response_format):
158
+ raise LiteLLMClientError(
159
+ "response_format must be a Pydantic BaseModel class, "
160
+ f"got {type(response_format).__name__}"
161
+ )
162
+
163
+ # Build user message content
164
+ user_content = self._build_user_content(prompt, images, image_media_type)
165
+
166
+ # Build messages list
167
+ messages = []
168
+ if system_message:
169
+ messages.append({"role": "system", "content": system_message})
170
+ messages.append({"role": "user", "content": user_content})
171
+
172
+ return self._make_request(messages, **kwargs)
173
+
174
+ def generate_chat_response(
175
+ self,
176
+ messages: list[dict[str, Any]],
177
+ system_message: str | None = None,
178
+ *,
179
+ tools: list[Any] | None = None,
180
+ tool_choice: str | dict[str, Any] | None = None,
181
+ model_role: ModelRole | None = None,
182
+ max_retries: int | None = None,
183
+ fallback_models: list[str] | None = None,
184
+ **kwargs: Any,
185
+ ) -> str | BaseModel | ToolCallingChatResponse:
186
+ """
187
+ Generate a response from a list of chat messages.
188
+
189
+ Args:
190
+ messages: List of messages in chat format [{"role": "...", "content": "..."}].
191
+ system_message: Optional system message to prepend.
192
+ tools: Optional list of tool definitions for tool-calling mode.
193
+ When provided, the return type is ``ToolCallingChatResponse``.
194
+ tool_choice: Optional tool choice control ("auto", "none", "required",
195
+ or a dict specifying a particular tool). Forwarded to the provider.
196
+ model_role: Optional ``ModelRole`` to override the model selected for
197
+ this request. The role is resolved via ``resolve_model_name`` using
198
+ the client's ``api_key_config``.
199
+ max_retries (int | None): Optional per-call override for the number of
200
+ retry attempts. When ``None`` (the default), the value falls back to
201
+ ``LiteLLMConfig.max_retries``.
202
+ fallback_models (list[str] | None): Optional per-call override for the
203
+ fallback model chain. When ``None`` (the default), the value falls
204
+ back to ``LiteLLMConfig.fallback_models``.
205
+ **kwargs: Additional parameters including:
206
+ - response_format: Pydantic BaseModel class for structured output
207
+ - parse_structured_output: Whether to parse structured output (default True)
208
+ - temperature: Override config temperature
209
+ - max_tokens: Override config max_tokens
210
+
211
+ Returns:
212
+ Generated response content. Returns string for text responses,
213
+ ``BaseModel`` instance for Pydantic model responses, or
214
+ ``ToolCallingChatResponse`` when ``tools`` is provided.
215
+
216
+ Raises:
217
+ LiteLLMClientError: If the API call fails after all retries,
218
+ or if response_format is not a Pydantic BaseModel class.
219
+ """
220
+ # Validate response_format if provided
221
+ response_format = kwargs.get("response_format")
222
+ if response_format is not None and not is_pydantic_model(response_format):
223
+ raise LiteLLMClientError(
224
+ "response_format must be a Pydantic BaseModel class, "
225
+ f"got {type(response_format).__name__}"
226
+ )
227
+
228
+ # Prepend system message if provided
229
+ final_messages = list(messages)
230
+ if system_message:
231
+ # Check if first message is already a system message
232
+ if final_messages and final_messages[0].get("role") == "system":
233
+ # Merge with existing system message. Replace the slot with a NEW
234
+ # dict rather than mutating in place — ``list(messages)`` is a
235
+ # shallow copy that shares the caller's dict objects, so an
236
+ # in-place edit would corrupt the caller's list (and re-prepend on
237
+ # reuse/retry).
238
+ final_messages[0] = {
239
+ **final_messages[0],
240
+ "content": f"{system_message}\n\n{final_messages[0]['content']}",
241
+ }
242
+ else:
243
+ final_messages.insert(0, {"role": "system", "content": system_message})
244
+
245
+ # Forward tool-calling and model-role kwargs into _make_request
246
+ if tools is not None:
247
+ kwargs["tools"] = tools
248
+ if tool_choice is not None:
249
+ kwargs["tool_choice"] = tool_choice
250
+ if model_role is not None:
251
+ kwargs["model_role"] = model_role
252
+ if max_retries is not None:
253
+ kwargs["max_retries"] = max_retries
254
+ if fallback_models is not None:
255
+ kwargs["fallback_models"] = fallback_models
256
+
257
+ return self._make_request(final_messages, **kwargs)
258
+
259
+ def _build_completion_params(
260
+ self, messages: list[dict[str, Any]], **kwargs: Any
261
+ ) -> tuple[dict[str, Any], Any, bool, int, list[str]]:
262
+ """Build completion request parameters from messages and kwargs.
263
+
264
+ Args:
265
+ messages: List of messages to send
266
+ **kwargs: Additional parameters (response_format, max_retries, model, etc.)
267
+
268
+ Returns:
269
+ Tuple of (params dict, response_format, parse_structured_output,
270
+ max_retries, fallback_models). ``fallback_models`` already has any
271
+ entry equal to the primary model removed.
272
+ """
273
+ response_format = kwargs.pop("response_format", None)
274
+ strict_response_format = kwargs.pop("strict_response_format", True)
275
+ parse_structured_output = kwargs.pop("parse_structured_output", True)
276
+ max_retries_arg = kwargs.pop("max_retries", self.config.max_retries)
277
+ try:
278
+ max_retries = max(1, int(max_retries_arg))
279
+ except (TypeError, ValueError):
280
+ max_retries = max(1, int(self.config.max_retries))
281
+
282
+ # Per-call fallback_models wins over config when explicitly provided.
283
+ # Use sentinel-style check so an explicit empty list disables fallback
284
+ # for the call even when the config has fallbacks set.
285
+ if "fallback_models" in kwargs:
286
+ fallback_models_raw = kwargs.pop("fallback_models") or []
287
+ else:
288
+ fallback_models_raw = list(self.config.fallback_models)
289
+
290
+ # Pop tool-calling kwargs before the final params.update(kwargs) so they
291
+ # don't leak into the params dict twice.
292
+ tools = kwargs.pop("tools", None)
293
+ tool_choice = kwargs.pop("tool_choice", None)
294
+ model_role: ModelRole | None = kwargs.pop("model_role", None)
295
+
296
+ actual_model = kwargs.pop("model", self.config.model)
297
+
298
+ # model_role takes priority over the default model but falls through
299
+ # to the custom_endpoint override below (highest priority).
300
+ if model_role is not None:
301
+ actual_model = resolve_model_name(
302
+ role=model_role,
303
+ site_var_value=None,
304
+ config_override=None,
305
+ api_key_config=self.config.api_key_config,
306
+ )
307
+
308
+ ce = (
309
+ self.config.api_key_config.custom_endpoint
310
+ if self.config.api_key_config
311
+ else None
312
+ )
313
+ if ce and ce.api_key and ce.api_base:
314
+ actual_model = ce.model
315
+
316
+ params: dict[str, Any] = {
317
+ "model": actual_model,
318
+ "messages": messages,
319
+ "timeout": kwargs.pop(
320
+ "timeout", self._effective_timeout_for_model(actual_model)
321
+ ),
322
+ }
323
+
324
+ # 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]
327
+
328
+ temperature = kwargs.pop("temperature", self.config.temperature)
329
+ if self._is_temperature_restricted_model(actual_model):
330
+ params["temperature"] = 1.0
331
+ else:
332
+ params["temperature"] = temperature
333
+
334
+ # Determinism knob: `seed` is always injected (defaulting to 42) on
335
+ # providers that honor it, since seed alone is cheap and harmless.
336
+ # The companion temperature=0 override is opt-in via an explicit
337
+ # REFLEXIO_LLM_SEED env var so that caller-configured temperature
338
+ # flows through by default — silently clobbering a user's configured
339
+ # temperature was surprising. Current-gen reasoning models (gpt-5-*)
340
+ # ignore both knobs; the seed is best-effort.
341
+ default_seed = 42
342
+ seed_explicit = "REFLEXIO_LLM_SEED" in os.environ
343
+ seed_raw = os.environ.get("REFLEXIO_LLM_SEED", str(default_seed))
344
+ try:
345
+ params["seed"] = int(seed_raw)
346
+ except ValueError:
347
+ self.logger.warning(
348
+ "REFLEXIO_LLM_SEED=%r is not an int; falling back to default seed=%d",
349
+ seed_raw,
350
+ default_seed,
351
+ )
352
+ params["seed"] = default_seed
353
+ # Keep seed best-effort without mutating LiteLLM's process-wide
354
+ # drop_params setting. Providers that do not support seed can ignore it.
355
+ params["drop_params"] = True
356
+ if seed_explicit and not self._is_temperature_restricted_model(actual_model):
357
+ params["temperature"] = 0.0
358
+
359
+ max_tokens = kwargs.pop("max_tokens", self.config.max_tokens)
360
+ if max_tokens:
361
+ params["max_tokens"] = max_tokens
362
+ if self.config.top_p != 1.0:
363
+ params["top_p"] = self.config.top_p
364
+ if response_format:
365
+ params["response_format"] = self._provider_response_format(
366
+ response_format=response_format,
367
+ model=actual_model,
368
+ strict_response_format=strict_response_format,
369
+ )
370
+ if tools is not None:
371
+ params["tools"] = tools
372
+ if tool_choice is not None:
373
+ params["tool_choice"] = tool_choice
374
+
375
+ if actual_model != self.config.model:
376
+ api_key, api_base, api_version = self._resolve_api_key(actual_model)
377
+ else:
378
+ api_key, api_base, api_version = (
379
+ self._api_key,
380
+ self._api_base,
381
+ self._api_version,
382
+ )
383
+ if api_key:
384
+ params["api_key"] = api_key
385
+ if api_base:
386
+ params["api_base"] = api_base
387
+ if api_version:
388
+ params["api_version"] = api_version
389
+
390
+ params.update(kwargs)
391
+
392
+ # Braintrust metadata for observability (no-op if callback not registered)
393
+ if os.environ.get("BRAINTRUST_API_KEY"):
394
+ params["metadata"] = {
395
+ **params.get("metadata", {}),
396
+ "project_name": os.environ.get("BRAINTRUST_PROJECT_NAME", "reflexio"),
397
+ }
398
+ params["messages"] = self._apply_prompt_caching(
399
+ params["messages"], params["model"]
400
+ )
401
+
402
+ return (
403
+ params,
404
+ response_format,
405
+ parse_structured_output,
406
+ max_retries,
407
+ fallback_models,
408
+ )
409
+
410
+ def _compute_cost_usd(self, response: Any, model: str | None) -> float | None:
411
+ """Compute call cost in USD via the litellm price table.
412
+
413
+ Falls back to None when the provider is not mapped (local ONNX,
414
+ claude-code CLI, etc.) rather than failing the request.
415
+
416
+ Args:
417
+ response: Raw LLM response object.
418
+ model: Fully-qualified model name used for the call.
419
+
420
+ Returns:
421
+ float | None: Cost in USD, or None when unavailable.
422
+ """
423
+ try:
424
+ import litellm
425
+
426
+ cost = litellm.completion_cost(completion_response=response, model=model)
427
+ return float(cost) if cost else None
428
+ except Exception:
429
+ return None
430
+
431
+ def _coerce_timeout_seconds(self, params: dict[str, Any]) -> float:
432
+ """Coerce ``params['timeout']`` to a float, falling back to the config
433
+ default when it is missing or non-numeric."""
434
+ try:
435
+ return float(params.get("timeout", self.config.timeout))
436
+ except (TypeError, ValueError):
437
+ return float(self.config.timeout)
438
+
439
+ def _completion_with_hard_timeout(
440
+ self, params: dict[str, Any], hard_timeout: float
441
+ ) -> Any:
442
+ """Run ``litellm.completion`` with a client-side wall-clock bound.
443
+
444
+ Some providers can exceed LiteLLM's ``timeout`` kwarg. Run the blocking
445
+ call in a child process so the caller can fail, release locks, and
446
+ terminate the in-flight provider request instead of waiting indefinitely.
447
+
448
+ ``hard_timeout`` is the wall-clock kill bound for the whole subprocess.
449
+ Because LiteLLM walks ``[primary, *fallbacks]`` inside this one call
450
+ (copying ``timeout`` unchanged into each rung), the caller sizes
451
+ ``hard_timeout`` to cover the entire fallback ladder, not a single
452
+ attempt — otherwise the subprocess would be killed before LiteLLM ever
453
+ reaches a fallback (the root cause of Sentry PYTHON-FASTAPI-62).
454
+ """
455
+ provider_timeout = params.get("timeout", self.config.timeout)
456
+ # timeout_seconds + grace_seconds below only classify test doubles in
457
+ # _should_process_isolate_completion (real litellm vs a monkeypatched
458
+ # closure) — they do NOT size the kill bound, which is the caller's
459
+ # ladder-wide ``hard_timeout``.
460
+ timeout_seconds = self._coerce_timeout_seconds(params)
461
+ grace_seconds = self._hard_timeout_grace_seconds()
462
+ hard_timeout = max(0.001, hard_timeout)
463
+
464
+ if not self._should_process_isolate_completion(timeout_seconds, grace_seconds):
465
+ return litellm.completion(**params)
466
+
467
+ process_context = multiprocessing.get_context()
468
+ result_queue = process_context.Queue(maxsize=1)
469
+ process = process_context.Process(
470
+ target=_litellm_completion_worker,
471
+ args=(params, result_queue),
472
+ daemon=True,
473
+ )
474
+ process.start()
475
+ try:
476
+ # Drain the result queue BEFORE joining the child. A large completion
477
+ # payload overflows the OS pipe buffer, so the child's queue-feeder
478
+ # thread blocks on ``put`` until a reader drains it — and the child
479
+ # cannot exit while that thread is blocked. Joining first would then
480
+ # deadlock the parent against a child that finished but is wedged on a
481
+ # full pipe, tripping a *false* hard timeout (a large-but-successful
482
+ # result reported as a wall-clock kill). Reading here unblocks the
483
+ # feeder so the child can exit. The read is bounded by the same
484
+ # ``hard_timeout`` budget the join used to enforce.
485
+ deadline = time.monotonic() + hard_timeout
486
+ result: tuple[str, Any] | None = None
487
+ while result is None:
488
+ remaining = deadline - time.monotonic()
489
+ try:
490
+ result = result_queue.get(timeout=max(0.0, min(0.1, remaining)))
491
+ break
492
+ except queue.Empty as exc:
493
+ if remaining <= 0:
494
+ # True wall-clock timeout: the child ran past the hard
495
+ # bound without producing a result. Kill it (if still
496
+ # alive) and surface the timeout; a child that exited
497
+ # without a result is a distinct failure.
498
+ if process.is_alive():
499
+ process.terminate()
500
+ process.join(timeout=1.0)
501
+ if process.is_alive():
502
+ process.kill()
503
+ process.join(timeout=1.0)
504
+ raise LLMHardTimeoutError(
505
+ f"LLM request exceeded hard timeout of {hard_timeout:.3f}s "
506
+ f"(provider timeout={provider_timeout!r})"
507
+ ) from exc
508
+ raise LiteLLMClientError(
509
+ "LLM request process exited without returning a result "
510
+ f"(exitcode={process.exitcode})"
511
+ ) from exc
512
+ if not process.is_alive():
513
+ # The child exited before the deadline. Give the queue one
514
+ # last read in case the feeder flushed the payload just
515
+ # before exit; otherwise it died without a result.
516
+ try:
517
+ result = result_queue.get(timeout=1.0)
518
+ except queue.Empty as exc2:
519
+ raise LiteLLMClientError(
520
+ "LLM request process exited without returning a result "
521
+ f"(exitcode={process.exitcode})"
522
+ ) from exc2
523
+
524
+ # The loop only exits with a drained result (every no-result path
525
+ # above raises); the assert makes that invariant explicit for pyright.
526
+ assert result is not None
527
+ status, payload = result
528
+ # The payload is drained, so the feeder is unblocked and the child can
529
+ # exit. Reap it (terminating if it lingers) to avoid a zombie.
530
+ process.join(timeout=1.0)
531
+ if process.is_alive():
532
+ process.terminate()
533
+ process.join(timeout=1.0)
534
+
535
+ if status == "ok":
536
+ return payload
537
+ # The worker always reports errors as a picklable snapshot.
538
+ context_parts = [f"model={payload.model}"]
539
+ if payload.llm_provider:
540
+ context_parts.append(f"provider={payload.llm_provider}")
541
+ raise LiteLLMClientError(
542
+ "litellm.completion failed in isolated worker: "
543
+ f"{payload.type_name}: {payload.message} "
544
+ f"({', '.join(context_parts)})"
545
+ )
546
+ finally:
547
+ result_queue.close()
548
+ result_queue.join_thread()
549
+
550
+ def _effective_timeout_for_model(self, model: str) -> int:
551
+ """Return the configured timeout, raised to the model's floor if one exists.
552
+
553
+ Args:
554
+ model: Resolved model name (e.g. 'minimax/MiniMax-M3').
555
+
556
+ Returns:
557
+ int: max(config.timeout, per-model floor). Callers that pass an
558
+ explicit timeout kwarg bypass this entirely.
559
+ """
560
+ return max(self.config.timeout, _MODEL_TIMEOUT_FLOOR_SECONDS.get(model, 0))
561
+
562
+ def _hard_timeout_grace_seconds(self) -> float:
563
+ raw = os.environ.get("REFLEXIO_LLM_HARD_TIMEOUT_GRACE_SECONDS", "5") or "5"
564
+ try:
565
+ return max(0.0, float(raw))
566
+ except ValueError:
567
+ self.logger.warning(
568
+ "Invalid REFLEXIO_LLM_HARD_TIMEOUT_GRACE_SECONDS=%r; using 5",
569
+ raw,
570
+ )
571
+ return 5.0
572
+
573
+ def _should_process_isolate_completion(
574
+ self, timeout_seconds: float, grace_seconds: float
575
+ ) -> bool:
576
+ """Use process isolation for real LiteLLM calls while preserving test doubles.
577
+
578
+ Unit tests often monkeypatch ``litellm.completion`` with local closures
579
+ that capture params in parent memory. Those closures cannot be observed
580
+ through a subprocess, so only real LiteLLM functions and explicit short
581
+ timeout tests go through the process path.
582
+ """
583
+ completion_module = getattr(litellm.completion, "__module__", "")
584
+ if completion_module.startswith("litellm"):
585
+ return True
586
+ return timeout_seconds + grace_seconds < 1.0
587
+
588
+ def _log_token_usage(self, params: dict[str, Any], response: Any) -> None:
589
+ """Log token usage with cache statistics and cost from an LLM response.
590
+
591
+ Args:
592
+ params: Request parameters (for model name)
593
+ response: LLM response object
594
+ """
595
+ usage = getattr(response, "usage", None)
596
+ if not usage:
597
+ return
598
+
599
+ cache_info = ""
600
+ details = getattr(usage, "prompt_tokens_details", None)
601
+ if details:
602
+ cached = getattr(details, "cached_tokens", 0)
603
+ if cached:
604
+ cache_info = f", cached: {cached}"
605
+ cache_creation = getattr(usage, "cache_creation_input_tokens", None)
606
+ cache_read = getattr(usage, "cache_read_input_tokens", None)
607
+ if cache_creation or cache_read:
608
+ cache_info = (
609
+ f", cache_write: {cache_creation or 0}, cache_read: {cache_read or 0}"
610
+ )
611
+
612
+ cost = self._compute_cost_usd(response, params.get("model"))
613
+ cost_suffix = f", cost: ${cost:.6f}" if cost is not None else ""
614
+
615
+ self.logger.info(
616
+ "Token usage - model: %s, input: %s, output: %s, total: %s%s%s",
617
+ params.get("model"),
618
+ usage.prompt_tokens,
619
+ usage.completion_tokens,
620
+ usage.total_tokens,
621
+ cache_info,
622
+ cost_suffix,
623
+ )
624
+
625
+ def _emit_fallback_observability(
626
+ self, response: Any, params: dict[str, Any]
627
+ ) -> None:
628
+ """Surface fallback-routing info to logs and Sentry when applicable.
629
+
630
+ LiteLLM rewrites ``response.model`` to the model that actually served
631
+ the call, so we detect a fallback by comparing it against the model
632
+ we asked for. The check is best-effort: any exception inside this
633
+ helper is swallowed so observability never breaks the request.
634
+
635
+ Args:
636
+ response: The litellm completion response object.
637
+ params: The params dict that was passed to ``litellm.completion`` —
638
+ used to read the originally requested primary model name.
639
+ """
640
+ try:
641
+ primary_model = params.get("model")
642
+ hidden = getattr(response, "_hidden_params", {}) or {}
643
+ served_model = (
644
+ hidden.get("model_id")
645
+ or hidden.get("model")
646
+ or getattr(response, "model", None)
647
+ )
648
+
649
+ if not served_model or served_model == primary_model:
650
+ return
651
+
652
+ self.logger.info(
653
+ "event=llm_fallback_used primary_model=%s served_model=%s",
654
+ primary_model,
655
+ served_model,
656
+ )
657
+
658
+ # Local import keeps sentry out of module-init paths the tests
659
+ # exercise without a Sentry SDK installed. sentry_sdk is an
660
+ # enterprise-only dependency; OSS callers run without it and the
661
+ # ImportError is intentionally absorbed by the outer except.
662
+ import sentry_sdk # type: ignore[import-not-found]
663
+
664
+ sentry_sdk.set_tag("llm.fallback_used", "true")
665
+ sentry_sdk.set_tag("llm.primary_model", str(primary_model))
666
+ sentry_sdk.set_tag("llm.fallback_model", str(served_model))
667
+ except Exception: # noqa: BLE001 — observability must not break the call
668
+ return
669
+
670
+ def _make_request(
671
+ self, messages: list[dict[str, Any]], **kwargs: Any
672
+ ) -> str | BaseModel | ToolCallingChatResponse:
673
+ """
674
+ Make a request to the LLM, delegating cross-model fallback to litellm.
675
+
676
+ Fallback is handed to ``litellm.completion`` via the native ``fallbacks``
677
+ kwarg, but ``num_retries`` is forced to 0: same-model retry of a *hung*
678
+ primary is what made the fallback unreachable and produced the 490s in
679
+ Sentry PYTHON-FASTAPI-62 (see the body comment). So the primary is tried
680
+ once, then each fallback once. The subprocess hard timeout is sized to
681
+ cover that whole ladder. The one retry we still own at the client level
682
+ is a single ``StructuredOutputParseError`` retry: LiteLLM cannot detect a
683
+ post-hoc Pydantic re-validation failure because it sees a successful
684
+ HTTP response.
685
+
686
+ Args:
687
+ messages: List of messages to send.
688
+ **kwargs: Additional parameters (response_format, max_retries,
689
+ fallback_models, tools, etc.).
690
+
691
+ Returns:
692
+ Response content as string, BaseModel instance, or
693
+ ToolCallingChatResponse when the request was in tool-calling mode.
694
+
695
+ Raises:
696
+ LiteLLMClientError: If the request fails after all retries and
697
+ fallbacks have been exhausted by litellm.
698
+ """
699
+ params, response_format, parse_structured_output, _max_retries, fallbacks = (
700
+ self._build_completion_params(messages, **kwargs)
701
+ )
702
+
703
+ # Hand the fallback ladder to litellm, but DISABLE same-model retries.
704
+ # litellm walks [primary, *fallbacks] inside one litellm.completion call,
705
+ # copying ``timeout`` unchanged into each rung. With num_retries>=1 it
706
+ # retries a *hung* primary num_retries+1 times (each up to a full
707
+ # provider timeout) before ever reaching a fallback — making the fallback
708
+ # unreachable within any sane wall-clock bound (root cause of Sentry
709
+ # PYTHON-FASTAPI-62). num_retries=0 makes the fallback LIST the resilience
710
+ # mechanism: each model is tried once, in order.
711
+ params["num_retries"] = 0
712
+ if fallbacks:
713
+ params["fallbacks"] = fallbacks
714
+
715
+ # Size the hard (wall-clock) timeout to cover the WHOLE ladder. litellm
716
+ # copies this single ``params["timeout"]`` into EVERY rung (primary + each
717
+ # fallback), so every rung shares the primary's per-attempt budget and the
718
+ # subprocess must be allowed to run ``(1 + len(fallbacks))`` of them plus
719
+ # one grace buffer before being killed — otherwise it is killed before
720
+ # litellm can reach a fallback.
721
+ #
722
+ # ASYMMETRIC-FLOOR FOOTGUN: because every rung shares one timeout, a
723
+ # fallback whose _MODEL_TIMEOUT_FLOOR_SECONDS floor is HIGHER than the
724
+ # primary's would run — and be killed — at the primary's shorter timeout,
725
+ # reintroducing the "fallback killed early" failure this fix removes. The
726
+ # floor table is single-valued today (MiniMax-M3 == the 120 default), so
727
+ # this is latent; revisit the sizing (e.g. max floor across rungs, passed
728
+ # as ``params["timeout"]``) before adding an asymmetric floor entry.
729
+ per_attempt_timeout = self._coerce_timeout_seconds(params)
730
+ hard_timeout = (
731
+ 1 + len(fallbacks)
732
+ ) * per_attempt_timeout + self._hard_timeout_grace_seconds()
733
+
734
+ request_start = time.perf_counter()
735
+ self.logger.info(
736
+ "event=llm_request_start model=%s timeout=%s has_response_format=%s num_retries=0 fallbacks=%s hard_timeout=%.3f",
737
+ params.get("model"),
738
+ params.get("timeout"),
739
+ response_format is not None,
740
+ fallbacks,
741
+ hard_timeout,
742
+ )
743
+
744
+ def _call_and_parse() -> str | BaseModel | ToolCallingChatResponse:
745
+ response = self._completion_with_hard_timeout(params, hard_timeout)
746
+ self._emit_fallback_observability(response, params)
747
+ message = response.choices[0].message # type: ignore[reportAttributeAccessIssue]
748
+ content = message.content
749
+ self._log_token_usage(params, response)
750
+ self.logger.info(
751
+ "event=llm_request_end model=%s timeout=%s has_response_format=%s elapsed_seconds=%.3f success=%s",
752
+ params.get("model"),
753
+ params.get("timeout"),
754
+ response_format is not None,
755
+ time.perf_counter() - request_start,
756
+ True,
757
+ )
758
+
759
+ # Tool-calling path: return a structured response instead of
760
+ # going through _maybe_parse_structured_output.
761
+ if "tools" in params:
762
+ raw_usage = getattr(response, "usage", None)
763
+ call_cost = self._compute_cost_usd(response, params.get("model"))
764
+ tool_calls = getattr(message, "tool_calls", None)
765
+ # Structured-output + tools: when the model ends the turn with a
766
+ # plain (non-tool) response and a response_format was requested,
767
+ # the content IS the final structured answer. Parse it here so a
768
+ # tool-loop caller can finish on it. A malformed parse raises
769
+ # StructuredOutputParseError, which the outer wrapper retries once.
770
+ parsed_output: BaseModel | None = None
771
+ if response_format is not None and not tool_calls:
772
+ parsed = self._maybe_parse_structured_output(
773
+ content, # type: ignore[reportArgumentType]
774
+ response_format,
775
+ parse_structured_output,
776
+ )
777
+ if isinstance(parsed, BaseModel):
778
+ parsed_output = parsed
779
+ return ToolCallingChatResponse(
780
+ content=content,
781
+ tool_calls=tool_calls,
782
+ finish_reason=response.choices[0].finish_reason, # type: ignore[reportAttributeAccessIssue]
783
+ usage=raw_usage,
784
+ cost_usd=call_cost,
785
+ parsed_output=parsed_output,
786
+ )
787
+
788
+ return self._maybe_parse_structured_output(
789
+ content, # type: ignore[reportArgumentType]
790
+ response_format,
791
+ parse_structured_output,
792
+ )
793
+
794
+ try:
795
+ try:
796
+ return _call_and_parse()
797
+ except StructuredOutputParseError:
798
+ # litellm's fallbacks cover API/timeout errors, but a Pydantic
799
+ # re-validation failure happens AFTER litellm sees a successful
800
+ # 200 — litellm can't detect it, so we owe one explicit second
801
+ # attempt at the model. PR #121 documented this as a MiniMax-M3
802
+ # mitigation. (A hard timeout is NOT retried here: same-model
803
+ # retry of a hang is what produced the 490s in PYTHON-FASTAPI-62;
804
+ # the fallback ladder inside _call_and_parse handles it instead.)
805
+ #
806
+ # This second pass re-walks the full ladder, so the worst-case
807
+ # wall clock is ~2x the ladder bound. That ceiling is only reached
808
+ # if a model returns a malformed-but-successful 200 AND runs near
809
+ # the timeout on BOTH passes — a hang (the common case) raises
810
+ # LLMHardTimeoutError, which is not caught here and exits after a
811
+ # single ladder.
812
+ self.logger.warning(
813
+ "event=llm_parse_retry model=%s — primary returned malformed structured output, retrying once",
814
+ params.get("model"),
815
+ )
816
+ return _call_and_parse()
817
+ except Exception as e:
818
+ self.logger.error(
819
+ "event=llm_request_end model=%s elapsed_seconds=%.3f success=False error_type=%s error=%s",
820
+ params.get("model"),
821
+ time.perf_counter() - request_start,
822
+ type(e).__name__,
823
+ e,
824
+ )
825
+ raise LiteLLMClientError(f"API call failed: {e}") from e
826
+
827
+ def _apply_prompt_caching(
828
+ self, messages: list[dict[str, Any]], model: str
829
+ ) -> list[dict[str, Any]]:
830
+ """
831
+ Apply prompt caching markers for supported providers.
832
+
833
+ For Anthropic models, transforms the system message content into content-block
834
+ format with cache_control markers to enable prefix caching.
835
+ For other providers, returns messages unchanged.
836
+
837
+ Args:
838
+ messages: List of chat messages.
839
+ model: Model name to determine provider.
840
+
841
+ Returns:
842
+ list[dict]: Messages with cache control applied where appropriate.
843
+ """
844
+ model_lower = model.lower()
845
+ # The claude-code/* custom provider routes through the Claude Code CLI,
846
+ # which does not accept Anthropic API cache_control content blocks.
847
+ if model_lower.startswith("claude-code/"):
848
+ return messages
849
+ is_anthropic = "claude" in model_lower or "anthropic" in model_lower
850
+
851
+ if not is_anthropic:
852
+ return messages
853
+
854
+ result = []
855
+ for msg in messages:
856
+ if msg.get("role") == "system" and isinstance(msg.get("content"), str):
857
+ # Transform system message to content-block format with cache_control
858
+ result.append(
859
+ {
860
+ "role": "system",
861
+ "content": [
862
+ {
863
+ "type": "text",
864
+ "text": msg["content"],
865
+ "cache_control": {"type": "ephemeral"},
866
+ }
867
+ ],
868
+ }
869
+ )
870
+ else:
871
+ result.append(msg)
872
+
873
+ return result
874
+
875
+ def _build_user_content(
876
+ self,
877
+ prompt: str,
878
+ images: list[str | bytes | dict] | None = None,
879
+ image_media_type: str | None = None,
880
+ ) -> str | list[dict[str, Any]]:
881
+ """
882
+ Build user content with optional images.
883
+
884
+ Args:
885
+ prompt: Text prompt.
886
+ images: Optional list of images.
887
+ image_media_type: Media type for byte images.
888
+
889
+ Returns:
890
+ String for text-only, or list of content blocks for multi-modal.
891
+ """
892
+ if not images:
893
+ return prompt
894
+
895
+ content_blocks = [{"type": "text", "text": prompt}]
896
+
897
+ for image in images:
898
+ if isinstance(image, dict):
899
+ # Already formatted content block
900
+ content_blocks.append(image)
901
+ elif isinstance(image, bytes):
902
+ # Raw bytes
903
+ media_type = image_media_type or "image/png"
904
+ base64_data = base64.b64encode(image).decode("utf-8")
905
+ content_blocks.append(
906
+ self._create_image_content_block(base64_data, media_type)
907
+ )
908
+ elif isinstance(image, str):
909
+ # File path or URL
910
+ if image.startswith(("http://", "https://")):
911
+ # URL - use directly
912
+ content_blocks.append(
913
+ {"type": "image_url", "image_url": {"url": image}} # type: ignore[reportArgumentType]
914
+ )
915
+ else:
916
+ # File path
917
+ base64_data, media_type = self.encode_image_to_base64(image)
918
+ content_blocks.append(
919
+ self._create_image_content_block(base64_data, media_type)
920
+ )
921
+
922
+ return content_blocks
923
+
924
+ def _create_image_content_block(
925
+ self, base64_data: str, media_type: str
926
+ ) -> dict[str, Any]:
927
+ """
928
+ Create an image content block for the API.
929
+
930
+ Args:
931
+ base64_data: Base64-encoded image data.
932
+ media_type: MIME type of the image.
933
+
934
+ Returns:
935
+ Image content block dictionary.
936
+ """
937
+ return {
938
+ "type": "image_url",
939
+ "image_url": {"url": f"data:{media_type};base64,{base64_data}"},
940
+ }
941
+
942
+ def encode_image_to_base64(self, image_path: str) -> tuple[str, str]:
943
+ """
944
+ Encode an image file to base64.
945
+
946
+ Delegates to :func:`reflexio.server.llm.image_utils.encode_image_to_base64`
947
+ and wraps errors as :class:`LiteLLMClientError`.
948
+
949
+ Args:
950
+ image_path (str): Path to the image file.
951
+
952
+ Returns:
953
+ tuple[str, str]: ``(base64_data, media_type)`` pair.
954
+
955
+ Raises:
956
+ LiteLLMClientError: If the image cannot be read or format is unsupported.
957
+ """
958
+ try:
959
+ return _encode_image_to_base64(image_path)
960
+ except ImageEncodingError as exc:
961
+ raise LiteLLMClientError(str(exc)) from exc
962
+
963
+ def _is_temperature_restricted_model(self, model: str) -> bool:
964
+ """
965
+ Check if a model has temperature restrictions (e.g., GPT-5 and Gemini 3 models only support temperature=1.0).
966
+
967
+ Args:
968
+ model: Model name to check.
969
+
970
+ Returns:
971
+ True if the model has temperature restrictions.
972
+ """
973
+ model_lower = model.lower()
974
+ # Strip provider routing prefixes (e.g., "openrouter/openai/gpt-5-nano" -> "gpt-5-nano")
975
+ model_name = model_lower.rsplit("/", 1)[-1]
976
+ # Check if model starts with any of the restricted model prefixes
977
+ return any(
978
+ model_name.startswith(restricted) or model_name == restricted
979
+ for restricted in self.TEMPERATURE_RESTRICTED_MODELS
980
+ )