claude-smart 0.2.47 → 0.2.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
- package/plugin/dashboard/package-lock.json +61 -390
- package/plugin/dashboard/package.json +2 -2
- package/plugin/pyproject.toml +1 -1
- package/plugin/uv.lock +1 -1
- package/plugin/vendor/reflexio/.env.example +7 -0
- package/plugin/vendor/reflexio/pyproject.toml +2 -1
- package/plugin/vendor/reflexio/reflexio/README.md +8 -5
- package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
- package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
- package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
- package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
- package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
- package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
- package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
- package/plugin/vendor/reflexio/reflexio/server/api.py +133 -3273
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
- package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
- package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
- package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
- package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
- package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
- package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
- package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
- package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
- package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
- package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
- package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
- package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
- package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
- package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
- package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
- package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
- package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -4
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +56 -351
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +6 -3
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +33 -8
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
- package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Pure JSON-extraction/repair helpers for structured-output parsing (Tier-2.5 leaf).
|
|
2
|
+
|
|
3
|
+
Stateless leaf module: these six functions never touched ``self`` on the former
|
|
4
|
+
``LiteLLMClient`` (they only called each other), so they move to a dependency-free
|
|
5
|
+
leaf. ``_maybe_parse_structured_output`` (StructuredOutputMixin) calls them.
|
|
6
|
+
|
|
7
|
+
Bodies are moved VERBATIM from the former ``litellm_client.py`` methods — the only
|
|
8
|
+
change is dropping the (unused) ``self`` parameter and rewriting the intra-cluster
|
|
9
|
+
``self._x(...)`` calls to direct calls. No behavior change.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
# Python-to-JSON keyword replacements used by _sanitize_json_string.
|
|
16
|
+
_PYTHON_TO_JSON_REPLACEMENTS = {"True": "true", "False": "false", "None": "null"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_json_from_string(content: str) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Extract JSON from a string, handling markdown code blocks.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
content: String potentially containing JSON.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Extracted JSON string.
|
|
28
|
+
"""
|
|
29
|
+
content = content.strip()
|
|
30
|
+
|
|
31
|
+
# Prefer a balanced JSON container first. Structured JSON may contain
|
|
32
|
+
# markdown fences inside string values; grabbing the first code block
|
|
33
|
+
# would extract the inner snippet instead of the response object.
|
|
34
|
+
json_container = _extract_first_json_container(content)
|
|
35
|
+
if json_container is not None:
|
|
36
|
+
return json_container
|
|
37
|
+
|
|
38
|
+
# Try to extract from markdown code blocks
|
|
39
|
+
json_block_pattern = r"```(?:json)?\s*([\s\S]*?)```"
|
|
40
|
+
matches = re.findall(json_block_pattern, content)
|
|
41
|
+
if matches:
|
|
42
|
+
return matches[0].strip()
|
|
43
|
+
|
|
44
|
+
return content
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_first_json_container(content: str) -> str | None:
|
|
48
|
+
"""Return the first balanced JSON-like object/array in ``content``."""
|
|
49
|
+
for start_idx, ch in enumerate(content):
|
|
50
|
+
if ch not in "{[":
|
|
51
|
+
continue
|
|
52
|
+
end_idx = _find_json_container_end(content, start_idx)
|
|
53
|
+
if end_idx is None:
|
|
54
|
+
continue
|
|
55
|
+
candidate = content[start_idx : end_idx + 1]
|
|
56
|
+
if _is_parseable_json_candidate(candidate):
|
|
57
|
+
return candidate
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _find_json_container_end(content: str, start_idx: int) -> int | None:
|
|
62
|
+
"""Find the matching end of a JSON container, respecting strings."""
|
|
63
|
+
pairs = {"{": "}", "[": "]"}
|
|
64
|
+
stack = [pairs[content[start_idx]]]
|
|
65
|
+
in_str = False
|
|
66
|
+
escape = False
|
|
67
|
+
|
|
68
|
+
for idx in range(start_idx + 1, len(content)):
|
|
69
|
+
ch = content[idx]
|
|
70
|
+
if escape:
|
|
71
|
+
escape = False
|
|
72
|
+
continue
|
|
73
|
+
if ch == "\\" and in_str:
|
|
74
|
+
escape = True
|
|
75
|
+
continue
|
|
76
|
+
if ch == '"':
|
|
77
|
+
in_str = not in_str
|
|
78
|
+
continue
|
|
79
|
+
if in_str:
|
|
80
|
+
continue
|
|
81
|
+
if ch in pairs:
|
|
82
|
+
stack.append(pairs[ch])
|
|
83
|
+
elif ch in ("}", "]"):
|
|
84
|
+
if not stack or stack.pop() != ch:
|
|
85
|
+
return None
|
|
86
|
+
if not stack:
|
|
87
|
+
return idx
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_parseable_json_candidate(candidate: str) -> bool:
|
|
92
|
+
"""Return True if a balanced candidate can parse after normal sanitizing."""
|
|
93
|
+
try:
|
|
94
|
+
json.loads(candidate)
|
|
95
|
+
return True
|
|
96
|
+
except Exception:
|
|
97
|
+
try:
|
|
98
|
+
json.loads(_sanitize_json_string(candidate))
|
|
99
|
+
return True
|
|
100
|
+
except Exception:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _looks_truncated_json(json_str: str) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Return True when a JSON-like string appears to end before it is complete.
|
|
107
|
+
|
|
108
|
+
This intentionally only treats content with a JSON container opener as
|
|
109
|
+
truncation. Plain text that is not JSON should proceed to the normal
|
|
110
|
+
parse failure path.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
json_str: Extracted JSON-like response text.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
True if the response has unclosed containers or strings.
|
|
117
|
+
"""
|
|
118
|
+
stripped = json_str.strip()
|
|
119
|
+
start_indices = [
|
|
120
|
+
idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1
|
|
121
|
+
]
|
|
122
|
+
if not stripped or not start_indices:
|
|
123
|
+
return False
|
|
124
|
+
stripped = stripped[min(start_indices) :]
|
|
125
|
+
|
|
126
|
+
stack: list[str] = []
|
|
127
|
+
in_str = False
|
|
128
|
+
escape = False
|
|
129
|
+
pairs = {"{": "}", "[": "]"}
|
|
130
|
+
|
|
131
|
+
for ch in stripped:
|
|
132
|
+
if escape:
|
|
133
|
+
escape = False
|
|
134
|
+
continue
|
|
135
|
+
if ch == "\\" and in_str:
|
|
136
|
+
escape = True
|
|
137
|
+
continue
|
|
138
|
+
if ch == '"':
|
|
139
|
+
in_str = not in_str
|
|
140
|
+
continue
|
|
141
|
+
if in_str:
|
|
142
|
+
continue
|
|
143
|
+
if ch in pairs:
|
|
144
|
+
stack.append(pairs[ch])
|
|
145
|
+
elif ch in ("}", "]") and (not stack or stack.pop() != ch):
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
return in_str or bool(stack)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _sanitize_json_string(json_str: str) -> str:
|
|
152
|
+
"""
|
|
153
|
+
Sanitize a JSON-like string that uses Python-style syntax into valid JSON.
|
|
154
|
+
|
|
155
|
+
Handles common LLM issues: single quotes, Python True/False/None,
|
|
156
|
+
and trailing commas before closing braces/brackets.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
json_str: A JSON-like string that may contain Python-style syntax.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
A sanitized string closer to valid JSON.
|
|
163
|
+
"""
|
|
164
|
+
s = json_str
|
|
165
|
+
|
|
166
|
+
# Walk character-by-character to:
|
|
167
|
+
# 1. Replace single-quoted strings with double-quoted strings
|
|
168
|
+
# 2. Replace Python True/False/None with JSON true/false/null ONLY outside strings
|
|
169
|
+
# 3. Handle escaped apostrophes inside single-quoted strings (e.g. 'didn\'t')
|
|
170
|
+
# 4. Escape literal double quotes that end up inside double-quoted strings
|
|
171
|
+
result = []
|
|
172
|
+
in_double = False
|
|
173
|
+
in_single = False
|
|
174
|
+
i = 0
|
|
175
|
+
while i < len(s):
|
|
176
|
+
ch = s[i]
|
|
177
|
+
if ch == "\\" and (in_double or in_single):
|
|
178
|
+
# Escaped character inside a string
|
|
179
|
+
if i + 1 < len(s):
|
|
180
|
+
next_ch = s[i + 1]
|
|
181
|
+
if in_single and next_ch == "'":
|
|
182
|
+
# \' inside single-quoted string → literal apostrophe
|
|
183
|
+
# In JSON double-quoted strings, apostrophe needs no escape
|
|
184
|
+
result.append("'")
|
|
185
|
+
i += 2
|
|
186
|
+
continue
|
|
187
|
+
result.append(ch)
|
|
188
|
+
result.append(next_ch)
|
|
189
|
+
i += 2
|
|
190
|
+
continue
|
|
191
|
+
result.append(ch)
|
|
192
|
+
elif ch == '"' and not in_single:
|
|
193
|
+
in_double = not in_double
|
|
194
|
+
result.append(ch)
|
|
195
|
+
elif ch == "'" and not in_double:
|
|
196
|
+
in_single = not in_single
|
|
197
|
+
result.append('"') # swap single → double
|
|
198
|
+
else:
|
|
199
|
+
# Escape unescaped double quotes inside single-quoted strings
|
|
200
|
+
# (they become part of a double-quoted JSON string)
|
|
201
|
+
if in_single and ch == '"':
|
|
202
|
+
result.append('\\"')
|
|
203
|
+
else:
|
|
204
|
+
result.append(ch)
|
|
205
|
+
i += 1
|
|
206
|
+
s = "".join(result)
|
|
207
|
+
|
|
208
|
+
# Replace Python booleans/None with JSON equivalents only outside quoted strings.
|
|
209
|
+
# We walk the already-double-quoted result so we only need to track double quotes.
|
|
210
|
+
output = []
|
|
211
|
+
in_str = False
|
|
212
|
+
j = 0
|
|
213
|
+
while j < len(s):
|
|
214
|
+
if s[j] == "\\" and in_str:
|
|
215
|
+
output.append(s[j : j + 2])
|
|
216
|
+
j += 2
|
|
217
|
+
continue
|
|
218
|
+
if s[j] == '"':
|
|
219
|
+
in_str = not in_str
|
|
220
|
+
output.append(s[j])
|
|
221
|
+
j += 1
|
|
222
|
+
continue
|
|
223
|
+
if not in_str:
|
|
224
|
+
matched = False
|
|
225
|
+
for py_val, json_val in _PYTHON_TO_JSON_REPLACEMENTS.items():
|
|
226
|
+
if s[j : j + len(py_val)] == py_val:
|
|
227
|
+
# Check word boundaries
|
|
228
|
+
before = s[j - 1] if j > 0 else " "
|
|
229
|
+
after = s[j + len(py_val)] if j + len(py_val) < len(s) else " "
|
|
230
|
+
if (
|
|
231
|
+
not before.isalnum()
|
|
232
|
+
and before != "_"
|
|
233
|
+
and not after.isalnum()
|
|
234
|
+
and after != "_"
|
|
235
|
+
):
|
|
236
|
+
output.append(json_val)
|
|
237
|
+
j += len(py_val)
|
|
238
|
+
matched = True
|
|
239
|
+
break
|
|
240
|
+
if not matched:
|
|
241
|
+
output.append(s[j])
|
|
242
|
+
j += 1
|
|
243
|
+
else:
|
|
244
|
+
output.append(s[j])
|
|
245
|
+
j += 1
|
|
246
|
+
s = "".join(output)
|
|
247
|
+
|
|
248
|
+
# Remove trailing commas before } or ]
|
|
249
|
+
return re.sub(r",\s*([}\]])", r"\1", s)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Structured-output concern for ``LiteLLMClient`` — schema selection + parsing (Tier-2.5).
|
|
2
|
+
|
|
3
|
+
``StructuredOutputMixin`` holds the response-format selection helpers
|
|
4
|
+
(``_supports_response_schema``, ``_provider_for_model``,
|
|
5
|
+
``_accepts_json_schema_response_format``, ``_provider_response_format``) and the
|
|
6
|
+
post-hoc parse orchestrator (``_maybe_parse_structured_output``), plus the
|
|
7
|
+
``_JSON_SCHEMA_PROVIDER_ALLOWLIST`` class constant they use.
|
|
8
|
+
|
|
9
|
+
SINK-1 (patch-where-used): ``assert_provider_safe_schema`` is imported HERE, so
|
|
10
|
+
tests patch it at ``_litellm_structured_output.assert_provider_safe_schema``
|
|
11
|
+
(the real guard raises under pytest; patching the old facade namespace would
|
|
12
|
+
no-op). ``litellm.supports_response_schema``/``get_llm_provider`` are called via
|
|
13
|
+
the shared ``litellm`` module attr.
|
|
14
|
+
|
|
15
|
+
Bodies moved VERBATIM; only the ``self``-typing (per-mixin TYPE_CHECKING stub of
|
|
16
|
+
``config``, Tier-1b idiom) is added. ``_provider_response_format`` and
|
|
17
|
+
``_maybe_parse_structured_output`` are the cross-mixin edges text-gen depends on,
|
|
18
|
+
so this module moves BEFORE ``_litellm_text_generation``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from functools import lru_cache
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
import litellm
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
from reflexio.server.llm._litellm_json_extraction import (
|
|
29
|
+
_extract_json_from_string,
|
|
30
|
+
_looks_truncated_json,
|
|
31
|
+
_sanitize_json_string,
|
|
32
|
+
)
|
|
33
|
+
from reflexio.server.llm._litellm_types import StructuredOutputParseError
|
|
34
|
+
from reflexio.server.llm.llm_utils import (
|
|
35
|
+
assert_provider_safe_schema,
|
|
36
|
+
is_pydantic_model,
|
|
37
|
+
strict_response_format_for_model,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from reflexio.server.llm._litellm_types import LiteLLMConfig
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StructuredOutputMixin:
|
|
45
|
+
"""Response-format schema selection + structured-output parsing.
|
|
46
|
+
|
|
47
|
+
Mixed into ``LiteLLMClient``; ``self.config`` is owned by the client-core
|
|
48
|
+
``__init__`` on the facade. The annotation-only stub below (Tier-1b idiom)
|
|
49
|
+
gives pyright the foreign-member type without shared class-level state.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
# OpenAI-compatible providers that accept a ``json_schema`` response_format
|
|
53
|
+
# but that ``litellm.supports_response_schema`` reports as unsupported. For
|
|
54
|
+
# these, the gate below would fall back to handing LiteLLM the raw Pydantic
|
|
55
|
+
# model; LiteLLM then builds the ``json_schema`` itself and emits ``oneOf``
|
|
56
|
+
# for discriminated unions, which strict structured-output endpoints reject
|
|
57
|
+
# (Sentry PYTHON-FASTAPI-9J). Listing the provider here forces our own
|
|
58
|
+
# normalized strict schema (``oneOf`` folded into ``anyOf``) to be sent.
|
|
59
|
+
_JSON_SCHEMA_PROVIDER_ALLOWLIST: frozenset[str] = frozenset({"minimax"})
|
|
60
|
+
|
|
61
|
+
# Base-owned attribute read for the parse-failure error message (init'd in
|
|
62
|
+
# the facade ``__init__``). Annotation-only; NEVER assign here.
|
|
63
|
+
config: "LiteLLMConfig"
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
@lru_cache(maxsize=256)
|
|
67
|
+
def _supports_response_schema(model: str) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
return bool(litellm.supports_response_schema(model=model))
|
|
70
|
+
except Exception:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
@lru_cache(maxsize=256)
|
|
75
|
+
def _provider_for_model(model: str) -> str | None:
|
|
76
|
+
try:
|
|
77
|
+
return litellm.get_llm_provider(model)[1]
|
|
78
|
+
except Exception:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def _accepts_json_schema_response_format(cls, model: str) -> bool:
|
|
83
|
+
"""Whether to send ``model`` an explicit strict ``json_schema`` schema.
|
|
84
|
+
|
|
85
|
+
True when LiteLLM reports native response-schema support, or when the
|
|
86
|
+
provider is a known OpenAI-compatible endpoint that LiteLLM
|
|
87
|
+
under-reports (see ``_JSON_SCHEMA_PROVIDER_ALLOWLIST``). In the latter
|
|
88
|
+
case LiteLLM would otherwise forward a ``json_schema`` it built itself,
|
|
89
|
+
emitting ``oneOf`` for discriminated unions that the endpoint rejects.
|
|
90
|
+
"""
|
|
91
|
+
if cls._supports_response_schema(model):
|
|
92
|
+
return True
|
|
93
|
+
return cls._provider_for_model(model) in cls._JSON_SCHEMA_PROVIDER_ALLOWLIST
|
|
94
|
+
|
|
95
|
+
def _provider_response_format(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
response_format: Any,
|
|
99
|
+
model: str,
|
|
100
|
+
strict_response_format: bool,
|
|
101
|
+
) -> Any:
|
|
102
|
+
"""Return the provider-facing response_format while preserving parser schema.
|
|
103
|
+
|
|
104
|
+
Callers pass a Pydantic model so local parsing stays type-safe. When the
|
|
105
|
+
target model accepts a JSON Schema response format — either LiteLLM
|
|
106
|
+
reports native support, or the provider is an OpenAI-compatible endpoint
|
|
107
|
+
LiteLLM under-reports (see ``_accepts_json_schema_response_format``) — we
|
|
108
|
+
send an explicit strict schema to constrain generation. Truly
|
|
109
|
+
unsupported providers keep the existing Pydantic response_format
|
|
110
|
+
behavior.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
if not is_pydantic_model(response_format):
|
|
114
|
+
return response_format
|
|
115
|
+
|
|
116
|
+
# Build the native schema once and reuse it for both the boundary guard and
|
|
117
|
+
# (when applicable) the strict normalizer, avoiding a second schema build.
|
|
118
|
+
# Boundary guard: models inheriting StrictStructuredOutput are safe by
|
|
119
|
+
# construction; this catches a model that forgot the base (raises under
|
|
120
|
+
# tests, warns in prod) regardless of which path is taken below.
|
|
121
|
+
schema = response_format.model_json_schema()
|
|
122
|
+
assert_provider_safe_schema(schema, name=response_format.__name__)
|
|
123
|
+
|
|
124
|
+
if strict_response_format and self._accepts_json_schema_response_format(model):
|
|
125
|
+
return strict_response_format_for_model(response_format, schema=schema)
|
|
126
|
+
return response_format
|
|
127
|
+
|
|
128
|
+
def _maybe_parse_structured_output(
|
|
129
|
+
self,
|
|
130
|
+
content: str,
|
|
131
|
+
response_format: Any,
|
|
132
|
+
parse_structured_output: bool,
|
|
133
|
+
) -> str | BaseModel:
|
|
134
|
+
"""
|
|
135
|
+
Parse structured output if applicable.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
content: Raw response content.
|
|
139
|
+
response_format: Expected response format (must be a Pydantic BaseModel class).
|
|
140
|
+
parse_structured_output: Whether to parse the output.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
String for text responses, or BaseModel instance for structured responses.
|
|
144
|
+
"""
|
|
145
|
+
if not response_format or not parse_structured_output:
|
|
146
|
+
return content
|
|
147
|
+
|
|
148
|
+
if content is None:
|
|
149
|
+
return content
|
|
150
|
+
|
|
151
|
+
# If content is already a Pydantic model (some providers return parsed)
|
|
152
|
+
if isinstance(content, BaseModel):
|
|
153
|
+
return content
|
|
154
|
+
|
|
155
|
+
# Try to parse JSON and convert to Pydantic model
|
|
156
|
+
# Extract JSON from markdown code blocks if present
|
|
157
|
+
json_str = _extract_json_from_string(content)
|
|
158
|
+
try:
|
|
159
|
+
parsed = json.loads(json_str)
|
|
160
|
+
|
|
161
|
+
# response_format must be a Pydantic model (validated at entry points)
|
|
162
|
+
return response_format.model_validate(parsed)
|
|
163
|
+
except Exception:
|
|
164
|
+
# LLMs sometimes produce Python-style output (single quotes, True/False,
|
|
165
|
+
# trailing commas). Try to sanitize before giving up.
|
|
166
|
+
try:
|
|
167
|
+
sanitized = _sanitize_json_string(json_str)
|
|
168
|
+
parsed = json.loads(sanitized)
|
|
169
|
+
return response_format.model_validate(parsed)
|
|
170
|
+
except Exception:
|
|
171
|
+
# Last resort: json-repair can recover complete responses with
|
|
172
|
+
# small syntax glitches, such as missing commas. Do not repair
|
|
173
|
+
# likely truncation: the retry loop should request a fresh
|
|
174
|
+
# complete response instead of accepting invented tail content.
|
|
175
|
+
try:
|
|
176
|
+
from json_repair import repair_json
|
|
177
|
+
|
|
178
|
+
if _looks_truncated_json(json_str):
|
|
179
|
+
raise StructuredOutputParseError(
|
|
180
|
+
"Structured output appears truncated"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
repaired = repair_json(json_str, return_objects=True)
|
|
184
|
+
return response_format.model_validate(repaired)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
model = self.config.model
|
|
187
|
+
snippet = (
|
|
188
|
+
content[:200]
|
|
189
|
+
if isinstance(content, str)
|
|
190
|
+
else repr(content)[:200]
|
|
191
|
+
)
|
|
192
|
+
raise StructuredOutputParseError(
|
|
193
|
+
f"Structured output parse failed for model={model!r}: {e}. "
|
|
194
|
+
f"Content snippet: {snippet!r}"
|
|
195
|
+
) from e
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Subprocess-isolation helpers for the hard-timeout completion path (Tier-2.5 leaf).
|
|
2
|
+
|
|
3
|
+
Stateless leaf module: the picklable ``_Completion*Snapshot`` dataclasses plus the
|
|
4
|
+
snapshot builders and the ``_litellm_completion_worker`` that runs
|
|
5
|
+
``litellm.completion`` inside a child process. Used by ``TextGenerationMixin``'s
|
|
6
|
+
``_completion_with_hard_timeout`` (Task 4) which sizes and kills the subprocess.
|
|
7
|
+
|
|
8
|
+
LLM-mock: the worker calls ``litellm.completion`` via the MODULE ATTR (never
|
|
9
|
+
``from litellm import completion``) so the global ``patch("litellm.completion")``
|
|
10
|
+
mock is inherited across the ``fork`` start-method. The multiprocessing
|
|
11
|
+
start-method is intentionally left unchanged.
|
|
12
|
+
|
|
13
|
+
SINK-2 (identity): the 6 snapshot classes and ``_litellm_completion_worker`` are
|
|
14
|
+
re-exported by import binding from the facade — they must be the SAME class/object
|
|
15
|
+
the worker constructs and tests ``isinstance``-check. Bodies moved VERBATIM.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import multiprocessing
|
|
19
|
+
import pickle
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import litellm
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class _CompletionMessageSnapshot:
|
|
28
|
+
content: str | None = None
|
|
29
|
+
tool_calls: Any | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class _CompletionChoiceSnapshot:
|
|
34
|
+
message: _CompletionMessageSnapshot
|
|
35
|
+
finish_reason: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _PromptTokenDetailsSnapshot:
|
|
40
|
+
cached_tokens: int = 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class _CompletionUsageSnapshot:
|
|
45
|
+
prompt_tokens: int | None = None
|
|
46
|
+
completion_tokens: int | None = None
|
|
47
|
+
total_tokens: int | None = None
|
|
48
|
+
prompt_tokens_details: _PromptTokenDetailsSnapshot | None = None
|
|
49
|
+
cache_creation_input_tokens: int | None = None
|
|
50
|
+
cache_read_input_tokens: int | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class _CompletionResponseSnapshot:
|
|
55
|
+
choices: list[_CompletionChoiceSnapshot]
|
|
56
|
+
usage: _CompletionUsageSnapshot | None = None
|
|
57
|
+
model: str | None = None
|
|
58
|
+
_hidden_params: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class _CompletionErrorSnapshot:
|
|
63
|
+
type_name: str
|
|
64
|
+
message: str
|
|
65
|
+
model: str | None = None
|
|
66
|
+
llm_provider: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _snapshot_completion_error(
|
|
70
|
+
exc: BaseException, params: dict[str, Any]
|
|
71
|
+
) -> _CompletionErrorSnapshot:
|
|
72
|
+
model = getattr(exc, "model", None) or params.get("model")
|
|
73
|
+
llm_provider = getattr(exc, "llm_provider", None)
|
|
74
|
+
return _CompletionErrorSnapshot(
|
|
75
|
+
type_name=type(exc).__name__,
|
|
76
|
+
message=str(exc),
|
|
77
|
+
model=str(model) if model else None,
|
|
78
|
+
llm_provider=str(llm_provider) if llm_provider else None,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ensure_picklable(value: Any) -> Any:
|
|
83
|
+
try:
|
|
84
|
+
pickle.dumps(value)
|
|
85
|
+
except Exception:
|
|
86
|
+
return repr(value)
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _snapshot_completion_response(response: Any) -> _CompletionResponseSnapshot:
|
|
91
|
+
choices: list[_CompletionChoiceSnapshot] = []
|
|
92
|
+
for choice in getattr(response, "choices", []) or []:
|
|
93
|
+
message = getattr(choice, "message", None)
|
|
94
|
+
choices.append(
|
|
95
|
+
_CompletionChoiceSnapshot(
|
|
96
|
+
message=_CompletionMessageSnapshot(
|
|
97
|
+
content=getattr(message, "content", None),
|
|
98
|
+
tool_calls=_ensure_picklable(getattr(message, "tool_calls", None)),
|
|
99
|
+
),
|
|
100
|
+
finish_reason=getattr(choice, "finish_reason", None),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
usage = getattr(response, "usage", None)
|
|
105
|
+
usage_snapshot = None
|
|
106
|
+
if usage is not None:
|
|
107
|
+
prompt_details = getattr(usage, "prompt_tokens_details", None)
|
|
108
|
+
prompt_details_snapshot = None
|
|
109
|
+
if prompt_details is not None:
|
|
110
|
+
prompt_details_snapshot = _PromptTokenDetailsSnapshot(
|
|
111
|
+
cached_tokens=int(getattr(prompt_details, "cached_tokens", 0) or 0)
|
|
112
|
+
)
|
|
113
|
+
usage_snapshot = _CompletionUsageSnapshot(
|
|
114
|
+
prompt_tokens=getattr(usage, "prompt_tokens", None),
|
|
115
|
+
completion_tokens=getattr(usage, "completion_tokens", None),
|
|
116
|
+
total_tokens=getattr(usage, "total_tokens", None),
|
|
117
|
+
prompt_tokens_details=prompt_details_snapshot,
|
|
118
|
+
cache_creation_input_tokens=getattr(
|
|
119
|
+
usage, "cache_creation_input_tokens", None
|
|
120
|
+
),
|
|
121
|
+
cache_read_input_tokens=getattr(usage, "cache_read_input_tokens", None),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
hidden_params = getattr(response, "_hidden_params", {}) or {}
|
|
125
|
+
if not isinstance(hidden_params, dict):
|
|
126
|
+
hidden_params = {}
|
|
127
|
+
|
|
128
|
+
return _CompletionResponseSnapshot(
|
|
129
|
+
choices=choices,
|
|
130
|
+
usage=usage_snapshot,
|
|
131
|
+
model=getattr(response, "model", None),
|
|
132
|
+
_hidden_params={str(k): _ensure_picklable(v) for k, v in hidden_params.items()},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _picklable_completion_result(response: Any) -> Any:
|
|
137
|
+
try:
|
|
138
|
+
pickle.dumps(response)
|
|
139
|
+
except Exception:
|
|
140
|
+
return _snapshot_completion_response(response)
|
|
141
|
+
return response
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _litellm_completion_worker(
|
|
145
|
+
params: dict[str, Any], result_queue: multiprocessing.Queue
|
|
146
|
+
) -> None:
|
|
147
|
+
try:
|
|
148
|
+
result_queue.put(
|
|
149
|
+
("ok", _picklable_completion_result(litellm.completion(**params)))
|
|
150
|
+
)
|
|
151
|
+
except BaseException as exc:
|
|
152
|
+
result_queue.put(("error", _snapshot_completion_error(exc, params)))
|