lm-deluge 0.0.73__tar.gz → 0.0.75__tar.gz

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 (87) hide show
  1. {lm_deluge-0.0.73/src/lm_deluge.egg-info → lm_deluge-0.0.75}/PKG-INFO +1 -1
  2. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/pyproject.toml +1 -1
  3. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/anthropic.py +35 -1
  4. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/bedrock.py +7 -4
  5. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/openai.py +70 -6
  6. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/client.py +14 -1
  7. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/config.py +2 -1
  8. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/anthropic.py +2 -2
  9. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/prompt.py +12 -2
  10. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/request_context.py +6 -1
  11. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/tool.py +60 -15
  12. lm_deluge-0.0.75/src/lm_deluge/util/schema.py +412 -0
  13. {lm_deluge-0.0.73 → lm_deluge-0.0.75/src/lm_deluge.egg-info}/PKG-INFO +1 -1
  14. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge.egg-info/SOURCES.txt +1 -0
  15. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/LICENSE +0 -0
  16. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/README.md +0 -0
  17. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/setup.cfg +0 -0
  18. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/__init__.py +0 -0
  19. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/__init__.py +0 -0
  20. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/base.py +0 -0
  21. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/chat_reasoning.py +0 -0
  22. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/common.py +0 -0
  23. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/deprecated/bedrock.py +0 -0
  24. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/deprecated/cohere.py +0 -0
  25. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/deprecated/deepseek.py +0 -0
  26. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/deprecated/mistral.py +0 -0
  27. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/deprecated/vertex.py +0 -0
  28. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/gemini.py +0 -0
  29. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/mistral.py +0 -0
  30. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/api_requests/response.py +0 -0
  31. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/batches.py +0 -0
  32. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/built_in_tools/anthropic/__init__.py +0 -0
  33. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/built_in_tools/anthropic/bash.py +0 -0
  34. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/built_in_tools/anthropic/computer_use.py +0 -0
  35. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/built_in_tools/anthropic/editor.py +0 -0
  36. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/built_in_tools/base.py +0 -0
  37. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/built_in_tools/openai.py +0 -0
  38. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/cache.py +0 -0
  39. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/cli.py +0 -0
  40. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/embed.py +0 -0
  41. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/errors.py +0 -0
  42. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/file.py +0 -0
  43. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/image.py +0 -0
  44. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/llm_tools/__init__.py +0 -0
  45. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/llm_tools/classify.py +0 -0
  46. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/llm_tools/extract.py +0 -0
  47. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/llm_tools/locate.py +0 -0
  48. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/llm_tools/ocr.py +0 -0
  49. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/llm_tools/score.py +0 -0
  50. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/llm_tools/translate.py +0 -0
  51. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/mock_openai.py +0 -0
  52. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/__init__.py +0 -0
  53. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/bedrock.py +0 -0
  54. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/cerebras.py +0 -0
  55. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/cohere.py +0 -0
  56. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/deepseek.py +0 -0
  57. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/fireworks.py +0 -0
  58. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/google.py +0 -0
  59. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/grok.py +0 -0
  60. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/groq.py +0 -0
  61. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/kimi.py +0 -0
  62. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/meta.py +0 -0
  63. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/minimax.py +0 -0
  64. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/mistral.py +0 -0
  65. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/openai.py +0 -0
  66. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/openrouter.py +0 -0
  67. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/models/together.py +0 -0
  68. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/presets/cerebras.py +0 -0
  69. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/presets/meta.py +0 -0
  70. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/rerank.py +0 -0
  71. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/tracker.py +0 -0
  72. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/usage.py +0 -0
  73. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/util/harmony.py +0 -0
  74. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/util/json.py +0 -0
  75. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/util/logprobs.py +0 -0
  76. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/util/spatial.py +0 -0
  77. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/util/validation.py +0 -0
  78. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/util/xml.py +0 -0
  79. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge/warnings.py +0 -0
  80. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge.egg-info/dependency_links.txt +0 -0
  81. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge.egg-info/requires.txt +0 -0
  82. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/src/lm_deluge.egg-info/top_level.txt +0 -0
  83. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/tests/test_builtin_tools.py +0 -0
  84. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/tests/test_file_upload.py +0 -0
  85. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/tests/test_mock_openai.py +0 -0
  86. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/tests/test_native_mcp_server.py +0 -0
  87. {lm_deluge-0.0.73 → lm_deluge-0.0.75}/tests/test_openrouter_generic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lm_deluge
3
- Version: 0.0.73
3
+ Version: 0.0.75
4
4
  Summary: Python utility for using LLM API models.
5
5
  Author-email: Benjamin Anderson <ben@trytaylor.ai>
6
6
  Requires-Python: >=3.10
@@ -3,7 +3,7 @@ requires = ["setuptools", "wheel"]
3
3
 
4
4
  [project]
5
5
  name = "lm_deluge"
6
- version = "0.0.73"
6
+ version = "0.0.75"
7
7
  authors = [{ name = "Benjamin Anderson", email = "ben@trytaylor.ai" }]
8
8
  description = "Python utility for using LLM API models."
9
9
  readme = "README.md"
@@ -12,6 +12,10 @@ from lm_deluge.prompt import (
12
12
  from lm_deluge.request_context import RequestContext
13
13
  from lm_deluge.tool import MCPServer, Tool
14
14
  from lm_deluge.usage import Usage
15
+ from lm_deluge.util.schema import (
16
+ prepare_output_schema,
17
+ transform_schema_for_anthropic,
18
+ )
15
19
 
16
20
  from ..models import APIModel
17
21
  from .base import APIRequestBase, APIResponse
@@ -84,12 +88,42 @@ def _build_anthropic_request(
84
88
  if "temperature" in request_json and "top_p" in request_json:
85
89
  request_json.pop("top_p")
86
90
 
91
+ # Handle structured outputs (output_format)
92
+ if context.output_schema:
93
+ if model.supports_json:
94
+ base_schema = prepare_output_schema(context.output_schema)
95
+
96
+ # Apply Anthropic-specific transformations (move unsupported constraints to description)
97
+ transformed_schema = transform_schema_for_anthropic(base_schema)
98
+
99
+ _add_beta(base_headers, "structured-outputs-2025-11-13")
100
+ request_json["output_format"] = {
101
+ "type": "json_schema",
102
+ "schema": transformed_schema,
103
+ }
104
+ else:
105
+ print(
106
+ f"WARNING: Model {model.name} does not support structured outputs. Ignoring output_schema."
107
+ )
108
+ elif sampling_params.json_mode:
109
+ # Anthropic doesn't support basic json_mode without a schema
110
+ print(
111
+ "WARNING: Anthropic does not support basic json_mode without a schema. "
112
+ "Use output_schema parameter for structured JSON outputs."
113
+ )
114
+
115
+ # Add beta header for strict tools when enabled
116
+ if tools and sampling_params.strict_tools and model.supports_json:
117
+ _add_beta(base_headers, "structured-outputs-2025-11-13")
118
+
87
119
  if tools:
88
120
  mcp_servers = []
89
121
  tool_definitions = []
90
122
  for tool in tools:
91
123
  if isinstance(tool, Tool):
92
- tool_definitions.append(tool.dump_for("anthropic"))
124
+ # Only use strict mode if model supports structured outputs
125
+ use_strict = sampling_params.strict_tools and model.supports_json
126
+ tool_definitions.append(tool.dump_for("anthropic", strict=use_strict))
93
127
  elif isinstance(tool, dict) and "url" in tool:
94
128
  _add_beta(base_headers, "mcp-client-2025-04-04")
95
129
  mcp_servers.append(tool)
@@ -106,7 +106,8 @@ async def _build_anthropic_bedrock_request(
106
106
  tool_definitions = []
107
107
  for tool in tools:
108
108
  if isinstance(tool, Tool):
109
- tool_definitions.append(tool.dump_for("anthropic"))
109
+ # Bedrock doesn't have the strict-mode betas Anthropic exposes yet
110
+ tool_definitions.append(tool.dump_for("anthropic", strict=False))
110
111
  elif isinstance(tool, dict):
111
112
  tool_definitions.append(tool)
112
113
  # add betas if needed
@@ -124,7 +125,9 @@ async def _build_anthropic_bedrock_request(
124
125
  # Convert to individual tools locally (like OpenAI does)
125
126
  individual_tools = await tool.to_tools()
126
127
  for individual_tool in individual_tools:
127
- tool_definitions.append(individual_tool.dump_for("anthropic"))
128
+ tool_definitions.append(
129
+ individual_tool.dump_for("anthropic", strict=False)
130
+ )
128
131
 
129
132
  # Add cache control to last tool if tools_only caching is specified
130
133
  if cache_pattern == "tools_only" and tool_definitions:
@@ -194,11 +197,11 @@ async def _build_openai_bedrock_request(
194
197
  request_tools = []
195
198
  for tool in tools:
196
199
  if isinstance(tool, Tool):
197
- request_tools.append(tool.dump_for("openai-completions"))
200
+ request_tools.append(tool.dump_for("openai-completions", strict=False))
198
201
  elif isinstance(tool, MCPServer):
199
202
  as_tools = await tool.to_tools()
200
203
  request_tools.extend(
201
- [t.dump_for("openai-completions") for t in as_tools]
204
+ [t.dump_for("openai-completions", strict=False) for t in as_tools]
202
205
  )
203
206
  request_json["tools"] = request_tools
204
207
 
@@ -9,6 +9,10 @@ from aiohttp import ClientResponse
9
9
  from lm_deluge.request_context import RequestContext
10
10
  from lm_deluge.tool import MCPServer, Tool
11
11
  from lm_deluge.warnings import maybe_warn
12
+ from lm_deluge.util.schema import (
13
+ prepare_output_schema,
14
+ transform_schema_for_openai,
15
+ )
12
16
 
13
17
  from ..config import SamplingParams
14
18
  from ..models import APIModel
@@ -83,17 +87,48 @@ async def _build_oa_chat_request(
83
87
  request_json["logprobs"] = True
84
88
  if sampling_params.top_logprobs is not None:
85
89
  request_json["top_logprobs"] = sampling_params.top_logprobs
86
- if sampling_params.json_mode and model.supports_json:
90
+
91
+ # Handle structured outputs (output_schema takes precedence over json_mode)
92
+ if context.output_schema:
93
+ if model.supports_json:
94
+ base_schema = prepare_output_schema(context.output_schema)
95
+
96
+ # Apply OpenAI-specific transformations (currently passthrough with copy)
97
+ transformed_schema = transform_schema_for_openai(base_schema)
98
+
99
+ request_json["response_format"] = {
100
+ "type": "json_schema",
101
+ "json_schema": {
102
+ "name": "response",
103
+ "schema": transformed_schema,
104
+ "strict": True,
105
+ },
106
+ }
107
+ else:
108
+ print(
109
+ f"WARNING: Model {model.name} does not support structured outputs. Ignoring output_schema."
110
+ )
111
+ elif sampling_params.json_mode and model.supports_json:
87
112
  request_json["response_format"] = {"type": "json_object"}
113
+
88
114
  if tools:
89
115
  request_tools = []
90
116
  for tool in tools:
91
117
  if isinstance(tool, Tool):
92
- request_tools.append(tool.dump_for("openai-completions"))
118
+ request_tools.append(
119
+ tool.dump_for(
120
+ "openai-completions", strict=sampling_params.strict_tools
121
+ )
122
+ )
93
123
  elif isinstance(tool, MCPServer):
94
124
  as_tools = await tool.to_tools()
95
125
  request_tools.extend(
96
- [t.dump_for("openai-completions") for t in as_tools]
126
+ [
127
+ t.dump_for(
128
+ "openai-completions", strict=sampling_params.strict_tools
129
+ )
130
+ for t in as_tools
131
+ ]
97
132
  )
98
133
  request_json["tools"] = request_tools
99
134
  return request_json
@@ -297,7 +332,27 @@ async def _build_oa_responses_request(
297
332
  if sampling_params.reasoning_effort:
298
333
  maybe_warn("WARN_REASONING_UNSUPPORTED", model_name=context.model_name)
299
334
 
300
- if sampling_params.json_mode and model.supports_json:
335
+ # Handle structured outputs (output_schema takes precedence over json_mode)
336
+ if context.output_schema:
337
+ if model.supports_json:
338
+ base_schema = prepare_output_schema(context.output_schema)
339
+
340
+ # Apply OpenAI-specific transformations (currently passthrough with copy)
341
+ transformed_schema = transform_schema_for_openai(base_schema)
342
+
343
+ request_json["text"] = {
344
+ "format": {
345
+ "type": "json_schema",
346
+ "name": "response",
347
+ "schema": transformed_schema,
348
+ "strict": True,
349
+ }
350
+ }
351
+ else:
352
+ print(
353
+ f"WARNING: Model {model.name} does not support structured outputs. Ignoring output_schema."
354
+ )
355
+ elif sampling_params.json_mode and model.supports_json:
301
356
  request_json["text"] = {"format": {"type": "json_object"}}
302
357
 
303
358
  # Handle tools
@@ -305,7 +360,9 @@ async def _build_oa_responses_request(
305
360
  # Add regular function tools
306
361
  for tool in tools or []:
307
362
  if isinstance(tool, Tool):
308
- request_tools.append(tool.dump_for("openai-responses"))
363
+ request_tools.append(
364
+ tool.dump_for("openai-responses", strict=sampling_params.strict_tools)
365
+ )
309
366
  elif isinstance(tool, dict):
310
367
  # if computer use, make sure model supports it
311
368
  if tool["type"] == "computer_use_preview":
@@ -317,7 +374,14 @@ async def _build_oa_responses_request(
317
374
  elif isinstance(tool, MCPServer):
318
375
  if context.force_local_mcp:
319
376
  as_tools = await tool.to_tools()
320
- request_tools.extend([t.dump_for("openai-responses") for t in as_tools])
377
+ request_tools.extend(
378
+ [
379
+ t.dump_for(
380
+ "openai-responses", strict=sampling_params.strict_tools
381
+ )
382
+ for t in as_tools
383
+ ]
384
+ )
321
385
  else:
322
386
  request_tools.append(tool.for_openai_responses())
323
387
 
@@ -561,6 +561,7 @@ class _LLMClient(BaseModel):
561
561
  return_completions_only: Literal[True],
562
562
  show_progress: bool = ...,
563
563
  tools: list[Tool | dict | MCPServer] | None = ...,
564
+ output_schema: type[BaseModel] | dict | None = ...,
564
565
  cache: CachePattern | None = ...,
565
566
  service_tier: Literal["auto", "default", "flex", "priority"] | None = ...,
566
567
  ) -> list[str | None]: ...
@@ -573,6 +574,7 @@ class _LLMClient(BaseModel):
573
574
  return_completions_only: Literal[False] = ...,
574
575
  show_progress: bool = ...,
575
576
  tools: list[Tool | dict | MCPServer] | None = ...,
577
+ output_schema: type[BaseModel] | dict | None = ...,
576
578
  cache: CachePattern | None = ...,
577
579
  service_tier: Literal["auto", "default", "flex", "priority"] | None = ...,
578
580
  ) -> list[APIResponse]: ...
@@ -584,6 +586,7 @@ class _LLMClient(BaseModel):
584
586
  return_completions_only: bool = False,
585
587
  show_progress: bool = True,
586
588
  tools: list[Tool | dict | MCPServer] | None = None,
589
+ output_schema: type[BaseModel] | dict | None = None,
587
590
  cache: CachePattern | None = None,
588
591
  service_tier: Literal["auto", "default", "flex", "priority"] | None = None,
589
592
  ) -> list[APIResponse] | list[str | None] | dict[str, int]:
@@ -612,6 +615,7 @@ class _LLMClient(BaseModel):
612
615
  task_id = self.start_nowait(
613
616
  prompt,
614
617
  tools=tools,
618
+ output_schema=output_schema,
615
619
  cache=cache,
616
620
  service_tier=service_tier,
617
621
  )
@@ -657,6 +661,7 @@ class _LLMClient(BaseModel):
657
661
  return_completions_only: bool = False,
658
662
  show_progress=True,
659
663
  tools: list[Tool | dict | MCPServer] | None = None,
664
+ output_schema: type[BaseModel] | dict | None = None,
660
665
  cache: CachePattern | None = None,
661
666
  ):
662
667
  return asyncio.run(
@@ -665,6 +670,7 @@ class _LLMClient(BaseModel):
665
670
  return_completions_only=return_completions_only,
666
671
  show_progress=show_progress,
667
672
  tools=tools,
673
+ output_schema=output_schema,
668
674
  cache=cache,
669
675
  )
670
676
  )
@@ -688,6 +694,7 @@ class _LLMClient(BaseModel):
688
694
  prompt: Prompt,
689
695
  *,
690
696
  tools: list[Tool | dict | MCPServer] | None = None,
697
+ output_schema: type[BaseModel] | dict | None = None,
691
698
  cache: CachePattern | None = None,
692
699
  service_tier: Literal["auto", "default", "flex", "priority"] | None = None,
693
700
  ) -> int:
@@ -706,6 +713,7 @@ class _LLMClient(BaseModel):
706
713
  request_timeout=self.request_timeout,
707
714
  status_tracker=tracker,
708
715
  tools=tools,
716
+ output_schema=output_schema,
709
717
  cache=cache,
710
718
  use_responses_api=self.use_responses_api,
711
719
  background=self.background,
@@ -723,11 +731,16 @@ class _LLMClient(BaseModel):
723
731
  prompt: Prompt,
724
732
  *,
725
733
  tools: list[Tool | dict | MCPServer] | None = None,
734
+ output_schema: type[BaseModel] | dict | None = None,
726
735
  cache: CachePattern | None = None,
727
736
  service_tier: Literal["auto", "default", "flex", "priority"] | None = None,
728
737
  ) -> APIResponse:
729
738
  task_id = self.start_nowait(
730
- prompt, tools=tools, cache=cache, service_tier=service_tier
739
+ prompt,
740
+ tools=tools,
741
+ output_schema=output_schema,
742
+ cache=cache,
743
+ service_tier=service_tier,
731
744
  )
732
745
  return await self.wait_for(task_id)
733
746
 
@@ -7,10 +7,11 @@ class SamplingParams(BaseModel):
7
7
  temperature: float = 0.0
8
8
  top_p: float = 1.0
9
9
  json_mode: bool = False
10
- max_new_tokens: int = 512
10
+ max_new_tokens: int = 2_048
11
11
  reasoning_effort: Literal["low", "medium", "high", "minimal", "none", None] = None
12
12
  logprobs: bool = False
13
13
  top_logprobs: int | None = None
14
+ strict_tools: bool = True
14
15
 
15
16
  def to_vllm(self):
16
17
  try:
@@ -27,7 +27,7 @@ ANTHROPIC_MODELS = {
27
27
  "name": "claude-sonnet-4-5-20250929",
28
28
  "api_base": "https://api.anthropic.com/v1",
29
29
  "api_key_env_var": "ANTHROPIC_API_KEY",
30
- "supports_json": False,
30
+ "supports_json": True,
31
31
  "api_spec": "anthropic",
32
32
  "input_cost": 3.0,
33
33
  "cached_input_cost": 0.30,
@@ -39,7 +39,7 @@ ANTHROPIC_MODELS = {
39
39
  "name": "claude-opus-4-1-20250805",
40
40
  "api_base": "https://api.anthropic.com/v1",
41
41
  "api_key_env_var": "ANTHROPIC_API_KEY",
42
- "supports_json": False,
42
+ "supports_json": True,
43
43
  "api_spec": "anthropic",
44
44
  "input_cost": 15.0,
45
45
  "cached_input_cost": 1.50,
@@ -1195,14 +1195,24 @@ class Conversation:
1195
1195
 
1196
1196
  @classmethod
1197
1197
  def from_unknown(
1198
- cls, messages: list[dict], *, system: str | list[dict] | None = None
1198
+ cls, messages: list[dict] | dict, *, system: str | list[dict] | None = None
1199
1199
  ) -> tuple["Conversation", str]:
1200
1200
  """Attempt to convert provider-formatted messages without knowing the provider.
1201
1201
 
1202
1202
  Returns the parsed conversation together with the provider label that succeeded
1203
- ("openai" or "anthropic").
1203
+ ("openai", "anthropic", or "log").
1204
1204
  """
1205
1205
 
1206
+ # Check if input is in log format (output from to_log())
1207
+ if isinstance(messages, dict) and "messages" in messages:
1208
+ return cls.from_log(messages), "log"
1209
+
1210
+ # Ensure messages is a list for provider detection
1211
+ if not isinstance(messages, list):
1212
+ raise ValueError(
1213
+ "messages must be a list of dicts or a dict with 'messages' key"
1214
+ )
1215
+
1206
1216
  def _detect_provider() -> str:
1207
1217
  has_openai_markers = False
1208
1218
  has_anthropic_markers = False
@@ -1,11 +1,14 @@
1
1
  from dataclasses import dataclass, field
2
2
  from functools import cached_property
3
- from typing import Any, Callable
3
+ from typing import Any, Callable, TYPE_CHECKING
4
4
 
5
5
  from .config import SamplingParams
6
6
  from .prompt import CachePattern, Conversation
7
7
  from .tracker import StatusTracker
8
8
 
9
+ if TYPE_CHECKING:
10
+ from pydantic import BaseModel
11
+
9
12
 
10
13
  @dataclass
11
14
  class RequestContext:
@@ -32,6 +35,7 @@ class RequestContext:
32
35
 
33
36
  # Optional features
34
37
  tools: list | None = None
38
+ output_schema: "type[BaseModel] | dict | None" = None
35
39
  cache: CachePattern | None = None
36
40
  use_responses_api: bool = False
37
41
  background: bool = False
@@ -66,6 +70,7 @@ class RequestContext:
66
70
  "results_arr": self.results_arr,
67
71
  "callback": self.callback,
68
72
  "tools": self.tools,
73
+ "output_schema": self.output_schema,
69
74
  "cache": self.cache,
70
75
  "use_responses_api": self.use_responses_api,
71
76
  "background": self.background,
@@ -713,17 +713,40 @@ class Tool(BaseModel):
713
713
  """just an alias for the above"""
714
714
  return self.for_openai_completions(strict=strict, **kwargs)
715
715
 
716
- def for_openai_responses(self, **kwargs) -> dict[str, Any]:
716
+ def for_openai_responses(self, *, strict: bool = True, **kwargs) -> dict[str, Any]:
717
717
  if self.is_built_in:
718
718
  return {"type": self.type, **self.built_in_args, **kwargs}
719
- return {
720
- "type": "function",
721
- "name": self.name,
722
- "description": self.description,
723
- "parameters": self._json_schema(include_additional_properties=True),
724
- }
725
719
 
726
- def for_anthropic(self, **kwargs) -> dict[str, Any]:
720
+ # Check if schema is compatible with strict mode
721
+ if strict and not self._is_strict_mode_compatible():
722
+ strict = False
723
+
724
+ if strict:
725
+ # For strict mode, remove defaults and make all parameters required
726
+ schema = self._json_schema(
727
+ include_additional_properties=True, remove_defaults=True
728
+ )
729
+ schema["required"] = list(
730
+ (self.parameters or {}).keys()
731
+ ) # All parameters required in strict mode
732
+
733
+ return {
734
+ "type": "function",
735
+ "name": self.name,
736
+ "description": self.description,
737
+ "parameters": schema,
738
+ "strict": True,
739
+ }
740
+ else:
741
+ # For non-strict mode, use the original required list
742
+ return {
743
+ "type": "function",
744
+ "name": self.name,
745
+ "description": self.description,
746
+ "parameters": self._json_schema(include_additional_properties=True),
747
+ }
748
+
749
+ def for_anthropic(self, *, strict: bool = True, **kwargs) -> dict[str, Any]:
727
750
  # built-in tools have "name", "type", maybe metadata
728
751
  if self.is_built_in:
729
752
  return {
@@ -732,11 +755,33 @@ class Tool(BaseModel):
732
755
  **self.built_in_args,
733
756
  **kwargs,
734
757
  }
735
- return {
736
- "name": self.name,
737
- "description": self.description,
738
- "input_schema": self._json_schema(),
739
- }
758
+
759
+ # Check if schema is compatible with strict mode
760
+ if strict and not self._is_strict_mode_compatible():
761
+ strict = False
762
+
763
+ if strict:
764
+ # For strict mode, remove defaults and make all parameters required
765
+ schema = self._json_schema(
766
+ include_additional_properties=True, remove_defaults=True
767
+ )
768
+ schema["required"] = list(
769
+ (self.parameters or {}).keys()
770
+ ) # All parameters required in strict mode
771
+
772
+ return {
773
+ "name": self.name,
774
+ "description": self.description,
775
+ "input_schema": schema,
776
+ "strict": True,
777
+ }
778
+ else:
779
+ # For non-strict mode, use the original required list
780
+ return {
781
+ "name": self.name,
782
+ "description": self.description,
783
+ "input_schema": self._json_schema(),
784
+ }
740
785
 
741
786
  def for_google(self) -> dict[str, Any]:
742
787
  """
@@ -759,11 +804,11 @@ class Tool(BaseModel):
759
804
  **kw,
760
805
  ) -> dict[str, Any]:
761
806
  if provider == "openai-responses":
762
- return self.for_openai_responses()
807
+ return self.for_openai_responses(**kw)
763
808
  if provider == "openai-completions":
764
809
  return self.for_openai_completions(**kw)
765
810
  if provider == "anthropic":
766
- return self.for_anthropic()
811
+ return self.for_anthropic(**kw)
767
812
  if provider == "google":
768
813
  return self.for_google()
769
814
  raise ValueError(provider)
@@ -0,0 +1,412 @@
1
+ """Schema transformation utilities for structured outputs.
2
+
3
+ This module provides utilities for transforming Pydantic models and JSON schemas
4
+ to be compatible with provider-specific structured output requirements (OpenAI, Anthropic).
5
+
6
+ Key functions:
7
+ - to_strict_json_schema: Convert Pydantic model to strict JSON schema
8
+ - transform_schema_for_openai: Apply OpenAI-specific transformations
9
+ - transform_schema_for_anthropic: Apply Anthropic-specific transformations
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ import inspect
16
+ from typing import Any, TypeGuard, TYPE_CHECKING, Type
17
+
18
+ if TYPE_CHECKING:
19
+ from pydantic import BaseModel
20
+
21
+ try:
22
+ import pydantic
23
+ from pydantic import BaseModel as _BaseModel
24
+ except ImportError:
25
+ pydantic = None
26
+ _BaseModel = None # type: ignore
27
+
28
+
29
+ def is_pydantic_model(obj: Any) -> bool:
30
+ """Check if an object is a Pydantic model class."""
31
+ if pydantic is None or _BaseModel is None:
32
+ return False
33
+ return inspect.isclass(obj) and issubclass(obj, _BaseModel)
34
+
35
+
36
+ def is_dict(obj: object) -> TypeGuard[dict[str, object]]:
37
+ """Type guard for dictionaries."""
38
+ return isinstance(obj, dict)
39
+
40
+
41
+ def has_more_than_n_keys(obj: dict[str, object], n: int) -> bool:
42
+ """Check if a dictionary has more than n keys."""
43
+ i = 0
44
+ for _ in obj.keys():
45
+ i += 1
46
+ if i > n:
47
+ return True
48
+ return False
49
+
50
+
51
+ def resolve_ref(*, root: dict[str, object], ref: str) -> object:
52
+ """Resolve a JSON Schema $ref pointer.
53
+
54
+ Args:
55
+ root: The root schema object
56
+ ref: The $ref string (e.g., "#/$defs/MyType")
57
+
58
+ Returns:
59
+ The resolved schema object
60
+
61
+ Raises:
62
+ ValueError: If the $ref format is invalid or cannot be resolved
63
+ """
64
+ if not ref.startswith("#/"):
65
+ raise ValueError(f"Unexpected $ref format {ref!r}; Does not start with #/")
66
+
67
+ path = ref[2:].split("/")
68
+ resolved = root
69
+ for key in path:
70
+ value = resolved[key]
71
+ if not is_dict(value):
72
+ raise ValueError(
73
+ f"Encountered non-dictionary entry while resolving {ref} - {resolved}"
74
+ )
75
+ resolved = value
76
+
77
+ return resolved
78
+
79
+
80
+ def to_strict_json_schema(model: Type["BaseModel"]) -> dict[str, Any]:
81
+ """Convert a Pydantic model to a strict JSON schema.
82
+
83
+ This function extracts the JSON schema from a Pydantic model and ensures
84
+ it conforms to the strict mode requirements for structured outputs.
85
+
86
+ Args:
87
+ model: A Pydantic BaseModel class
88
+
89
+ Returns:
90
+ A JSON schema dict that conforms to strict mode requirements
91
+
92
+ Raises:
93
+ TypeError: If the model is not a Pydantic BaseModel
94
+ ImportError: If pydantic is not installed
95
+ """
96
+ if pydantic is None or _BaseModel is None:
97
+ raise ImportError(
98
+ "pydantic is required for Pydantic model support. "
99
+ "Install it with: pip install pydantic"
100
+ )
101
+
102
+ if not is_pydantic_model(model):
103
+ raise TypeError(
104
+ f"Expected a Pydantic BaseModel class, got {type(model).__name__}"
105
+ )
106
+
107
+ schema = model.model_json_schema()
108
+ return _ensure_strict_json_schema(schema, path=(), root=schema)
109
+
110
+
111
+ def prepare_output_schema(
112
+ schema_obj: Type["BaseModel"] | dict[str, Any],
113
+ ) -> dict[str, Any]:
114
+ """Normalize a user-provided schema into strict JSON schema form.
115
+
116
+ Args:
117
+ schema_obj: Either a Pydantic BaseModel subclass or a JSON schema dict.
118
+
119
+ Returns:
120
+ A strict JSON schema suitable for provider-specific transformation.
121
+
122
+ Notes:
123
+ Dict schemas are deep-copied before normalization so the caller's
124
+ original object is left untouched.
125
+ """
126
+
127
+ if is_pydantic_model(schema_obj):
128
+ return to_strict_json_schema(schema_obj) # type: ignore[arg-type]
129
+
130
+ if is_dict(schema_obj):
131
+ schema_copy = copy.deepcopy(schema_obj)
132
+ return _ensure_strict_json_schema(
133
+ schema_copy,
134
+ path=(),
135
+ root=schema_copy,
136
+ )
137
+
138
+ raise TypeError(
139
+ "output_schema must be a Pydantic BaseModel subclass or a JSON schema dict"
140
+ )
141
+
142
+
143
+ def _ensure_strict_json_schema(
144
+ json_schema: object,
145
+ *,
146
+ path: tuple[str, ...],
147
+ root: dict[str, object],
148
+ ) -> dict[str, Any]:
149
+ """Recursively ensure a JSON schema conforms to strict mode requirements.
150
+
151
+ This function:
152
+ - Adds additionalProperties: false to all objects
153
+ - Makes all properties required
154
+ - Removes unsupported constraints and adds them to descriptions
155
+ - Expands $refs that are mixed with other properties
156
+ - Processes $defs, anyOf, allOf, etc.
157
+
158
+ Args:
159
+ json_schema: The schema to transform
160
+ path: Current path in the schema (for error messages)
161
+ root: The root schema (for resolving $refs)
162
+
163
+ Returns:
164
+ The transformed schema
165
+ """
166
+ if not is_dict(json_schema):
167
+ raise TypeError(f"Expected {json_schema} to be a dictionary; path={path}")
168
+
169
+ # Process $defs recursively
170
+ defs = json_schema.get("$defs")
171
+ if is_dict(defs):
172
+ for def_name, def_schema in defs.items():
173
+ _ensure_strict_json_schema(
174
+ def_schema, path=(*path, "$defs", def_name), root=root
175
+ )
176
+
177
+ # Process definitions recursively
178
+ definitions = json_schema.get("definitions")
179
+ if is_dict(definitions):
180
+ for definition_name, definition_schema in definitions.items():
181
+ _ensure_strict_json_schema(
182
+ definition_schema,
183
+ path=(*path, "definitions", definition_name),
184
+ root=root,
185
+ )
186
+
187
+ typ = json_schema.get("type")
188
+
189
+ # Object types - add additionalProperties: false and make all fields required
190
+ if typ == "object" and "additionalProperties" not in json_schema:
191
+ json_schema["additionalProperties"] = False
192
+
193
+ properties = json_schema.get("properties")
194
+ if is_dict(properties):
195
+ # Make all properties required
196
+ json_schema["required"] = list(properties.keys())
197
+
198
+ # Process each property recursively
199
+ json_schema["properties"] = {
200
+ key: _ensure_strict_json_schema(
201
+ prop_schema, path=(*path, "properties", key), root=root
202
+ )
203
+ for key, prop_schema in properties.items()
204
+ }
205
+
206
+ # Arrays - process items schema
207
+ items = json_schema.get("items")
208
+ if is_dict(items):
209
+ json_schema["items"] = _ensure_strict_json_schema(
210
+ items, path=(*path, "items"), root=root
211
+ )
212
+
213
+ # Unions - process each variant
214
+ any_of = json_schema.get("anyOf")
215
+ if isinstance(any_of, list):
216
+ json_schema["anyOf"] = [
217
+ _ensure_strict_json_schema(
218
+ variant, path=(*path, "anyOf", str(i)), root=root
219
+ )
220
+ for i, variant in enumerate(any_of)
221
+ ]
222
+
223
+ # Intersections - process each entry
224
+ all_of = json_schema.get("allOf")
225
+ if isinstance(all_of, list):
226
+ if len(all_of) == 1:
227
+ # Flatten single-element allOf
228
+ json_schema.update(
229
+ _ensure_strict_json_schema(
230
+ all_of[0], path=(*path, "allOf", "0"), root=root
231
+ )
232
+ )
233
+ json_schema.pop("allOf")
234
+ else:
235
+ json_schema["allOf"] = [
236
+ _ensure_strict_json_schema(
237
+ entry, path=(*path, "allOf", str(i)), root=root
238
+ )
239
+ for i, entry in enumerate(all_of)
240
+ ]
241
+
242
+ # Remove None defaults (redundant with nullable)
243
+ if "default" in json_schema and json_schema["default"] is None:
244
+ json_schema.pop("default")
245
+
246
+ # Expand $refs that are mixed with other properties
247
+ ref = json_schema.get("$ref")
248
+ if ref and has_more_than_n_keys(json_schema, 1):
249
+ if not isinstance(ref, str):
250
+ raise ValueError(f"Received non-string $ref - {ref}")
251
+
252
+ resolved = resolve_ref(root=root, ref=ref)
253
+ if not is_dict(resolved):
254
+ raise ValueError(
255
+ f"Expected `$ref: {ref}` to resolve to a dictionary but got {resolved}"
256
+ )
257
+
258
+ # Properties from json_schema take priority over $ref
259
+ json_schema.update({**resolved, **json_schema})
260
+ json_schema.pop("$ref")
261
+
262
+ # Re-process the expanded schema
263
+ return _ensure_strict_json_schema(json_schema, path=path, root=root)
264
+
265
+ return json_schema
266
+
267
+
268
+ def _move_constraints_to_description(
269
+ json_schema: dict[str, Any],
270
+ constraint_keys: list[str],
271
+ ) -> dict[str, Any]:
272
+ """Move unsupported constraints to the description field.
273
+
274
+ This helps the model follow constraints even when they can't be enforced
275
+ by the grammar.
276
+
277
+ Args:
278
+ json_schema: The schema to modify
279
+ constraint_keys: List of constraint keys to move to description
280
+
281
+ Returns:
282
+ The modified schema
283
+ """
284
+ constraints_found = {}
285
+
286
+ for key in constraint_keys:
287
+ if key in json_schema:
288
+ constraints_found[key] = json_schema.pop(key)
289
+
290
+ if constraints_found:
291
+ description = json_schema.get("description", "")
292
+ constraint_str = ", ".join(
293
+ f"{key}: {value}" for key, value in constraints_found.items()
294
+ )
295
+
296
+ if description:
297
+ json_schema["description"] = f"{description}\n\n{{{constraint_str}}}"
298
+ else:
299
+ json_schema["description"] = f"{{{constraint_str}}}"
300
+
301
+ return json_schema
302
+
303
+
304
+ def transform_schema_for_openai(schema: dict[str, Any]) -> dict[str, Any]:
305
+ """Return a deep copy of the schema for OpenAI requests.
306
+
307
+ OpenAI Structured Outputs currently support the standard constraints we
308
+ rely on (min/max length, numeric bounds, etc.), so we intentionally leave
309
+ the schema untouched apart from copying it to prevent downstream mutation.
310
+ """
311
+
312
+ return copy.deepcopy(schema)
313
+
314
+
315
+ def _transform_schema_recursive_anthropic(
316
+ json_schema: dict[str, Any],
317
+ root: dict[str, Any],
318
+ ) -> dict[str, Any]:
319
+ """Recursively strip unsupported constraints for Anthropic."""
320
+ if not is_dict(json_schema):
321
+ return json_schema
322
+
323
+ # Process $defs
324
+ if "$defs" in json_schema and is_dict(json_schema["$defs"]):
325
+ for def_name, def_schema in json_schema["$defs"].items():
326
+ if is_dict(def_schema):
327
+ _transform_schema_recursive_anthropic(def_schema, root)
328
+
329
+ # Process definitions
330
+ if "definitions" in json_schema and is_dict(json_schema["definitions"]):
331
+ for def_name, def_schema in json_schema["definitions"].items():
332
+ if is_dict(def_schema):
333
+ _transform_schema_recursive_anthropic(def_schema, root)
334
+
335
+ typ = json_schema.get("type")
336
+
337
+ # Handle unsupported constraints based on type
338
+ if typ == "string":
339
+ _move_constraints_to_description(
340
+ json_schema,
341
+ ["minLength", "maxLength", "pattern"],
342
+ )
343
+ elif typ in ("number", "integer"):
344
+ _move_constraints_to_description(
345
+ json_schema,
346
+ [
347
+ "minimum",
348
+ "maximum",
349
+ "exclusiveMinimum",
350
+ "exclusiveMaximum",
351
+ "multipleOf",
352
+ ],
353
+ )
354
+ elif typ == "array":
355
+ _move_constraints_to_description(
356
+ json_schema,
357
+ [
358
+ "minItems",
359
+ "maxItems",
360
+ ],
361
+ )
362
+
363
+ # Recursively process nested schemas
364
+ if "properties" in json_schema and is_dict(json_schema["properties"]):
365
+ for prop_name, prop_schema in json_schema["properties"].items():
366
+ if is_dict(prop_schema):
367
+ _transform_schema_recursive_anthropic(prop_schema, root)
368
+
369
+ if "items" in json_schema and is_dict(json_schema["items"]):
370
+ _transform_schema_recursive_anthropic(json_schema["items"], root)
371
+
372
+ if "anyOf" in json_schema and isinstance(json_schema["anyOf"], list):
373
+ for variant in json_schema["anyOf"]:
374
+ if is_dict(variant):
375
+ _transform_schema_recursive_anthropic(variant, root)
376
+
377
+ if "allOf" in json_schema and isinstance(json_schema["allOf"], list):
378
+ for entry in json_schema["allOf"]:
379
+ if is_dict(entry):
380
+ _transform_schema_recursive_anthropic(entry, root)
381
+
382
+ return json_schema
383
+
384
+
385
+ def transform_schema_for_anthropic(schema: dict[str, Any]) -> dict[str, Any]:
386
+ """Transform a JSON schema for Anthropic's structured output requirements."""
387
+
388
+ schema_copy = copy.deepcopy(schema)
389
+ return _transform_schema_recursive_anthropic(schema_copy, schema_copy)
390
+
391
+
392
+ def get_json_schema(obj: Type["BaseModel"] | dict[str, Any]) -> dict[str, Any]:
393
+ """Get JSON schema from a Pydantic model or dict.
394
+
395
+ This is a convenience function that handles both Pydantic models
396
+ and raw dictionaries.
397
+
398
+ Args:
399
+ obj: Either a Pydantic BaseModel class or a dict
400
+
401
+ Returns:
402
+ The JSON schema dict
403
+ """
404
+ if is_pydantic_model(obj):
405
+ # Type narrowing: if is_pydantic_model returns True, obj must have model_json_schema
406
+ return obj.model_json_schema() # type: ignore
407
+ elif is_dict(obj):
408
+ return obj # type: ignore
409
+ else:
410
+ raise TypeError(
411
+ f"Expected Pydantic BaseModel or dict, got {type(obj).__name__}"
412
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lm_deluge
3
- Version: 0.0.73
3
+ Version: 0.0.75
4
4
  Summary: Python utility for using LLM API models.
5
5
  Author-email: Benjamin Anderson <ben@trytaylor.ai>
6
6
  Requires-Python: >=3.10
@@ -74,6 +74,7 @@ src/lm_deluge/presets/meta.py
74
74
  src/lm_deluge/util/harmony.py
75
75
  src/lm_deluge/util/json.py
76
76
  src/lm_deluge/util/logprobs.py
77
+ src/lm_deluge/util/schema.py
77
78
  src/lm_deluge/util/spatial.py
78
79
  src/lm_deluge/util/validation.py
79
80
  src/lm_deluge/util/xml.py
File without changes
File without changes
File without changes