Skip to content

Commit 8952466

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 8952466

21 files changed

Lines changed: 3261 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: 136 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,58 @@ 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) 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+
previous = extracted.setdefault(extracted_name, schema)
251+
if previous != schema:
252+
raise CodegenError(f"Conflicting extracted inline model {extracted_name!r}")
253+
properties[property_name] = {"$ref": f"#/components/schemas/{extracted_name}"}
254+
else:
255+
visit(schema, owner, [*path, property_name])
256+
for key, child in value.items():
257+
if key != "properties":
258+
visit(child, owner, path)
259+
elif isinstance(value, list):
260+
for child in value:
261+
visit(child, owner, path)
262+
263+
for name, schema in list(schemas.items()):
264+
visit(schema, name, [])
265+
schemas.update(extracted)
266+
return rewritten
267+
268+
220269
def _slice_model_spec(spec: Mapping[str, Any], operation_ids: Set[str]) -> Dict[str, Any]:
221270
"""Keep selected operations and the transitive component closure they reference."""
222271
selected_paths: Dict[str, Any] = {}
@@ -410,7 +459,7 @@ def generate_tree(output_root: Path, config: Mapping[str, Any], spec: Mapping[st
410459
report = validate_spec(spec, config)
411460
operations, inline_models = _collect_generated_operations(spec, config)
412461
selected_spec = _slice_model_spec(spec, {operation.operation_id for operation in operations})
413-
model_spec = _with_inline_models(selected_spec, inline_models)
462+
model_spec = _with_inline_models(_extract_colliding_inline_models(selected_spec), inline_models)
414463
output_root.mkdir(parents=True, exist_ok=True)
415464
selected_spec_path = output_root.parent / "selected-spec.json"
416465
monolithic_models_path = output_root.parent / "models.py"
@@ -528,6 +577,64 @@ def visit(node: Any) -> None:
528577
return dict(sorted(aliases.items()))
529578

530579

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

606714

715+
def _validate_specialized_operations(
716+
operations: Sequence[Tuple[str, str, Any, Mapping[str, Any], Mapping[str, Any]]],
717+
endpoint: Mapping[str, Any],
718+
) -> None:
719+
configured = set(endpoint["specialized_operations"])
720+
tagged_operation_ids = {
721+
operation_id
722+
for method, _, operation_id, operation, _ in operations
723+
if method != "options" and _generated_operation_tags(operation, endpoint["generated_tags"])
724+
}
725+
stale = configured - tagged_operation_ids
726+
if stale:
727+
operation_id = sorted(stale)[0]
728+
raise CodegenError(
729+
f"endpoint_generator.specialized_operations references an operation outside generated tags {operation_id!r}"
730+
)
731+
732+
607733
def _validate_selected_operations(
608734
operations: Sequence[Tuple[str, str, Any, Mapping[str, Any], Mapping[str, Any]]],
609735
endpoint: Mapping[str, Any],
@@ -669,10 +795,13 @@ def _collect_generated_operations(
669795
idempotent_writes = set(endpoint["idempotent_writes"])
670796
operations: List[GeneratedOperation] = []
671797
inline_models: Dict[str, Mapping[str, Any]] = {}
672-
for method, path, operation_id, operation, path_item in _iter_operations(spec):
798+
selected_operations = _selected_operations(
799+
list(_iter_operations(spec)),
800+
endpoint["generated_tags"],
801+
endpoint["specialized_operations"],
802+
)
803+
for method, path, operation_id, operation, path_item in selected_operations:
673804
operation_generated_tags = _generated_operation_tags(operation, endpoint["generated_tags"])
674-
if method == "options" or not operation_generated_tags:
675-
continue
676805
parameters = _operation_parameters(path_item, operation, spec)
677806
request_body_type, request_body_required = _operation_request_body(operation, spec)
678807
response_type, statuses, json_statuses, inline_schema = _operation_response(operation_id, operation, spec)
@@ -992,7 +1121,7 @@ def _endpoint_config(config: Mapping[str, Any]) -> Mapping[str, Any]:
9921121
or len(generated_tags) != len(set(generated_tags))
9931122
):
9941123
raise CodegenError("endpoint_generator.generated_tags must be a unique list of non-empty strings")
995-
for key in ("safe_reads", "idempotent_writes"):
1124+
for key in ("safe_reads", "idempotent_writes", "specialized_operations"):
9961125
values = endpoint.get(key)
9971126
if (
9981127
not isinstance(values, list)

0 commit comments

Comments
 (0)