claude-smart 0.2.47 → 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 (113) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  7. package/plugin/dashboard/package-lock.json +61 -390
  8. package/plugin/dashboard/package.json +2 -2
  9. package/plugin/pyproject.toml +1 -1
  10. package/plugin/uv.lock +1 -1
  11. package/plugin/vendor/reflexio/.env.example +7 -0
  12. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  13. package/plugin/vendor/reflexio/reflexio/README.md +8 -5
  14. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  15. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  16. package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
  17. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  18. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
  19. package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
  20. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  21. package/plugin/vendor/reflexio/reflexio/server/api.py +133 -3273
  22. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  23. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  24. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  25. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  26. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  27. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  28. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  29. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  30. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  31. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  32. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  33. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
  34. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  35. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  36. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  37. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  38. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  39. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  40. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  41. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  42. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  43. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  44. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  45. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  46. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  47. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  48. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  49. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  50. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
  51. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  52. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  53. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  54. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  55. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  56. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  57. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  58. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  59. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  60. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  61. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  62. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
  63. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
  64. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  65. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  66. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  68. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -4
  70. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  71. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +56 -351
  72. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
  73. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
  74. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
  75. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  76. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  77. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  87. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  88. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  89. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +6 -3
  90. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
  91. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  95. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +33 -8
  96. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  97. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  103. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  104. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
@@ -5,60 +5,82 @@ This module provides a unified interface to multiple LLM providers (OpenAI, Clau
5
5
  using LiteLLM. It maintains the same interface as the existing LLMClient for easy replacement.
6
6
  """
7
7
 
8
- import base64
9
- import json
10
8
  import logging
11
- import multiprocessing
12
9
  import os
13
- import pickle
14
- import queue
15
- import re
16
- import time
17
- from dataclasses import dataclass, field
18
- from functools import lru_cache
19
- from typing import Any
20
10
 
21
11
  import litellm
22
- import tiktoken
23
- from pydantic import BaseModel
24
12
 
25
13
  from reflexio.models.config_schema import APIKeyConfig
26
- from reflexio.server.llm.image_utils import (
27
- SUPPORTED_IMAGE_MIME_TYPES,
28
- ImageEncodingError,
14
+ from reflexio.server.llm._litellm_embedding import (
15
+ _TRUNCATION_WARNED_MODELS as _TRUNCATION_WARNED_MODELS,
29
16
  )
30
- from reflexio.server.llm.image_utils import (
31
- encode_image_to_base64 as _encode_image_to_base64,
17
+
18
+ # Identity-preserving re-exports (SINK-2): every moved name is re-bound here by
19
+ # import, never redefined, so ``from ...litellm_client import <name>`` keeps
20
+ # resolving the SAME object/class the moved code uses and tests touch. The facade
21
+ # now composes the three concern mixins; the client-core __init__/creds/config
22
+ # accessors + create_litellm_client stay here.
23
+ from reflexio.server.llm._litellm_embedding import (
24
+ EmbeddingMixin,
32
25
  )
33
- from reflexio.server.llm.llm_utils import (
34
- assert_provider_safe_schema,
35
- is_pydantic_model,
36
- strict_response_format_for_model,
26
+ from reflexio.server.llm._litellm_embedding import (
27
+ _get_embedding_encoding as _get_embedding_encoding,
37
28
  )
38
- from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
39
- from reflexio.server.llm.providers.claude_code_provider import (
40
- register_if_enabled as _register_claude_code,
29
+ from reflexio.server.llm._litellm_embedding import (
30
+ _get_embedding_limit as _get_embedding_limit,
41
31
  )
42
- from reflexio.server.llm.providers.embedding_service_provider import (
43
- EmbeddingUnavailableError,
44
- embedding_provider_mode,
45
- get_service_embeddings,
46
- should_use_embedding_service,
32
+ from reflexio.server.llm._litellm_embedding import (
33
+ _truncate_for_embedding as _truncate_for_embedding,
47
34
  )
48
- from reflexio.server.llm.providers.local_embedding_provider import (
49
- LocalEmbedder,
35
+ from reflexio.server.llm._litellm_json_extraction import (
36
+ _extract_json_from_string as _extract_json_from_string,
50
37
  )
51
- from reflexio.server.llm.providers.local_embedding_provider import (
52
- is_chromadb_importable as _is_chromadb_importable,
38
+ from reflexio.server.llm._litellm_json_extraction import (
39
+ _sanitize_json_string as _sanitize_json_string,
53
40
  )
54
- from reflexio.server.llm.providers.local_embedding_provider import (
55
- register_if_chromadb_available as _register_local_embedder,
41
+ from reflexio.server.llm._litellm_structured_output import (
42
+ StructuredOutputMixin,
56
43
  )
57
- from reflexio.server.llm.providers.nomic_embedding_provider import (
58
- NomicEmbedder,
44
+ from reflexio.server.llm._litellm_subprocess import (
45
+ _CompletionChoiceSnapshot as _CompletionChoiceSnapshot,
59
46
  )
60
- from reflexio.server.llm.providers.nomic_embedding_provider import (
61
- is_nomic_model as _is_nomic_model,
47
+ from reflexio.server.llm._litellm_subprocess import (
48
+ _CompletionErrorSnapshot as _CompletionErrorSnapshot,
49
+ )
50
+ from reflexio.server.llm._litellm_subprocess import (
51
+ _CompletionMessageSnapshot as _CompletionMessageSnapshot,
52
+ )
53
+ from reflexio.server.llm._litellm_subprocess import (
54
+ _CompletionResponseSnapshot as _CompletionResponseSnapshot,
55
+ )
56
+ from reflexio.server.llm._litellm_subprocess import (
57
+ _CompletionUsageSnapshot as _CompletionUsageSnapshot,
58
+ )
59
+ from reflexio.server.llm._litellm_subprocess import (
60
+ _litellm_completion_worker as _litellm_completion_worker,
61
+ )
62
+ from reflexio.server.llm._litellm_subprocess import (
63
+ _PromptTokenDetailsSnapshot as _PromptTokenDetailsSnapshot,
64
+ )
65
+ from reflexio.server.llm._litellm_text_generation import (
66
+ TextGenerationMixin,
67
+ )
68
+ from reflexio.server.llm._litellm_types import (
69
+ LiteLLMClientError,
70
+ LiteLLMConfig,
71
+ ToolCallingChatResponse,
72
+ )
73
+ from reflexio.server.llm._litellm_types import (
74
+ LLMHardTimeoutError as LLMHardTimeoutError,
75
+ )
76
+ from reflexio.server.llm._litellm_types import (
77
+ StructuredOutputParseError as StructuredOutputParseError,
78
+ )
79
+ from reflexio.server.llm.providers.claude_code_provider import (
80
+ register_if_enabled as _register_claude_code,
81
+ )
82
+ from reflexio.server.llm.providers.local_embedding_provider import (
83
+ register_if_chromadb_available as _register_local_embedder,
62
84
  )
63
85
  from reflexio.server.llm.providers.nomic_embedding_provider import (
64
86
  register_if_enabled as _register_nomic_embedder,
@@ -77,388 +99,18 @@ _register_openclaw()
77
99
  _register_local_embedder()
78
100
  _register_nomic_embedder()
79
101
 
80
- _LOGGER = logging.getLogger(__name__)
81
-
82
- # OpenAI's documented max input length for text-embedding-3-* and ada-002 is
83
- # 8191 tokens. Used as the fallback limit only when a model's name looks
84
- # OpenAI-family but litellm's registry has no entry for it.
85
- _OPENAI_EMBEDDING_FALLBACK_MAX_TOKENS = 8191
86
-
87
- # Models whose truncation warning has already been emitted this process. Keeps
88
- # batch backfills of millions of long docs from flooding logs — the first hit
89
- # per model goes to WARNING, everything after to DEBUG.
90
- _TRUNCATION_WARNED_MODELS: set[str] = set()
91
-
92
- # Model-name prefixes that route through OpenAI's embedding API (and therefore
93
- # share the 8191-token cap). Anything that does not start with one of these is
94
- # treated as "unknown provider" when litellm has no registry entry.
95
- _OPENAI_EMBEDDING_FAMILY_PREFIXES = ("text-embedding-", "openai/", "azure/")
96
-
97
- # Python-to-JSON keyword replacements used by _sanitize_json_string.
98
- _PYTHON_TO_JSON_REPLACEMENTS = {"True": "true", "False": "false", "None": "null"}
99
-
100
-
101
- @lru_cache(maxsize=32)
102
- def _get_embedding_limit(model: str) -> int | None:
103
- """
104
- Resolve the maximum input token count for an embedding model.
105
-
106
- Consults ``litellm.get_model_info`` first so provider-specific caps are
107
- respected (OpenAI ~8191, Cohere 512, Voyage 32000, etc.). When litellm has
108
- no entry for the model, falls back to the OpenAI 8191 cap only when the
109
- model name looks OpenAI-family; otherwise returns ``None`` to disable
110
- truncation for unknown providers (safer than over-truncating their input).
111
-
112
- Args:
113
- model (str): Embedding model name (e.g. 'text-embedding-3-small',
114
- 'cohere/embed-english-v3.0').
115
-
116
- Returns:
117
- int | None: Maximum input tokens, or ``None`` when the limit is unknown
118
- and no safe fallback applies.
119
- """
120
- try:
121
- info = litellm.get_model_info(model)
122
- except Exception:
123
- info = None
124
- if info and info.get("mode") == "embedding":
125
- max_tokens = info.get("max_input_tokens")
126
- if isinstance(max_tokens, int) and max_tokens > 0:
127
- return max_tokens
128
- if model.startswith(_OPENAI_EMBEDDING_FAMILY_PREFIXES):
129
- return _OPENAI_EMBEDDING_FALLBACK_MAX_TOKENS
130
- return None
131
-
132
-
133
- @lru_cache(maxsize=16)
134
- def _get_embedding_encoding(model: str) -> tiktoken.Encoding:
135
- """
136
- Return the tiktoken encoding for an embedding model, falling back to cl100k_base.
137
-
138
- For non-OpenAI providers tiktoken does not know the real tokenizer, so the
139
- cl100k_base fallback is an approximate proxy for token counting. That is
140
- acceptable here because we truncate toward the provider's cap with the
141
- proxy, which tends to over-truncate by a small fraction rather than under-
142
- truncate and cause upstream 400s.
143
-
144
- Args:
145
- model (str): Embedding model name (e.g. 'text-embedding-3-small').
146
-
147
- Returns:
148
- tiktoken.Encoding: Encoder to use for token counting and truncation.
149
- """
150
- try:
151
- return tiktoken.encoding_for_model(model)
152
- except KeyError:
153
- return tiktoken.get_encoding("cl100k_base")
154
-
155
-
156
- def _reject_cloud_mode(embedding_model: str, mode: str) -> None:
157
- """
158
- Raise when a local-only embedding model is configured for cloud mode.
159
-
160
- Args:
161
- embedding_model (str): The resolved embedding model name.
162
- mode (str): The resolved embedding provider mode.
163
-
164
- Raises:
165
- EmbeddingUnavailableError: If ``mode`` is ``"cloud"``.
166
- """
167
- if mode == "cloud":
168
- raise EmbeddingUnavailableError(
169
- f"Local embedding model {embedding_model!r} cannot use cloud mode"
170
- )
171
-
172
-
173
- def _truncate_for_embedding(
174
- text: str, model: str, max_tokens: int | None = None
175
- ) -> str:
176
- """
177
- Truncate a string so its token count fits within an embedding model's input limit.
178
-
179
- The token budget is auto-resolved from ``_get_embedding_limit`` by default.
180
- When the model has no known limit (unknown provider not in litellm's
181
- registry and not OpenAI-family), returns the text unchanged — over-
182
- truncating an unknown provider's input is worse than passing it through
183
- and letting the provider's own error surface.
184
-
185
- Args:
186
- text (str): Raw input text.
187
- model (str): Embedding model name, used to pick the tokenizer and the
188
- per-provider token cap.
189
- max_tokens (int | None): Override for the resolved budget. Primarily
190
- used by tests to exercise the truncation path on short strings;
191
- leave as ``None`` in production callers.
192
-
193
- Returns:
194
- str: Original text if it already fits (or the model has no known
195
- limit), otherwise a token-bounded prefix.
196
- """
197
- if not text:
198
- return text
199
- if max_tokens is None:
200
- max_tokens = _get_embedding_limit(model)
201
- if max_tokens is None:
202
- return text
203
- encoding = _get_embedding_encoding(model)
204
- tokens = encoding.encode(text, disallowed_special=())
205
- if len(tokens) <= max_tokens:
206
- return text
207
- if model in _TRUNCATION_WARNED_MODELS:
208
- _LOGGER.debug(
209
- "Truncating embedding input from %d to %d tokens for model %s",
210
- len(tokens),
211
- max_tokens,
212
- model,
213
- )
214
- else:
215
- _TRUNCATION_WARNED_MODELS.add(model)
216
- _LOGGER.warning(
217
- "Truncating embedding input from %d to %d tokens for model %s "
218
- "(further occurrences will be logged at DEBUG)",
219
- len(tokens),
220
- max_tokens,
221
- model,
222
- )
223
- return encoding.decode(tokens[:max_tokens])
224
-
225
-
226
- @dataclass
227
- class LiteLLMConfig:
228
- """
229
- Configuration for LiteLLM client.
230
-
231
- Args:
232
- model: Model name to use (e.g., 'gpt-4o', 'claude-3-5-sonnet-20241022').
233
- temperature: Temperature for response generation (0.0 to 2.0).
234
- max_tokens: Maximum tokens to generate.
235
- timeout: Request timeout in seconds.
236
- max_retries: Maximum same-model retry attempts. Used by the embedding
237
- path (litellm's num_retries) and clamped in _build_completion_params.
238
- NOT used on the chat-completion path — that forces num_retries=0 so a
239
- hung primary can't be retried before the fallback (PYTHON-FASTAPI-62).
240
- Default 3.
241
- retry_delay: Currently unused — LiteLLM owns retry backoff. Kept for
242
- backward compatibility; remove in a follow-up sweep.
243
- top_p: Top-p sampling parameter.
244
- api_key_config: Optional API key configuration from Config (overrides env vars).
245
- fallback_models: Models LiteLLM tries in order after the primary's single
246
- attempt. Passed directly to litellm's fallbacks param.
247
- Default is an empty list (no fallback) so local reflexio and the
248
- claude-smart integration are never silently routed to an unintended
249
- provider. Production opts in via the env var
250
- REFLEXIO_LLM_FALLBACK_MODELS (comma-separated, e.g. "gpt-5.4-mini").
251
- Self-references are deduped at request time.
252
- """
253
-
254
- model: str
255
- temperature: float = 0.7
256
- max_tokens: int | None = None
257
- timeout: int = 120
258
- max_retries: int = 3
259
- retry_delay: float = 1.0
260
- top_p: float = 1.0
261
- api_key_config: APIKeyConfig | None = None
262
- fallback_models: list[str] = field(
263
- default_factory=lambda: [
264
- m.strip()
265
- for m in os.environ.get("REFLEXIO_LLM_FALLBACK_MODELS", "").split(",")
266
- if m.strip()
267
- ]
268
- )
269
-
270
-
271
- # Per-model provider-timeout floors. Values are floors, not overrides: the
272
- # effective timeout is max(configured, floor), and an explicit per-call timeout
273
- # kwarg always wins.
274
- #
275
- # MiniMax-M3 was pinned to 240s when it was the sole model. That let a *hung*
276
- # primary block ~240s before falling back, dominating the wasted time behind
277
- # Sentry PYTHON-FASTAPI-62. It is now floored at the 120s default so a hang is
278
- # abandoned sooner and the fallback (e.g. gpt-5-mini) is reached faster. This is
279
- # the key post-deploy tuning knob: raise it if legitimately-slow calls start
280
- # timing out, lower it to cut more waste.
281
- _MODEL_TIMEOUT_FLOOR_SECONDS: dict[str, int] = {
282
- "minimax/MiniMax-M3": 120,
283
- }
284
-
285
-
286
- @dataclass
287
- class ToolCallingChatResponse:
288
- """Response from a chat call that was routed in tool-calling mode.
289
-
290
- Returned instead of ``str | BaseModel`` whenever the caller passes
291
- ``tools=...`` to ``generate_chat_response``. Callers inspect
292
- ``tool_calls`` to drive a tool loop; ``content`` is set on the
293
- terminal (non-tool) turn.
294
-
295
- Args:
296
- content: Text content from the model, or None when the model emitted tool calls.
297
- tool_calls: List of tool call objects from the model, or None on the terminal turn.
298
- finish_reason: The stop reason reported by the provider (e.g. "tool_calls", "stop").
299
- usage: Raw usage object from the LLM response (provider-dependent shape), or None.
300
- cost_usd: Estimated cost in USD for this call via litellm price table, or None when
301
- the provider is not in the table (local ONNX, claude-code CLI, etc.).
302
- parsed_output: When ``response_format`` is passed alongside ``tools`` and the model
303
- ends the turn with a plain (non-tool) response, the content parsed into the
304
- ``response_format`` schema. None when the turn emitted tool calls, when no
305
- ``response_format`` was requested, or when the content was not parseable.
306
- """
307
-
308
- content: str | None
309
- tool_calls: list[Any] | None
310
- finish_reason: str | None
311
- usage: Any | None = None
312
- cost_usd: float | None = None
313
- parsed_output: BaseModel | None = None
314
-
315
-
316
- class LiteLLMClientError(Exception):
317
- """Custom exception for LiteLLM client errors."""
318
-
319
-
320
- class StructuredOutputParseError(Exception):
321
- """Raised when a structured-output LLM call returns content that cannot be parsed.
322
-
323
- Caught by the retry loop in ``_make_request`` so a malformed response
324
- burns a retry attempt rather than silently returning unparsed content.
325
- """
326
-
327
-
328
- class LLMHardTimeoutError(TimeoutError):
329
- """Raised when an LLM call exceeds the client-side wall-clock timeout."""
330
-
331
-
332
- @dataclass
333
- class _CompletionMessageSnapshot:
334
- content: str | None = None
335
- tool_calls: Any | None = None
336
-
337
-
338
- @dataclass
339
- class _CompletionChoiceSnapshot:
340
- message: _CompletionMessageSnapshot
341
- finish_reason: str | None = None
342
-
343
-
344
- @dataclass
345
- class _PromptTokenDetailsSnapshot:
346
- cached_tokens: int = 0
347
-
348
-
349
- @dataclass
350
- class _CompletionUsageSnapshot:
351
- prompt_tokens: int | None = None
352
- completion_tokens: int | None = None
353
- total_tokens: int | None = None
354
- prompt_tokens_details: _PromptTokenDetailsSnapshot | None = None
355
- cache_creation_input_tokens: int | None = None
356
- cache_read_input_tokens: int | None = None
357
-
358
-
359
- @dataclass
360
- class _CompletionResponseSnapshot:
361
- choices: list[_CompletionChoiceSnapshot]
362
- usage: _CompletionUsageSnapshot | None = None
363
- model: str | None = None
364
- _hidden_params: dict[str, Any] = field(default_factory=dict)
365
-
366
-
367
- @dataclass
368
- class _CompletionErrorSnapshot:
369
- type_name: str
370
- message: str
371
- model: str | None = None
372
- llm_provider: str | None = None
373
-
374
-
375
- def _snapshot_completion_error(
376
- exc: BaseException, params: dict[str, Any]
377
- ) -> _CompletionErrorSnapshot:
378
- model = getattr(exc, "model", None) or params.get("model")
379
- llm_provider = getattr(exc, "llm_provider", None)
380
- return _CompletionErrorSnapshot(
381
- type_name=type(exc).__name__,
382
- message=str(exc),
383
- model=str(model) if model else None,
384
- llm_provider=str(llm_provider) if llm_provider else None,
385
- )
386
-
387
-
388
- def _ensure_picklable(value: Any) -> Any:
389
- try:
390
- pickle.dumps(value)
391
- except Exception:
392
- return repr(value)
393
- return value
394
-
395
-
396
- def _snapshot_completion_response(response: Any) -> _CompletionResponseSnapshot:
397
- choices: list[_CompletionChoiceSnapshot] = []
398
- for choice in getattr(response, "choices", []) or []:
399
- message = getattr(choice, "message", None)
400
- choices.append(
401
- _CompletionChoiceSnapshot(
402
- message=_CompletionMessageSnapshot(
403
- content=getattr(message, "content", None),
404
- tool_calls=_ensure_picklable(getattr(message, "tool_calls", None)),
405
- ),
406
- finish_reason=getattr(choice, "finish_reason", None),
407
- )
408
- )
409
-
410
- usage = getattr(response, "usage", None)
411
- usage_snapshot = None
412
- if usage is not None:
413
- prompt_details = getattr(usage, "prompt_tokens_details", None)
414
- prompt_details_snapshot = None
415
- if prompt_details is not None:
416
- prompt_details_snapshot = _PromptTokenDetailsSnapshot(
417
- cached_tokens=int(getattr(prompt_details, "cached_tokens", 0) or 0)
418
- )
419
- usage_snapshot = _CompletionUsageSnapshot(
420
- prompt_tokens=getattr(usage, "prompt_tokens", None),
421
- completion_tokens=getattr(usage, "completion_tokens", None),
422
- total_tokens=getattr(usage, "total_tokens", None),
423
- prompt_tokens_details=prompt_details_snapshot,
424
- cache_creation_input_tokens=getattr(
425
- usage, "cache_creation_input_tokens", None
426
- ),
427
- cache_read_input_tokens=getattr(usage, "cache_read_input_tokens", None),
428
- )
429
-
430
- hidden_params = getattr(response, "_hidden_params", {}) or {}
431
- if not isinstance(hidden_params, dict):
432
- hidden_params = {}
433
-
434
- return _CompletionResponseSnapshot(
435
- choices=choices,
436
- usage=usage_snapshot,
437
- model=getattr(response, "model", None),
438
- _hidden_params={str(k): _ensure_picklable(v) for k, v in hidden_params.items()},
439
- )
440
-
441
-
442
- def _picklable_completion_result(response: Any) -> Any:
443
- try:
444
- pickle.dumps(response)
445
- except Exception:
446
- return _snapshot_completion_response(response)
447
- return response
448
-
449
-
450
- def _litellm_completion_worker(
451
- params: dict[str, Any], result_queue: multiprocessing.Queue
452
- ) -> None:
453
- try:
454
- result_queue.put(
455
- ("ok", _picklable_completion_result(litellm.completion(**params)))
456
- )
457
- except BaseException as exc:
458
- result_queue.put(("error", _snapshot_completion_error(exc, params)))
459
-
460
-
461
- class LiteLLMClient:
102
+ # Public importer surface (the #1 invariant of the Tier-2.5 decomposition). These
103
+ # five names — plus the test-imported internals re-exported below the split — must
104
+ # stay importable from ``reflexio.server.llm.litellm_client`` for all ~102 importers.
105
+ __all__ = [
106
+ "LiteLLMClient",
107
+ "LiteLLMConfig",
108
+ "LiteLLMClientError",
109
+ "ToolCallingChatResponse",
110
+ "create_litellm_client",
111
+ ]
112
+
113
+ class LiteLLMClient(TextGenerationMixin, EmbeddingMixin, StructuredOutputMixin):
462
114
  """
463
115
  Unified LLM client using LiteLLM for multi-provider support.
464
116
 
@@ -466,8 +118,6 @@ class LiteLLMClient:
466
118
  Provides structured output support, multi-modal (image) input, and embeddings.
467
119
  """
468
120
 
469
- SUPPORTED_IMAGE_FORMATS: set[str] = set(SUPPORTED_IMAGE_MIME_TYPES.keys())
470
-
471
121
  # Providers that use a simple "prefix/" -> api_key mapping
472
122
  _SIMPLE_PROVIDER_PREFIXES: dict[str, str] = {
473
123
  "gemini/": "gemini",
@@ -479,25 +129,6 @@ class LiteLLMClient:
479
129
  "xai/": "xai",
480
130
  }
481
131
 
482
- # OpenAI-compatible providers that accept a ``json_schema`` response_format
483
- # but that ``litellm.supports_response_schema`` reports as unsupported. For
484
- # these, the gate below would fall back to handing LiteLLM the raw Pydantic
485
- # model; LiteLLM then builds the ``json_schema`` itself and emits ``oneOf``
486
- # for discriminated unions, which strict structured-output endpoints reject
487
- # (Sentry PYTHON-FASTAPI-9J). Listing the provider here forces our own
488
- # normalized strict schema (``oneOf`` folded into ``anyOf``) to be sent.
489
- _JSON_SCHEMA_PROVIDER_ALLOWLIST: frozenset[str] = frozenset({"minimax"})
490
-
491
- # Models that only support temperature=1.0 (custom values cause errors or degraded performance)
492
- TEMPERATURE_RESTRICTED_MODELS = {
493
- "gpt-5",
494
- "gpt-5.4-mini",
495
- "gpt-5-nano",
496
- "gpt-5-codex",
497
- "gemini-3-flash-preview",
498
- "gemini-3-pro-preview",
499
- }
500
-
501
132
  def __init__(self, config: LiteLLMConfig):
502
133
  """
503
134
  Initialize the LiteLLM client.
@@ -607,1383 +238,6 @@ class LiteLLMClient:
607
238
 
608
239
  return None, None, None
609
240
 
610
- def generate_response(
611
- self,
612
- prompt: str,
613
- system_message: str | None = None,
614
- images: list[str | bytes | dict] | None = None,
615
- image_media_type: str | None = None,
616
- **kwargs: Any,
617
- ) -> str | BaseModel | ToolCallingChatResponse:
618
- """
619
- Generate a response using the configured LLM.
620
-
621
- Args:
622
- prompt: The user prompt/message.
623
- system_message: Optional system message to set context.
624
- images: Optional list of images (file paths, bytes, or pre-formatted content blocks).
625
- image_media_type: Media type for images if passing bytes (e.g., 'image/png').
626
- **kwargs: Additional parameters including:
627
- - response_format: Pydantic BaseModel class for structured output
628
- - parse_structured_output: Whether to parse structured output (default True)
629
- - temperature: Override config temperature
630
- - max_tokens: Override config max_tokens
631
-
632
- Returns:
633
- Generated response content. Returns string for text responses,
634
- or BaseModel instance for Pydantic model responses.
635
-
636
- Raises:
637
- LiteLLMClientError: If the API call fails after all retries,
638
- or if response_format is not a Pydantic BaseModel class.
639
- """
640
- # Validate response_format if provided
641
- response_format = kwargs.get("response_format")
642
- if response_format is not None and not is_pydantic_model(response_format):
643
- raise LiteLLMClientError(
644
- "response_format must be a Pydantic BaseModel class, "
645
- f"got {type(response_format).__name__}"
646
- )
647
-
648
- # Build user message content
649
- user_content = self._build_user_content(prompt, images, image_media_type)
650
-
651
- # Build messages list
652
- messages = []
653
- if system_message:
654
- messages.append({"role": "system", "content": system_message})
655
- messages.append({"role": "user", "content": user_content})
656
-
657
- return self._make_request(messages, **kwargs)
658
-
659
- def generate_chat_response(
660
- self,
661
- messages: list[dict[str, Any]],
662
- system_message: str | None = None,
663
- *,
664
- tools: list[Any] | None = None,
665
- tool_choice: str | dict[str, Any] | None = None,
666
- model_role: ModelRole | None = None,
667
- max_retries: int | None = None,
668
- fallback_models: list[str] | None = None,
669
- **kwargs: Any,
670
- ) -> str | BaseModel | ToolCallingChatResponse:
671
- """
672
- Generate a response from a list of chat messages.
673
-
674
- Args:
675
- messages: List of messages in chat format [{"role": "...", "content": "..."}].
676
- system_message: Optional system message to prepend.
677
- tools: Optional list of tool definitions for tool-calling mode.
678
- When provided, the return type is ``ToolCallingChatResponse``.
679
- tool_choice: Optional tool choice control ("auto", "none", "required",
680
- or a dict specifying a particular tool). Forwarded to the provider.
681
- model_role: Optional ``ModelRole`` to override the model selected for
682
- this request. The role is resolved via ``resolve_model_name`` using
683
- the client's ``api_key_config``.
684
- max_retries (int | None): Optional per-call override for the number of
685
- retry attempts. When ``None`` (the default), the value falls back to
686
- ``LiteLLMConfig.max_retries``.
687
- fallback_models (list[str] | None): Optional per-call override for the
688
- fallback model chain. When ``None`` (the default), the value falls
689
- back to ``LiteLLMConfig.fallback_models``.
690
- **kwargs: Additional parameters including:
691
- - response_format: Pydantic BaseModel class for structured output
692
- - parse_structured_output: Whether to parse structured output (default True)
693
- - temperature: Override config temperature
694
- - max_tokens: Override config max_tokens
695
-
696
- Returns:
697
- Generated response content. Returns string for text responses,
698
- ``BaseModel`` instance for Pydantic model responses, or
699
- ``ToolCallingChatResponse`` when ``tools`` is provided.
700
-
701
- Raises:
702
- LiteLLMClientError: If the API call fails after all retries,
703
- or if response_format is not a Pydantic BaseModel class.
704
- """
705
- # Validate response_format if provided
706
- response_format = kwargs.get("response_format")
707
- if response_format is not None and not is_pydantic_model(response_format):
708
- raise LiteLLMClientError(
709
- "response_format must be a Pydantic BaseModel class, "
710
- f"got {type(response_format).__name__}"
711
- )
712
-
713
- # Prepend system message if provided
714
- final_messages = list(messages)
715
- if system_message:
716
- # Check if first message is already a system message
717
- if final_messages and final_messages[0].get("role") == "system":
718
- # Merge with existing system message
719
- final_messages[0]["content"] = (
720
- f"{system_message}\n\n{final_messages[0]['content']}"
721
- )
722
- else:
723
- final_messages.insert(0, {"role": "system", "content": system_message})
724
-
725
- # Forward tool-calling and model-role kwargs into _make_request
726
- if tools is not None:
727
- kwargs["tools"] = tools
728
- if tool_choice is not None:
729
- kwargs["tool_choice"] = tool_choice
730
- if model_role is not None:
731
- kwargs["model_role"] = model_role
732
- if max_retries is not None:
733
- kwargs["max_retries"] = max_retries
734
- if fallback_models is not None:
735
- kwargs["fallback_models"] = fallback_models
736
-
737
- return self._make_request(final_messages, **kwargs)
738
-
739
- def _resolve_default_embedding_model(self) -> str:
740
- """
741
- Resolve the embedding model to use when callers do not specify one.
742
-
743
- Routes through the same auto-detection chain as the rest of reflexio
744
- (``resolve_model_name`` for ``ModelRole.EMBEDDING``) so a session that
745
- has the local ONNX embedder enabled — or any non-OpenAI provider —
746
- does not silently fall back to ``text-embedding-3-small`` and produce
747
- OpenAI 401s. Higher-precedence org config and site-var overrides are
748
- the caller's responsibility to resolve and pass via ``model=``; this
749
- helper handles only the auto-detect tier.
750
-
751
- Returns:
752
- str: The auto-detected embedding model name (cached after first call).
753
-
754
- Raises:
755
- RuntimeError: Propagated from ``resolve_model_name`` when no
756
- embedding-capable provider is available.
757
- """
758
- if self._default_embedding_model is None:
759
- self._default_embedding_model = resolve_model_name(
760
- ModelRole.EMBEDDING,
761
- api_key_config=self.config.api_key_config,
762
- )
763
- return self._default_embedding_model
764
-
765
- def get_embedding(
766
- self, text: str, model: str | None = None, dimensions: int | None = None
767
- ) -> list[float]:
768
- """
769
- Get embedding vector for the given text.
770
-
771
- Args:
772
- text: The text to get embedding for.
773
- model: Optional embedding model. When omitted, the model is
774
- auto-detected via ``resolve_model_name(ModelRole.EMBEDDING)``
775
- so callers inherit the local-embedder gate and any non-OpenAI
776
- provider configured for this client.
777
- dimensions: Optional number of dimensions for the embedding vector.
778
-
779
- Returns:
780
- List of floats representing the embedding vector.
781
-
782
- Raises:
783
- LiteLLMClientError: If embedding generation fails.
784
- """
785
- embedding_model = model or self._resolve_default_embedding_model()
786
- mode = embedding_provider_mode(embedding_model)
787
- if mode == "off":
788
- raise EmbeddingUnavailableError("Embedding provider is disabled")
789
- if should_use_embedding_service(embedding_model):
790
- return get_service_embeddings(
791
- [text], model=embedding_model, dimensions=dimensions
792
- )[0]
793
-
794
- # local/nomic-embed-* must stay on the Nomic provider (137M params,
795
- # 768d Matryoshka-truncated to 512). Falling through to MiniLM would
796
- # mix embedding models inside existing vector stores.
797
- if _is_nomic_model(embedding_model):
798
- _reject_cloud_mode(embedding_model, mode)
799
- try:
800
- return NomicEmbedder.get().embed([text])[0]
801
- except Exception as e:
802
- raise LiteLLMClientError(
803
- f"Nomic embedding generation failed: {str(e)}"
804
- ) from e
805
-
806
- # local/* models route through the in-process ONNX embedder — no
807
- # network call, no litellm API, no tiktoken truncation (the embedder
808
- # applies its own token cap). The dispatch is gated solely on
809
- # ``chromadb`` being importable; the env-var opt-in (claude-smart's
810
- # ``CLAUDE_SMART_USE_LOCAL_EMBEDDING``) is enforced earlier in the
811
- # auto-detection layer (see ``model_defaults._auto_detect_model``).
812
- if embedding_model.startswith("local/"):
813
- _reject_cloud_mode(embedding_model, mode)
814
- if not _is_chromadb_importable():
815
- raise LiteLLMClientError(
816
- f"Embedding model {embedding_model!r} requires chromadb. "
817
- "Run `pip install chromadb`."
818
- )
819
- try:
820
- return LocalEmbedder.get().embed([text])[0]
821
- except Exception as e:
822
- raise LiteLLMClientError(
823
- f"Local embedding generation failed: {str(e)}"
824
- ) from e
825
-
826
- text = _truncate_for_embedding(text, embedding_model)
827
-
828
- try:
829
- params = {"model": embedding_model, "input": [text]}
830
- if dimensions:
831
- params["dimensions"] = dimensions
832
-
833
- # Resolve and add API key configuration if provided (overrides env vars)
834
- api_key, api_base, api_version = self._resolve_api_key(
835
- embedding_model, for_embedding=True
836
- )
837
- if api_key:
838
- params["api_key"] = api_key
839
- if api_base:
840
- params["api_base"] = api_base
841
- if api_version:
842
- params["api_version"] = api_version
843
-
844
- response = litellm.embedding(
845
- **params,
846
- timeout=self.config.timeout,
847
- num_retries=self.config.max_retries,
848
- )
849
- return response.data[0]["embedding"]
850
- except Exception as e:
851
- raise LiteLLMClientError(f"Embedding generation failed: {str(e)}") from e
852
-
853
- def get_embeddings(
854
- self,
855
- texts: list[str],
856
- model: str | None = None,
857
- dimensions: int | None = None,
858
- ) -> list[list[float]]:
859
- """
860
- Get embedding vectors for multiple texts in a single API call.
861
-
862
- Args:
863
- texts: List of texts to get embeddings for.
864
- model: Optional embedding model. When omitted, the model is
865
- auto-detected via ``resolve_model_name(ModelRole.EMBEDDING)``
866
- so callers inherit the local-embedder gate and any non-OpenAI
867
- provider configured for this client.
868
- dimensions: Optional number of dimensions for the embedding vectors.
869
-
870
- Returns:
871
- List of embedding vectors, one per input text, in the same order as input.
872
-
873
- Raises:
874
- LiteLLMClientError: If embedding generation fails.
875
- """
876
- if not texts:
877
- return []
878
-
879
- embedding_model = model or self._resolve_default_embedding_model()
880
- mode = embedding_provider_mode(embedding_model)
881
- if mode == "off":
882
- raise EmbeddingUnavailableError("Embedding provider is disabled")
883
- if should_use_embedding_service(embedding_model):
884
- return get_service_embeddings(
885
- list(texts), model=embedding_model, dimensions=dimensions
886
- )
887
-
888
- # See matching short-circuits in get_embedding above.
889
- if _is_nomic_model(embedding_model):
890
- _reject_cloud_mode(embedding_model, mode)
891
- try:
892
- return NomicEmbedder.get().embed(list(texts))
893
- except Exception as e:
894
- raise LiteLLMClientError(
895
- f"Nomic batch embedding generation failed: {str(e)}"
896
- ) from e
897
-
898
- if embedding_model.startswith("local/"):
899
- _reject_cloud_mode(embedding_model, mode)
900
- if not _is_chromadb_importable():
901
- raise LiteLLMClientError(
902
- f"Embedding model {embedding_model!r} requires chromadb. "
903
- "Run `pip install chromadb`."
904
- )
905
- try:
906
- return LocalEmbedder.get().embed(list(texts))
907
- except Exception as e:
908
- raise LiteLLMClientError(
909
- f"Local batch embedding generation failed: {str(e)}"
910
- ) from e
911
-
912
- texts = [_truncate_for_embedding(t, embedding_model) for t in texts]
913
-
914
- try:
915
- params = {"model": embedding_model, "input": texts}
916
- if dimensions:
917
- params["dimensions"] = dimensions
918
-
919
- # Resolve and add API key configuration if provided (overrides env vars)
920
- api_key, api_base, api_version = self._resolve_api_key(
921
- embedding_model, for_embedding=True
922
- )
923
- if api_key:
924
- params["api_key"] = api_key
925
- if api_base:
926
- params["api_base"] = api_base
927
- if api_version:
928
- params["api_version"] = api_version
929
-
930
- response = litellm.embedding(
931
- **params,
932
- timeout=self.config.timeout,
933
- num_retries=self.config.max_retries,
934
- )
935
- # Response data may not be in order, sort by index to ensure correct ordering
936
- sorted_data = sorted(response.data, key=lambda x: x["index"])
937
- return [item["embedding"] for item in sorted_data]
938
- except Exception as e:
939
- raise LiteLLMClientError(
940
- f"Batch embedding generation failed: {str(e)}"
941
- ) from e
942
-
943
- def _build_completion_params(
944
- self, messages: list[dict[str, Any]], **kwargs: Any
945
- ) -> tuple[dict[str, Any], Any, bool, int, list[str]]:
946
- """Build completion request parameters from messages and kwargs.
947
-
948
- Args:
949
- messages: List of messages to send
950
- **kwargs: Additional parameters (response_format, max_retries, model, etc.)
951
-
952
- Returns:
953
- Tuple of (params dict, response_format, parse_structured_output,
954
- max_retries, fallback_models). ``fallback_models`` already has any
955
- entry equal to the primary model removed.
956
- """
957
- response_format = kwargs.pop("response_format", None)
958
- strict_response_format = kwargs.pop("strict_response_format", True)
959
- parse_structured_output = kwargs.pop("parse_structured_output", True)
960
- max_retries_arg = kwargs.pop("max_retries", self.config.max_retries)
961
- try:
962
- max_retries = max(1, int(max_retries_arg))
963
- except (TypeError, ValueError):
964
- max_retries = max(1, int(self.config.max_retries))
965
-
966
- # Per-call fallback_models wins over config when explicitly provided.
967
- # Use sentinel-style check so an explicit empty list disables fallback
968
- # for the call even when the config has fallbacks set.
969
- if "fallback_models" in kwargs:
970
- fallback_models_raw = kwargs.pop("fallback_models") or []
971
- else:
972
- fallback_models_raw = list(self.config.fallback_models)
973
-
974
- # Pop tool-calling kwargs before the final params.update(kwargs) so they
975
- # don't leak into the params dict twice.
976
- tools = kwargs.pop("tools", None)
977
- tool_choice = kwargs.pop("tool_choice", None)
978
- model_role: ModelRole | None = kwargs.pop("model_role", None)
979
-
980
- actual_model = kwargs.pop("model", self.config.model)
981
-
982
- # model_role takes priority over the default model but falls through
983
- # to the custom_endpoint override below (highest priority).
984
- if model_role is not None:
985
- actual_model = resolve_model_name(
986
- role=model_role,
987
- site_var_value=None,
988
- config_override=None,
989
- api_key_config=self.config.api_key_config,
990
- )
991
-
992
- ce = (
993
- self.config.api_key_config.custom_endpoint
994
- if self.config.api_key_config
995
- else None
996
- )
997
- if ce and ce.api_key and ce.api_base:
998
- actual_model = ce.model
999
-
1000
- params: dict[str, Any] = {
1001
- "model": actual_model,
1002
- "messages": messages,
1003
- "timeout": kwargs.pop(
1004
- "timeout", self._effective_timeout_for_model(actual_model)
1005
- ),
1006
- }
1007
-
1008
- # Drop any fallback entry that points back at the primary — sending the
1009
- # same broken endpoint twice never helps.
1010
- fallback_models = [m for m in fallback_models_raw if m != actual_model]
1011
-
1012
- temperature = kwargs.pop("temperature", self.config.temperature)
1013
- if self._is_temperature_restricted_model(actual_model):
1014
- params["temperature"] = 1.0
1015
- else:
1016
- params["temperature"] = temperature
1017
-
1018
- # Determinism knob: `seed` is always injected (defaulting to 42) on
1019
- # providers that honor it, since seed alone is cheap and harmless.
1020
- # The companion temperature=0 override is opt-in via an explicit
1021
- # REFLEXIO_LLM_SEED env var so that caller-configured temperature
1022
- # flows through by default — silently clobbering a user's configured
1023
- # temperature was surprising. Current-gen reasoning models (gpt-5-*)
1024
- # ignore both knobs; the seed is best-effort.
1025
- default_seed = 42
1026
- seed_explicit = "REFLEXIO_LLM_SEED" in os.environ
1027
- seed_raw = os.environ.get("REFLEXIO_LLM_SEED", str(default_seed))
1028
- try:
1029
- params["seed"] = int(seed_raw)
1030
- except ValueError:
1031
- self.logger.warning(
1032
- "REFLEXIO_LLM_SEED=%r is not an int; falling back to default seed=%d",
1033
- seed_raw,
1034
- default_seed,
1035
- )
1036
- params["seed"] = default_seed
1037
- # Keep seed best-effort without mutating LiteLLM's process-wide
1038
- # drop_params setting. Providers that do not support seed can ignore it.
1039
- params["drop_params"] = True
1040
- if seed_explicit and not self._is_temperature_restricted_model(actual_model):
1041
- params["temperature"] = 0.0
1042
-
1043
- max_tokens = kwargs.pop("max_tokens", self.config.max_tokens)
1044
- if max_tokens:
1045
- params["max_tokens"] = max_tokens
1046
- if self.config.top_p != 1.0:
1047
- params["top_p"] = self.config.top_p
1048
- if response_format:
1049
- params["response_format"] = self._provider_response_format(
1050
- response_format=response_format,
1051
- model=actual_model,
1052
- strict_response_format=strict_response_format,
1053
- )
1054
- if tools is not None:
1055
- params["tools"] = tools
1056
- if tool_choice is not None:
1057
- params["tool_choice"] = tool_choice
1058
-
1059
- if actual_model != self.config.model:
1060
- api_key, api_base, api_version = self._resolve_api_key(actual_model)
1061
- else:
1062
- api_key, api_base, api_version = (
1063
- self._api_key,
1064
- self._api_base,
1065
- self._api_version,
1066
- )
1067
- if api_key:
1068
- params["api_key"] = api_key
1069
- if api_base:
1070
- params["api_base"] = api_base
1071
- if api_version:
1072
- params["api_version"] = api_version
1073
-
1074
- params.update(kwargs)
1075
-
1076
- # Braintrust metadata for observability (no-op if callback not registered)
1077
- if os.environ.get("BRAINTRUST_API_KEY"):
1078
- params["metadata"] = {
1079
- **params.get("metadata", {}),
1080
- "project_name": os.environ.get("BRAINTRUST_PROJECT_NAME", "reflexio"),
1081
- }
1082
- params["messages"] = self._apply_prompt_caching(
1083
- params["messages"], params["model"]
1084
- )
1085
-
1086
- return (
1087
- params,
1088
- response_format,
1089
- parse_structured_output,
1090
- max_retries,
1091
- fallback_models,
1092
- )
1093
-
1094
- @staticmethod
1095
- @lru_cache(maxsize=256)
1096
- def _supports_response_schema(model: str) -> bool:
1097
- try:
1098
- return bool(litellm.supports_response_schema(model=model))
1099
- except Exception:
1100
- return False
1101
-
1102
- @staticmethod
1103
- @lru_cache(maxsize=256)
1104
- def _provider_for_model(model: str) -> str | None:
1105
- try:
1106
- return litellm.get_llm_provider(model)[1]
1107
- except Exception:
1108
- return None
1109
-
1110
- @classmethod
1111
- def _accepts_json_schema_response_format(cls, model: str) -> bool:
1112
- """Whether to send ``model`` an explicit strict ``json_schema`` schema.
1113
-
1114
- True when LiteLLM reports native response-schema support, or when the
1115
- provider is a known OpenAI-compatible endpoint that LiteLLM
1116
- under-reports (see ``_JSON_SCHEMA_PROVIDER_ALLOWLIST``). In the latter
1117
- case LiteLLM would otherwise forward a ``json_schema`` it built itself,
1118
- emitting ``oneOf`` for discriminated unions that the endpoint rejects.
1119
- """
1120
- if cls._supports_response_schema(model):
1121
- return True
1122
- return cls._provider_for_model(model) in cls._JSON_SCHEMA_PROVIDER_ALLOWLIST
1123
-
1124
- def _provider_response_format(
1125
- self,
1126
- *,
1127
- response_format: Any,
1128
- model: str,
1129
- strict_response_format: bool,
1130
- ) -> Any:
1131
- """Return the provider-facing response_format while preserving parser schema.
1132
-
1133
- Callers pass a Pydantic model so local parsing stays type-safe. When the
1134
- target model accepts a JSON Schema response format — either LiteLLM
1135
- reports native support, or the provider is an OpenAI-compatible endpoint
1136
- LiteLLM under-reports (see ``_accepts_json_schema_response_format``) — we
1137
- send an explicit strict schema to constrain generation. Truly
1138
- unsupported providers keep the existing Pydantic response_format
1139
- behavior.
1140
- """
1141
-
1142
- if not is_pydantic_model(response_format):
1143
- return response_format
1144
-
1145
- # Build the native schema once and reuse it for both the boundary guard and
1146
- # (when applicable) the strict normalizer, avoiding a second schema build.
1147
- # Boundary guard: models inheriting StrictStructuredOutput are safe by
1148
- # construction; this catches a model that forgot the base (raises under
1149
- # tests, warns in prod) regardless of which path is taken below.
1150
- schema = response_format.model_json_schema()
1151
- assert_provider_safe_schema(schema, name=response_format.__name__)
1152
-
1153
- if strict_response_format and self._accepts_json_schema_response_format(model):
1154
- return strict_response_format_for_model(response_format, schema=schema)
1155
- return response_format
1156
-
1157
- def _compute_cost_usd(self, response: Any, model: str | None) -> float | None:
1158
- """Compute call cost in USD via the litellm price table.
1159
-
1160
- Falls back to None when the provider is not mapped (local ONNX,
1161
- claude-code CLI, etc.) rather than failing the request.
1162
-
1163
- Args:
1164
- response: Raw LLM response object.
1165
- model: Fully-qualified model name used for the call.
1166
-
1167
- Returns:
1168
- float | None: Cost in USD, or None when unavailable.
1169
- """
1170
- try:
1171
- import litellm
1172
-
1173
- cost = litellm.completion_cost(completion_response=response, model=model)
1174
- return float(cost) if cost else None
1175
- except Exception:
1176
- return None
1177
-
1178
- def _coerce_timeout_seconds(self, params: dict[str, Any]) -> float:
1179
- """Coerce ``params['timeout']`` to a float, falling back to the config
1180
- default when it is missing or non-numeric."""
1181
- try:
1182
- return float(params.get("timeout", self.config.timeout))
1183
- except (TypeError, ValueError):
1184
- return float(self.config.timeout)
1185
-
1186
- def _completion_with_hard_timeout(
1187
- self, params: dict[str, Any], hard_timeout: float
1188
- ) -> Any:
1189
- """Run ``litellm.completion`` with a client-side wall-clock bound.
1190
-
1191
- Some providers can exceed LiteLLM's ``timeout`` kwarg. Run the blocking
1192
- call in a child process so the caller can fail, release locks, and
1193
- terminate the in-flight provider request instead of waiting indefinitely.
1194
-
1195
- ``hard_timeout`` is the wall-clock kill bound for the whole subprocess.
1196
- Because LiteLLM walks ``[primary, *fallbacks]`` inside this one call
1197
- (copying ``timeout`` unchanged into each rung), the caller sizes
1198
- ``hard_timeout`` to cover the entire fallback ladder, not a single
1199
- attempt — otherwise the subprocess would be killed before LiteLLM ever
1200
- reaches a fallback (the root cause of Sentry PYTHON-FASTAPI-62).
1201
- """
1202
- provider_timeout = params.get("timeout", self.config.timeout)
1203
- # timeout_seconds + grace_seconds below only classify test doubles in
1204
- # _should_process_isolate_completion (real litellm vs a monkeypatched
1205
- # closure) — they do NOT size the kill bound, which is the caller's
1206
- # ladder-wide ``hard_timeout``.
1207
- timeout_seconds = self._coerce_timeout_seconds(params)
1208
- grace_seconds = self._hard_timeout_grace_seconds()
1209
- hard_timeout = max(0.001, hard_timeout)
1210
-
1211
- if not self._should_process_isolate_completion(timeout_seconds, grace_seconds):
1212
- return litellm.completion(**params)
1213
-
1214
- process_context = multiprocessing.get_context()
1215
- result_queue = process_context.Queue(maxsize=1)
1216
- process = process_context.Process(
1217
- target=_litellm_completion_worker,
1218
- args=(params, result_queue),
1219
- daemon=True,
1220
- )
1221
- process.start()
1222
- try:
1223
- process.join(timeout=hard_timeout)
1224
- if process.is_alive():
1225
- process.terminate()
1226
- process.join(timeout=1.0)
1227
- if process.is_alive():
1228
- process.kill()
1229
- process.join(timeout=1.0)
1230
- raise LLMHardTimeoutError(
1231
- f"LLM request exceeded hard timeout of {hard_timeout:.3f}s "
1232
- f"(provider timeout={provider_timeout!r})"
1233
- )
1234
-
1235
- try:
1236
- status, payload = result_queue.get(timeout=1.0)
1237
- except queue.Empty as exc:
1238
- raise LiteLLMClientError(
1239
- "LLM request process exited without returning a result "
1240
- f"(exitcode={process.exitcode})"
1241
- ) from exc
1242
-
1243
- if status == "ok":
1244
- return payload
1245
- # The worker always reports errors as a picklable snapshot.
1246
- context_parts = [f"model={payload.model}"]
1247
- if payload.llm_provider:
1248
- context_parts.append(f"provider={payload.llm_provider}")
1249
- raise LiteLLMClientError(
1250
- "litellm.completion failed in isolated worker: "
1251
- f"{payload.type_name}: {payload.message} "
1252
- f"({', '.join(context_parts)})"
1253
- )
1254
- finally:
1255
- result_queue.close()
1256
- result_queue.join_thread()
1257
-
1258
- def _effective_timeout_for_model(self, model: str) -> int:
1259
- """Return the configured timeout, raised to the model's floor if one exists.
1260
-
1261
- Args:
1262
- model: Resolved model name (e.g. 'minimax/MiniMax-M3').
1263
-
1264
- Returns:
1265
- int: max(config.timeout, per-model floor). Callers that pass an
1266
- explicit timeout kwarg bypass this entirely.
1267
- """
1268
- return max(self.config.timeout, _MODEL_TIMEOUT_FLOOR_SECONDS.get(model, 0))
1269
-
1270
- def _hard_timeout_grace_seconds(self) -> float:
1271
- raw = os.environ.get("REFLEXIO_LLM_HARD_TIMEOUT_GRACE_SECONDS", "5") or "5"
1272
- try:
1273
- return max(0.0, float(raw))
1274
- except ValueError:
1275
- self.logger.warning(
1276
- "Invalid REFLEXIO_LLM_HARD_TIMEOUT_GRACE_SECONDS=%r; using 5",
1277
- raw,
1278
- )
1279
- return 5.0
1280
-
1281
- def _should_process_isolate_completion(
1282
- self, timeout_seconds: float, grace_seconds: float
1283
- ) -> bool:
1284
- """Use process isolation for real LiteLLM calls while preserving test doubles.
1285
-
1286
- Unit tests often monkeypatch ``litellm.completion`` with local closures
1287
- that capture params in parent memory. Those closures cannot be observed
1288
- through a subprocess, so only real LiteLLM functions and explicit short
1289
- timeout tests go through the process path.
1290
- """
1291
- completion_module = getattr(litellm.completion, "__module__", "")
1292
- if completion_module.startswith("litellm"):
1293
- return True
1294
- return timeout_seconds + grace_seconds < 1.0
1295
-
1296
- def _log_token_usage(self, params: dict[str, Any], response: Any) -> None:
1297
- """Log token usage with cache statistics and cost from an LLM response.
1298
-
1299
- Args:
1300
- params: Request parameters (for model name)
1301
- response: LLM response object
1302
- """
1303
- usage = getattr(response, "usage", None)
1304
- if not usage:
1305
- return
1306
-
1307
- cache_info = ""
1308
- details = getattr(usage, "prompt_tokens_details", None)
1309
- if details:
1310
- cached = getattr(details, "cached_tokens", 0)
1311
- if cached:
1312
- cache_info = f", cached: {cached}"
1313
- cache_creation = getattr(usage, "cache_creation_input_tokens", None)
1314
- cache_read = getattr(usage, "cache_read_input_tokens", None)
1315
- if cache_creation or cache_read:
1316
- cache_info = (
1317
- f", cache_write: {cache_creation or 0}, cache_read: {cache_read or 0}"
1318
- )
1319
-
1320
- cost = self._compute_cost_usd(response, params.get("model"))
1321
- cost_suffix = f", cost: ${cost:.6f}" if cost is not None else ""
1322
-
1323
- self.logger.info(
1324
- "Token usage - model: %s, input: %s, output: %s, total: %s%s%s",
1325
- params.get("model"),
1326
- usage.prompt_tokens,
1327
- usage.completion_tokens,
1328
- usage.total_tokens,
1329
- cache_info,
1330
- cost_suffix,
1331
- )
1332
-
1333
- def _emit_fallback_observability(
1334
- self, response: Any, params: dict[str, Any]
1335
- ) -> None:
1336
- """Surface fallback-routing info to logs and Sentry when applicable.
1337
-
1338
- LiteLLM rewrites ``response.model`` to the model that actually served
1339
- the call, so we detect a fallback by comparing it against the model
1340
- we asked for. The check is best-effort: any exception inside this
1341
- helper is swallowed so observability never breaks the request.
1342
-
1343
- Args:
1344
- response: The litellm completion response object.
1345
- params: The params dict that was passed to ``litellm.completion`` —
1346
- used to read the originally requested primary model name.
1347
- """
1348
- try:
1349
- primary_model = params.get("model")
1350
- hidden = getattr(response, "_hidden_params", {}) or {}
1351
- served_model = (
1352
- hidden.get("model_id")
1353
- or hidden.get("model")
1354
- or getattr(response, "model", None)
1355
- )
1356
-
1357
- if not served_model or served_model == primary_model:
1358
- return
1359
-
1360
- self.logger.info(
1361
- "event=llm_fallback_used primary_model=%s served_model=%s",
1362
- primary_model,
1363
- served_model,
1364
- )
1365
-
1366
- # Local import keeps sentry out of module-init paths the tests
1367
- # exercise without a Sentry SDK installed. sentry_sdk is an
1368
- # enterprise-only dependency; OSS callers run without it and the
1369
- # ImportError is intentionally absorbed by the outer except.
1370
- import sentry_sdk # type: ignore[import-not-found]
1371
-
1372
- sentry_sdk.set_tag("llm.fallback_used", "true")
1373
- sentry_sdk.set_tag("llm.primary_model", str(primary_model))
1374
- sentry_sdk.set_tag("llm.fallback_model", str(served_model))
1375
- except Exception: # noqa: BLE001 — observability must not break the call
1376
- return
1377
-
1378
- def _make_request(
1379
- self, messages: list[dict[str, Any]], **kwargs: Any
1380
- ) -> str | BaseModel | ToolCallingChatResponse:
1381
- """
1382
- Make a request to the LLM, delegating cross-model fallback to litellm.
1383
-
1384
- Fallback is handed to ``litellm.completion`` via the native ``fallbacks``
1385
- kwarg, but ``num_retries`` is forced to 0: same-model retry of a *hung*
1386
- primary is what made the fallback unreachable and produced the 490s in
1387
- Sentry PYTHON-FASTAPI-62 (see the body comment). So the primary is tried
1388
- once, then each fallback once. The subprocess hard timeout is sized to
1389
- cover that whole ladder. The one retry we still own at the client level
1390
- is a single ``StructuredOutputParseError`` retry: LiteLLM cannot detect a
1391
- post-hoc Pydantic re-validation failure because it sees a successful
1392
- HTTP response.
1393
-
1394
- Args:
1395
- messages: List of messages to send.
1396
- **kwargs: Additional parameters (response_format, max_retries,
1397
- fallback_models, tools, etc.).
1398
-
1399
- Returns:
1400
- Response content as string, BaseModel instance, or
1401
- ToolCallingChatResponse when the request was in tool-calling mode.
1402
-
1403
- Raises:
1404
- LiteLLMClientError: If the request fails after all retries and
1405
- fallbacks have been exhausted by litellm.
1406
- """
1407
- params, response_format, parse_structured_output, _max_retries, fallbacks = (
1408
- self._build_completion_params(messages, **kwargs)
1409
- )
1410
-
1411
- # Hand the fallback ladder to litellm, but DISABLE same-model retries.
1412
- # litellm walks [primary, *fallbacks] inside one litellm.completion call,
1413
- # copying ``timeout`` unchanged into each rung. With num_retries>=1 it
1414
- # retries a *hung* primary num_retries+1 times (each up to a full
1415
- # provider timeout) before ever reaching a fallback — making the fallback
1416
- # unreachable within any sane wall-clock bound (root cause of Sentry
1417
- # PYTHON-FASTAPI-62). num_retries=0 makes the fallback LIST the resilience
1418
- # mechanism: each model is tried once, in order.
1419
- params["num_retries"] = 0
1420
- if fallbacks:
1421
- params["fallbacks"] = fallbacks
1422
-
1423
- # Size the hard (wall-clock) timeout to cover the WHOLE ladder. litellm
1424
- # copies this single ``params["timeout"]`` into EVERY rung (primary + each
1425
- # fallback), so every rung shares the primary's per-attempt budget and the
1426
- # subprocess must be allowed to run ``(1 + len(fallbacks))`` of them plus
1427
- # one grace buffer before being killed — otherwise it is killed before
1428
- # litellm can reach a fallback.
1429
- #
1430
- # ASYMMETRIC-FLOOR FOOTGUN: because every rung shares one timeout, a
1431
- # fallback whose _MODEL_TIMEOUT_FLOOR_SECONDS floor is HIGHER than the
1432
- # primary's would run — and be killed — at the primary's shorter timeout,
1433
- # reintroducing the "fallback killed early" failure this fix removes. The
1434
- # floor table is single-valued today (MiniMax-M3 == the 120 default), so
1435
- # this is latent; revisit the sizing (e.g. max floor across rungs, passed
1436
- # as ``params["timeout"]``) before adding an asymmetric floor entry.
1437
- per_attempt_timeout = self._coerce_timeout_seconds(params)
1438
- hard_timeout = (
1439
- 1 + len(fallbacks)
1440
- ) * per_attempt_timeout + self._hard_timeout_grace_seconds()
1441
-
1442
- request_start = time.perf_counter()
1443
- self.logger.info(
1444
- "event=llm_request_start model=%s timeout=%s has_response_format=%s num_retries=0 fallbacks=%s hard_timeout=%.3f",
1445
- params.get("model"),
1446
- params.get("timeout"),
1447
- response_format is not None,
1448
- fallbacks,
1449
- hard_timeout,
1450
- )
1451
-
1452
- def _call_and_parse() -> str | BaseModel | ToolCallingChatResponse:
1453
- response = self._completion_with_hard_timeout(params, hard_timeout)
1454
- self._emit_fallback_observability(response, params)
1455
- message = response.choices[0].message # type: ignore[reportAttributeAccessIssue]
1456
- content = message.content
1457
- self._log_token_usage(params, response)
1458
- self.logger.info(
1459
- "event=llm_request_end model=%s timeout=%s has_response_format=%s elapsed_seconds=%.3f success=%s",
1460
- params.get("model"),
1461
- params.get("timeout"),
1462
- response_format is not None,
1463
- time.perf_counter() - request_start,
1464
- True,
1465
- )
1466
-
1467
- # Tool-calling path: return a structured response instead of
1468
- # going through _maybe_parse_structured_output.
1469
- if "tools" in params:
1470
- raw_usage = getattr(response, "usage", None)
1471
- call_cost = self._compute_cost_usd(response, params.get("model"))
1472
- tool_calls = getattr(message, "tool_calls", None)
1473
- # Structured-output + tools: when the model ends the turn with a
1474
- # plain (non-tool) response and a response_format was requested,
1475
- # the content IS the final structured answer. Parse it here so a
1476
- # tool-loop caller can finish on it. A malformed parse raises
1477
- # StructuredOutputParseError, which the outer wrapper retries once.
1478
- parsed_output: BaseModel | None = None
1479
- if response_format is not None and not tool_calls:
1480
- parsed = self._maybe_parse_structured_output(
1481
- content, # type: ignore[reportArgumentType]
1482
- response_format,
1483
- parse_structured_output,
1484
- )
1485
- if isinstance(parsed, BaseModel):
1486
- parsed_output = parsed
1487
- return ToolCallingChatResponse(
1488
- content=content,
1489
- tool_calls=tool_calls,
1490
- finish_reason=response.choices[0].finish_reason, # type: ignore[reportAttributeAccessIssue]
1491
- usage=raw_usage,
1492
- cost_usd=call_cost,
1493
- parsed_output=parsed_output,
1494
- )
1495
-
1496
- return self._maybe_parse_structured_output(
1497
- content, # type: ignore[reportArgumentType]
1498
- response_format,
1499
- parse_structured_output,
1500
- )
1501
-
1502
- try:
1503
- try:
1504
- return _call_and_parse()
1505
- except StructuredOutputParseError:
1506
- # litellm's fallbacks cover API/timeout errors, but a Pydantic
1507
- # re-validation failure happens AFTER litellm sees a successful
1508
- # 200 — litellm can't detect it, so we owe one explicit second
1509
- # attempt at the model. PR #121 documented this as a MiniMax-M3
1510
- # mitigation. (A hard timeout is NOT retried here: same-model
1511
- # retry of a hang is what produced the 490s in PYTHON-FASTAPI-62;
1512
- # the fallback ladder inside _call_and_parse handles it instead.)
1513
- #
1514
- # This second pass re-walks the full ladder, so the worst-case
1515
- # wall clock is ~2x the ladder bound. That ceiling is only reached
1516
- # if a model returns a malformed-but-successful 200 AND runs near
1517
- # the timeout on BOTH passes — a hang (the common case) raises
1518
- # LLMHardTimeoutError, which is not caught here and exits after a
1519
- # single ladder.
1520
- self.logger.warning(
1521
- "event=llm_parse_retry model=%s — primary returned malformed structured output, retrying once",
1522
- params.get("model"),
1523
- )
1524
- return _call_and_parse()
1525
- except Exception as e:
1526
- self.logger.error(
1527
- "event=llm_request_end model=%s elapsed_seconds=%.3f success=False error_type=%s error=%s",
1528
- params.get("model"),
1529
- time.perf_counter() - request_start,
1530
- type(e).__name__,
1531
- e,
1532
- )
1533
- raise LiteLLMClientError(f"API call failed: {e}") from e
1534
-
1535
- def _apply_prompt_caching(
1536
- self, messages: list[dict[str, Any]], model: str
1537
- ) -> list[dict[str, Any]]:
1538
- """
1539
- Apply prompt caching markers for supported providers.
1540
-
1541
- For Anthropic models, transforms the system message content into content-block
1542
- format with cache_control markers to enable prefix caching.
1543
- For other providers, returns messages unchanged.
1544
-
1545
- Args:
1546
- messages: List of chat messages.
1547
- model: Model name to determine provider.
1548
-
1549
- Returns:
1550
- list[dict]: Messages with cache control applied where appropriate.
1551
- """
1552
- model_lower = model.lower()
1553
- # The claude-code/* custom provider routes through the Claude Code CLI,
1554
- # which does not accept Anthropic API cache_control content blocks.
1555
- if model_lower.startswith("claude-code/"):
1556
- return messages
1557
- is_anthropic = "claude" in model_lower or "anthropic" in model_lower
1558
-
1559
- if not is_anthropic:
1560
- return messages
1561
-
1562
- result = []
1563
- for msg in messages:
1564
- if msg.get("role") == "system" and isinstance(msg.get("content"), str):
1565
- # Transform system message to content-block format with cache_control
1566
- result.append(
1567
- {
1568
- "role": "system",
1569
- "content": [
1570
- {
1571
- "type": "text",
1572
- "text": msg["content"],
1573
- "cache_control": {"type": "ephemeral"},
1574
- }
1575
- ],
1576
- }
1577
- )
1578
- else:
1579
- result.append(msg)
1580
-
1581
- return result
1582
-
1583
- def _build_user_content(
1584
- self,
1585
- prompt: str,
1586
- images: list[str | bytes | dict] | None = None,
1587
- image_media_type: str | None = None,
1588
- ) -> str | list[dict[str, Any]]:
1589
- """
1590
- Build user content with optional images.
1591
-
1592
- Args:
1593
- prompt: Text prompt.
1594
- images: Optional list of images.
1595
- image_media_type: Media type for byte images.
1596
-
1597
- Returns:
1598
- String for text-only, or list of content blocks for multi-modal.
1599
- """
1600
- if not images:
1601
- return prompt
1602
-
1603
- content_blocks = [{"type": "text", "text": prompt}]
1604
-
1605
- for image in images:
1606
- if isinstance(image, dict):
1607
- # Already formatted content block
1608
- content_blocks.append(image)
1609
- elif isinstance(image, bytes):
1610
- # Raw bytes
1611
- media_type = image_media_type or "image/png"
1612
- base64_data = base64.b64encode(image).decode("utf-8")
1613
- content_blocks.append(
1614
- self._create_image_content_block(base64_data, media_type)
1615
- )
1616
- elif isinstance(image, str):
1617
- # File path or URL
1618
- if image.startswith(("http://", "https://")):
1619
- # URL - use directly
1620
- content_blocks.append(
1621
- {"type": "image_url", "image_url": {"url": image}} # type: ignore[reportArgumentType]
1622
- )
1623
- else:
1624
- # File path
1625
- base64_data, media_type = self.encode_image_to_base64(image)
1626
- content_blocks.append(
1627
- self._create_image_content_block(base64_data, media_type)
1628
- )
1629
-
1630
- return content_blocks
1631
-
1632
- def _create_image_content_block(
1633
- self, base64_data: str, media_type: str
1634
- ) -> dict[str, Any]:
1635
- """
1636
- Create an image content block for the API.
1637
-
1638
- Args:
1639
- base64_data: Base64-encoded image data.
1640
- media_type: MIME type of the image.
1641
-
1642
- Returns:
1643
- Image content block dictionary.
1644
- """
1645
- return {
1646
- "type": "image_url",
1647
- "image_url": {"url": f"data:{media_type};base64,{base64_data}"},
1648
- }
1649
-
1650
- def encode_image_to_base64(self, image_path: str) -> tuple[str, str]:
1651
- """
1652
- Encode an image file to base64.
1653
-
1654
- Delegates to :func:`reflexio.server.llm.image_utils.encode_image_to_base64`
1655
- and wraps errors as :class:`LiteLLMClientError`.
1656
-
1657
- Args:
1658
- image_path (str): Path to the image file.
1659
-
1660
- Returns:
1661
- tuple[str, str]: ``(base64_data, media_type)`` pair.
1662
-
1663
- Raises:
1664
- LiteLLMClientError: If the image cannot be read or format is unsupported.
1665
- """
1666
- try:
1667
- return _encode_image_to_base64(image_path)
1668
- except ImageEncodingError as exc:
1669
- raise LiteLLMClientError(str(exc)) from exc
1670
-
1671
- def _is_temperature_restricted_model(self, model: str) -> bool:
1672
- """
1673
- Check if a model has temperature restrictions (e.g., GPT-5 and Gemini 3 models only support temperature=1.0).
1674
-
1675
- Args:
1676
- model: Model name to check.
1677
-
1678
- Returns:
1679
- True if the model has temperature restrictions.
1680
- """
1681
- model_lower = model.lower()
1682
- # Strip provider routing prefixes (e.g., "openrouter/openai/gpt-5-nano" -> "gpt-5-nano")
1683
- model_name = model_lower.rsplit("/", 1)[-1]
1684
- # Check if model starts with any of the restricted model prefixes
1685
- return any(
1686
- model_name.startswith(restricted) or model_name == restricted
1687
- for restricted in self.TEMPERATURE_RESTRICTED_MODELS
1688
- )
1689
-
1690
- def _maybe_parse_structured_output(
1691
- self,
1692
- content: str,
1693
- response_format: Any,
1694
- parse_structured_output: bool,
1695
- ) -> str | BaseModel:
1696
- """
1697
- Parse structured output if applicable.
1698
-
1699
- Args:
1700
- content: Raw response content.
1701
- response_format: Expected response format (must be a Pydantic BaseModel class).
1702
- parse_structured_output: Whether to parse the output.
1703
-
1704
- Returns:
1705
- String for text responses, or BaseModel instance for structured responses.
1706
- """
1707
- if not response_format or not parse_structured_output:
1708
- return content
1709
-
1710
- if content is None:
1711
- return content
1712
-
1713
- # If content is already a Pydantic model (some providers return parsed)
1714
- if isinstance(content, BaseModel):
1715
- return content
1716
-
1717
- # Try to parse JSON and convert to Pydantic model
1718
- # Extract JSON from markdown code blocks if present
1719
- json_str = self._extract_json_from_string(content)
1720
- try:
1721
- parsed = json.loads(json_str)
1722
-
1723
- # response_format must be a Pydantic model (validated at entry points)
1724
- return response_format.model_validate(parsed)
1725
- except Exception:
1726
- # LLMs sometimes produce Python-style output (single quotes, True/False,
1727
- # trailing commas). Try to sanitize before giving up.
1728
- try:
1729
- sanitized = self._sanitize_json_string(json_str)
1730
- parsed = json.loads(sanitized)
1731
- return response_format.model_validate(parsed)
1732
- except Exception:
1733
- # Last resort: json-repair can recover complete responses with
1734
- # small syntax glitches, such as missing commas. Do not repair
1735
- # likely truncation: the retry loop should request a fresh
1736
- # complete response instead of accepting invented tail content.
1737
- try:
1738
- from json_repair import repair_json
1739
-
1740
- if self._looks_truncated_json(json_str):
1741
- raise StructuredOutputParseError(
1742
- "Structured output appears truncated"
1743
- )
1744
-
1745
- repaired = repair_json(json_str, return_objects=True)
1746
- return response_format.model_validate(repaired)
1747
- except Exception as e:
1748
- model = self.config.model
1749
- snippet = (
1750
- content[:200]
1751
- if isinstance(content, str)
1752
- else repr(content)[:200]
1753
- )
1754
- raise StructuredOutputParseError(
1755
- f"Structured output parse failed for model={model!r}: {e}. "
1756
- f"Content snippet: {snippet!r}"
1757
- ) from e
1758
-
1759
- def _extract_json_from_string(self, content: str) -> str:
1760
- """
1761
- Extract JSON from a string, handling markdown code blocks.
1762
-
1763
- Args:
1764
- content: String potentially containing JSON.
1765
-
1766
- Returns:
1767
- Extracted JSON string.
1768
- """
1769
- content = content.strip()
1770
-
1771
- # Prefer a balanced JSON container first. Structured JSON may contain
1772
- # markdown fences inside string values; grabbing the first code block
1773
- # would extract the inner snippet instead of the response object.
1774
- json_container = self._extract_first_json_container(content)
1775
- if json_container is not None:
1776
- return json_container
1777
-
1778
- # Try to extract from markdown code blocks
1779
- json_block_pattern = r"```(?:json)?\s*([\s\S]*?)```"
1780
- matches = re.findall(json_block_pattern, content)
1781
- if matches:
1782
- return matches[0].strip()
1783
-
1784
- return content
1785
-
1786
- def _extract_first_json_container(self, content: str) -> str | None:
1787
- """Return the first balanced JSON-like object/array in ``content``."""
1788
- for start_idx, ch in enumerate(content):
1789
- if ch not in "{[":
1790
- continue
1791
- end_idx = self._find_json_container_end(content, start_idx)
1792
- if end_idx is None:
1793
- continue
1794
- candidate = content[start_idx : end_idx + 1]
1795
- if self._is_parseable_json_candidate(candidate):
1796
- return candidate
1797
- return None
1798
-
1799
- @staticmethod
1800
- def _find_json_container_end(content: str, start_idx: int) -> int | None:
1801
- """Find the matching end of a JSON container, respecting strings."""
1802
- pairs = {"{": "}", "[": "]"}
1803
- stack = [pairs[content[start_idx]]]
1804
- in_str = False
1805
- escape = False
1806
-
1807
- for idx in range(start_idx + 1, len(content)):
1808
- ch = content[idx]
1809
- if escape:
1810
- escape = False
1811
- continue
1812
- if ch == "\\" and in_str:
1813
- escape = True
1814
- continue
1815
- if ch == '"':
1816
- in_str = not in_str
1817
- continue
1818
- if in_str:
1819
- continue
1820
- if ch in pairs:
1821
- stack.append(pairs[ch])
1822
- elif ch in ("}", "]"):
1823
- if not stack or stack.pop() != ch:
1824
- return None
1825
- if not stack:
1826
- return idx
1827
- return None
1828
-
1829
- def _is_parseable_json_candidate(self, candidate: str) -> bool:
1830
- """Return True if a balanced candidate can parse after normal sanitizing."""
1831
- try:
1832
- json.loads(candidate)
1833
- return True
1834
- except Exception:
1835
- try:
1836
- json.loads(self._sanitize_json_string(candidate))
1837
- return True
1838
- except Exception:
1839
- return False
1840
-
1841
- def _looks_truncated_json(self, json_str: str) -> bool:
1842
- """
1843
- Return True when a JSON-like string appears to end before it is complete.
1844
-
1845
- This intentionally only treats content with a JSON container opener as
1846
- truncation. Plain text that is not JSON should proceed to the normal
1847
- parse failure path.
1848
-
1849
- Args:
1850
- json_str: Extracted JSON-like response text.
1851
-
1852
- Returns:
1853
- True if the response has unclosed containers or strings.
1854
- """
1855
- stripped = json_str.strip()
1856
- start_indices = [
1857
- idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1
1858
- ]
1859
- if not stripped or not start_indices:
1860
- return False
1861
- stripped = stripped[min(start_indices) :]
1862
-
1863
- stack: list[str] = []
1864
- in_str = False
1865
- escape = False
1866
- pairs = {"{": "}", "[": "]"}
1867
-
1868
- for ch in stripped:
1869
- if escape:
1870
- escape = False
1871
- continue
1872
- if ch == "\\" and in_str:
1873
- escape = True
1874
- continue
1875
- if ch == '"':
1876
- in_str = not in_str
1877
- continue
1878
- if in_str:
1879
- continue
1880
- if ch in pairs:
1881
- stack.append(pairs[ch])
1882
- elif ch in ("}", "]") and (not stack or stack.pop() != ch):
1883
- return False
1884
-
1885
- return in_str or bool(stack)
1886
-
1887
- def _sanitize_json_string(self, json_str: str) -> str:
1888
- """
1889
- Sanitize a JSON-like string that uses Python-style syntax into valid JSON.
1890
-
1891
- Handles common LLM issues: single quotes, Python True/False/None,
1892
- and trailing commas before closing braces/brackets.
1893
-
1894
- Args:
1895
- json_str: A JSON-like string that may contain Python-style syntax.
1896
-
1897
- Returns:
1898
- A sanitized string closer to valid JSON.
1899
- """
1900
- s = json_str
1901
-
1902
- # Walk character-by-character to:
1903
- # 1. Replace single-quoted strings with double-quoted strings
1904
- # 2. Replace Python True/False/None with JSON true/false/null ONLY outside strings
1905
- # 3. Handle escaped apostrophes inside single-quoted strings (e.g. 'didn\'t')
1906
- # 4. Escape literal double quotes that end up inside double-quoted strings
1907
- result = []
1908
- in_double = False
1909
- in_single = False
1910
- i = 0
1911
- while i < len(s):
1912
- ch = s[i]
1913
- if ch == "\\" and (in_double or in_single):
1914
- # Escaped character inside a string
1915
- if i + 1 < len(s):
1916
- next_ch = s[i + 1]
1917
- if in_single and next_ch == "'":
1918
- # \' inside single-quoted string → literal apostrophe
1919
- # In JSON double-quoted strings, apostrophe needs no escape
1920
- result.append("'")
1921
- i += 2
1922
- continue
1923
- result.append(ch)
1924
- result.append(next_ch)
1925
- i += 2
1926
- continue
1927
- result.append(ch)
1928
- elif ch == '"' and not in_single:
1929
- in_double = not in_double
1930
- result.append(ch)
1931
- elif ch == "'" and not in_double:
1932
- in_single = not in_single
1933
- result.append('"') # swap single → double
1934
- else:
1935
- # Escape unescaped double quotes inside single-quoted strings
1936
- # (they become part of a double-quoted JSON string)
1937
- if in_single and ch == '"':
1938
- result.append('\\"')
1939
- else:
1940
- result.append(ch)
1941
- i += 1
1942
- s = "".join(result)
1943
-
1944
- # Replace Python booleans/None with JSON equivalents only outside quoted strings.
1945
- # We walk the already-double-quoted result so we only need to track double quotes.
1946
- output = []
1947
- in_str = False
1948
- j = 0
1949
- while j < len(s):
1950
- if s[j] == "\\" and in_str:
1951
- output.append(s[j : j + 2])
1952
- j += 2
1953
- continue
1954
- if s[j] == '"':
1955
- in_str = not in_str
1956
- output.append(s[j])
1957
- j += 1
1958
- continue
1959
- if not in_str:
1960
- matched = False
1961
- for py_val, json_val in _PYTHON_TO_JSON_REPLACEMENTS.items():
1962
- if s[j : j + len(py_val)] == py_val:
1963
- # Check word boundaries
1964
- before = s[j - 1] if j > 0 else " "
1965
- after = s[j + len(py_val)] if j + len(py_val) < len(s) else " "
1966
- if (
1967
- not before.isalnum()
1968
- and before != "_"
1969
- and not after.isalnum()
1970
- and after != "_"
1971
- ):
1972
- output.append(json_val)
1973
- j += len(py_val)
1974
- matched = True
1975
- break
1976
- if not matched:
1977
- output.append(s[j])
1978
- j += 1
1979
- else:
1980
- output.append(s[j])
1981
- j += 1
1982
- s = "".join(output)
1983
-
1984
- # Remove trailing commas before } or ]
1985
- return re.sub(r",\s*([}\]])", r"\1", s)
1986
-
1987
241
  def update_config(self, **kwargs) -> None:
1988
242
  """
1989
243
  Update client configuration.