claude-smart 0.2.47 → 0.2.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  7. package/plugin/dashboard/package-lock.json +61 -390
  8. package/plugin/dashboard/package.json +2 -2
  9. package/plugin/pyproject.toml +1 -1
  10. package/plugin/uv.lock +1 -1
  11. package/plugin/vendor/reflexio/.env.example +7 -0
  12. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  13. package/plugin/vendor/reflexio/reflexio/README.md +8 -5
  14. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  15. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  16. package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
  17. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  18. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
  19. package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
  20. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  21. package/plugin/vendor/reflexio/reflexio/server/api.py +133 -3273
  22. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  23. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  24. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  25. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  26. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  27. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  28. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  29. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  30. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  31. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  32. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  33. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
  34. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  35. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  36. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  37. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  38. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  39. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  40. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  41. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  42. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  43. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  44. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  45. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  46. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  47. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  48. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  49. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  50. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
  51. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  52. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  53. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  54. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  55. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  56. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  57. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  58. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  59. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  60. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  61. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  62. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
  63. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
  64. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  65. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  66. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  68. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +29 -4
  70. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  71. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +56 -351
  72. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
  73. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +6 -1
  74. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
  75. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  76. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  77. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  87. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  88. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  89. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +6 -3
  90. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +13 -7
  91. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  95. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +33 -8
  96. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  97. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  98. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  103. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  104. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +44 -86
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
@@ -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)))