msgflux 0.1.0__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.
- msgflux/__init__.py +104 -0
- msgflux/_private/__init__.py +0 -0
- msgflux/_private/client.py +105 -0
- msgflux/_private/core.py +14 -0
- msgflux/_private/executor.py +134 -0
- msgflux/_private/response.py +230 -0
- msgflux/auto/__init__.py +5 -0
- msgflux/auto/params.py +167 -0
- msgflux/cache.py +54 -0
- msgflux/core/__init__.py +5 -0
- msgflux/core/dotdict.py +264 -0
- msgflux/core/examples.py +169 -0
- msgflux/core/message.py +45 -0
- msgflux/data/__init__.py +0 -0
- msgflux/data/audio.py +1 -0
- msgflux/data/dbs/__init__.py +16 -0
- msgflux/data/dbs/base.py +124 -0
- msgflux/data/dbs/db.py +65 -0
- msgflux/data/dbs/providers/__init__.py +0 -0
- msgflux/data/dbs/providers/cachetools.py +57 -0
- msgflux/data/dbs/providers/diskcache.py +48 -0
- msgflux/data/dbs/providers/faiss.py +220 -0
- msgflux/data/dbs/registry.py +19 -0
- msgflux/data/dbs/response.py +10 -0
- msgflux/data/dbs/types.py +26 -0
- msgflux/data/parsers/__init__.py +16 -0
- msgflux/data/parsers/base.py +325 -0
- msgflux/data/parsers/parser.py +101 -0
- msgflux/data/parsers/providers/__init__.py +32 -0
- msgflux/data/parsers/providers/csv_parser.py +278 -0
- msgflux/data/parsers/providers/email_parser.py +264 -0
- msgflux/data/parsers/providers/html_parser.py +265 -0
- msgflux/data/parsers/providers/openpyxl.py +333 -0
- msgflux/data/parsers/providers/pypdf.py +186 -0
- msgflux/data/parsers/providers/python_docx.py +334 -0
- msgflux/data/parsers/providers/python_pptx.py +197 -0
- msgflux/data/parsers/registry.py +19 -0
- msgflux/data/parsers/response.py +32 -0
- msgflux/data/parsers/types.py +110 -0
- msgflux/data/retrievers/__init__.py +16 -0
- msgflux/data/retrievers/base.py +185 -0
- msgflux/data/retrievers/providers/__init__.py +0 -0
- msgflux/data/retrievers/providers/bm25.py +230 -0
- msgflux/data/retrievers/providers/bm25s.py +221 -0
- msgflux/data/retrievers/providers/rank_bm25.py +148 -0
- msgflux/data/retrievers/providers/rapidfuzz.py +125 -0
- msgflux/data/retrievers/providers/wikipedia.py +281 -0
- msgflux/data/retrievers/registry.py +19 -0
- msgflux/data/retrievers/retriever.py +83 -0
- msgflux/data/retrievers/types.py +24 -0
- msgflux/data/types/__init__.py +29 -0
- msgflux/data/types/audio.py +112 -0
- msgflux/data/types/base.py +70 -0
- msgflux/data/types/file.py +114 -0
- msgflux/data/types/image.py +134 -0
- msgflux/data/types/video.py +121 -0
- msgflux/dsl/__init__.py +0 -0
- msgflux/dsl/inline.py +484 -0
- msgflux/dsl/signature.py +393 -0
- msgflux/dsl/typed_parsers/__init__.py +19 -0
- msgflux/dsl/typed_parsers/base.py +24 -0
- msgflux/dsl/typed_parsers/providers/__init__.py +0 -0
- msgflux/dsl/typed_parsers/providers/xml.py +259 -0
- msgflux/dsl/typed_parsers/registry.py +14 -0
- msgflux/envs.py +100 -0
- msgflux/exceptions.py +84 -0
- msgflux/generation/__init__.py +0 -0
- msgflux/generation/control_flow.py +163 -0
- msgflux/generation/reasoning/__init__.py +5 -0
- msgflux/generation/reasoning/cot.py +7 -0
- msgflux/generation/reasoning/react.py +289 -0
- msgflux/generation/reasoning/self_consistency.py +16 -0
- msgflux/generation/templates.py +53 -0
- msgflux/logger.py +159 -0
- msgflux/models/__init__.py +16 -0
- msgflux/models/base.py +39 -0
- msgflux/models/cache.py +125 -0
- msgflux/models/gateway.py +466 -0
- msgflux/models/httpx.py +81 -0
- msgflux/models/model.py +122 -0
- msgflux/models/profiles/__init__.py +48 -0
- msgflux/models/profiles/base.py +122 -0
- msgflux/models/profiles/loader.py +264 -0
- msgflux/models/profiles/registry.py +207 -0
- msgflux/models/providers/__init__.py +0 -0
- msgflux/models/providers/cerebras.py +30 -0
- msgflux/models/providers/groq.py +52 -0
- msgflux/models/providers/imagerouter.py +33 -0
- msgflux/models/providers/jinaai.py +331 -0
- msgflux/models/providers/ollama.py +51 -0
- msgflux/models/providers/openai.py +1884 -0
- msgflux/models/providers/openrouter.py +64 -0
- msgflux/models/providers/replicate.py +218 -0
- msgflux/models/providers/sambanova.py +38 -0
- msgflux/models/providers/together.py +57 -0
- msgflux/models/providers/vllm.py +131 -0
- msgflux/models/registry.py +19 -0
- msgflux/models/response.py +26 -0
- msgflux/models/tool_call_agg.py +85 -0
- msgflux/models/types.py +196 -0
- msgflux/nn/__init__.py +57 -0
- msgflux/nn/functional.py +645 -0
- msgflux/nn/hooks/__init__.py +24 -0
- msgflux/nn/hooks/guard.py +109 -0
- msgflux/nn/hooks/hook.py +123 -0
- msgflux/nn/modules/__init__.py +68 -0
- msgflux/nn/modules/agent.py +1927 -0
- msgflux/nn/modules/container.py +672 -0
- msgflux/nn/modules/embedder.py +261 -0
- msgflux/nn/modules/generator.py +21 -0
- msgflux/nn/modules/mediamaker.py +285 -0
- msgflux/nn/modules/module.py +2106 -0
- msgflux/nn/modules/predictor.py +238 -0
- msgflux/nn/modules/searcher.py +383 -0
- msgflux/nn/modules/speaker.py +222 -0
- msgflux/nn/modules/tool.py +839 -0
- msgflux/nn/modules/transcriber.py +273 -0
- msgflux/nn/parameter.py +77 -0
- msgflux/protocols/__init__.py +1 -0
- msgflux/protocols/mcp/__init__.py +61 -0
- msgflux/protocols/mcp/auth/__init__.py +17 -0
- msgflux/protocols/mcp/auth/apikey.py +70 -0
- msgflux/protocols/mcp/auth/base.py +83 -0
- msgflux/protocols/mcp/auth/basic.py +57 -0
- msgflux/protocols/mcp/auth/bearer.py +97 -0
- msgflux/protocols/mcp/auth/custom.py +76 -0
- msgflux/protocols/mcp/auth/oauth2.py +133 -0
- msgflux/protocols/mcp/client.py +454 -0
- msgflux/protocols/mcp/exceptions.py +25 -0
- msgflux/protocols/mcp/integration.py +95 -0
- msgflux/protocols/mcp/loglevels.py +16 -0
- msgflux/protocols/mcp/transports.py +435 -0
- msgflux/protocols/mcp/types.py +51 -0
- msgflux/py.typed +0 -0
- msgflux/telemetry/__init__.py +22 -0
- msgflux/telemetry/config.py +104 -0
- msgflux/telemetry/span.py +245 -0
- msgflux/tools/__init__.py +10 -0
- msgflux/tools/builtin/__init__.py +5 -0
- msgflux/tools/builtin/web_fetch.py +121 -0
- msgflux/tools/config.py +196 -0
- msgflux/tools/definitions.py +11 -0
- msgflux/utils/__init__.py +0 -0
- msgflux/utils/chat.py +682 -0
- msgflux/utils/common.py +36 -0
- msgflux/utils/console.py +55 -0
- msgflux/utils/convert.py +20 -0
- msgflux/utils/encode.py +192 -0
- msgflux/utils/html.py +171 -0
- msgflux/utils/imports.py +115 -0
- msgflux/utils/inspect.py +81 -0
- msgflux/utils/logging.py +15 -0
- msgflux/utils/mermaid.py +20 -0
- msgflux/utils/msgspec.py +1116 -0
- msgflux/utils/pooling.py +51 -0
- msgflux/utils/templates.py +51 -0
- msgflux/utils/tenacity.py +38 -0
- msgflux/utils/torch.py +7 -0
- msgflux/utils/validation.py +31 -0
- msgflux/utils/xml.py +7 -0
- msgflux/version.py +1 -0
- msgflux-0.1.0.dist-info/METADATA +170 -0
- msgflux-0.1.0.dist-info/RECORD +165 -0
- msgflux-0.1.0.dist-info/WHEEL +4 -0
- msgflux-0.1.0.dist-info/licenses/LICENSE +21 -0
msgflux/utils/msgspec.py
ADDED
|
@@ -0,0 +1,1116 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from collections.abc import Mapping as ABCMapping
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import (
|
|
8
|
+
Any,
|
|
9
|
+
Dict,
|
|
10
|
+
List,
|
|
11
|
+
Literal,
|
|
12
|
+
Mapping,
|
|
13
|
+
Optional,
|
|
14
|
+
Set,
|
|
15
|
+
Tuple,
|
|
16
|
+
Type,
|
|
17
|
+
Union,
|
|
18
|
+
get_args,
|
|
19
|
+
get_origin,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
import msgspec
|
|
23
|
+
from msgspec import Meta, Struct, defstruct
|
|
24
|
+
from typing_extensions import Annotated
|
|
25
|
+
|
|
26
|
+
from msgflux.core.dotdict import dotdict
|
|
27
|
+
from msgflux.dsl.signature import FieldInfo
|
|
28
|
+
from msgflux.logger import logger
|
|
29
|
+
from msgflux.utils.common import type_mapping
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StructFactory:
|
|
33
|
+
"""Rebuild msgspec.Struct from a JSON-schema or str signature."""
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.reconstructed_classes = OrderedDict()
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_json_schema(cls, json_schema: Dict[str, Any]) -> msgspec.Struct:
|
|
40
|
+
self = cls() # Temp instance
|
|
41
|
+
if "$defs" not in json_schema:
|
|
42
|
+
raise ValueError("json_schema must contain definitions in `$defs`")
|
|
43
|
+
|
|
44
|
+
definitions = json_schema["$defs"]
|
|
45
|
+
|
|
46
|
+
dependency_order = self._get_dependency_order(definitions)
|
|
47
|
+
|
|
48
|
+
for class_name in dependency_order:
|
|
49
|
+
class_def = definitions[class_name]
|
|
50
|
+
self._reconstruct_class(class_name, class_def, definitions)
|
|
51
|
+
|
|
52
|
+
main_struct = self.reconstructed_classes.popitem(last=True)[1]
|
|
53
|
+
return main_struct
|
|
54
|
+
|
|
55
|
+
def _get_dependency_order(self, definitions: Dict[str, Any]) -> List[str]:
|
|
56
|
+
dependencies = {}
|
|
57
|
+
for class_name, class_def in definitions.items():
|
|
58
|
+
deps = set()
|
|
59
|
+
self._find_dependencies(class_def, deps, definitions.keys())
|
|
60
|
+
dependencies[class_name] = deps
|
|
61
|
+
|
|
62
|
+
ordered = []
|
|
63
|
+
remaining = set(definitions.keys())
|
|
64
|
+
|
|
65
|
+
while remaining:
|
|
66
|
+
ready = [
|
|
67
|
+
name for name in remaining if dependencies[name].issubset(set(ordered))
|
|
68
|
+
]
|
|
69
|
+
if not ready: # Circular dependency - uses alphabetical order as fallback
|
|
70
|
+
ready = [min(remaining)]
|
|
71
|
+
for name in ready:
|
|
72
|
+
ordered.append(name)
|
|
73
|
+
remaining.remove(name)
|
|
74
|
+
|
|
75
|
+
return ordered
|
|
76
|
+
|
|
77
|
+
def _find_dependencies(
|
|
78
|
+
self, definition: Dict[str, Any], deps: Set[str], available_classes: Set[str]
|
|
79
|
+
):
|
|
80
|
+
if isinstance(definition, dict):
|
|
81
|
+
if "$ref" in definition:
|
|
82
|
+
ref_name = definition["$ref"].split("/")[-1]
|
|
83
|
+
if ref_name in available_classes:
|
|
84
|
+
deps.add(ref_name)
|
|
85
|
+
|
|
86
|
+
if "properties" in definition:
|
|
87
|
+
for prop_def in definition["properties"].values():
|
|
88
|
+
self._find_dependencies(prop_def, deps, available_classes)
|
|
89
|
+
|
|
90
|
+
if "anyOf" in definition:
|
|
91
|
+
for item in definition["anyOf"]:
|
|
92
|
+
self._find_dependencies(item, deps, available_classes)
|
|
93
|
+
|
|
94
|
+
if "items" in definition:
|
|
95
|
+
self._find_dependencies(definition["items"], deps, available_classes)
|
|
96
|
+
|
|
97
|
+
def _reconstruct_class(
|
|
98
|
+
self,
|
|
99
|
+
class_name: str,
|
|
100
|
+
class_def: Dict[str, Any],
|
|
101
|
+
all_definitions: Dict[str, Any],
|
|
102
|
+
):
|
|
103
|
+
if class_name in self.reconstructed_classes:
|
|
104
|
+
return self.reconstructed_classes[class_name]
|
|
105
|
+
|
|
106
|
+
properties = class_def.get("properties", {})
|
|
107
|
+
required_fields = set(class_def.get("required", []))
|
|
108
|
+
|
|
109
|
+
fields = []
|
|
110
|
+
|
|
111
|
+
for field_name, field_def in properties.items():
|
|
112
|
+
field_type = self._resolve_field_type(field_def, all_definitions)
|
|
113
|
+
|
|
114
|
+
if field_name in required_fields:
|
|
115
|
+
fields.append((field_name, field_type))
|
|
116
|
+
else:
|
|
117
|
+
fields.append((field_name, field_type, None))
|
|
118
|
+
|
|
119
|
+
reconstructed_class = defstruct(
|
|
120
|
+
class_name,
|
|
121
|
+
fields,
|
|
122
|
+
kw_only=True,
|
|
123
|
+
module=__name__,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
self.reconstructed_classes[class_name] = reconstructed_class
|
|
127
|
+
return reconstructed_class
|
|
128
|
+
|
|
129
|
+
def _resolve_field_type(
|
|
130
|
+
self, field_def: Dict[str, Any], all_definitions: Dict[str, Any]
|
|
131
|
+
):
|
|
132
|
+
if "anyOf" in field_def:
|
|
133
|
+
types = []
|
|
134
|
+
description = None
|
|
135
|
+
|
|
136
|
+
for option in field_def["anyOf"]:
|
|
137
|
+
if option.get("type") == "null":
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if "description" in option:
|
|
141
|
+
description = option["description"]
|
|
142
|
+
|
|
143
|
+
option_type = self._resolve_single_type(option, all_definitions)
|
|
144
|
+
if option_type and option_type is not type(None):
|
|
145
|
+
types.append(option_type)
|
|
146
|
+
|
|
147
|
+
if len(types) == 1:
|
|
148
|
+
base_type = types[0]
|
|
149
|
+
if description:
|
|
150
|
+
annotated_type = Annotated[base_type, Meta(description=description)]
|
|
151
|
+
return Optional[annotated_type]
|
|
152
|
+
else:
|
|
153
|
+
return Optional[base_type]
|
|
154
|
+
elif len(types) > 1:
|
|
155
|
+
if description:
|
|
156
|
+
union_type = Union[tuple(types)]
|
|
157
|
+
annotated_type = Annotated[
|
|
158
|
+
union_type, Meta(description=description)
|
|
159
|
+
]
|
|
160
|
+
return Optional[annotated_type]
|
|
161
|
+
else:
|
|
162
|
+
return Optional[Union[tuple(types)]]
|
|
163
|
+
else:
|
|
164
|
+
return Optional[str]
|
|
165
|
+
|
|
166
|
+
return self._resolve_single_type(field_def, all_definitions)
|
|
167
|
+
|
|
168
|
+
def _resolve_single_type(
|
|
169
|
+
self, type_def: Dict[str, Any], all_definitions: Dict[str, Any]
|
|
170
|
+
):
|
|
171
|
+
if "$ref" in type_def:
|
|
172
|
+
ref_name = type_def["$ref"].split("/")[-1]
|
|
173
|
+
if ref_name in self.reconstructed_classes:
|
|
174
|
+
return self.reconstructed_classes[ref_name]
|
|
175
|
+
else:
|
|
176
|
+
return ref_name
|
|
177
|
+
|
|
178
|
+
if "enum" in type_def:
|
|
179
|
+
enum_values = type_def["enum"]
|
|
180
|
+
description = type_def.get("description")
|
|
181
|
+
|
|
182
|
+
if len(enum_values) > 0:
|
|
183
|
+
if len(enum_values) == 1:
|
|
184
|
+
literal_type = Literal[enum_values[0]]
|
|
185
|
+
else:
|
|
186
|
+
literal_type = Literal[tuple(enum_values)]
|
|
187
|
+
|
|
188
|
+
if description:
|
|
189
|
+
return Annotated[literal_type, Meta(description=description)]
|
|
190
|
+
else:
|
|
191
|
+
return literal_type
|
|
192
|
+
|
|
193
|
+
if "type" in type_def:
|
|
194
|
+
type_name = type_def["type"]
|
|
195
|
+
|
|
196
|
+
if type_name == "array":
|
|
197
|
+
if "items" in type_def:
|
|
198
|
+
item_type = self._resolve_single_type(
|
|
199
|
+
type_def["items"], all_definitions
|
|
200
|
+
)
|
|
201
|
+
return List[item_type]
|
|
202
|
+
else:
|
|
203
|
+
return List[Any]
|
|
204
|
+
|
|
205
|
+
return type_mapping.get(type_name, str)
|
|
206
|
+
|
|
207
|
+
return str
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def from_signature(
|
|
211
|
+
cls,
|
|
212
|
+
signature: str,
|
|
213
|
+
struct_name: Optional[str] = "DynamicStruct",
|
|
214
|
+
field_descriptions: Optional[Dict[str, str]] = None,
|
|
215
|
+
) -> type:
|
|
216
|
+
annotations = cls._parse_annotations(signature)
|
|
217
|
+
struct_fields = []
|
|
218
|
+
|
|
219
|
+
for info in annotations:
|
|
220
|
+
try:
|
|
221
|
+
parsed_type = cls._parse_type_string(info.dtype)
|
|
222
|
+
|
|
223
|
+
if field_descriptions and info.name in field_descriptions:
|
|
224
|
+
annotated_type = Annotated[
|
|
225
|
+
parsed_type, Meta(description=field_descriptions[info.name])
|
|
226
|
+
]
|
|
227
|
+
struct_fields.append((info.name, annotated_type))
|
|
228
|
+
else:
|
|
229
|
+
struct_fields.append((info.name, parsed_type))
|
|
230
|
+
|
|
231
|
+
except ValueError as e:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"Error parsing field `{info.name}` (type='{info.dtype}')"
|
|
234
|
+
) from e
|
|
235
|
+
except Exception as e:
|
|
236
|
+
raise RuntimeError(
|
|
237
|
+
f"Unexpected error parsing field `{info.name}`"
|
|
238
|
+
) from e
|
|
239
|
+
|
|
240
|
+
if not struct_fields and signature.strip():
|
|
241
|
+
raise ValueError("No valid fields parsed from the signature.")
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
DynamicStruct = msgspec.defstruct(struct_name, struct_fields) # noqa: N806
|
|
245
|
+
except Exception as e:
|
|
246
|
+
raise RuntimeError(f"Error creating struct `{struct_name}`") from e
|
|
247
|
+
|
|
248
|
+
return DynamicStruct
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def _parse_literal_args(cls, args_str: str) -> Tuple:
|
|
252
|
+
"""Parse arguments inside Literal[...] robustly, respecting quotes
|
|
253
|
+
and nested structures.
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
# split respecting nested brackets and quotes
|
|
257
|
+
parts = cls._split_args(args_str)
|
|
258
|
+
values = []
|
|
259
|
+
for p in parts:
|
|
260
|
+
try:
|
|
261
|
+
values.append(ast.literal_eval(p))
|
|
262
|
+
except Exception:
|
|
263
|
+
# fallback: an unquoted identifier - treat as string
|
|
264
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", p):
|
|
265
|
+
values.append(p)
|
|
266
|
+
else:
|
|
267
|
+
raise
|
|
268
|
+
return tuple(values)
|
|
269
|
+
except (SyntaxError, ValueError, TypeError) as e:
|
|
270
|
+
logger.error(str(e))
|
|
271
|
+
raise ValueError(f"Invalid literal arguments: `{args_str}`") from e
|
|
272
|
+
|
|
273
|
+
@classmethod
|
|
274
|
+
def _split_args(cls, args_str: str) -> list[str]:
|
|
275
|
+
"""Split by commas at top-level only. Respects nested brackets and quotes.
|
|
276
|
+
|
|
277
|
+
This is an improved version of your previous _split_args but keeps the same
|
|
278
|
+
method name to remain compatible with existing calls.
|
|
279
|
+
"""
|
|
280
|
+
args = []
|
|
281
|
+
level = 0
|
|
282
|
+
current_arg_start = 0
|
|
283
|
+
in_quotes = None
|
|
284
|
+
|
|
285
|
+
if not args_str.strip():
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
for i, char in enumerate(args_str):
|
|
289
|
+
if char in ("[", "{", "(") and not in_quotes:
|
|
290
|
+
level += 1
|
|
291
|
+
elif char in ("]", "}", ")") and not in_quotes:
|
|
292
|
+
level -= 1
|
|
293
|
+
if level < 0:
|
|
294
|
+
raise ValueError("Unbalanced brackets in type arguments")
|
|
295
|
+
elif char in ("'", '"'):
|
|
296
|
+
if in_quotes == char:
|
|
297
|
+
in_quotes = None
|
|
298
|
+
elif in_quotes is None:
|
|
299
|
+
in_quotes = char
|
|
300
|
+
elif char == "," and level == 0 and not in_quotes:
|
|
301
|
+
args.append(args_str[current_arg_start:i].strip())
|
|
302
|
+
current_arg_start = i + 1
|
|
303
|
+
|
|
304
|
+
args.append(args_str[current_arg_start:].strip())
|
|
305
|
+
return [arg for arg in args if arg]
|
|
306
|
+
|
|
307
|
+
@classmethod
|
|
308
|
+
def _parse_type_string(cls, type_str: str) -> type: # noqa: C901
|
|
309
|
+
"""Recursive parser for nested generics like 'list[dict[str, int]]'.
|
|
310
|
+
|
|
311
|
+
Supported base names (case-insensitive): list, dict, tuple, union, optional,
|
|
312
|
+
literal, str, int, float, bool, any, none.
|
|
313
|
+
"""
|
|
314
|
+
type_str = type_str.strip()
|
|
315
|
+
if not type_str:
|
|
316
|
+
raise ValueError("The type string cannot be empty.")
|
|
317
|
+
|
|
318
|
+
# map textual base names to typing objects
|
|
319
|
+
generic_bases = {
|
|
320
|
+
"list": List,
|
|
321
|
+
"dict": Dict,
|
|
322
|
+
"tuple": Tuple,
|
|
323
|
+
"union": Union,
|
|
324
|
+
"optional": Optional,
|
|
325
|
+
"literal": Literal,
|
|
326
|
+
"any": Any,
|
|
327
|
+
"str": str,
|
|
328
|
+
"int": int,
|
|
329
|
+
"float": float,
|
|
330
|
+
"bool": bool,
|
|
331
|
+
"none": type(None),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
# simple (non-generic) form
|
|
335
|
+
if "[" not in type_str:
|
|
336
|
+
key = type_str.lower()
|
|
337
|
+
if key in generic_bases:
|
|
338
|
+
return generic_bases[key]
|
|
339
|
+
# fallback to global type_mapping if it exists
|
|
340
|
+
if key in globals().get("type_mapping", {}):
|
|
341
|
+
return globals()["type_mapping"][key]
|
|
342
|
+
raise ValueError(f"Unsupported or unknown type: `{type_str}`")
|
|
343
|
+
|
|
344
|
+
# match Base[arg,...]
|
|
345
|
+
m = re.match(r"^\s*([^\[\]]+)\s*\[(.*)\]\s*$", type_str, flags=re.DOTALL)
|
|
346
|
+
if not m:
|
|
347
|
+
raise ValueError(f"Malformed generic type: `{type_str}`")
|
|
348
|
+
base_name, inner = m.groups()
|
|
349
|
+
base_key = base_name.strip().lower()
|
|
350
|
+
|
|
351
|
+
if base_key not in generic_bases:
|
|
352
|
+
# try to resolve via type_mapping
|
|
353
|
+
if base_key in globals().get("type_mapping", {}):
|
|
354
|
+
base = globals()["type_mapping"][base_key]
|
|
355
|
+
else:
|
|
356
|
+
raise ValueError(
|
|
357
|
+
f"Base type not supported: `{base_name}` in `{type_str}`"
|
|
358
|
+
)
|
|
359
|
+
else:
|
|
360
|
+
base = generic_bases[base_key]
|
|
361
|
+
|
|
362
|
+
# Literal[...] special handling
|
|
363
|
+
if base is Literal:
|
|
364
|
+
lit_args = cls._parse_literal_args(inner)
|
|
365
|
+
if len(lit_args) == 1:
|
|
366
|
+
return Literal[lit_args[0]]
|
|
367
|
+
return Literal[lit_args]
|
|
368
|
+
|
|
369
|
+
# Optional[T] -> Union[T, NoneType]
|
|
370
|
+
if base is Optional:
|
|
371
|
+
parts = cls._split_args(inner)
|
|
372
|
+
if len(parts) != 1:
|
|
373
|
+
raise ValueError("Optional[...] requires exactly 1 argument")
|
|
374
|
+
inner_t = cls._parse_type_string(parts[0])
|
|
375
|
+
return Union[inner_t, type(None)]
|
|
376
|
+
|
|
377
|
+
# Union[...] -> Union[T1, T2, ...]
|
|
378
|
+
if base is Union:
|
|
379
|
+
parts = cls._split_args(inner)
|
|
380
|
+
if not parts:
|
|
381
|
+
raise ValueError("Union[...] cannot be empty")
|
|
382
|
+
parsed = tuple(cls._parse_type_string(p) for p in parts)
|
|
383
|
+
if len(parsed) == 1:
|
|
384
|
+
return parsed[0]
|
|
385
|
+
return Union[parsed]
|
|
386
|
+
|
|
387
|
+
# Tuple[...] handling
|
|
388
|
+
if base is Tuple:
|
|
389
|
+
parts = cls._split_args(inner)
|
|
390
|
+
if len(parts) == 1 and parts[0].endswith("..."):
|
|
391
|
+
item_type = cls._parse_type_string(parts[0][:-3].strip())
|
|
392
|
+
return Tuple[item_type, ...]
|
|
393
|
+
if not parts:
|
|
394
|
+
return Tuple[()]
|
|
395
|
+
parsed = tuple(cls._parse_type_string(p) for p in parts)
|
|
396
|
+
return Tuple[parsed]
|
|
397
|
+
|
|
398
|
+
# generic with positional args: List[T], Dict[K, V], etc.
|
|
399
|
+
parts = cls._split_args(inner)
|
|
400
|
+
parsed_parts = tuple(cls._parse_type_string(p) for p in parts) if parts else ()
|
|
401
|
+
|
|
402
|
+
if base is List:
|
|
403
|
+
if len(parsed_parts) != 1:
|
|
404
|
+
raise ValueError("List requires exactly 1 argument")
|
|
405
|
+
return List[parsed_parts[0]]
|
|
406
|
+
if base is Dict:
|
|
407
|
+
if len(parsed_parts) != 2:
|
|
408
|
+
raise ValueError("Dict requires exactly 2 arguments")
|
|
409
|
+
return Dict[parsed_parts[0], parsed_parts[1]]
|
|
410
|
+
|
|
411
|
+
# Attempt to parameterize any other generic base
|
|
412
|
+
try:
|
|
413
|
+
if parsed_parts:
|
|
414
|
+
return (
|
|
415
|
+
base[parsed_parts]
|
|
416
|
+
if len(parsed_parts) > 1
|
|
417
|
+
else base[parsed_parts[0]]
|
|
418
|
+
)
|
|
419
|
+
except Exception as e:
|
|
420
|
+
raise ValueError(
|
|
421
|
+
f"Could not create parameterized type for `{type_str}`: {e}"
|
|
422
|
+
) from e
|
|
423
|
+
|
|
424
|
+
raise ValueError(f"Malformed or unsupported generic: `{type_str}`")
|
|
425
|
+
|
|
426
|
+
@classmethod
|
|
427
|
+
def _parse_annotations(cls, signature: str) -> List[Any]: # noqa: C901
|
|
428
|
+
fields = []
|
|
429
|
+
current_pos = 0
|
|
430
|
+
level = 0
|
|
431
|
+
in_quotes = None
|
|
432
|
+
current_field_start = 0
|
|
433
|
+
signature = signature.strip()
|
|
434
|
+
|
|
435
|
+
if not signature:
|
|
436
|
+
return []
|
|
437
|
+
|
|
438
|
+
while current_pos < len(signature):
|
|
439
|
+
char = signature[current_pos]
|
|
440
|
+
if char in ("[", "{", "(") and not in_quotes:
|
|
441
|
+
level += 1
|
|
442
|
+
elif char in ("]", "}", ")") and not in_quotes:
|
|
443
|
+
level -= 1
|
|
444
|
+
if level < 0:
|
|
445
|
+
raise ValueError(
|
|
446
|
+
"Unbalanced nesting near `{signature[current_pos:]}`"
|
|
447
|
+
)
|
|
448
|
+
elif char in ("'", '"'):
|
|
449
|
+
if in_quotes == char:
|
|
450
|
+
in_quotes = None
|
|
451
|
+
elif in_quotes is None:
|
|
452
|
+
in_quotes = char
|
|
453
|
+
|
|
454
|
+
if char == "," and level == 0 and not in_quotes:
|
|
455
|
+
field_str = signature[current_field_start:current_pos].strip()
|
|
456
|
+
if field_str:
|
|
457
|
+
fields.append(field_str)
|
|
458
|
+
current_field_start = current_pos + 1
|
|
459
|
+
current_pos += 1
|
|
460
|
+
|
|
461
|
+
if level != 0:
|
|
462
|
+
raise ValueError("Unbalanced brackets/parentheses in signature.")
|
|
463
|
+
if in_quotes:
|
|
464
|
+
raise ValueError("Unclosed quotation marks in signature.")
|
|
465
|
+
|
|
466
|
+
last_field_str = signature[current_field_start:].strip()
|
|
467
|
+
if last_field_str:
|
|
468
|
+
fields.append(last_field_str)
|
|
469
|
+
|
|
470
|
+
result: List[Any] = []
|
|
471
|
+
for field_str in fields:
|
|
472
|
+
parts = field_str.split(":", 1)
|
|
473
|
+
if len(parts) == 2:
|
|
474
|
+
key = parts[0].strip()
|
|
475
|
+
value_dtype = parts[1].strip()
|
|
476
|
+
if not key:
|
|
477
|
+
raise ValueError(f"Field name cannot be empty in `{field_str}`")
|
|
478
|
+
if not value_dtype:
|
|
479
|
+
raise ValueError(f"Type cannot be empty after ':' in `{field_str}`")
|
|
480
|
+
else:
|
|
481
|
+
key = field_str.strip()
|
|
482
|
+
value_dtype = "str"
|
|
483
|
+
if not key:
|
|
484
|
+
raise ValueError(f"Field name cannot be empty in `{field_str}`")
|
|
485
|
+
|
|
486
|
+
# FieldInfo is expected to be a simple struct/dataclass with (name, dtype)
|
|
487
|
+
result.append(FieldInfo(name=key, dtype=value_dtype))
|
|
488
|
+
|
|
489
|
+
return result
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def msgspec_dumps(obj: object) -> str:
|
|
493
|
+
return msgspec.json.encode(obj).decode("utf-8")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def export_to_json(
|
|
497
|
+
obj: object, filepath: Union[str, os.PathLike], indent: Optional[int] = 4
|
|
498
|
+
):
|
|
499
|
+
with open(filepath, "wb") as f:
|
|
500
|
+
obj_b = msgspec.json.encode(obj)
|
|
501
|
+
formatted_obj_b = msgspec.json.format(obj_b, indent=indent)
|
|
502
|
+
f.write(formatted_obj_b)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def save(obj: object, f: Union[str, os.PathLike]):
|
|
506
|
+
"""Save a Python object to a file in either JSON format.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
data:
|
|
510
|
+
Saved object.
|
|
511
|
+
filepath:
|
|
512
|
+
A string or os.PathLike object containing a file name.
|
|
513
|
+
|
|
514
|
+
Raises:
|
|
515
|
+
ValueError:
|
|
516
|
+
If the file format is not "json".
|
|
517
|
+
FileNotFoundError:
|
|
518
|
+
If the directory of the provided filepath does not exist.
|
|
519
|
+
|
|
520
|
+
!!! example
|
|
521
|
+
``` python
|
|
522
|
+
data = {"name": "Satoshi", "age": 42}
|
|
523
|
+
save(data, "output.json")
|
|
524
|
+
```
|
|
525
|
+
"""
|
|
526
|
+
directory = os.path.dirname(f)
|
|
527
|
+
if directory and not os.path.exists(directory):
|
|
528
|
+
raise FileNotFoundError(f"The directory `{directory}` does not exist")
|
|
529
|
+
|
|
530
|
+
if f.endswith("json"):
|
|
531
|
+
export_to_json(obj, f)
|
|
532
|
+
else:
|
|
533
|
+
raise ValueError(f"Unsupported format: `{f}`. Use `json`.")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def read_json(filepath: Union[str, os.PathLike]) -> Mapping[str, Any]:
|
|
537
|
+
with open(filepath, "rb") as f:
|
|
538
|
+
return msgspec.json.decode(f.read())
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def load(f: Union[str, os.PathLike]) -> Any:
|
|
542
|
+
"""Load data from a file in either JSON.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
f: A string or os.PathLike object containing a file name.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
The Python object loaded from the file.
|
|
549
|
+
|
|
550
|
+
Raises:
|
|
551
|
+
FileNotFoundError:
|
|
552
|
+
If the file does not exist.
|
|
553
|
+
ValueError:
|
|
554
|
+
If the file extension is not ".json".
|
|
555
|
+
|
|
556
|
+
!!! example
|
|
557
|
+
``` python
|
|
558
|
+
data = load("data.json")
|
|
559
|
+
```
|
|
560
|
+
"""
|
|
561
|
+
if not os.path.exists(f):
|
|
562
|
+
raise FileNotFoundError(f"The file `{f}` does not exist.")
|
|
563
|
+
|
|
564
|
+
if f.endswith(".json"):
|
|
565
|
+
return read_json(f)
|
|
566
|
+
else:
|
|
567
|
+
raise ValueError(f"Unsupported file extension: `{f}`. Use `.json`")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def struct_to_dict(obj: object):
|
|
571
|
+
"""Recursively converts a msgspec.Struct object to a pure Python dictionary."""
|
|
572
|
+
if isinstance(obj, msgspec.Struct):
|
|
573
|
+
# Convert the struct to a dictionary and recursively process each value
|
|
574
|
+
return {k: struct_to_dict(v) for k, v in msgspec.structs.asdict(obj).items()}
|
|
575
|
+
elif isinstance(obj, list):
|
|
576
|
+
# Convert each item in the list recursively
|
|
577
|
+
return [struct_to_dict(item) for item in obj]
|
|
578
|
+
elif isinstance(obj, dict):
|
|
579
|
+
# If it is a dictionary, recursively convert its values
|
|
580
|
+
return dotdict({k: struct_to_dict(v) for k, v in obj.items()})
|
|
581
|
+
else: # Returns the value as is for simple types
|
|
582
|
+
return obj
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def lower_msgspec_struct_for_openai( # noqa: C901
|
|
586
|
+
struct_class: Type[Struct],
|
|
587
|
+
) -> Type[Struct]:
|
|
588
|
+
"""Build an OpenAI-compatible transport struct from a logical msgspec schema.
|
|
589
|
+
|
|
590
|
+
OpenAI Structured Outputs reject open-ended JSON objects such as
|
|
591
|
+
``dict[str, T]``. This helper lowers those types to a closed object shape
|
|
592
|
+
using ``entries: list[{key, value}]`` while keeping the logical schema
|
|
593
|
+
untouched for the rest of msgflux.
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
def _is_struct_type(type_hint: Any) -> bool:
|
|
597
|
+
return isinstance(type_hint, type) and issubclass(type_hint, msgspec.Struct)
|
|
598
|
+
|
|
599
|
+
def _to_camel_case(name: str) -> str:
|
|
600
|
+
sanitized = re.sub(r"[^0-9A-Za-z]+", "_", name).strip("_") or "Generated"
|
|
601
|
+
parts = [part for part in sanitized.split("_") if part]
|
|
602
|
+
camel = "".join(part[:1].upper() + part[1:] for part in parts) or "Generated"
|
|
603
|
+
if camel[0].isdigit():
|
|
604
|
+
camel = f"T{camel}"
|
|
605
|
+
return camel
|
|
606
|
+
|
|
607
|
+
def _wrap_annotated(base_type: Any, metadata: Tuple[Any, ...]) -> Any:
|
|
608
|
+
if not metadata:
|
|
609
|
+
return base_type
|
|
610
|
+
return Annotated.__class_getitem__((base_type, *metadata))
|
|
611
|
+
|
|
612
|
+
def _format_type_hint(type_hint: Any) -> str:
|
|
613
|
+
if isinstance(type_hint, type):
|
|
614
|
+
return type_hint.__name__
|
|
615
|
+
return str(type_hint).replace("typing.", "")
|
|
616
|
+
|
|
617
|
+
class _Compiler:
|
|
618
|
+
def __init__(self):
|
|
619
|
+
self.cache: Dict[Any, Any] = {}
|
|
620
|
+
|
|
621
|
+
def _unsupported(self, path: str, type_hint: Any, reason: str) -> None:
|
|
622
|
+
path_str = path or "<root>"
|
|
623
|
+
msg = (
|
|
624
|
+
"Unsupported OpenAI structured output type "
|
|
625
|
+
f"at `{path_str}`: `{_format_type_hint(type_hint)}`. {reason}"
|
|
626
|
+
)
|
|
627
|
+
raise TypeError(msg)
|
|
628
|
+
|
|
629
|
+
def _validate_dict_key_type(self, key_type: Any, path: str) -> None: # noqa: C901
|
|
630
|
+
origin = get_origin(key_type)
|
|
631
|
+
|
|
632
|
+
if origin is Annotated:
|
|
633
|
+
self._validate_dict_key_type(get_args(key_type)[0], path)
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
if key_type is Any:
|
|
637
|
+
self._unsupported(
|
|
638
|
+
path,
|
|
639
|
+
key_type,
|
|
640
|
+
"`Any` is too broad for OpenAI structured outputs.",
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
if key_type in {str, int, float, bool, type(None)}:
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
if isinstance(key_type, type) and issubclass(key_type, Enum):
|
|
647
|
+
return
|
|
648
|
+
|
|
649
|
+
if origin is Literal:
|
|
650
|
+
if all(
|
|
651
|
+
isinstance(item, (str, int, float, bool, type(None)))
|
|
652
|
+
for item in get_args(key_type)
|
|
653
|
+
):
|
|
654
|
+
return
|
|
655
|
+
self._unsupported(
|
|
656
|
+
path,
|
|
657
|
+
key_type,
|
|
658
|
+
"Dict keys must resolve to hashable scalar values.",
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
if origin is Union:
|
|
662
|
+
non_none_args = [
|
|
663
|
+
arg for arg in get_args(key_type) if arg is not type(None)
|
|
664
|
+
]
|
|
665
|
+
if len(non_none_args) != 1:
|
|
666
|
+
self._unsupported(
|
|
667
|
+
path,
|
|
668
|
+
key_type,
|
|
669
|
+
"Dict keys only support Optional[T] style unions.",
|
|
670
|
+
)
|
|
671
|
+
self._validate_dict_key_type(non_none_args[0], path)
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
if origin in (tuple, Tuple):
|
|
675
|
+
tuple_args = get_args(key_type)
|
|
676
|
+
if len(tuple_args) == 2 and tuple_args[1] is Ellipsis:
|
|
677
|
+
self._validate_dict_key_type(tuple_args[0], f"{path}[]")
|
|
678
|
+
return
|
|
679
|
+
for index, item_type in enumerate(tuple_args):
|
|
680
|
+
self._validate_dict_key_type(item_type, f"{path}[{index}]")
|
|
681
|
+
return
|
|
682
|
+
|
|
683
|
+
self._unsupported(
|
|
684
|
+
path,
|
|
685
|
+
key_type,
|
|
686
|
+
"Dict keys must be hashable scalar, Literal, Enum, or Tuple values.",
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
def lower_struct(
|
|
690
|
+
self, schema_type: Type[Struct], name_hint: str, path_hint: str = ""
|
|
691
|
+
) -> Type[Struct]:
|
|
692
|
+
cache_key = ("struct", schema_type)
|
|
693
|
+
cached = self.cache.get(cache_key)
|
|
694
|
+
if cached is not None:
|
|
695
|
+
return cached
|
|
696
|
+
|
|
697
|
+
fields = []
|
|
698
|
+
changed = False
|
|
699
|
+
|
|
700
|
+
for field in msgspec.structs.fields(schema_type):
|
|
701
|
+
field_path = f"{path_hint}.{field.name}" if path_hint else field.name
|
|
702
|
+
lowered_type = self.lower_type(
|
|
703
|
+
field.type, f"{name_hint}_{field.name}", field_path
|
|
704
|
+
)
|
|
705
|
+
changed = changed or lowered_type is not field.type
|
|
706
|
+
|
|
707
|
+
if field.default_factory is not msgspec.NODEFAULT:
|
|
708
|
+
fields.append(
|
|
709
|
+
(
|
|
710
|
+
field.name,
|
|
711
|
+
lowered_type,
|
|
712
|
+
msgspec.field(default_factory=field.default_factory),
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
elif field.default is not msgspec.NODEFAULT:
|
|
716
|
+
fields.append((field.name, lowered_type, field.default))
|
|
717
|
+
else:
|
|
718
|
+
fields.append((field.name, lowered_type))
|
|
719
|
+
|
|
720
|
+
if not changed:
|
|
721
|
+
self.cache[cache_key] = schema_type
|
|
722
|
+
return schema_type
|
|
723
|
+
|
|
724
|
+
lowered_struct = defstruct(
|
|
725
|
+
_to_camel_case(name_hint),
|
|
726
|
+
fields,
|
|
727
|
+
kw_only=True,
|
|
728
|
+
module=__name__,
|
|
729
|
+
)
|
|
730
|
+
self.cache[cache_key] = lowered_struct
|
|
731
|
+
return lowered_struct
|
|
732
|
+
|
|
733
|
+
def lower_type(self, type_hint: Any, name_hint: str, path: str) -> Any: # noqa: C901
|
|
734
|
+
origin = get_origin(type_hint)
|
|
735
|
+
metadata: Tuple[Any, ...] = ()
|
|
736
|
+
base_type = type_hint
|
|
737
|
+
|
|
738
|
+
if origin is Annotated:
|
|
739
|
+
args = get_args(type_hint)
|
|
740
|
+
base_type = args[0]
|
|
741
|
+
metadata = tuple(args[1:])
|
|
742
|
+
origin = get_origin(base_type)
|
|
743
|
+
|
|
744
|
+
if base_type is Any:
|
|
745
|
+
self._unsupported(
|
|
746
|
+
path,
|
|
747
|
+
base_type,
|
|
748
|
+
"`Any` is too broad for OpenAI structured outputs.",
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
if base_type in (dict, Dict):
|
|
752
|
+
self._unsupported(
|
|
753
|
+
path,
|
|
754
|
+
base_type,
|
|
755
|
+
"Use `Dict[K, V]` with explicit key and value types.",
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
if base_type in (list, List):
|
|
759
|
+
self._unsupported(
|
|
760
|
+
path,
|
|
761
|
+
base_type,
|
|
762
|
+
"Use `List[T]` with an explicit item type.",
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
if base_type in (tuple, Tuple):
|
|
766
|
+
self._unsupported(
|
|
767
|
+
path,
|
|
768
|
+
base_type,
|
|
769
|
+
"Use `Tuple[...]` with explicit item types.",
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
if base_type in (set, Set, frozenset):
|
|
773
|
+
self._unsupported(
|
|
774
|
+
path,
|
|
775
|
+
base_type,
|
|
776
|
+
"Sets are not supported by OpenAI structured outputs.",
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
if _is_struct_type(base_type):
|
|
780
|
+
lowered = self.lower_struct(
|
|
781
|
+
base_type, name_hint or base_type.__name__, path
|
|
782
|
+
)
|
|
783
|
+
return _wrap_annotated(lowered, metadata)
|
|
784
|
+
|
|
785
|
+
if origin in (list, List):
|
|
786
|
+
if len(get_args(base_type)) != 1:
|
|
787
|
+
self._unsupported(
|
|
788
|
+
path,
|
|
789
|
+
base_type,
|
|
790
|
+
"Lists require exactly one item type.",
|
|
791
|
+
)
|
|
792
|
+
(item_type,) = get_args(base_type)
|
|
793
|
+
lowered = List[
|
|
794
|
+
self.lower_type(item_type, f"{name_hint}_item", f"{path}[]")
|
|
795
|
+
]
|
|
796
|
+
return _wrap_annotated(lowered, metadata)
|
|
797
|
+
|
|
798
|
+
if origin in (dict, Dict):
|
|
799
|
+
if len(get_args(base_type)) != 2:
|
|
800
|
+
self._unsupported(
|
|
801
|
+
path,
|
|
802
|
+
base_type,
|
|
803
|
+
"Dicts require explicit key and value types.",
|
|
804
|
+
)
|
|
805
|
+
key_type, value_type = get_args(base_type)
|
|
806
|
+
self._validate_dict_key_type(key_type, f"{path}.<key>")
|
|
807
|
+
lowered_key_type = self.lower_type(
|
|
808
|
+
key_type, f"{name_hint}_key", f"{path}.<key>"
|
|
809
|
+
)
|
|
810
|
+
lowered_value_type = self.lower_type(
|
|
811
|
+
value_type, f"{name_hint}_value", f"{path}.<value>"
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
entry_struct = defstruct(
|
|
815
|
+
f"{_to_camel_case(name_hint)}Entry",
|
|
816
|
+
[
|
|
817
|
+
("key", lowered_key_type),
|
|
818
|
+
("value", lowered_value_type),
|
|
819
|
+
],
|
|
820
|
+
kw_only=True,
|
|
821
|
+
module=__name__,
|
|
822
|
+
)
|
|
823
|
+
lowered = defstruct(
|
|
824
|
+
f"{_to_camel_case(name_hint)}Map",
|
|
825
|
+
[("entries", List[entry_struct])],
|
|
826
|
+
kw_only=True,
|
|
827
|
+
module=__name__,
|
|
828
|
+
)
|
|
829
|
+
return _wrap_annotated(lowered, metadata)
|
|
830
|
+
|
|
831
|
+
if origin is Union:
|
|
832
|
+
non_none_args = [
|
|
833
|
+
arg for arg in get_args(base_type) if arg is not type(None)
|
|
834
|
+
]
|
|
835
|
+
if len(non_none_args) > 1:
|
|
836
|
+
self._unsupported(
|
|
837
|
+
path,
|
|
838
|
+
base_type,
|
|
839
|
+
"Only Optional[T] unions are supported.",
|
|
840
|
+
)
|
|
841
|
+
lowered_args = tuple(
|
|
842
|
+
self.lower_type(arg, f"{name_hint}_option_{index}", path)
|
|
843
|
+
for index, arg in enumerate(get_args(base_type))
|
|
844
|
+
)
|
|
845
|
+
lowered = Union[lowered_args]
|
|
846
|
+
return _wrap_annotated(lowered, metadata)
|
|
847
|
+
|
|
848
|
+
if origin in (tuple, Tuple):
|
|
849
|
+
tuple_args = get_args(base_type)
|
|
850
|
+
if not tuple_args:
|
|
851
|
+
self._unsupported(
|
|
852
|
+
path,
|
|
853
|
+
base_type,
|
|
854
|
+
"Tuples require explicit item types.",
|
|
855
|
+
)
|
|
856
|
+
if len(tuple_args) == 2 and tuple_args[1] is Ellipsis:
|
|
857
|
+
lowered = Tuple[
|
|
858
|
+
self.lower_type(
|
|
859
|
+
tuple_args[0], f"{name_hint}_item", f"{path}[]"
|
|
860
|
+
), # noqa: E501
|
|
861
|
+
...,
|
|
862
|
+
]
|
|
863
|
+
else:
|
|
864
|
+
lowered = Tuple[
|
|
865
|
+
tuple(
|
|
866
|
+
self.lower_type(
|
|
867
|
+
arg,
|
|
868
|
+
f"{name_hint}_item_{index}",
|
|
869
|
+
f"{path}[{index}]",
|
|
870
|
+
)
|
|
871
|
+
for index, arg in enumerate(tuple_args)
|
|
872
|
+
)
|
|
873
|
+
]
|
|
874
|
+
return _wrap_annotated(lowered, metadata)
|
|
875
|
+
|
|
876
|
+
if origin in (set, Set, frozenset):
|
|
877
|
+
self._unsupported(
|
|
878
|
+
path,
|
|
879
|
+
base_type,
|
|
880
|
+
"Sets are not supported by OpenAI structured outputs.",
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
if origin in (Mapping, ABCMapping):
|
|
884
|
+
self._unsupported(
|
|
885
|
+
path,
|
|
886
|
+
base_type,
|
|
887
|
+
"Use `Dict[K, V]` instead of mapping abstractions.",
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
if origin is not None and origin is not Literal:
|
|
891
|
+
self._unsupported(
|
|
892
|
+
path,
|
|
893
|
+
base_type,
|
|
894
|
+
"This generic type is not supported by the OpenAI lowering layer.",
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
return _wrap_annotated(base_type, metadata)
|
|
898
|
+
|
|
899
|
+
if not issubclass(struct_class, msgspec.Struct):
|
|
900
|
+
raise TypeError(
|
|
901
|
+
"`struct_class` must be a `msgspec.Struct` subclass "
|
|
902
|
+
f"given `{type(struct_class)}`"
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
compiler = _Compiler()
|
|
906
|
+
return compiler.lower_struct(struct_class, struct_class.__name__)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def restore_transport_value( # noqa: C901
|
|
910
|
+
value: Any,
|
|
911
|
+
logical_type: Any,
|
|
912
|
+
*,
|
|
913
|
+
dict_factory: Type[dict] = dotdict,
|
|
914
|
+
strict: bool = False,
|
|
915
|
+
) -> Any:
|
|
916
|
+
"""Restore transport-lowered values using the original logical type hint.
|
|
917
|
+
|
|
918
|
+
The helper is intentionally reusable:
|
|
919
|
+
- ``strict=True`` is appropriate when restoring provider structured outputs.
|
|
920
|
+
- ``strict=False`` is appropriate when preparing tool kwargs, where values may
|
|
921
|
+
already be in their logical shape.
|
|
922
|
+
"""
|
|
923
|
+
origin = get_origin(logical_type)
|
|
924
|
+
|
|
925
|
+
if origin is Annotated:
|
|
926
|
+
logical_type = get_args(logical_type)[0]
|
|
927
|
+
origin = get_origin(logical_type)
|
|
928
|
+
|
|
929
|
+
if value is None:
|
|
930
|
+
return None
|
|
931
|
+
|
|
932
|
+
if logical_type in (Any, object):
|
|
933
|
+
return value
|
|
934
|
+
|
|
935
|
+
if origin is Literal:
|
|
936
|
+
return value
|
|
937
|
+
|
|
938
|
+
if logical_type is bool:
|
|
939
|
+
if isinstance(value, bool):
|
|
940
|
+
return value
|
|
941
|
+
if value in (0, 1):
|
|
942
|
+
return bool(value)
|
|
943
|
+
if strict:
|
|
944
|
+
raise TypeError(f"Expected bool given `{type(value)}`")
|
|
945
|
+
return value
|
|
946
|
+
|
|
947
|
+
if logical_type in (str, int, float):
|
|
948
|
+
if isinstance(value, logical_type):
|
|
949
|
+
return value
|
|
950
|
+
try:
|
|
951
|
+
return logical_type(value)
|
|
952
|
+
except (TypeError, ValueError):
|
|
953
|
+
if strict:
|
|
954
|
+
raise
|
|
955
|
+
return value
|
|
956
|
+
|
|
957
|
+
if isinstance(logical_type, type) and issubclass(logical_type, Enum):
|
|
958
|
+
try:
|
|
959
|
+
return logical_type(value)
|
|
960
|
+
except ValueError:
|
|
961
|
+
if strict:
|
|
962
|
+
raise
|
|
963
|
+
return value
|
|
964
|
+
|
|
965
|
+
if isinstance(logical_type, type) and issubclass(logical_type, msgspec.Struct):
|
|
966
|
+
if not isinstance(value, Mapping):
|
|
967
|
+
if strict:
|
|
968
|
+
raise TypeError(
|
|
969
|
+
"Expected a mapping transport value for "
|
|
970
|
+
f"`{logical_type.__name__}` given `{type(value)}`"
|
|
971
|
+
)
|
|
972
|
+
return value
|
|
973
|
+
restored = {}
|
|
974
|
+
for field in msgspec.structs.fields(logical_type):
|
|
975
|
+
if field.name not in value:
|
|
976
|
+
continue
|
|
977
|
+
restored[field.name] = restore_transport_value(
|
|
978
|
+
value[field.name],
|
|
979
|
+
field.type,
|
|
980
|
+
dict_factory=dict_factory,
|
|
981
|
+
strict=strict,
|
|
982
|
+
)
|
|
983
|
+
return dotdict(restored)
|
|
984
|
+
|
|
985
|
+
if origin in (list, List):
|
|
986
|
+
if not isinstance(value, list):
|
|
987
|
+
if strict:
|
|
988
|
+
raise TypeError(
|
|
989
|
+
"Expected a list transport value for "
|
|
990
|
+
f"`{str(logical_type).replace('typing.', '')}` "
|
|
991
|
+
f"given `{type(value)}`"
|
|
992
|
+
)
|
|
993
|
+
return value
|
|
994
|
+
item_type = get_args(logical_type)[0]
|
|
995
|
+
return [
|
|
996
|
+
restore_transport_value(
|
|
997
|
+
item,
|
|
998
|
+
item_type,
|
|
999
|
+
dict_factory=dict_factory,
|
|
1000
|
+
strict=strict,
|
|
1001
|
+
)
|
|
1002
|
+
for item in value
|
|
1003
|
+
]
|
|
1004
|
+
|
|
1005
|
+
if origin in (dict, Dict, Mapping, ABCMapping):
|
|
1006
|
+
args = get_args(logical_type)
|
|
1007
|
+
key_type, value_type = args if len(args) == 2 else (Any, Any)
|
|
1008
|
+
if not isinstance(value, Mapping):
|
|
1009
|
+
if strict:
|
|
1010
|
+
raise TypeError(
|
|
1011
|
+
"Expected a mapping transport value for "
|
|
1012
|
+
f"`{str(logical_type).replace('typing.', '')}` "
|
|
1013
|
+
f"given `{type(value)}`"
|
|
1014
|
+
)
|
|
1015
|
+
return value
|
|
1016
|
+
|
|
1017
|
+
if "entries" in value:
|
|
1018
|
+
items = value["entries"]
|
|
1019
|
+
elif strict:
|
|
1020
|
+
raise ValueError(
|
|
1021
|
+
"Expected transport mapping wrapper with required `entries` field "
|
|
1022
|
+
f"for `{str(logical_type).replace('typing.', '')}`"
|
|
1023
|
+
)
|
|
1024
|
+
else:
|
|
1025
|
+
items = [{"key": key, "value": item} for key, item in value.items()]
|
|
1026
|
+
|
|
1027
|
+
restored = {}
|
|
1028
|
+
for item in items:
|
|
1029
|
+
key = restore_transport_value(
|
|
1030
|
+
item["key"],
|
|
1031
|
+
key_type,
|
|
1032
|
+
dict_factory=dict_factory,
|
|
1033
|
+
strict=strict,
|
|
1034
|
+
)
|
|
1035
|
+
restored[key] = restore_transport_value(
|
|
1036
|
+
item["value"],
|
|
1037
|
+
value_type,
|
|
1038
|
+
dict_factory=dict_factory,
|
|
1039
|
+
strict=strict,
|
|
1040
|
+
)
|
|
1041
|
+
return dict_factory(restored)
|
|
1042
|
+
|
|
1043
|
+
if origin is Union:
|
|
1044
|
+
args = tuple(arg for arg in get_args(logical_type) if arg is not type(None))
|
|
1045
|
+
if len(args) == 1:
|
|
1046
|
+
return restore_transport_value(
|
|
1047
|
+
value,
|
|
1048
|
+
args[0],
|
|
1049
|
+
dict_factory=dict_factory,
|
|
1050
|
+
strict=strict,
|
|
1051
|
+
)
|
|
1052
|
+
if args and strict:
|
|
1053
|
+
raise TypeError(
|
|
1054
|
+
"Unsupported logical type during transport restoration: "
|
|
1055
|
+
f"`{str(logical_type).replace('typing.', '')}`. "
|
|
1056
|
+
"Only Optional[T] unions are supported."
|
|
1057
|
+
)
|
|
1058
|
+
return value
|
|
1059
|
+
|
|
1060
|
+
if origin in (tuple, Tuple):
|
|
1061
|
+
if not isinstance(value, (list, tuple)):
|
|
1062
|
+
if strict:
|
|
1063
|
+
raise TypeError(
|
|
1064
|
+
"Expected a tuple-compatible transport value for "
|
|
1065
|
+
f"`{str(logical_type).replace('typing.', '')}` "
|
|
1066
|
+
f"given `{type(value)}`"
|
|
1067
|
+
)
|
|
1068
|
+
return value
|
|
1069
|
+
tuple_args = get_args(logical_type)
|
|
1070
|
+
if len(tuple_args) == 2 and tuple_args[1] is Ellipsis:
|
|
1071
|
+
item_type = tuple_args[0]
|
|
1072
|
+
return tuple(
|
|
1073
|
+
restore_transport_value(
|
|
1074
|
+
item,
|
|
1075
|
+
item_type,
|
|
1076
|
+
dict_factory=dict_factory,
|
|
1077
|
+
strict=strict,
|
|
1078
|
+
)
|
|
1079
|
+
for item in value
|
|
1080
|
+
)
|
|
1081
|
+
return tuple(
|
|
1082
|
+
restore_transport_value(
|
|
1083
|
+
item,
|
|
1084
|
+
tuple_args[index],
|
|
1085
|
+
dict_factory=dict_factory,
|
|
1086
|
+
strict=strict,
|
|
1087
|
+
)
|
|
1088
|
+
for index, item in enumerate(value)
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
return value
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def restore_openai_structured_output(value: Any, logical_type: Any) -> Any:
|
|
1095
|
+
"""Restore provider-specific transport shapes to the logical output schema."""
|
|
1096
|
+
return restore_transport_value(
|
|
1097
|
+
value,
|
|
1098
|
+
logical_type,
|
|
1099
|
+
dict_factory=dotdict,
|
|
1100
|
+
strict=True,
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def is_optional_field(struct_class: Type[Struct], field_name: str) -> bool:
|
|
1105
|
+
"""Check if field is Optional."""
|
|
1106
|
+
field_type = struct_class.__annotations__.get(field_name)
|
|
1107
|
+
|
|
1108
|
+
if field_type is None:
|
|
1109
|
+
return False
|
|
1110
|
+
|
|
1111
|
+
origin = get_origin(field_type)
|
|
1112
|
+
if origin is Union or origin is Optional:
|
|
1113
|
+
args = get_args(field_type)
|
|
1114
|
+
return type(None) in args
|
|
1115
|
+
|
|
1116
|
+
return False
|