Skip to content

Commit 9e49307

Browse files
committed
feat(api): generate and migrate Function REST resources
### AI Summary Implements the Function metadata slice of #683 by publishing the six CRUD operations tagged Functions and moving `load_parameters()` retrieval onto the public REST resources. Function request and response models are also exported through the public API. `postFunctionIdInvoke` remains on the specialized invocation path because it requires proxy routing, streaming, invocation-specific request shaping, and sync/async behavior outside the generic REST resource contract. ### Migration flow ```text Before load_parameters(id=...) ------> raw GET /v1/function/{id} load_parameters(slug=...) ----> raw GET /v1/function After load_parameters(id=...) ------> generated GET /v1/function/{id} load_parameters(slug=...) ----> generated GET /v1/function ``` | SDK workflow | Previous wire call | Public REST call | | --- | --- | --- | | Load by ID | Raw `GET /v1/function/{id}` | Generated `GET /v1/function/{id}` | | Load by project and slug | Raw `GET /v1/function` | Generated `GET /v1/function` | | Function metadata CRUD | No generated resource | `client.openapi.functions` | | Behavior | Result | | --- | --- | | Explicit credentials | Use isolated, cached generated clients without mutating global login | | Active global login | Reuse the existing generated API client and transport | | Version and environment | Explicit version continues to take precedence over environment | | Cache fallback | Preserve local parameter cache fallback for retryable server failures | | Retry behavior | GET operations are safe reads; writes are not retried | | Invocation | Continue using the specialized proxy and streaming implementation | | Type names | Keep the REST model as `Function` and contextually name nested tool-call models | | Wire keys | Preserve `__schema` without Python name mangling | Refs #683
1 parent 53b1a18 commit 9e49307

21 files changed

Lines changed: 3301 additions & 645 deletions

‎openapi/README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@ make check-api-client-codegen
1616
```
1717

1818
The check regenerates in a temporary directory and reports drift without changing the worktree.
19-
Currently selected tags are Projects, Experiments, Datasets, and Prompts. Each tag produces one resource and
20-
operation registry. Models used by one resource stay in that resource's model module; shared models
19+
Currently selected tags are Projects, Experiments, Datasets, Prompts, and Functions. Each tag produces one resource
20+
and operation registry. Models used by one resource stay in that resource's model module; shared models
2121
live in `models/common.py`; unreachable models are omitted.
2222

2323
Method and inline-response names come directly from normalized OpenAPI `operationId` values. Generated
2424
models preserve exact wire keys, including leading underscores, and methods do not add implicit request
2525
defaults. GET and HEAD operations use the safe-read retry policy.
2626
Logical POST reads and verified idempotent writes must be listed explicitly in `safe_reads` and
27-
`idempotent_writes`; all other writes are non-retrying.
27+
`idempotent_writes`; all other writes are non-retrying. Operations listed in `specialized_operations`
28+
remain on their handwritten SDK paths and are excluded from the generic generated resource. Anonymous
29+
nested objects that collide with component names receive contextual names, keeping component names stable.
2830

2931
## Refresh the snapshot
3032

‎openapi/config.json‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333
"Projects",
3434
"Experiments",
3535
"Datasets",
36-
"Prompts"
36+
"Prompts",
37+
"Functions"
38+
],
39+
"specialized_operations": [
40+
"postFunctionIdInvoke"
3741
],
3842
"safe_reads": [
3943
"postExperimentIdFetch",

‎py/scripts/openapi_codegen.py‎

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,12 @@ def validate_spec(spec: Mapping[str, Any], config: Mapping[str, Any]) -> Validat
152152
endpoint = _endpoint_config(config)
153153
all_operations = list(_iter_operations(spec))
154154
_validate_unique_operation_ids(all_operations)
155-
operations = _selected_operations(all_operations, endpoint["generated_tags"])
155+
_validate_specialized_operations(all_operations, endpoint)
156+
operations = _selected_operations(
157+
all_operations,
158+
endpoint["generated_tags"],
159+
endpoint["specialized_operations"],
160+
)
156161
reference_roots = []
157162
for _, _, _, operation, path_item in operations:
158163
reference_roots.append(operation)
@@ -209,14 +214,64 @@ def _generated_operation_tags(operation: Mapping[str, Any], generated_tags: Sequ
209214
def _selected_operations(
210215
operations: Sequence[Tuple[str, str, Any, Mapping[str, Any], Mapping[str, Any]]],
211216
generated_tags: Sequence[str],
217+
specialized_operations: Sequence[str],
212218
) -> List[Tuple[str, str, Any, Mapping[str, Any], Mapping[str, Any]]]:
219+
specialized = set(specialized_operations)
213220
return [
214221
operation_entry
215222
for operation_entry in operations
216-
if operation_entry[0] != "options" and _generated_operation_tags(operation_entry[3], generated_tags)
223+
if operation_entry[0] != "options"
224+
and operation_entry[2] not in specialized
225+
and _generated_operation_tags(operation_entry[3], generated_tags)
217226
]
218227

219228

229+
def _extract_colliding_inline_models(spec: Mapping[str, Any]) -> Dict[str, Any]:
230+
"""Give anonymous object models contextual names when they collide with components."""
231+
232+
rewritten = copy.deepcopy(spec)
233+
schemas = rewritten.get("components", {}).get("schemas", {})
234+
component_names = {_python_type_name(name): name for name in schemas}
235+
extracted: Dict[str, Mapping[str, Any]] = {}
236+
237+
def visit(value: Any, owner: str, path: List[str]) -> None:
238+
if isinstance(value, dict):
239+
properties = value.get("properties")
240+
if isinstance(properties, dict):
241+
for property_name, schema in list(properties.items()):
242+
if not isinstance(property_name, str) or not isinstance(schema, dict):
243+
continue
244+
if (
245+
"$ref" not in schema
246+
and (schema.get("type") == "object" or isinstance(schema.get("properties"), dict))
247+
and _python_type_name(property_name) in component_names
248+
):
249+
extracted_name = _python_type_name("_".join([owner, *path, property_name]))
250+
colliding_component = component_names.get(extracted_name)
251+
if colliding_component is not None:
252+
raise CodegenError(
253+
f"Contextual inline model {extracted_name!r} collides with component schema "
254+
f"{colliding_component!r}"
255+
)
256+
previous = extracted.setdefault(extracted_name, schema)
257+
if previous != schema:
258+
raise CodegenError(f"Conflicting extracted inline model {extracted_name!r}")
259+
properties[property_name] = {"$ref": f"#/components/schemas/{extracted_name}"}
260+
else:
261+
visit(schema, owner, [*path, property_name])
262+
for key, child in value.items():
263+
if key != "properties":
264+
visit(child, owner, path)
265+
elif isinstance(value, list):
266+
for child in value:
267+
visit(child, owner, path)
268+
269+
for name, schema in list(schemas.items()):
270+
visit(schema, name, [])
271+
schemas.update(extracted)
272+
return rewritten
273+
274+
220275
def _slice_model_spec(spec: Mapping[str, Any], operation_ids: Set[str]) -> Dict[str, Any]:
221276
"""Keep selected operations and the transitive component closure they reference."""
222277
selected_paths: Dict[str, Any] = {}
@@ -410,7 +465,7 @@ def generate_tree(output_root: Path, config: Mapping[str, Any], spec: Mapping[st
410465
report = validate_spec(spec, config)
411466
operations, inline_models = _collect_generated_operations(spec, config)
412467
selected_spec = _slice_model_spec(spec, {operation.operation_id for operation in operations})
413-
model_spec = _with_inline_models(selected_spec, inline_models)
468+
model_spec = _with_inline_models(_extract_colliding_inline_models(selected_spec), inline_models)
414469
output_root.mkdir(parents=True, exist_ok=True)
415470
selected_spec_path = output_root.parent / "selected-spec.json"
416471
monolithic_models_path = output_root.parent / "models.py"
@@ -528,6 +583,64 @@ def visit(node: Any) -> None:
528583
return dict(sorted(aliases.items()))
529584

530585

586+
def _rewrite_dunder_typeddicts(path: Path) -> None:
587+
"""Use functional TypedDict syntax when class syntax would mangle a wire key."""
588+
589+
source = path.read_text(encoding="utf-8")
590+
tree = ast.parse(source)
591+
lines = source.splitlines(keepends=True)
592+
offsets = [0]
593+
for line in lines:
594+
offsets.append(offsets[-1] + len(line))
595+
596+
replacements: List[Tuple[int, int, str]] = []
597+
for node in tree.body:
598+
if not isinstance(node, ast.ClassDef):
599+
continue
600+
fields = [
601+
statement
602+
for statement in node.body
603+
if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name)
604+
]
605+
if not any(field.target.id.startswith("__") and not field.target.id.endswith("__") for field in fields):
606+
continue
607+
if len(node.bases) != 1 or not isinstance(node.bases[0], ast.Name) or node.bases[0].id != "TypedDict":
608+
raise CodegenError(f"Generated TypedDict {node.name!r} with a dunder field has unsupported bases")
609+
unsupported = [
610+
statement
611+
for statement in node.body
612+
if not isinstance(statement, ast.AnnAssign)
613+
and not (isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant))
614+
]
615+
if unsupported or node.keywords:
616+
raise CodegenError(f"Generated TypedDict {node.name!r} with a dunder field has unsupported contents")
617+
618+
field_lines = []
619+
for field in fields:
620+
annotation = ast.get_source_segment(source, field.annotation)
621+
if annotation is None:
622+
raise CodegenError(f"Could not recover annotation for {node.name}.{field.target.id}")
623+
field_lines.append(f" {field.target.id!r}: {annotation},")
624+
replacement = "\n".join(
625+
[
626+
f"{node.name} = TypedDict(",
627+
f" {node.name!r},",
628+
" {",
629+
*field_lines,
630+
" },",
631+
")",
632+
]
633+
)
634+
start = offsets[node.lineno - 1] + node.col_offset
635+
end = offsets[node.end_lineno - 1] + node.end_col_offset
636+
replacements.append((start, end, replacement))
637+
638+
for start, end, replacement in reversed(replacements):
639+
source = source[:start] + replacement + source[end:]
640+
if replacements:
641+
path.write_text(source, encoding="utf-8")
642+
643+
531644
def _generate_models(spec_path: Path, output_path: Path, config: Mapping[str, Any]) -> None:
532645
output_path.parent.mkdir(parents=True, exist_ok=True)
533646
header = _generated_header(config, "CONTENT_HASH_PLACEHOLDER").rstrip()
@@ -552,6 +665,7 @@ def _generate_models(spec_path: Path, output_path: Path, config: Mapping[str, An
552665
raise CodegenError(f"datamodel-code-generator failed: {detail}") from exc
553666
if not output_path.is_file():
554667
raise CodegenError("datamodel-code-generator did not emit the model module")
668+
_rewrite_dunder_typeddicts(output_path)
555669
model_paths = [output_path]
556670
# datamodel-code-generator's own formatter pass is not a fixed point; one pinned Ruff pass over
557671
# the complete module tree makes the committed output stable and finalizes each content hash.
@@ -604,6 +718,24 @@ def _model_package_source(model_modules: Mapping[str, str]) -> str:
604718
return "\n".join(lines)
605719

606720

721+
def _validate_specialized_operations(
722+
operations: Sequence[Tuple[str, str, Any, Mapping[str, Any], Mapping[str, Any]]],
723+
endpoint: Mapping[str, Any],
724+
) -> None:
725+
configured = set(endpoint["specialized_operations"])
726+
tagged_operation_ids = {
727+
operation_id
728+
for method, _, operation_id, operation, _ in operations
729+
if method != "options" and _generated_operation_tags(operation, endpoint["generated_tags"])
730+
}
731+
stale = configured - tagged_operation_ids
732+
if stale:
733+
operation_id = sorted(stale)[0]
734+
raise CodegenError(
735+
f"endpoint_generator.specialized_operations references an operation outside generated tags {operation_id!r}"
736+
)
737+
738+
607739
def _validate_selected_operations(
608740
operations: Sequence[Tuple[str, str, Any, Mapping[str, Any], Mapping[str, Any]]],
609741
endpoint: Mapping[str, Any],
@@ -669,10 +801,13 @@ def _collect_generated_operations(
669801
idempotent_writes = set(endpoint["idempotent_writes"])
670802
operations: List[GeneratedOperation] = []
671803
inline_models: Dict[str, Mapping[str, Any]] = {}
672-
for method, path, operation_id, operation, path_item in _iter_operations(spec):
804+
selected_operations = _selected_operations(
805+
list(_iter_operations(spec)),
806+
endpoint["generated_tags"],
807+
endpoint["specialized_operations"],
808+
)
809+
for method, path, operation_id, operation, path_item in selected_operations:
673810
operation_generated_tags = _generated_operation_tags(operation, endpoint["generated_tags"])
674-
if method == "options" or not operation_generated_tags:
675-
continue
676811
parameters = _operation_parameters(path_item, operation, spec)
677812
request_body_type, request_body_required = _operation_request_body(operation, spec)
678813
response_type, statuses, json_statuses, inline_schema = _operation_response(operation_id, operation, spec)
@@ -992,7 +1127,7 @@ def _endpoint_config(config: Mapping[str, Any]) -> Mapping[str, Any]:
9921127
or len(generated_tags) != len(set(generated_tags))
9931128
):
9941129
raise CodegenError("endpoint_generator.generated_tags must be a unique list of non-empty strings")
995-
for key in ("safe_reads", "idempotent_writes"):
1130+
for key in ("safe_reads", "idempotent_writes", "specialized_operations"):
9961131
values = endpoint.get(key)
9971132
if (
9981133
not isinstance(values, list)

0 commit comments

Comments
 (0)