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
|
@@ -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.
|
|
27
|
-
|
|
28
|
-
ImageEncodingError,
|
|
14
|
+
from reflexio.server.llm._litellm_embedding import (
|
|
15
|
+
_TRUNCATION_WARNED_MODELS as _TRUNCATION_WARNED_MODELS,
|
|
29
16
|
)
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
34
|
-
|
|
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.
|
|
39
|
-
|
|
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.
|
|
43
|
-
|
|
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.
|
|
49
|
-
|
|
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.
|
|
52
|
-
|
|
38
|
+
from reflexio.server.llm._litellm_json_extraction import (
|
|
39
|
+
_sanitize_json_string as _sanitize_json_string,
|
|
53
40
|
)
|
|
54
|
-
from reflexio.server.llm.
|
|
55
|
-
|
|
41
|
+
from reflexio.server.llm._litellm_structured_output import (
|
|
42
|
+
StructuredOutputMixin,
|
|
56
43
|
)
|
|
57
|
-
from reflexio.server.llm.
|
|
58
|
-
|
|
44
|
+
from reflexio.server.llm._litellm_subprocess import (
|
|
45
|
+
_CompletionChoiceSnapshot as _CompletionChoiceSnapshot,
|
|
59
46
|
)
|
|
60
|
-
from reflexio.server.llm.
|
|
61
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|