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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
- package/plugin/dashboard/package-lock.json +61 -390
- package/plugin/dashboard/package.json +2 -2
- package/plugin/pyproject.toml +1 -1
- package/plugin/uv.lock +1 -1
- package/plugin/vendor/reflexio/.env.example +7 -0
- package/plugin/vendor/reflexio/pyproject.toml +2 -1
- package/plugin/vendor/reflexio/reflexio/README.md +8 -5
- package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
- package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
- package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
- package/plugin/vendor/reflexio/reflexio/server/api.py +133 -3273
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
- package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
- package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
- package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
- package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
- package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
- package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
- package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +56 -351
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +33 -8
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
|
@@ -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
|
+
)
|