mcp-mesh 0.6.1__py3-none-any.whl → 0.6.3__py3-none-any.whl
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.
- _mcp_mesh/__init__.py +1 -1
- _mcp_mesh/engine/llm_config.py +10 -1
- _mcp_mesh/engine/mesh_llm_agent.py +51 -33
- _mcp_mesh/engine/mesh_llm_agent_injector.py +72 -9
- _mcp_mesh/engine/provider_handlers/claude_handler.py +322 -42
- _mcp_mesh/engine/provider_handlers/openai_handler.py +65 -9
- _mcp_mesh/engine/response_parser.py +54 -15
- _mcp_mesh/pipeline/api_heartbeat/api_dependency_resolution.py +54 -21
- _mcp_mesh/pipeline/mcp_heartbeat/dependency_resolution.py +43 -26
- {mcp_mesh-0.6.1.dist-info → mcp_mesh-0.6.3.dist-info}/METADATA +1 -1
- {mcp_mesh-0.6.1.dist-info → mcp_mesh-0.6.3.dist-info}/RECORD +14 -14
- {mcp_mesh-0.6.1.dist-info → mcp_mesh-0.6.3.dist-info}/WHEEL +1 -1
- mesh/decorators.py +39 -2
- {mcp_mesh-0.6.1.dist-info → mcp_mesh-0.6.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -3,15 +3,33 @@ Claude/Anthropic provider handler.
|
|
|
3
3
|
|
|
4
4
|
Optimized for Claude API (Claude 3.x, Sonnet, Opus, Haiku)
|
|
5
5
|
using Anthropic's best practices for tool calling and JSON responses.
|
|
6
|
+
|
|
7
|
+
Supports three output modes for performance/reliability tradeoffs:
|
|
8
|
+
- strict: Use response_format for guaranteed schema compliance (slowest, 100% reliable)
|
|
9
|
+
- hint: Use prompt-based JSON instructions (medium speed, ~95% reliable)
|
|
10
|
+
- text: Plain text output for str return types (fastest)
|
|
11
|
+
|
|
12
|
+
Features:
|
|
13
|
+
- Automatic prompt caching for system messages (up to 90% cost reduction)
|
|
14
|
+
- Anti-XML tool calling instructions
|
|
15
|
+
- Output mode optimization based on return type
|
|
6
16
|
"""
|
|
7
17
|
|
|
8
18
|
import json
|
|
9
|
-
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Optional, get_args, get_origin
|
|
10
21
|
|
|
11
22
|
from pydantic import BaseModel
|
|
12
23
|
|
|
13
24
|
from .base_provider_handler import BaseProviderHandler
|
|
14
25
|
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Output mode constants
|
|
29
|
+
OUTPUT_MODE_STRICT = "strict"
|
|
30
|
+
OUTPUT_MODE_HINT = "hint"
|
|
31
|
+
OUTPUT_MODE_TEXT = "text"
|
|
32
|
+
|
|
15
33
|
|
|
16
34
|
class ClaudeHandler(BaseProviderHandler):
|
|
17
35
|
"""
|
|
@@ -19,48 +37,260 @@ class ClaudeHandler(BaseProviderHandler):
|
|
|
19
37
|
|
|
20
38
|
Claude Characteristics:
|
|
21
39
|
- Excellent at following detailed instructions
|
|
22
|
-
-
|
|
23
|
-
- Handles JSON output well with schema instructions
|
|
40
|
+
- Native structured output via response_format (requires strict schema)
|
|
24
41
|
- Native tool calling (via Anthropic messages API)
|
|
25
42
|
- Performs best with anti-XML tool calling instructions
|
|
43
|
+
- Automatic prompt caching for cost optimization
|
|
44
|
+
|
|
45
|
+
Output Modes:
|
|
46
|
+
- strict: response_format with JSON schema (slowest, guaranteed valid JSON)
|
|
47
|
+
- hint: JSON schema in prompt (medium speed, usually valid JSON)
|
|
48
|
+
- text: Plain text output for str return types (fastest)
|
|
26
49
|
|
|
27
50
|
Best Practices (from Anthropic docs):
|
|
28
|
-
-
|
|
29
|
-
-
|
|
51
|
+
- Use response_format for guaranteed JSON schema compliance
|
|
52
|
+
- Schema must have additionalProperties: false on all objects
|
|
30
53
|
- Add anti-XML instructions to prevent <invoke> style tool calls
|
|
31
54
|
- Use one tool call at a time for better reliability
|
|
55
|
+
- Use cache_control for system prompts to reduce costs
|
|
32
56
|
"""
|
|
33
57
|
|
|
34
58
|
def __init__(self):
|
|
35
59
|
"""Initialize Claude handler."""
|
|
36
60
|
super().__init__(vendor="anthropic")
|
|
37
61
|
|
|
62
|
+
def _is_simple_schema(self, model_class: type[BaseModel]) -> bool:
|
|
63
|
+
"""
|
|
64
|
+
Check if a Pydantic model has a simple schema.
|
|
65
|
+
|
|
66
|
+
Simple schema criteria:
|
|
67
|
+
- Less than 5 fields
|
|
68
|
+
- All fields are basic types (str, int, float, bool, list, Optional)
|
|
69
|
+
- No nested Pydantic models
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
model_class: Pydantic model class
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True if schema is simple, False otherwise
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
schema = model_class.model_json_schema()
|
|
79
|
+
properties = schema.get("properties", {})
|
|
80
|
+
|
|
81
|
+
# Check field count
|
|
82
|
+
if len(properties) >= 5:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# Check for nested objects or complex types
|
|
86
|
+
for field_name, field_schema in properties.items():
|
|
87
|
+
field_type = field_schema.get("type")
|
|
88
|
+
|
|
89
|
+
# Check for nested objects (indicates nested Pydantic model)
|
|
90
|
+
if field_type == "object" and "properties" in field_schema:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
# Check for $ref (nested model reference)
|
|
94
|
+
if "$ref" in field_schema:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
# Check array items for complex types
|
|
98
|
+
if field_type == "array":
|
|
99
|
+
items = field_schema.get("items", {})
|
|
100
|
+
if items.get("type") == "object" or "$ref" in items:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
return True
|
|
104
|
+
except Exception:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def determine_output_mode(
|
|
108
|
+
self, output_type: type, override_mode: Optional[str] = None
|
|
109
|
+
) -> str:
|
|
110
|
+
"""
|
|
111
|
+
Determine the output mode based on return type.
|
|
112
|
+
|
|
113
|
+
Logic:
|
|
114
|
+
- If override_mode specified, use it
|
|
115
|
+
- If return type is str, use "text" mode
|
|
116
|
+
- If return type is simple schema (<5 fields, basic types), use "hint" mode
|
|
117
|
+
- Otherwise, use "strict" mode
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
output_type: Return type (str or BaseModel subclass)
|
|
121
|
+
override_mode: Optional override ("strict", "hint", or "text")
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Output mode string
|
|
125
|
+
"""
|
|
126
|
+
# Allow explicit override
|
|
127
|
+
if override_mode:
|
|
128
|
+
return override_mode
|
|
129
|
+
|
|
130
|
+
# String return type -> text mode
|
|
131
|
+
if output_type is str:
|
|
132
|
+
return OUTPUT_MODE_TEXT
|
|
133
|
+
|
|
134
|
+
# Check if it's a Pydantic model
|
|
135
|
+
if isinstance(output_type, type) and issubclass(output_type, BaseModel):
|
|
136
|
+
if self._is_simple_schema(output_type):
|
|
137
|
+
return OUTPUT_MODE_HINT
|
|
138
|
+
else:
|
|
139
|
+
return OUTPUT_MODE_STRICT
|
|
140
|
+
|
|
141
|
+
# Default to strict for unknown types
|
|
142
|
+
return OUTPUT_MODE_STRICT
|
|
143
|
+
|
|
144
|
+
def _make_schema_strict(self, schema: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
"""
|
|
146
|
+
Make a JSON schema strict for Claude's structured output.
|
|
147
|
+
|
|
148
|
+
Claude requires additionalProperties: false on all object types.
|
|
149
|
+
This recursively processes the schema to add this constraint.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
schema: JSON schema dict
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Schema with additionalProperties: false on all objects
|
|
156
|
+
"""
|
|
157
|
+
if not isinstance(schema, dict):
|
|
158
|
+
return schema
|
|
159
|
+
|
|
160
|
+
result = schema.copy()
|
|
161
|
+
|
|
162
|
+
# If this is an object type, add additionalProperties: false
|
|
163
|
+
if result.get("type") == "object":
|
|
164
|
+
result["additionalProperties"] = False
|
|
165
|
+
|
|
166
|
+
# Recursively process nested schemas
|
|
167
|
+
if "properties" in result:
|
|
168
|
+
result["properties"] = {
|
|
169
|
+
k: self._make_schema_strict(v) for k, v in result["properties"].items()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# Process $defs (Pydantic uses this for nested models)
|
|
173
|
+
if "$defs" in result:
|
|
174
|
+
result["$defs"] = {
|
|
175
|
+
k: self._make_schema_strict(v) for k, v in result["$defs"].items()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Process items for arrays
|
|
179
|
+
if "items" in result:
|
|
180
|
+
result["items"] = self._make_schema_strict(result["items"])
|
|
181
|
+
|
|
182
|
+
# Process anyOf, oneOf, allOf
|
|
183
|
+
for key in ["anyOf", "oneOf", "allOf"]:
|
|
184
|
+
if key in result:
|
|
185
|
+
result[key] = [self._make_schema_strict(s) for s in result[key]]
|
|
186
|
+
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
def _apply_prompt_caching(
|
|
190
|
+
self, messages: list[dict[str, Any]]
|
|
191
|
+
) -> list[dict[str, Any]]:
|
|
192
|
+
"""
|
|
193
|
+
Apply prompt caching to system messages for Claude.
|
|
194
|
+
|
|
195
|
+
Claude's prompt caching feature caches the system prompt prefix,
|
|
196
|
+
reducing costs by up to 90% and improving latency for repeated calls.
|
|
197
|
+
|
|
198
|
+
The cache_control with type "ephemeral" tells Claude to cache
|
|
199
|
+
this content for the duration of the session (typically 5 minutes).
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
messages: List of message dicts
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Messages with cache_control applied to system messages
|
|
206
|
+
|
|
207
|
+
Reference:
|
|
208
|
+
https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
|
|
209
|
+
"""
|
|
210
|
+
cached_messages = []
|
|
211
|
+
|
|
212
|
+
for msg in messages:
|
|
213
|
+
if msg.get("role") == "system":
|
|
214
|
+
content = msg.get("content", "")
|
|
215
|
+
|
|
216
|
+
# Convert string content to cached content block format
|
|
217
|
+
if isinstance(content, str):
|
|
218
|
+
cached_msg = {
|
|
219
|
+
"role": "system",
|
|
220
|
+
"content": [
|
|
221
|
+
{
|
|
222
|
+
"type": "text",
|
|
223
|
+
"text": content,
|
|
224
|
+
"cache_control": {"type": "ephemeral"},
|
|
225
|
+
}
|
|
226
|
+
],
|
|
227
|
+
}
|
|
228
|
+
cached_messages.append(cached_msg)
|
|
229
|
+
logger.debug(
|
|
230
|
+
f"🗄️ Applied prompt caching to system message ({len(content)} chars)"
|
|
231
|
+
)
|
|
232
|
+
elif isinstance(content, list):
|
|
233
|
+
# Already in content block format - add cache_control to last block
|
|
234
|
+
cached_content = []
|
|
235
|
+
for i, block in enumerate(content):
|
|
236
|
+
if isinstance(block, dict):
|
|
237
|
+
block_copy = block.copy()
|
|
238
|
+
# Add cache_control to the last text block
|
|
239
|
+
if i == len(content) - 1 and block.get("type") == "text":
|
|
240
|
+
block_copy["cache_control"] = {"type": "ephemeral"}
|
|
241
|
+
cached_content.append(block_copy)
|
|
242
|
+
else:
|
|
243
|
+
cached_content.append(block)
|
|
244
|
+
cached_messages.append(
|
|
245
|
+
{"role": "system", "content": cached_content}
|
|
246
|
+
)
|
|
247
|
+
logger.debug("🗄️ Applied prompt caching to system content blocks")
|
|
248
|
+
else:
|
|
249
|
+
# Unknown format - pass through unchanged
|
|
250
|
+
cached_messages.append(msg)
|
|
251
|
+
else:
|
|
252
|
+
# Non-system messages pass through unchanged
|
|
253
|
+
cached_messages.append(msg)
|
|
254
|
+
|
|
255
|
+
return cached_messages
|
|
256
|
+
|
|
38
257
|
def prepare_request(
|
|
39
258
|
self,
|
|
40
|
-
messages:
|
|
41
|
-
tools: Optional[
|
|
42
|
-
output_type: type
|
|
43
|
-
**kwargs: Any
|
|
44
|
-
) ->
|
|
259
|
+
messages: list[dict[str, Any]],
|
|
260
|
+
tools: Optional[list[dict[str, Any]]],
|
|
261
|
+
output_type: type,
|
|
262
|
+
**kwargs: Any,
|
|
263
|
+
) -> dict[str, Any]:
|
|
45
264
|
"""
|
|
46
|
-
Prepare request parameters for Claude API.
|
|
265
|
+
Prepare request parameters for Claude API with output mode support.
|
|
47
266
|
|
|
48
|
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
- No
|
|
267
|
+
Output Mode Strategy:
|
|
268
|
+
- strict: Use response_format for guaranteed JSON schema compliance (slowest)
|
|
269
|
+
- hint: No response_format, rely on prompt instructions (medium speed)
|
|
270
|
+
- text: No response_format, plain text output (fastest)
|
|
52
271
|
|
|
53
272
|
Args:
|
|
54
273
|
messages: List of message dicts
|
|
55
274
|
tools: Optional list of tool schemas
|
|
56
|
-
output_type: Pydantic model
|
|
57
|
-
**kwargs: Additional model parameters
|
|
275
|
+
output_type: Return type (str or Pydantic model)
|
|
276
|
+
**kwargs: Additional model parameters (may include output_mode override)
|
|
58
277
|
|
|
59
278
|
Returns:
|
|
60
279
|
Dictionary of parameters for litellm.completion()
|
|
61
280
|
"""
|
|
281
|
+
# Extract output_mode from kwargs if provided
|
|
282
|
+
output_mode = kwargs.pop("output_mode", None)
|
|
283
|
+
determined_mode = self.determine_output_mode(output_type, output_mode)
|
|
284
|
+
|
|
285
|
+
# Remove response_format from kwargs - we control this based on output mode
|
|
286
|
+
# The decorator's response_format="json" is just a hint for parsing, not API param
|
|
287
|
+
kwargs.pop("response_format", None)
|
|
288
|
+
|
|
289
|
+
# Apply prompt caching to system messages for cost optimization
|
|
290
|
+
cached_messages = self._apply_prompt_caching(messages)
|
|
291
|
+
|
|
62
292
|
request_params = {
|
|
63
|
-
"messages":
|
|
293
|
+
"messages": cached_messages,
|
|
64
294
|
**kwargs, # Pass through temperature, max_tokens, etc.
|
|
65
295
|
}
|
|
66
296
|
|
|
@@ -69,32 +299,49 @@ class ClaudeHandler(BaseProviderHandler):
|
|
|
69
299
|
if tools:
|
|
70
300
|
request_params["tools"] = tools
|
|
71
301
|
|
|
302
|
+
# Only add response_format in "strict" mode
|
|
303
|
+
if determined_mode == OUTPUT_MODE_STRICT:
|
|
304
|
+
# Claude requires additionalProperties: false on all object types
|
|
305
|
+
if isinstance(output_type, type) and issubclass(output_type, BaseModel):
|
|
306
|
+
schema = output_type.model_json_schema()
|
|
307
|
+
strict_schema = self._make_schema_strict(schema)
|
|
308
|
+
request_params["response_format"] = {
|
|
309
|
+
"type": "json_schema",
|
|
310
|
+
"json_schema": {
|
|
311
|
+
"name": output_type.__name__,
|
|
312
|
+
"schema": strict_schema,
|
|
313
|
+
"strict": False, # Allow optional fields with defaults
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
|
|
72
317
|
return request_params
|
|
73
318
|
|
|
74
319
|
def format_system_prompt(
|
|
75
320
|
self,
|
|
76
321
|
base_prompt: str,
|
|
77
|
-
tool_schemas: Optional[
|
|
78
|
-
output_type: type
|
|
322
|
+
tool_schemas: Optional[list[dict[str, Any]]],
|
|
323
|
+
output_type: type,
|
|
324
|
+
output_mode: Optional[str] = None,
|
|
79
325
|
) -> str:
|
|
80
326
|
"""
|
|
81
|
-
Format system prompt for Claude with
|
|
327
|
+
Format system prompt for Claude with output mode support.
|
|
82
328
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
4. Claude performs best with verbose, explicit guidelines
|
|
329
|
+
Output Mode Strategy:
|
|
330
|
+
- strict: Minimal JSON instructions (response_format handles schema)
|
|
331
|
+
- hint: Add detailed JSON schema instructions in prompt
|
|
332
|
+
- text: No JSON instructions (plain text output)
|
|
88
333
|
|
|
89
334
|
Args:
|
|
90
335
|
base_prompt: Base system prompt
|
|
91
336
|
tool_schemas: Optional tool schemas
|
|
92
337
|
output_type: Expected response type
|
|
338
|
+
output_mode: Optional override for output mode
|
|
93
339
|
|
|
94
340
|
Returns:
|
|
95
341
|
Formatted system prompt optimized for Claude
|
|
96
342
|
"""
|
|
97
343
|
system_content = base_prompt
|
|
344
|
+
determined_mode = self.determine_output_mode(output_type, output_mode)
|
|
98
345
|
|
|
99
346
|
# Add tool calling instructions if tools available
|
|
100
347
|
# These prevent Claude from using XML-style <invoke> syntax
|
|
@@ -103,26 +350,58 @@ class ClaudeHandler(BaseProviderHandler):
|
|
|
103
350
|
|
|
104
351
|
IMPORTANT TOOL CALLING RULES:
|
|
105
352
|
- You have access to tools that you can call to gather information
|
|
106
|
-
- Make ONE tool call at a time
|
|
107
|
-
- NEVER combine multiple tools in a single tool_use block
|
|
353
|
+
- Make ONE tool call at a time
|
|
108
354
|
- NEVER use XML-style syntax like <invoke name="tool_name"/>
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
- Once you have gathered all necessary information, provide your final response
|
|
355
|
+
- After receiving tool results, you can make additional calls if needed
|
|
356
|
+
- Once you have all needed information, provide your final response
|
|
112
357
|
"""
|
|
113
358
|
|
|
114
|
-
# Add
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
359
|
+
# Add output format instructions based on mode
|
|
360
|
+
if determined_mode == OUTPUT_MODE_TEXT:
|
|
361
|
+
# Text mode: No JSON instructions
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
elif determined_mode == OUTPUT_MODE_STRICT:
|
|
365
|
+
# Strict mode: Minimal instructions (response_format handles schema)
|
|
366
|
+
if isinstance(output_type, type) and issubclass(output_type, BaseModel):
|
|
367
|
+
system_content += f"\n\nYour final response will be structured as JSON matching the {output_type.__name__} format."
|
|
368
|
+
|
|
369
|
+
elif determined_mode == OUTPUT_MODE_HINT:
|
|
370
|
+
# Hint mode: Add detailed JSON schema instructions
|
|
371
|
+
if isinstance(output_type, type) and issubclass(output_type, BaseModel):
|
|
372
|
+
schema = output_type.model_json_schema()
|
|
373
|
+
properties = schema.get("properties", {})
|
|
374
|
+
required = schema.get("required", [])
|
|
375
|
+
|
|
376
|
+
# Build human-readable schema description
|
|
377
|
+
field_descriptions = []
|
|
378
|
+
for field_name, field_schema in properties.items():
|
|
379
|
+
field_type = field_schema.get("type", "any")
|
|
380
|
+
is_required = field_name in required
|
|
381
|
+
req_marker = " (required)" if is_required else " (optional)"
|
|
382
|
+
desc = field_schema.get("description", "")
|
|
383
|
+
desc_text = f" - {desc}" if desc else ""
|
|
384
|
+
field_descriptions.append(
|
|
385
|
+
f" - {field_name}: {field_type}{req_marker}{desc_text}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
fields_text = "\n".join(field_descriptions)
|
|
389
|
+
system_content += f"""
|
|
390
|
+
|
|
391
|
+
RESPONSE FORMAT:
|
|
392
|
+
You MUST respond with valid JSON matching this schema:
|
|
393
|
+
{{
|
|
394
|
+
{fields_text}
|
|
395
|
+
}}
|
|
396
|
+
|
|
397
|
+
Example format:
|
|
398
|
+
{json.dumps({k: f"<{v.get('type', 'value')}>" for k, v in properties.items()}, indent=2)}
|
|
399
|
+
|
|
400
|
+
IMPORTANT: Respond ONLY with valid JSON. No markdown code fences, no preamble text."""
|
|
122
401
|
|
|
123
402
|
return system_content
|
|
124
403
|
|
|
125
|
-
def get_vendor_capabilities(self) ->
|
|
404
|
+
def get_vendor_capabilities(self) -> dict[str, bool]:
|
|
126
405
|
"""
|
|
127
406
|
Return Claude-specific capabilities.
|
|
128
407
|
|
|
@@ -131,8 +410,9 @@ IMPORTANT TOOL CALLING RULES:
|
|
|
131
410
|
"""
|
|
132
411
|
return {
|
|
133
412
|
"native_tool_calling": True, # Claude has native function calling
|
|
134
|
-
"structured_output":
|
|
413
|
+
"structured_output": True, # Native response_format support via LiteLLM
|
|
135
414
|
"streaming": True, # Supports streaming
|
|
136
415
|
"vision": True, # Claude 3+ supports vision
|
|
137
|
-
"json_mode":
|
|
416
|
+
"json_mode": True, # Native JSON mode via response_format
|
|
417
|
+
"prompt_caching": True, # Automatic system prompt caching for cost savings
|
|
138
418
|
}
|
|
@@ -46,11 +46,11 @@ class OpenAIHandler(BaseProviderHandler):
|
|
|
46
46
|
|
|
47
47
|
def prepare_request(
|
|
48
48
|
self,
|
|
49
|
-
messages:
|
|
50
|
-
tools: Optional[
|
|
49
|
+
messages: list[dict[str, Any]],
|
|
50
|
+
tools: Optional[list[dict[str, Any]]],
|
|
51
51
|
output_type: type[BaseModel],
|
|
52
|
-
**kwargs: Any
|
|
53
|
-
) ->
|
|
52
|
+
**kwargs: Any,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
54
|
"""
|
|
55
55
|
Prepare request parameters for OpenAI API with structured output.
|
|
56
56
|
|
|
@@ -83,6 +83,10 @@ class OpenAIHandler(BaseProviderHandler):
|
|
|
83
83
|
# rather than relying on prompt instructions alone
|
|
84
84
|
schema = output_type.model_json_schema()
|
|
85
85
|
|
|
86
|
+
# Transform schema for OpenAI strict mode
|
|
87
|
+
# OpenAI requires additionalProperties: false on all object schemas
|
|
88
|
+
schema = self._add_additional_properties_false(schema)
|
|
89
|
+
|
|
86
90
|
# OpenAI structured output format
|
|
87
91
|
# See: https://platform.openai.com/docs/guides/structured-outputs
|
|
88
92
|
request_params["response_format"] = {
|
|
@@ -90,8 +94,8 @@ class OpenAIHandler(BaseProviderHandler):
|
|
|
90
94
|
"json_schema": {
|
|
91
95
|
"name": output_type.__name__,
|
|
92
96
|
"schema": schema,
|
|
93
|
-
"strict":
|
|
94
|
-
}
|
|
97
|
+
"strict": True, # Enforce schema compliance
|
|
98
|
+
},
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
return request_params
|
|
@@ -99,8 +103,8 @@ class OpenAIHandler(BaseProviderHandler):
|
|
|
99
103
|
def format_system_prompt(
|
|
100
104
|
self,
|
|
101
105
|
base_prompt: str,
|
|
102
|
-
tool_schemas: Optional[
|
|
103
|
-
output_type: type[BaseModel]
|
|
106
|
+
tool_schemas: Optional[list[dict[str, Any]]],
|
|
107
|
+
output_type: type[BaseModel],
|
|
104
108
|
) -> str:
|
|
105
109
|
"""
|
|
106
110
|
Format system prompt for OpenAI (concise approach).
|
|
@@ -147,7 +151,7 @@ IMPORTANT TOOL CALLING RULES:
|
|
|
147
151
|
|
|
148
152
|
return system_content
|
|
149
153
|
|
|
150
|
-
def get_vendor_capabilities(self) ->
|
|
154
|
+
def get_vendor_capabilities(self) -> dict[str, bool]:
|
|
151
155
|
"""
|
|
152
156
|
Return OpenAI-specific capabilities.
|
|
153
157
|
|
|
@@ -161,3 +165,55 @@ IMPORTANT TOOL CALLING RULES:
|
|
|
161
165
|
"vision": True, # GPT-4V and later support vision
|
|
162
166
|
"json_mode": True, # Has dedicated JSON mode via response_format
|
|
163
167
|
}
|
|
168
|
+
|
|
169
|
+
def _add_additional_properties_false(
|
|
170
|
+
self, schema: dict[str, Any]
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
"""
|
|
173
|
+
Recursively add additionalProperties: false to all object schemas.
|
|
174
|
+
|
|
175
|
+
OpenAI strict mode requires this for all object schemas.
|
|
176
|
+
See: https://platform.openai.com/docs/guides/structured-outputs
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
schema: JSON schema from Pydantic model
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Modified schema with additionalProperties: false on all objects
|
|
183
|
+
"""
|
|
184
|
+
import copy
|
|
185
|
+
|
|
186
|
+
schema = copy.deepcopy(schema)
|
|
187
|
+
self._add_additional_properties_recursive(schema)
|
|
188
|
+
return schema
|
|
189
|
+
|
|
190
|
+
def _add_additional_properties_recursive(self, obj: Any) -> None:
|
|
191
|
+
"""Recursively process schema for OpenAI strict mode compliance."""
|
|
192
|
+
if isinstance(obj, dict):
|
|
193
|
+
# If this is an object type, add additionalProperties: false
|
|
194
|
+
# and ensure required includes all properties
|
|
195
|
+
if obj.get("type") == "object":
|
|
196
|
+
obj["additionalProperties"] = False
|
|
197
|
+
# OpenAI strict mode: required must include ALL property keys
|
|
198
|
+
if "properties" in obj:
|
|
199
|
+
obj["required"] = list(obj["properties"].keys())
|
|
200
|
+
|
|
201
|
+
# Process $defs (Pydantic uses this for nested models)
|
|
202
|
+
if "$defs" in obj:
|
|
203
|
+
for def_schema in obj["$defs"].values():
|
|
204
|
+
self._add_additional_properties_recursive(def_schema)
|
|
205
|
+
|
|
206
|
+
# Process properties
|
|
207
|
+
if "properties" in obj:
|
|
208
|
+
for prop_schema in obj["properties"].values():
|
|
209
|
+
self._add_additional_properties_recursive(prop_schema)
|
|
210
|
+
|
|
211
|
+
# Process items (for arrays)
|
|
212
|
+
if "items" in obj:
|
|
213
|
+
self._add_additional_properties_recursive(obj["items"])
|
|
214
|
+
|
|
215
|
+
# Process anyOf, oneOf, allOf
|
|
216
|
+
for key in ("anyOf", "oneOf", "allOf"):
|
|
217
|
+
if key in obj:
|
|
218
|
+
for item in obj[key]:
|
|
219
|
+
self._add_additional_properties_recursive(item)
|
|
@@ -8,7 +8,7 @@ Separated from MeshLlmAgent for better testability and reusability.
|
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
10
|
import re
|
|
11
|
-
from typing import Any, TypeVar
|
|
11
|
+
from typing import Any, TypeVar, Union
|
|
12
12
|
|
|
13
13
|
from pydantic import BaseModel, ValidationError
|
|
14
14
|
|
|
@@ -40,12 +40,12 @@ class ResponseParser:
|
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
42
|
@staticmethod
|
|
43
|
-
def parse(content:
|
|
43
|
+
def parse(content: Any, output_type: type[T]) -> T:
|
|
44
44
|
"""
|
|
45
45
|
Parse LLM response into Pydantic model.
|
|
46
46
|
|
|
47
47
|
Args:
|
|
48
|
-
content: Raw response content from LLM
|
|
48
|
+
content: Raw response content from LLM (string or pre-parsed dict/list)
|
|
49
49
|
output_type: Pydantic BaseModel class to parse into
|
|
50
50
|
|
|
51
51
|
Returns:
|
|
@@ -57,16 +57,26 @@ class ResponseParser:
|
|
|
57
57
|
logger.debug(f"📝 Parsing response into {output_type.__name__}...")
|
|
58
58
|
|
|
59
59
|
try:
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
# If content is already parsed (e.g., OpenAI strict mode), skip string processing
|
|
61
|
+
if isinstance(content, (dict, list)):
|
|
62
|
+
logger.debug("📦 Content already parsed, skipping string processing")
|
|
63
|
+
response_data = content
|
|
64
|
+
else:
|
|
65
|
+
# String processing for Claude, Gemini, and non-strict OpenAI
|
|
66
|
+
# Extract JSON from mixed content (narrative + XML + JSON)
|
|
67
|
+
extracted_content = ResponseParser._extract_json_from_mixed_content(
|
|
68
|
+
content
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Strip markdown code fences if present
|
|
72
|
+
cleaned_content = ResponseParser._strip_markdown_fences(
|
|
73
|
+
extracted_content
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Try to parse as JSON
|
|
77
|
+
response_data = ResponseParser._parse_json_with_fallback(
|
|
78
|
+
cleaned_content, output_type
|
|
79
|
+
)
|
|
70
80
|
|
|
71
81
|
# Validate against output type
|
|
72
82
|
return ResponseParser._validate_and_create(response_data, output_type)
|
|
@@ -175,12 +185,16 @@ class ResponseParser:
|
|
|
175
185
|
raise ResponseParseError(f"Invalid JSON response: {e}")
|
|
176
186
|
|
|
177
187
|
@staticmethod
|
|
178
|
-
def _validate_and_create(response_data:
|
|
188
|
+
def _validate_and_create(response_data: Any, output_type: type[T]) -> T:
|
|
179
189
|
"""
|
|
180
190
|
Validate data against Pydantic model and create instance.
|
|
181
191
|
|
|
192
|
+
Handles both dict and list responses:
|
|
193
|
+
- Dict: Direct unpacking into model
|
|
194
|
+
- List: Auto-wrap into first list field of model (for OpenAI strict mode)
|
|
195
|
+
|
|
182
196
|
Args:
|
|
183
|
-
response_data: Parsed JSON data
|
|
197
|
+
response_data: Parsed JSON data (dict or list)
|
|
184
198
|
output_type: Target Pydantic model
|
|
185
199
|
|
|
186
200
|
Returns:
|
|
@@ -190,6 +204,31 @@ class ResponseParser:
|
|
|
190
204
|
ResponseParseError: If validation fails
|
|
191
205
|
"""
|
|
192
206
|
try:
|
|
207
|
+
# Handle list responses - wrap into first list field of model
|
|
208
|
+
if isinstance(response_data, list):
|
|
209
|
+
# Find the first list field in the model
|
|
210
|
+
model_fields = output_type.model_fields
|
|
211
|
+
list_field_name = None
|
|
212
|
+
for field_name, field_info in model_fields.items():
|
|
213
|
+
# Check if field annotation is a list type
|
|
214
|
+
field_type = field_info.annotation
|
|
215
|
+
if (
|
|
216
|
+
hasattr(field_type, "__origin__")
|
|
217
|
+
and field_type.__origin__ is list
|
|
218
|
+
):
|
|
219
|
+
list_field_name = field_name
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
if list_field_name:
|
|
223
|
+
logger.debug(
|
|
224
|
+
f"📦 Wrapping list response into '{list_field_name}' field"
|
|
225
|
+
)
|
|
226
|
+
response_data = {list_field_name: response_data}
|
|
227
|
+
else:
|
|
228
|
+
raise ResponseParseError(
|
|
229
|
+
f"Response is a list but {output_type.__name__} has no list field to wrap into"
|
|
230
|
+
)
|
|
231
|
+
|
|
193
232
|
parsed = output_type(**response_data)
|
|
194
233
|
logger.debug(f"✅ Response parsed successfully: {parsed}")
|
|
195
234
|
return parsed
|