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.
@@ -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
- from typing import Any, Dict, List, Optional
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
- - Prefers verbose system prompts with explicit guidelines
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
- - Provide clear, detailed system prompts
29
- - Include explicit JSON schema in prompt for structured output
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: List[Dict[str, Any]],
41
- tools: Optional[List[Dict[str, Any]]],
42
- output_type: type[BaseModel],
43
- **kwargs: Any
44
- ) -> Dict[str, Any]:
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
- Claude uses standard LiteLLM interface with:
49
- - Standard messages format
50
- - OpenAI-compatible tools format (LiteLLM converts to Anthropic format)
51
- - No special response_format needed (uses prompt-based JSON instructions)
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 for response
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": 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[List[Dict[str, Any]]],
78
- output_type: type[BaseModel]
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 detailed instructions.
327
+ Format system prompt for Claude with output mode support.
82
328
 
83
- Claude Strategy:
84
- 1. Use base prompt as-is (detailed is better for Claude)
85
- 2. Add anti-XML tool calling instructions if tools present
86
- 3. Add explicit JSON schema instructions for final response
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 - each tool call must be separate
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
- - Each tool must be called using proper JSON tool_use format
110
- - After receiving results from a tool, you can make additional tool calls if needed
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 JSON schema instructions for final response
115
- # Claude needs explicit schema in prompt (no native response_format)
116
- schema = output_type.model_json_schema()
117
- schema_str = json.dumps(schema, indent=2)
118
- system_content += (
119
- f"\n\nIMPORTANT: You must return your final response as valid JSON matching this schema:\n"
120
- f"{schema_str}\n\nReturn ONLY the JSON object, no additional text."
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) -> Dict[str, bool]:
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": False, # No response_format, uses prompt-based JSON
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": False, # No dedicated JSON mode, uses prompting
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: List[Dict[str, Any]],
50
- tools: Optional[List[Dict[str, Any]]],
49
+ messages: list[dict[str, Any]],
50
+ tools: Optional[list[dict[str, Any]]],
51
51
  output_type: type[BaseModel],
52
- **kwargs: Any
53
- ) -> Dict[str, Any]:
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": False, # Allow optional fields with defaults
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[List[Dict[str, Any]]],
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) -> Dict[str, bool]:
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: str, output_type: type[T]) -> T:
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
- # Extract JSON from mixed content (narrative + XML + JSON)
61
- extracted_content = ResponseParser._extract_json_from_mixed_content(content)
62
-
63
- # Strip markdown code fences if present
64
- cleaned_content = ResponseParser._strip_markdown_fences(extracted_content)
65
-
66
- # Try to parse as JSON
67
- response_data = ResponseParser._parse_json_with_fallback(
68
- cleaned_content, output_type
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: dict[str, Any], output_type: type[T]) -> T:
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