Skip to content

Commit 9c291fb

Browse files
committed
Fix tool schema constraints being silently dropped during OCI conversion
tool.args goes through Pydantic's tool_call_schema re-generation which strips rich JSON Schema properties (enum, min/max, pattern, format, etc.). Switch to args_schema.model_json_schema() which preserves the original schema. GenericProvider: add _sanitize_tool_property() with allowlist of standard JSON Schema keys, resolves Pydantic v2 anyOf patterns for Optional[T]. CohereProvider: add _enrich_description() to embed constraints (enum, format, range, pattern) into the description string since CohereParameterDefinition only supports type/description/is_required.
1 parent 62f1f15 commit 9c291fb

File tree

3 files changed

+444
-14
lines changed

3 files changed

+444
-14
lines changed

libs/oci/langchain_oci/chat_models/providers/cohere.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,31 @@ def messages_to_oci_params(
463463
# Remove keys with None values
464464
return {k: v for k, v in oci_params.items() if v is not None}
465465

466+
@staticmethod
467+
def _enrich_description(description: str, p_def: Dict[str, Any]) -> str:
468+
"""Embed schema constraints into the description for Cohere models.
469+
470+
CohereParameterDefinition only supports type, description, and
471+
is_required. Rich schema metadata (enum, format, range, pattern)
472+
is embedded into the description string so the LLM can still see
473+
and respect these constraints.
474+
"""
475+
parts = [description] if description else []
476+
if "enum" in p_def:
477+
parts.append(f"Allowed values: {p_def['enum']}")
478+
if "format" in p_def:
479+
parts.append(f"Format: {p_def['format']}")
480+
if "minimum" in p_def or "maximum" in p_def:
481+
range_parts = []
482+
if "minimum" in p_def:
483+
range_parts.append(f"min={p_def['minimum']}")
484+
if "maximum" in p_def:
485+
range_parts.append(f"max={p_def['maximum']}")
486+
parts.append(f"Range: {', '.join(range_parts)}")
487+
if "pattern" in p_def:
488+
parts.append(f"Pattern: {p_def['pattern']}")
489+
return ". ".join(parts) if parts else ""
490+
466491
def convert_to_oci_tool(
467492
self,
468493
tool: Union[Dict[str, Any], Type[BaseModel], Callable, BaseTool],
@@ -474,21 +499,31 @@ def convert_to_oci_tool(
474499
or Pydantic models/callables.
475500
"""
476501
if isinstance(tool, BaseTool):
502+
# Use args_schema.model_json_schema() to get rich properties
503+
# (enum, constraints) that tool.args loses via tool_call_schema.
504+
if tool.args_schema and hasattr(tool.args_schema, "model_json_schema"):
505+
schema = tool.args_schema.model_json_schema()
506+
properties = schema.get("properties", {})
507+
else:
508+
properties = tool.args
509+
477510
return self.oci_tool(
478511
name=tool.name,
479512
description=OCIUtils.remove_signature_from_tool_description(
480513
tool.name, tool.description
481514
),
482515
parameter_definitions={
483516
p_name: self.oci_tool_param(
484-
description=p_def.get("description", ""),
517+
description=self._enrich_description(
518+
p_def.get("description", ""), p_def
519+
),
485520
type=JSON_TO_PYTHON_TYPES.get(
486521
p_def.get("type"),
487522
p_def.get("type", "any"),
488523
),
489524
is_required="default" not in p_def,
490525
)
491-
for p_name, p_def in tool.args.items()
526+
for p_name, p_def in properties.items()
492527
},
493528
)
494529
elif isinstance(tool, dict):
@@ -502,7 +537,9 @@ def convert_to_oci_tool(
502537
description=tool.get("description"),
503538
parameter_definitions={
504539
p_name: self.oci_tool_param(
505-
description=p_def.get("description", ""),
540+
description=self._enrich_description(
541+
p_def.get("description", ""), p_def
542+
),
506543
type=JSON_TO_PYTHON_TYPES.get(
507544
p_def.get("type"),
508545
p_def.get("type", "any"),
@@ -524,7 +561,9 @@ def convert_to_oci_tool(
524561
),
525562
parameter_definitions={
526563
p_name: self.oci_tool_param(
527-
description=p_def.get("description", ""),
564+
description=self._enrich_description(
565+
p_def.get("description", ""), p_def
566+
),
528567
type=JSON_TO_PYTHON_TYPES.get(
529568
p_def.get("type"),
530569
p_def.get("type", "any"),

libs/oci/langchain_oci/chat_models/providers/generic.py

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,21 @@ def convert_to_oci_tool(
378378
"""
379379
# Check BaseTool first since it's callable but needs special handling
380380
if isinstance(tool, BaseTool):
381+
# Use args_schema.model_json_schema() if available — it preserves
382+
# rich schema metadata (enum, min/max, pattern, etc.) that tool.args
383+
# loses via Pydantic re-generation through tool_call_schema.
384+
if tool.args_schema and hasattr(tool.args_schema, "model_json_schema"):
385+
schema = tool.args_schema.model_json_schema()
386+
properties = schema.get("properties", {})
387+
required = schema.get("required", [])
388+
else:
389+
properties = tool.args
390+
required = [
391+
p_name
392+
for p_name, p_def in properties.items()
393+
if "default" not in p_def
394+
]
395+
381396
return self.oci_function_definition(
382397
name=tool.name,
383398
description=OCIUtils.remove_signature_from_tool_description(
@@ -386,17 +401,10 @@ def convert_to_oci_tool(
386401
parameters={
387402
"type": "object",
388403
"properties": {
389-
p_name: {
390-
"type": p_def.get("type", "any"),
391-
"description": p_def.get("description", ""),
392-
}
393-
for p_name, p_def in tool.args.items()
404+
p_name: self._sanitize_tool_property(p_def)
405+
for p_name, p_def in properties.items()
394406
},
395-
"required": [
396-
p_name
397-
for p_name, p_def in tool.args.items()
398-
if "default" not in p_def
399-
],
407+
"required": required,
400408
},
401409
)
402410
if (isinstance(tool, type) and issubclass(tool, BaseModel)) or callable(tool):
@@ -420,6 +428,89 @@ def convert_to_oci_tool(
420428
"instance, TypedDict class, or BaseModel type."
421429
)
422430

431+
# JSON Schema properties safe to pass through to OCI GenAI API.
432+
# OCI's FunctionDefinition.parameters accepts any JSON Schema object.
433+
_PASSTHROUGH_KEYS = {
434+
"type",
435+
"description",
436+
"enum",
437+
"const",
438+
"minimum",
439+
"maximum",
440+
"exclusiveMinimum",
441+
"exclusiveMaximum",
442+
"minLength",
443+
"maxLength",
444+
"pattern",
445+
"format",
446+
"items",
447+
"minItems",
448+
"maxItems",
449+
"uniqueItems",
450+
"properties",
451+
"required",
452+
"additionalProperties",
453+
"default",
454+
"examples",
455+
}
456+
457+
@classmethod
458+
def _sanitize_tool_property(cls, p_def: Dict[str, Any]) -> Dict[str, Any]:
459+
"""Sanitize a tool property schema for OCI GenAI API compatibility.
460+
461+
Preserves JSON-Schema-standard properties (enum, format, min/max,
462+
pattern, items, etc.) while resolving Pydantic v2 ``anyOf`` patterns
463+
(generated for ``Optional[T]``). Falls back to ``"string"`` for
464+
unrecognized schemas.
465+
466+
Args:
467+
p_def: Property definition from tool schema.
468+
469+
Returns:
470+
Sanitized property dict with allowed JSON Schema properties.
471+
"""
472+
allowed = cls._PASSTHROUGH_KEYS
473+
474+
# Resolve anyOf first (Pydantic v2 Optional[T] pattern)
475+
if "anyOf" in p_def and "type" not in p_def:
476+
non_null = [
477+
t
478+
for t in p_def["anyOf"]
479+
if not (isinstance(t, dict) and t.get("type") == "null")
480+
]
481+
if non_null:
482+
resolved = dict(non_null[0])
483+
else:
484+
resolved = {"type": "string"}
485+
# Merge top-level metadata into resolved type
486+
for key in allowed:
487+
if key in p_def and key not in resolved:
488+
resolved[key] = p_def[key]
489+
p_def = resolved
490+
491+
# Build result with allowed keys only
492+
result: Dict[str, Any] = {}
493+
for key in allowed:
494+
if key in p_def:
495+
result[key] = p_def[key]
496+
497+
# Ensure type and description are always present
498+
if "type" not in result:
499+
result["type"] = "string"
500+
if "description" not in result:
501+
result["description"] = ""
502+
503+
# Recursively sanitize nested items/properties
504+
if "items" in result and isinstance(result["items"], dict):
505+
result["items"] = cls._sanitize_tool_property(result["items"])
506+
if "properties" in result and isinstance(result["properties"], dict):
507+
result["properties"] = {
508+
k: cls._sanitize_tool_property(v)
509+
for k, v in result["properties"].items()
510+
}
511+
512+
return result
513+
423514
def process_tool_choice(
424515
self,
425516
tool_choice: Optional[

0 commit comments

Comments
 (0)