mcp-mesh 0.8.0b9__py3-none-any.whl → 0.9.0b1__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.
@@ -6,9 +6,12 @@ that customize how different LLM vendors (Claude, OpenAI, Gemini, etc.) are call
6
6
  """
7
7
 
8
8
  import copy
9
+ import logging
9
10
  from abc import ABC, abstractmethod
10
11
  from typing import Any, Optional
11
12
 
13
+ logger = logging.getLogger(__name__)
14
+
12
15
  from pydantic import BaseModel
13
16
 
14
17
  # ============================================================================
@@ -61,6 +64,144 @@ def make_schema_strict(
61
64
  return result
62
65
 
63
66
 
67
+ def is_simple_schema(schema: dict[str, Any]) -> bool:
68
+ """
69
+ Check if a JSON schema is simple enough for hint mode.
70
+
71
+ Simple schema criteria:
72
+ - Less than 5 fields
73
+ - All fields are basic types (str, int, float, bool, list)
74
+ - No nested Pydantic models ($ref or nested objects with properties)
75
+
76
+ This is used by provider handlers to decide between hint mode
77
+ (prompt-based JSON instructions) and strict mode (response_format).
78
+
79
+ Args:
80
+ schema: JSON schema dict
81
+
82
+ Returns:
83
+ True if schema is simple, False otherwise
84
+ """
85
+ try:
86
+ properties = schema.get("properties", {})
87
+
88
+ # Check field count
89
+ if len(properties) >= 5:
90
+ return False
91
+
92
+ # Check for nested objects or complex types
93
+ for field_name, field_schema in properties.items():
94
+ field_type = field_schema.get("type")
95
+
96
+ # Check for nested objects (indicates nested Pydantic model)
97
+ if field_type == "object" and "properties" in field_schema:
98
+ return False
99
+
100
+ # Check for $ref (nested model reference)
101
+ if "$ref" in field_schema:
102
+ return False
103
+
104
+ # Check array items for complex types
105
+ if field_type == "array":
106
+ items = field_schema.get("items", {})
107
+ if items.get("type") == "object" or "$ref" in items:
108
+ return False
109
+
110
+ return True
111
+ except Exception:
112
+ return False
113
+
114
+
115
+ def sanitize_schema_for_structured_output(schema: dict[str, Any]) -> dict[str, Any]:
116
+ """
117
+ Sanitize a JSON schema by removing validation keywords unsupported by LLM APIs.
118
+
119
+ LLM structured output APIs (Claude, OpenAI, Gemini) typically only support
120
+ the structural parts of JSON Schema, not validation constraints. This function
121
+ removes unsupported keywords to ensure uniform behavior across all providers.
122
+
123
+ Removed keywords:
124
+ - minimum, maximum (number range)
125
+ - exclusiveMinimum, exclusiveMaximum (exclusive bounds)
126
+ - minLength, maxLength (string length)
127
+ - minItems, maxItems (array size)
128
+ - pattern (regex validation)
129
+ - multipleOf (divisibility)
130
+
131
+ Args:
132
+ schema: JSON schema dict (will not be mutated)
133
+
134
+ Returns:
135
+ New schema with unsupported validation keywords removed
136
+ """
137
+ result = copy.deepcopy(schema)
138
+ _strip_unsupported_keywords_recursive(result)
139
+ logger.debug(
140
+ "Sanitized schema for structured output (removed validation-only keywords)"
141
+ )
142
+ return result
143
+
144
+
145
+ # Keywords that are validation-only and not supported by LLM structured output APIs
146
+ _UNSUPPORTED_SCHEMA_KEYWORDS = {
147
+ "minimum",
148
+ "maximum",
149
+ "exclusiveMinimum",
150
+ "exclusiveMaximum",
151
+ "minLength",
152
+ "maxLength",
153
+ "minItems",
154
+ "maxItems",
155
+ "pattern",
156
+ "multipleOf",
157
+ }
158
+
159
+
160
+ def _strip_unsupported_keywords_recursive(obj: Any) -> None:
161
+ """
162
+ Recursively strip unsupported validation keywords from a schema object.
163
+
164
+ Args:
165
+ obj: Schema object to process (mutated in place)
166
+ """
167
+ if not isinstance(obj, dict):
168
+ return
169
+
170
+ # Remove unsupported keywords at this level
171
+ for keyword in _UNSUPPORTED_SCHEMA_KEYWORDS:
172
+ obj.pop(keyword, None)
173
+
174
+ # Process $defs (Pydantic uses this for nested models)
175
+ if "$defs" in obj:
176
+ for def_schema in obj["$defs"].values():
177
+ _strip_unsupported_keywords_recursive(def_schema)
178
+
179
+ # Process properties
180
+ if "properties" in obj:
181
+ for prop_schema in obj["properties"].values():
182
+ _strip_unsupported_keywords_recursive(prop_schema)
183
+
184
+ # Process items (for arrays)
185
+ if "items" in obj:
186
+ items = obj["items"]
187
+ if isinstance(items, dict):
188
+ _strip_unsupported_keywords_recursive(items)
189
+ elif isinstance(items, list):
190
+ for item in items:
191
+ _strip_unsupported_keywords_recursive(item)
192
+
193
+ # Process prefixItems (tuple validation)
194
+ if "prefixItems" in obj:
195
+ for item in obj["prefixItems"]:
196
+ _strip_unsupported_keywords_recursive(item)
197
+
198
+ # Process anyOf, oneOf, allOf
199
+ for key in ("anyOf", "oneOf", "allOf"):
200
+ if key in obj:
201
+ for item in obj[key]:
202
+ _strip_unsupported_keywords_recursive(item)
203
+
204
+
64
205
  def _add_strict_constraints_recursive(obj: Any, add_all_required: bool) -> None:
65
206
  """
66
207
  Recursively add strict constraints to a schema object.
@@ -223,6 +364,42 @@ class BaseProviderHandler(ABC):
223
364
  "json_mode": False,
224
365
  }
225
366
 
367
+ def apply_structured_output(
368
+ self,
369
+ output_schema: dict[str, Any],
370
+ output_type_name: Optional[str],
371
+ model_params: dict[str, Any],
372
+ ) -> dict[str, Any]:
373
+ """
374
+ Apply vendor-specific structured output handling to model params.
375
+
376
+ This is used by LLM providers (via mesh) when they receive an output_schema
377
+ from a consumer. Each vendor can customize how structured output is enforced.
378
+
379
+ Default behavior: Apply response_format with strict schema.
380
+ Override in subclasses for vendor-specific behavior (e.g., Claude hint mode).
381
+
382
+ Args:
383
+ output_schema: JSON schema dict from consumer
384
+ output_type_name: Name of the output type (e.g., "AnalysisResult")
385
+ model_params: Current model parameters dict (will be modified)
386
+
387
+ Returns:
388
+ Modified model_params with structured output settings applied
389
+ """
390
+ # Sanitize schema first to remove unsupported validation keywords
391
+ sanitized_schema = sanitize_schema_for_structured_output(output_schema)
392
+ strict_schema = make_schema_strict(sanitized_schema, add_all_required=True)
393
+ model_params["response_format"] = {
394
+ "type": "json_schema",
395
+ "json_schema": {
396
+ "name": output_type_name or "Response",
397
+ "schema": strict_schema,
398
+ "strict": True,
399
+ },
400
+ }
401
+ return model_params
402
+
226
403
  def __repr__(self) -> str:
227
404
  """String representation of handler."""
228
405
  return f"{self.__class__.__name__}(vendor='{self.vendor}')"
@@ -4,15 +4,17 @@ Claude/Anthropic provider handler.
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
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)
7
+ Supports two output modes:
8
+ - hint: Use prompt-based JSON instructions with DECISION GUIDE (~95% reliable)
10
9
  - text: Plain text output for str return types (fastest)
11
10
 
11
+ Native response_format (strict mode) is NOT used due to cross-runtime
12
+ incompatibilities when tools are present, and grammar compilation overhead.
13
+
12
14
  Features:
13
15
  - Automatic prompt caching for system messages (up to 90% cost reduction)
14
16
  - Anti-XML tool calling instructions
15
- - Output mode optimization based on return type
17
+ - DECISION GUIDE for tool vs. direct JSON response decisions
16
18
  """
17
19
 
18
20
  import json
@@ -23,15 +25,16 @@ from pydantic import BaseModel
23
25
 
24
26
  from .base_provider_handler import (
25
27
  BASE_TOOL_INSTRUCTIONS,
26
- BaseProviderHandler,
27
28
  CLAUDE_ANTI_XML_INSTRUCTION,
28
- make_schema_strict,
29
+ BaseProviderHandler,
29
30
  )
30
31
 
31
32
  logger = logging.getLogger(__name__)
32
33
 
33
34
  # Output mode constants
34
- OUTPUT_MODE_STRICT = "strict"
35
+ OUTPUT_MODE_STRICT = (
36
+ "strict" # Unused for Claude (kept for override_mode compatibility)
37
+ )
35
38
  OUTPUT_MODE_HINT = "hint"
36
39
  OUTPUT_MODE_TEXT = "text"
37
40
 
@@ -42,19 +45,20 @@ class ClaudeHandler(BaseProviderHandler):
42
45
 
43
46
  Claude Characteristics:
44
47
  - Excellent at following detailed instructions
45
- - Native structured output via response_format (requires strict schema)
46
48
  - Native tool calling (via Anthropic messages API)
47
49
  - Performs best with anti-XML tool calling instructions
48
50
  - Automatic prompt caching for cost optimization
49
51
 
50
- Output Modes:
51
- - strict: response_format with JSON schema (slowest, guaranteed valid JSON)
52
- - hint: JSON schema in prompt (medium speed, usually valid JSON)
52
+ Output Modes (TEXT + HINT only):
53
+ - hint: JSON schema in prompt with DECISION GUIDE (~95% reliable)
53
54
  - text: Plain text output for str return types (fastest)
54
55
 
56
+ Native response_format (strict mode) is not used. HINT mode with
57
+ detailed prompt instructions provides sufficient reliability (~95%)
58
+ without the cross-runtime incompatibilities and grammar compilation
59
+ overhead of native structured output.
60
+
55
61
  Best Practices (from Anthropic docs):
56
- - Use response_format for guaranteed JSON schema compliance
57
- - Schema must have additionalProperties: false on all objects
58
62
  - Add anti-XML instructions to prevent <invoke> style tool calls
59
63
  - Use one tool call at a time for better reliability
60
64
  - Use cache_control for system prompts to reduce costs
@@ -64,62 +68,18 @@ class ClaudeHandler(BaseProviderHandler):
64
68
  """Initialize Claude handler."""
65
69
  super().__init__(vendor="anthropic")
66
70
 
67
- def _is_simple_schema(self, model_class: type[BaseModel]) -> bool:
68
- """
69
- Check if a Pydantic model has a simple schema.
70
-
71
- Simple schema criteria:
72
- - Less than 5 fields
73
- - All fields are basic types (str, int, float, bool, list, Optional)
74
- - No nested Pydantic models
75
-
76
- Args:
77
- model_class: Pydantic model class
78
-
79
- Returns:
80
- True if schema is simple, False otherwise
81
- """
82
- try:
83
- schema = model_class.model_json_schema()
84
- properties = schema.get("properties", {})
85
-
86
- # Check field count
87
- if len(properties) >= 5:
88
- return False
89
-
90
- # Check for nested objects or complex types
91
- for field_name, field_schema in properties.items():
92
- field_type = field_schema.get("type")
93
-
94
- # Check for nested objects (indicates nested Pydantic model)
95
- if field_type == "object" and "properties" in field_schema:
96
- return False
97
-
98
- # Check for $ref (nested model reference)
99
- if "$ref" in field_schema:
100
- return False
101
-
102
- # Check array items for complex types
103
- if field_type == "array":
104
- items = field_schema.get("items", {})
105
- if items.get("type") == "object" or "$ref" in items:
106
- return False
107
-
108
- return True
109
- except Exception:
110
- return False
111
-
112
71
  def determine_output_mode(
113
72
  self, output_type: type, override_mode: Optional[str] = None
114
73
  ) -> str:
115
74
  """
116
75
  Determine the output mode based on return type.
117
76
 
77
+ Strategy: TEXT + HINT only. No STRICT mode for Claude.
78
+
118
79
  Logic:
119
80
  - If override_mode specified, use it
120
81
  - If return type is str, use "text" mode
121
- - If return type is simple schema (<5 fields, basic types), use "hint" mode
122
- - Otherwise, use "strict" mode
82
+ - All schema types use "hint" mode (prompt-based JSON instructions)
123
83
 
124
84
  Args:
125
85
  output_type: Return type (str or BaseModel subclass)
@@ -136,15 +96,11 @@ class ClaudeHandler(BaseProviderHandler):
136
96
  if output_type is str:
137
97
  return OUTPUT_MODE_TEXT
138
98
 
139
- # Check if it's a Pydantic model
99
+ # All schema types use HINT mode -- no STRICT for Claude
140
100
  if isinstance(output_type, type) and issubclass(output_type, BaseModel):
141
- if self._is_simple_schema(output_type):
142
- return OUTPUT_MODE_HINT
143
- else:
144
- return OUTPUT_MODE_STRICT
101
+ return OUTPUT_MODE_HINT
145
102
 
146
- # Default to strict for unknown types
147
- return OUTPUT_MODE_STRICT
103
+ return OUTPUT_MODE_HINT
148
104
 
149
105
  def _apply_prompt_caching(
150
106
  self, messages: list[dict[str, Any]]
@@ -224,9 +180,8 @@ class ClaudeHandler(BaseProviderHandler):
224
180
  """
225
181
  Prepare request parameters for Claude API with output mode support.
226
182
 
227
- Output Mode Strategy:
228
- - strict: Use response_format for guaranteed JSON schema compliance (slowest)
229
- - hint: No response_format, rely on prompt instructions (medium speed)
183
+ Output Mode Strategy (TEXT + HINT only):
184
+ - hint: No response_format, rely on prompt instructions (~95% reliable)
230
185
  - text: No response_format, plain text output (fastest)
231
186
 
232
187
  Args:
@@ -238,9 +193,8 @@ class ClaudeHandler(BaseProviderHandler):
238
193
  Returns:
239
194
  Dictionary of parameters for litellm.completion()
240
195
  """
241
- # Extract output_mode from kwargs if provided
242
- output_mode = kwargs.pop("output_mode", None)
243
- determined_mode = self.determine_output_mode(output_type, output_mode)
196
+ # Extract output_mode from kwargs to prevent it leaking into request params
197
+ kwargs.pop("output_mode", None)
244
198
 
245
199
  # Remove response_format from kwargs - we control this based on output mode
246
200
  # The decorator's response_format="json" is just a hint for parsing, not API param
@@ -259,22 +213,6 @@ class ClaudeHandler(BaseProviderHandler):
259
213
  if tools:
260
214
  request_params["tools"] = tools
261
215
 
262
- # Only add response_format in "strict" mode
263
- if determined_mode == OUTPUT_MODE_STRICT:
264
- # Claude requires additionalProperties: false on all object types
265
- # Unlike OpenAI/Gemini, Claude doesn't require all properties in 'required'
266
- if isinstance(output_type, type) and issubclass(output_type, BaseModel):
267
- schema = output_type.model_json_schema()
268
- strict_schema = make_schema_strict(schema, add_all_required=False)
269
- request_params["response_format"] = {
270
- "type": "json_schema",
271
- "json_schema": {
272
- "name": output_type.__name__,
273
- "schema": strict_schema,
274
- "strict": False, # Allow optional fields with defaults
275
- },
276
- }
277
-
278
216
  return request_params
279
217
 
280
218
  def format_system_prompt(
@@ -287,9 +225,8 @@ class ClaudeHandler(BaseProviderHandler):
287
225
  """
288
226
  Format system prompt for Claude with output mode support.
289
227
 
290
- Output Mode Strategy:
291
- - strict: Minimal JSON instructions (response_format handles schema)
292
- - hint: Add detailed JSON schema instructions in prompt
228
+ Output Mode Strategy (TEXT + HINT only):
229
+ - hint: Add detailed JSON schema instructions with DECISION GUIDE in prompt
293
230
  - text: No JSON instructions (plain text output)
294
231
 
295
232
  Args:
@@ -319,13 +256,8 @@ class ClaudeHandler(BaseProviderHandler):
319
256
  # Text mode: No JSON instructions
320
257
  pass
321
258
 
322
- elif determined_mode == OUTPUT_MODE_STRICT:
323
- # Strict mode: Minimal instructions (response_format handles schema)
324
- if isinstance(output_type, type) and issubclass(output_type, BaseModel):
325
- system_content += f"\n\nYour final response will be structured as JSON matching the {output_type.__name__} format."
326
-
327
259
  elif determined_mode == OUTPUT_MODE_HINT:
328
- # Hint mode: Add detailed JSON schema instructions
260
+ # Hint mode: Add detailed JSON schema instructions with DECISION GUIDE
329
261
  if isinstance(output_type, type) and issubclass(output_type, BaseModel):
330
262
  schema = output_type.model_json_schema()
331
263
  properties = schema.get("properties", {})
@@ -344,8 +276,19 @@ class ClaudeHandler(BaseProviderHandler):
344
276
  )
345
277
 
346
278
  fields_text = "\n".join(field_descriptions)
347
- system_content += f"""
348
279
 
280
+ # Add DECISION GUIDE when tools are present
281
+ decision_guide = ""
282
+ if tool_schemas:
283
+ decision_guide = """
284
+ DECISION GUIDE:
285
+ - If your answer requires real-time data (weather, calculations, etc.), call the appropriate tool FIRST, then format your response as JSON.
286
+ - If your answer is general knowledge (like facts, explanations, definitions), directly return your response as JSON WITHOUT calling tools.
287
+ - After calling a tool and receiving results, STOP calling tools and return your final JSON response.
288
+ """
289
+
290
+ system_content += f"""
291
+ {decision_guide}
349
292
  RESPONSE FORMAT:
350
293
  You MUST respond with valid JSON matching this schema:
351
294
  {{
@@ -355,7 +298,10 @@ You MUST respond with valid JSON matching this schema:
355
298
  Example format:
356
299
  {json.dumps({k: f"<{v.get('type', 'value')}>" for k, v in properties.items()}, indent=2)}
357
300
 
358
- IMPORTANT: Respond ONLY with valid JSON. No markdown code fences, no preamble text."""
301
+ CRITICAL: Your response must be ONLY the raw JSON object.
302
+ - DO NOT wrap in markdown code fences (```json or ```)
303
+ - DO NOT include any text before or after the JSON
304
+ - Start directly with {{ and end with }}"""
359
305
 
360
306
  return system_content
361
307
 
@@ -368,9 +314,90 @@ IMPORTANT: Respond ONLY with valid JSON. No markdown code fences, no preamble te
368
314
  """
369
315
  return {
370
316
  "native_tool_calling": True, # Claude has native function calling
371
- "structured_output": True, # Native response_format support via LiteLLM
317
+ "structured_output": False, # Uses HINT mode (prompt-based), not native response_format
372
318
  "streaming": True, # Supports streaming
373
319
  "vision": True, # Claude 3+ supports vision
374
- "json_mode": True, # Native JSON mode via response_format
320
+ "json_mode": False, # No native JSON mode used
375
321
  "prompt_caching": True, # Automatic system prompt caching for cost savings
376
322
  }
323
+
324
+ def apply_structured_output(
325
+ self,
326
+ output_schema: dict[str, Any],
327
+ output_type_name: Optional[str],
328
+ model_params: dict[str, Any],
329
+ ) -> dict[str, Any]:
330
+ """
331
+ Apply Claude-specific structured output for mesh delegation using HINT mode.
332
+
333
+ Instead of using response_format (strict mode), injects detailed JSON schema
334
+ instructions into the system message. This is consistent with the TEXT + HINT
335
+ only strategy and avoids cross-runtime incompatibilities.
336
+
337
+ Args:
338
+ output_schema: JSON schema dict from consumer
339
+ output_type_name: Name of the output type (e.g., "AnalysisResult")
340
+ model_params: Current model parameters dict (will be modified)
341
+
342
+ Returns:
343
+ Modified model_params with HINT-mode instructions in system prompt
344
+ """
345
+ # Build HINT mode instructions from the schema
346
+ properties = output_schema.get("properties", {})
347
+ required = output_schema.get("required", [])
348
+
349
+ field_descriptions = []
350
+ for field_name, field_schema in properties.items():
351
+ field_type = field_schema.get("type", "any")
352
+ is_required = field_name in required
353
+ req_marker = " (required)" if is_required else " (optional)"
354
+ desc = field_schema.get("description", "")
355
+ desc_text = f" - {desc}" if desc else ""
356
+ field_descriptions.append(
357
+ f" - {field_name}: {field_type}{req_marker}{desc_text}"
358
+ )
359
+
360
+ fields_text = "\n".join(field_descriptions)
361
+ type_name = output_type_name or "Response"
362
+
363
+ hint_instructions = f"""
364
+
365
+ DECISION GUIDE:
366
+ - If your answer requires real-time data (weather, calculations, etc.), call the appropriate tool FIRST, then format your response as JSON.
367
+ - If your answer is general knowledge, directly return your response as JSON WITHOUT calling tools.
368
+ - After calling a tool and receiving results, STOP calling tools and return your final JSON response.
369
+
370
+ RESPONSE FORMAT:
371
+ You MUST respond with valid JSON matching this schema:
372
+ {{
373
+ {fields_text}
374
+ }}
375
+
376
+ Example format:
377
+ {json.dumps({k: f"<{v.get('type', 'value')}>" for k, v in properties.items()}, indent=2)}
378
+
379
+ CRITICAL: Your response must be ONLY the raw JSON object.
380
+ - DO NOT wrap in markdown code fences (```json or ```)
381
+ - DO NOT include any text before or after the JSON
382
+ - Start directly with {{ and end with }}"""
383
+
384
+ # Inject into system message
385
+ messages = model_params.get("messages", [])
386
+ for msg in messages:
387
+ if msg.get("role") == "system":
388
+ content = msg.get("content", "")
389
+ if isinstance(content, str):
390
+ msg["content"] = content + hint_instructions
391
+ elif isinstance(content, list):
392
+ # Content block format -- append to last text block
393
+ for block in reversed(content):
394
+ if isinstance(block, dict) and block.get("type") == "text":
395
+ block["text"] = block["text"] + hint_instructions
396
+ break
397
+ break
398
+
399
+ logger.info(
400
+ f"Claude hint mode for '{type_name}' "
401
+ f"(mesh delegation, schema in prompt)"
402
+ )
403
+ return model_params