From 9e49307c45626293db2ea18629c1669716f50b11 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 10 Sep 2026 10:37:33 -0400 Subject: [PATCH] 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 --- openapi/README.md | 8 +- openapi/config.json | 6 +- py/scripts/openapi_codegen.py | 149 +++- py/src/braintrust/api/_generated/functions.py | 324 ++++++++ .../api/_generated/models/__init__.py | 277 +++++-- .../api/_generated/models/common.py | 411 +++++++++- .../api/_generated/models/functions.py | 753 ++++++++++++++++++ .../api/_generated/models/prompts.py | 414 +--------- py/src/braintrust/api/_generated/prompts.py | 19 +- ...unctions_end_to_end_with_real_backend.yaml | 546 +++++++++++++ ...d_parameters_uses_generated_resources.yaml | 505 ++++++++++++ py/src/braintrust/api/client.py | 2 + py/src/braintrust/api/test_functions.py | 149 ++++ .../braintrust/api/test_generated_models.py | 8 +- py/src/braintrust/api/types/__init__.py | 8 + py/src/braintrust/logger.py | 64 +- py/src/braintrust/test_logger.py | 156 ++-- .../braintrust/type_tests/test_api_client.py | 25 + py/tests/api_codegen/conftest.py | 1 + py/tests/api_codegen/test_generation.py | 111 ++- py/tests/api_codegen/test_validation.py | 10 +- 21 files changed, 3301 insertions(+), 645 deletions(-) create mode 100644 py/src/braintrust/api/_generated/functions.py create mode 100644 py/src/braintrust/api/_generated/models/functions.py create mode 100644 py/src/braintrust/api/cassettes/test_functions_end_to_end_with_real_backend.yaml create mode 100644 py/src/braintrust/api/cassettes/test_high_level_load_parameters_uses_generated_resources.yaml create mode 100644 py/src/braintrust/api/test_functions.py diff --git a/openapi/README.md b/openapi/README.md index 73ba5446..fae49363 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -16,15 +16,17 @@ make check-api-client-codegen ``` The check regenerates in a temporary directory and reports drift without changing the worktree. -Currently selected tags are Projects, Experiments, Datasets, and Prompts. Each tag produces one resource and -operation registry. Models used by one resource stay in that resource's model module; shared models +Currently selected tags are Projects, Experiments, Datasets, Prompts, and Functions. Each tag produces one resource +and operation registry. Models used by one resource stay in that resource's model module; shared models live in `models/common.py`; unreachable models are omitted. Method and inline-response names come directly from normalized OpenAPI `operationId` values. Generated models preserve exact wire keys, including leading underscores, and methods do not add implicit request defaults. GET and HEAD operations use the safe-read retry policy. Logical POST reads and verified idempotent writes must be listed explicitly in `safe_reads` and -`idempotent_writes`; all other writes are non-retrying. +`idempotent_writes`; all other writes are non-retrying. Operations listed in `specialized_operations` +remain on their handwritten SDK paths and are excluded from the generic generated resource. Anonymous +nested objects that collide with component names receive contextual names, keeping component names stable. ## Refresh the snapshot diff --git a/openapi/config.json b/openapi/config.json index 7c11e179..49815ceb 100644 --- a/openapi/config.json +++ b/openapi/config.json @@ -33,7 +33,11 @@ "Projects", "Experiments", "Datasets", - "Prompts" + "Prompts", + "Functions" + ], + "specialized_operations": [ + "postFunctionIdInvoke" ], "safe_reads": [ "postExperimentIdFetch", diff --git a/py/scripts/openapi_codegen.py b/py/scripts/openapi_codegen.py index 2d096230..e3df2c84 100644 --- a/py/scripts/openapi_codegen.py +++ b/py/scripts/openapi_codegen.py @@ -152,7 +152,12 @@ def validate_spec(spec: Mapping[str, Any], config: Mapping[str, Any]) -> Validat endpoint = _endpoint_config(config) all_operations = list(_iter_operations(spec)) _validate_unique_operation_ids(all_operations) - operations = _selected_operations(all_operations, endpoint["generated_tags"]) + _validate_specialized_operations(all_operations, endpoint) + operations = _selected_operations( + all_operations, + endpoint["generated_tags"], + endpoint["specialized_operations"], + ) reference_roots = [] for _, _, _, operation, path_item in operations: reference_roots.append(operation) @@ -209,14 +214,64 @@ def _generated_operation_tags(operation: Mapping[str, Any], generated_tags: Sequ def _selected_operations( operations: Sequence[Tuple[str, str, Any, Mapping[str, Any], Mapping[str, Any]]], generated_tags: Sequence[str], + specialized_operations: Sequence[str], ) -> List[Tuple[str, str, Any, Mapping[str, Any], Mapping[str, Any]]]: + specialized = set(specialized_operations) return [ operation_entry for operation_entry in operations - if operation_entry[0] != "options" and _generated_operation_tags(operation_entry[3], generated_tags) + if operation_entry[0] != "options" + and operation_entry[2] not in specialized + and _generated_operation_tags(operation_entry[3], generated_tags) ] +def _extract_colliding_inline_models(spec: Mapping[str, Any]) -> Dict[str, Any]: + """Give anonymous object models contextual names when they collide with components.""" + + rewritten = copy.deepcopy(spec) + schemas = rewritten.get("components", {}).get("schemas", {}) + component_names = {_python_type_name(name): name for name in schemas} + extracted: Dict[str, Mapping[str, Any]] = {} + + def visit(value: Any, owner: str, path: List[str]) -> None: + if isinstance(value, dict): + properties = value.get("properties") + if isinstance(properties, dict): + for property_name, schema in list(properties.items()): + if not isinstance(property_name, str) or not isinstance(schema, dict): + continue + if ( + "$ref" not in schema + and (schema.get("type") == "object" or isinstance(schema.get("properties"), dict)) + and _python_type_name(property_name) in component_names + ): + extracted_name = _python_type_name("_".join([owner, *path, property_name])) + colliding_component = component_names.get(extracted_name) + if colliding_component is not None: + raise CodegenError( + f"Contextual inline model {extracted_name!r} collides with component schema " + f"{colliding_component!r}" + ) + previous = extracted.setdefault(extracted_name, schema) + if previous != schema: + raise CodegenError(f"Conflicting extracted inline model {extracted_name!r}") + properties[property_name] = {"$ref": f"#/components/schemas/{extracted_name}"} + else: + visit(schema, owner, [*path, property_name]) + for key, child in value.items(): + if key != "properties": + visit(child, owner, path) + elif isinstance(value, list): + for child in value: + visit(child, owner, path) + + for name, schema in list(schemas.items()): + visit(schema, name, []) + schemas.update(extracted) + return rewritten + + def _slice_model_spec(spec: Mapping[str, Any], operation_ids: Set[str]) -> Dict[str, Any]: """Keep selected operations and the transitive component closure they reference.""" selected_paths: Dict[str, Any] = {} @@ -410,7 +465,7 @@ def generate_tree(output_root: Path, config: Mapping[str, Any], spec: Mapping[st report = validate_spec(spec, config) operations, inline_models = _collect_generated_operations(spec, config) selected_spec = _slice_model_spec(spec, {operation.operation_id for operation in operations}) - model_spec = _with_inline_models(selected_spec, inline_models) + model_spec = _with_inline_models(_extract_colliding_inline_models(selected_spec), inline_models) output_root.mkdir(parents=True, exist_ok=True) selected_spec_path = output_root.parent / "selected-spec.json" monolithic_models_path = output_root.parent / "models.py" @@ -528,6 +583,64 @@ def visit(node: Any) -> None: return dict(sorted(aliases.items())) +def _rewrite_dunder_typeddicts(path: Path) -> None: + """Use functional TypedDict syntax when class syntax would mangle a wire key.""" + + source = path.read_text(encoding="utf-8") + tree = ast.parse(source) + lines = source.splitlines(keepends=True) + offsets = [0] + for line in lines: + offsets.append(offsets[-1] + len(line)) + + replacements: List[Tuple[int, int, str]] = [] + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + fields = [ + statement + for statement in node.body + if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name) + ] + if not any(field.target.id.startswith("__") and not field.target.id.endswith("__") for field in fields): + continue + if len(node.bases) != 1 or not isinstance(node.bases[0], ast.Name) or node.bases[0].id != "TypedDict": + raise CodegenError(f"Generated TypedDict {node.name!r} with a dunder field has unsupported bases") + unsupported = [ + statement + for statement in node.body + if not isinstance(statement, ast.AnnAssign) + and not (isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant)) + ] + if unsupported or node.keywords: + raise CodegenError(f"Generated TypedDict {node.name!r} with a dunder field has unsupported contents") + + field_lines = [] + for field in fields: + annotation = ast.get_source_segment(source, field.annotation) + if annotation is None: + raise CodegenError(f"Could not recover annotation for {node.name}.{field.target.id}") + field_lines.append(f" {field.target.id!r}: {annotation},") + replacement = "\n".join( + [ + f"{node.name} = TypedDict(", + f" {node.name!r},", + " {", + *field_lines, + " },", + ")", + ] + ) + start = offsets[node.lineno - 1] + node.col_offset + end = offsets[node.end_lineno - 1] + node.end_col_offset + replacements.append((start, end, replacement)) + + for start, end, replacement in reversed(replacements): + source = source[:start] + replacement + source[end:] + if replacements: + path.write_text(source, encoding="utf-8") + + def _generate_models(spec_path: Path, output_path: Path, config: Mapping[str, Any]) -> None: output_path.parent.mkdir(parents=True, exist_ok=True) 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 raise CodegenError(f"datamodel-code-generator failed: {detail}") from exc if not output_path.is_file(): raise CodegenError("datamodel-code-generator did not emit the model module") + _rewrite_dunder_typeddicts(output_path) model_paths = [output_path] # datamodel-code-generator's own formatter pass is not a fixed point; one pinned Ruff pass over # 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: return "\n".join(lines) +def _validate_specialized_operations( + operations: Sequence[Tuple[str, str, Any, Mapping[str, Any], Mapping[str, Any]]], + endpoint: Mapping[str, Any], +) -> None: + configured = set(endpoint["specialized_operations"]) + tagged_operation_ids = { + operation_id + for method, _, operation_id, operation, _ in operations + if method != "options" and _generated_operation_tags(operation, endpoint["generated_tags"]) + } + stale = configured - tagged_operation_ids + if stale: + operation_id = sorted(stale)[0] + raise CodegenError( + f"endpoint_generator.specialized_operations references an operation outside generated tags {operation_id!r}" + ) + + def _validate_selected_operations( operations: Sequence[Tuple[str, str, Any, Mapping[str, Any], Mapping[str, Any]]], endpoint: Mapping[str, Any], @@ -669,10 +801,13 @@ def _collect_generated_operations( idempotent_writes = set(endpoint["idempotent_writes"]) operations: List[GeneratedOperation] = [] inline_models: Dict[str, Mapping[str, Any]] = {} - for method, path, operation_id, operation, path_item in _iter_operations(spec): + selected_operations = _selected_operations( + list(_iter_operations(spec)), + endpoint["generated_tags"], + endpoint["specialized_operations"], + ) + for method, path, operation_id, operation, path_item in selected_operations: operation_generated_tags = _generated_operation_tags(operation, endpoint["generated_tags"]) - if method == "options" or not operation_generated_tags: - continue parameters = _operation_parameters(path_item, operation, spec) request_body_type, request_body_required = _operation_request_body(operation, spec) 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]: or len(generated_tags) != len(set(generated_tags)) ): raise CodegenError("endpoint_generator.generated_tags must be a unique list of non-empty strings") - for key in ("safe_reads", "idempotent_writes"): + for key in ("safe_reads", "idempotent_writes", "specialized_operations"): values = endpoint.get(key) if ( not isinstance(values, list) diff --git a/py/src/braintrust/api/_generated/functions.py b/py/src/braintrust/api/_generated/functions.py new file mode 100644 index 00000000..3bcc5e15 --- /dev/null +++ b/py/src/braintrust/api/_generated/functions.py @@ -0,0 +1,324 @@ +# Generated by scripts/generate-api-client.py. DO NOT EDIT. +# OpenAPI commit: 8d6e24ce78d8e33cbf9ba5f35d2fbc71749306d7 +# OpenAPI spec SHA-256: ebac33c1422e5673f875a254b3f7a13e4d8b5f5ab9b5095fd6b48e836613d507 +# datamodel-code-generator: 0.72.4 +# ruff: 0.15.21 +# Generator Python: 3.14 +# Content SHA-256: c1a850487c10705bba39e61f347b1599f7138baef68035558e5fb06e7598636c + +"""Generated Functions REST operations and resource.""" + +from typing import cast + +from .._service import Operation, Parameter, ResourceAPI +from ..policies import RetryMode +from .models.common import ( + AppLimitParam, + EndingBefore, + Ids, + OrgName, + ProjectIdQuery, + ProjectName, + PromptEnvironment, + PromptVersion, + Slug, + StartingAfter, +) +from .models.functions import ( + CreateFunction, + Function, + FunctionIdParam, + FunctionName, + GetFunctionResponse, + PatchFunction, +) + + +POST_FUNCTION = Operation( + operation_id="postFunction", + method="POST", + path="/v1/function", + parameters=(), + has_request_body=True, + success_statuses=(200,), + json_success_statuses=(200,), + retry_mode=RetryMode.NONE, +) + + +PUT_FUNCTION = Operation( + operation_id="putFunction", + method="PUT", + path="/v1/function", + parameters=(), + has_request_body=True, + success_statuses=(200,), + json_success_statuses=(200,), + retry_mode=RetryMode.NONE, +) + + +GET_FUNCTION = Operation( + operation_id="getFunction", + method="GET", + path="/v1/function", + parameters=( + Parameter( + argument_name="limit", + name="limit", + location="query", + required=False, + ), + Parameter( + argument_name="starting_after", + name="starting_after", + location="query", + required=False, + ), + Parameter( + argument_name="ending_before", + name="ending_before", + location="query", + required=False, + ), + Parameter( + argument_name="ids", + name="ids", + location="query", + required=False, + ), + Parameter( + argument_name="function_name", + name="function_name", + location="query", + required=False, + ), + Parameter( + argument_name="project_name", + name="project_name", + location="query", + required=False, + ), + Parameter( + argument_name="project_id", + name="project_id", + location="query", + required=False, + ), + Parameter( + argument_name="slug", + name="slug", + location="query", + required=False, + ), + Parameter( + argument_name="version", + name="version", + location="query", + required=False, + ), + Parameter( + argument_name="environment", + name="environment", + location="query", + required=False, + ), + Parameter( + argument_name="org_name", + name="org_name", + location="query", + required=False, + ), + ), + has_request_body=False, + success_statuses=(200,), + json_success_statuses=(200,), + retry_mode=RetryMode.SAFE_READ, +) + + +GET_FUNCTION_ID = Operation( + operation_id="getFunctionId", + method="GET", + path="/v1/function/{function_id}", + parameters=( + Parameter( + argument_name="function_id", + name="function_id", + location="path", + required=True, + ), + Parameter( + argument_name="version", + name="version", + location="query", + required=False, + ), + Parameter( + argument_name="environment", + name="environment", + location="query", + required=False, + ), + ), + has_request_body=False, + success_statuses=(200,), + json_success_statuses=(200,), + retry_mode=RetryMode.SAFE_READ, +) + + +PATCH_FUNCTION_ID = Operation( + operation_id="patchFunctionId", + method="PATCH", + path="/v1/function/{function_id}", + parameters=( + Parameter( + argument_name="function_id", + name="function_id", + location="path", + required=True, + ), + ), + has_request_body=True, + success_statuses=(200,), + json_success_statuses=(200,), + retry_mode=RetryMode.NONE, +) + + +DELETE_FUNCTION_ID = Operation( + operation_id="deleteFunctionId", + method="DELETE", + path="/v1/function/{function_id}", + parameters=( + Parameter( + argument_name="function_id", + name="function_id", + location="path", + required=True, + ), + ), + has_request_body=False, + success_statuses=(200,), + json_success_statuses=(200,), + retry_mode=RetryMode.NONE, +) + + +OPERATIONS = { + "postFunction": POST_FUNCTION, + "putFunction": PUT_FUNCTION, + "getFunction": GET_FUNCTION, + "getFunctionId": GET_FUNCTION_ID, + "patchFunctionId": PATCH_FUNCTION_ID, + "deleteFunctionId": DELETE_FUNCTION_ID, +} + + +class FunctionsAPI(ResourceAPI): + """Generated Functions REST API.""" + + def post_function( + self, + *, + body: "CreateFunction", + ) -> "Function": + return cast( + "Function", + self.execute( + POST_FUNCTION, + body=body, + ), + ) + + def put_function( + self, + *, + body: "CreateFunction", + ) -> "Function": + return cast( + "Function", + self.execute( + PUT_FUNCTION, + body=body, + ), + ) + + def get_function( + self, + *, + limit: "AppLimitParam | None" = None, + starting_after: "StartingAfter | None" = None, + ending_before: "EndingBefore | None" = None, + ids: "Ids | None" = None, + function_name: "FunctionName | None" = None, + project_name: "ProjectName | None" = None, + project_id: "ProjectIdQuery | None" = None, + slug: "Slug | None" = None, + version: "PromptVersion | None" = None, + environment: "PromptEnvironment | None" = None, + org_name: "OrgName | None" = None, + ) -> "GetFunctionResponse": + return cast( + "GetFunctionResponse", + self.execute( + GET_FUNCTION, + query_parameters={ + "limit": limit, + "starting_after": starting_after, + "ending_before": ending_before, + "ids": ids, + "function_name": function_name, + "project_name": project_name, + "project_id": project_id, + "slug": slug, + "version": version, + "environment": environment, + "org_name": org_name, + }, + ), + ) + + def get_function_id( + self, + function_id: "FunctionIdParam", + *, + version: "PromptVersion | None" = None, + environment: "PromptEnvironment | None" = None, + ) -> "Function": + return cast( + "Function", + self.execute( + GET_FUNCTION_ID, + path_parameters={"function_id": function_id}, + query_parameters={"version": version, "environment": environment}, + ), + ) + + def patch_function_id( + self, + function_id: "FunctionIdParam", + *, + body: "PatchFunction | None" = None, + ) -> "Function": + return cast( + "Function", + self.execute( + PATCH_FUNCTION_ID, + path_parameters={"function_id": function_id}, + body=body, + ), + ) + + def delete_function_id( + self, + function_id: "FunctionIdParam", + ) -> "Function": + return cast( + "Function", + self.execute( + DELETE_FUNCTION_ID, + path_parameters={"function_id": function_id}, + ), + ) diff --git a/py/src/braintrust/api/_generated/models/__init__.py b/py/src/braintrust/api/_generated/models/__init__.py index 4978c916..9fdb1765 100644 --- a/py/src/braintrust/api/_generated/models/__init__.py +++ b/py/src/braintrust/api/_generated/models/__init__.py @@ -4,12 +4,30 @@ # datamodel-code-generator: 0.72.4 # ruff: 0.15.21 # Generator Python: 3.14 -# Content SHA-256: 494c7e1ba491afef034f77e2f459fc17c49217b4057b10ab3abd2b5982ff48be +# Content SHA-256: e704e1b6be9125a28feae60cfc2cd941250bdab6c9f3bf7f08246770d4b6bfa1 """Generated private model types with stable package-level imports.""" from .common import ( AppLimitParam, + CacheControl, + ChatCompletionContentPart, + ChatCompletionContentPartFileFile, + ChatCompletionContentPartFileWithTitle, + ChatCompletionContentPartImageWithTitle, + ChatCompletionContentPartText, + ChatCompletionContentPartTextWithTitle, + ChatCompletionMessageParam, + ChatCompletionMessageParam1, + ChatCompletionMessageParam2, + ChatCompletionMessageParam3, + ChatCompletionMessageParam4, + ChatCompletionMessageParam5, + ChatCompletionMessageParam6, + ChatCompletionMessageParam7, + ChatCompletionMessageReasoning, + ChatCompletionMessageToolCall, + ChatCompletionMessageToolCallFunction, Classification, EndingBefore, FeedbackResponseSchema, @@ -18,20 +36,61 @@ FetchLimitParam, FetchPaginationCursor, FieldArrayDeleteItem, + FunctionCall, + FunctionCall1, FunctionTypeEnum, + FunctionTypeEnumNullish, Ids, + ImageUrl, InsertEventsResponse, MaxRootSpanId, MaxXactId, + Mcp, + Mcp1, Metadata, + ModelParams, + ModelParams1, + ModelParams2, + ModelParams3, + ModelParams4, + ModelParams5, + ModelParamsToolChoiceFunction, ObjectReferenceNullish, OrgName, + Origin2, + PreprocessorId, + PreprocessorId1, + PreprocessorId2, + PreprocessorId3, ProjectIdQuery, ProjectName, + PromptBlockDataNullish, + PromptBlockDataNullish1, + PromptBlockDataNullish2, + PromptDataNullish, + PromptEnvironment, + PromptOptionsNullish, + PromptParserNullish, + PromptVersion, + ResponseFormatJsonSchema, + ResponseFormatNullish, + ResponseFormatNullish1, + ResponseFormatNullish2, + ResponseFormatNullish3, SavedFunctionId, SavedFunctionId1, SavedFunctionId2, + Slug, StartingAfter, + ToolChoice, + ToolFunction, + ToolFunction1, + ToolFunction2, + ToolFunction3, + ToolFunction4, + ToolFunction5, + ToolFunction6, + ToolFunction7, Version, ) from .datasets import ( @@ -77,6 +136,80 @@ SummarizeExperimentResponse, SummarizeScores, ) +from .functions import ( + AclObjectType, + BatchedFacetData, + CodeBundle, + CreateFunction, + Data, + Data1, + Data2, + Data3, + Facet, + FacetData, + FacetPreprocessorId, + FacetPreprocessorId1, + FacetPreprocessorId2, + FacetPreprocessorId3, + FieldSchema, + Function, + FunctionData, + FunctionData1, + FunctionData2, + FunctionData3, + FunctionData4, + FunctionData5, + FunctionDataNullish, + FunctionDataNullish1, + FunctionDataNullish2, + FunctionDataNullish3, + FunctionDataNullish4, + FunctionDataNullish5, + FunctionIdParam, + FunctionIdRef, + FunctionName, + FunctionSchema, + GetFunctionResponse, + GraphData, + GraphEdge, + GraphNode, + GraphNode1, + GraphNode2, + GraphNode3, + GraphNode4, + GraphNode5, + GraphNode6, + GraphNode7, + GraphNode8, + Location, + Location1, + Location2, + Origin, + PatchFunction, + Position, + Position1, + Position2, + Position3, + PromptBlockData, + PromptBlockData1, + PromptBlockData2, + RuntimeContext, + SandboxSpec, + SandboxSpec1, + Source, + SourceFacetFunction, + SourceFacetFunction1, + SourceFacetFunction2, + SourceFacetFunction3, + SourceFacetFunction4, + SourceFacetFunction5, + SourceFacetFunction6, + SourceFacetFunction7, + Target, + TopicMap, + TopicMapData, + TopicMapGenerationSettings, +) from .projects import ( CreateProject, GetProjectResponse, @@ -90,78 +223,14 @@ RemoteEvalSource, SpanFieldOrderItem, ) -from .prompts import ( - CacheControl, - ChatCompletionContentPart, - ChatCompletionContentPartFileFile, - ChatCompletionContentPartFileWithTitle, - ChatCompletionContentPartImageWithTitle, - ChatCompletionContentPartText, - ChatCompletionContentPartTextWithTitle, - ChatCompletionMessageParam, - ChatCompletionMessageParam1, - ChatCompletionMessageParam2, - ChatCompletionMessageParam3, - ChatCompletionMessageParam4, - ChatCompletionMessageParam5, - ChatCompletionMessageParam6, - ChatCompletionMessageParam7, - ChatCompletionMessageReasoning, - ChatCompletionMessageToolCall, - CreatePrompt, - Function, - Function1, - FunctionCall, - FunctionCall1, - FunctionTypeEnumNullish, - GetPromptResponse, - ImageUrl, - Mcp, - Mcp1, - ModelParams, - ModelParams1, - ModelParams2, - ModelParams3, - ModelParams4, - ModelParams5, - Origin, - PatchPrompt, - PreprocessorId, - PreprocessorId1, - PreprocessorId2, - PreprocessorId3, - Prompt, - PromptBlockDataNullish, - PromptBlockDataNullish1, - PromptBlockDataNullish2, - PromptDataNullish, - PromptEnvironment, - PromptIdParam, - PromptName, - PromptOptionsNullish, - PromptParserNullish, - PromptVersion, - ResponseFormatJsonSchema, - ResponseFormatNullish, - ResponseFormatNullish1, - ResponseFormatNullish2, - ResponseFormatNullish3, - Slug, - ToolChoice, - ToolFunction, - ToolFunction1, - ToolFunction2, - ToolFunction3, - ToolFunction4, - ToolFunction5, - ToolFunction6, - ToolFunction7, -) +from .prompts import CreatePrompt, GetPromptResponse, PatchPrompt, Prompt, PromptIdParam, PromptName __all__ = [ + "AclObjectType", "AppLimitParam", "AppLimitWithDefaultParam", + "BatchedFacetData", "CacheControl", "ChatCompletionContentPart", "ChatCompletionContentPartFileFile", @@ -179,13 +248,20 @@ "ChatCompletionMessageParam7", "ChatCompletionMessageReasoning", "ChatCompletionMessageToolCall", + "ChatCompletionMessageToolCallFunction", "Classification", + "CodeBundle", "ComparisonExperimentId", "Context", "CreateDataset", "CreateExperiment", + "CreateFunction", "CreateProject", "CreatePrompt", + "Data", + "Data1", + "Data2", + "Data3", "DataSummary", "Dataset", "DatasetEvent", @@ -196,6 +272,12 @@ "ExperimentEvent", "ExperimentIdParam", "ExperimentName", + "Facet", + "FacetData", + "FacetPreprocessorId", + "FacetPreprocessorId1", + "FacetPreprocessorId2", + "FacetPreprocessorId3", "FeedbackDatasetEventRequest", "FeedbackDatasetItem", "FeedbackExperimentEventRequest", @@ -208,16 +290,44 @@ "FetchLimitParam", "FetchPaginationCursor", "FieldArrayDeleteItem", + "FieldSchema", "Function", - "Function1", "FunctionCall", "FunctionCall1", + "FunctionData", + "FunctionData1", + "FunctionData2", + "FunctionData3", + "FunctionData4", + "FunctionData5", + "FunctionDataNullish", + "FunctionDataNullish1", + "FunctionDataNullish2", + "FunctionDataNullish3", + "FunctionDataNullish4", + "FunctionDataNullish5", + "FunctionIdParam", + "FunctionIdRef", + "FunctionName", + "FunctionSchema", "FunctionTypeEnum", "FunctionTypeEnumNullish", "GetDatasetResponse", "GetExperimentResponse", + "GetFunctionResponse", "GetProjectResponse", "GetPromptResponse", + "GraphData", + "GraphEdge", + "GraphNode", + "GraphNode1", + "GraphNode2", + "GraphNode3", + "GraphNode4", + "GraphNode5", + "GraphNode6", + "GraphNode7", + "GraphNode8", "Ids", "ImageUrl", "InsertDatasetEvent", @@ -226,6 +336,9 @@ "InsertExperimentEvent", "InsertExperimentEventRequest", "InternalMetadata", + "Location", + "Location1", + "Location2", "MaxRootSpanId", "MaxXactId", "Mcp", @@ -239,16 +352,23 @@ "ModelParams3", "ModelParams4", "ModelParams5", + "ModelParamsToolChoiceFunction", "NullableSavedFunctionId", "NullableSavedFunctionId1", "NullableSavedFunctionId2", "ObjectReferenceNullish", "OrgName", "Origin", + "Origin2", "PatchDataset", "PatchExperiment", + "PatchFunction", "PatchProject", "PatchPrompt", + "Position", + "Position1", + "Position2", + "Position3", "PreprocessorId", "PreprocessorId1", "PreprocessorId2", @@ -259,6 +379,9 @@ "ProjectName", "ProjectSettings", "Prompt", + "PromptBlockData", + "PromptBlockData1", + "PromptBlockData2", "PromptBlockDataNullish", "PromptBlockDataNullish1", "PromptBlockDataNullish2", @@ -276,11 +399,23 @@ "ResponseFormatNullish1", "ResponseFormatNullish2", "ResponseFormatNullish3", + "RuntimeContext", + "SandboxSpec", + "SandboxSpec1", "SavedFunctionId", "SavedFunctionId1", "SavedFunctionId2", "ScoreSummary", "Slug", + "Source", + "SourceFacetFunction", + "SourceFacetFunction1", + "SourceFacetFunction2", + "SourceFacetFunction3", + "SourceFacetFunction4", + "SourceFacetFunction5", + "SourceFacetFunction6", + "SourceFacetFunction7", "SpanAttributes", "SpanFieldOrderItem", "SpanType", @@ -289,6 +424,7 @@ "SummarizeDatasetResponse", "SummarizeExperimentResponse", "SummarizeScores", + "Target", "ToolChoice", "ToolFunction", "ToolFunction1", @@ -298,5 +434,8 @@ "ToolFunction5", "ToolFunction6", "ToolFunction7", + "TopicMap", + "TopicMapData", + "TopicMapGenerationSettings", "Version", ] diff --git a/py/src/braintrust/api/_generated/models/common.py b/py/src/braintrust/api/_generated/models/common.py index 3035db86..29253385 100644 --- a/py/src/braintrust/api/_generated/models/common.py +++ b/py/src/braintrust/api/_generated/models/common.py @@ -4,7 +4,7 @@ # datamodel-code-generator: 0.72.4 # ruff: 0.15.21 # Generator Python: 3.14 -# Content SHA-256: 4f4034d79da55f9379228307ac3f68923c3c0ef40d89c07614d9f141d6cf9862 +# Content SHA-256: 7296a2e10cecbbc7116e3a28f496a1170697047817c46f0528b3586c02da920c from typing import Any, Literal, TypeAlias, TypedDict from typing_extensions import NotRequired @@ -16,6 +16,90 @@ """ +class ChatCompletionContentPartFileFile(TypedDict): + file_data: NotRequired[str] + file_id: NotRequired[str] + filename: NotRequired[str] + + +class CacheControl(TypedDict): + ttl: NotRequired[Literal["5m", "1h"]] + type: Literal["ephemeral"] + + +class ChatCompletionContentPartFileWithTitle(TypedDict): + cache_control: NotRequired[CacheControl] + file: ChatCompletionContentPartFileFile + type: Literal["file"] + + +class ImageUrl(TypedDict): + detail: NotRequired[Literal["auto"] | Literal["low"] | Literal["high"]] + url: str + + +class ChatCompletionContentPartImageWithTitle(TypedDict): + cache_control: NotRequired[CacheControl] + image_url: ImageUrl + type: Literal["image_url"] + + +class ChatCompletionContentPartText(TypedDict): + cache_control: NotRequired[CacheControl] + text: NotRequired[str] + type: Literal["text"] + + +class ChatCompletionContentPartTextWithTitle(TypedDict): + cache_control: NotRequired[CacheControl] + text: NotRequired[str] + type: Literal["text"] + + +class ChatCompletionMessageParam1(TypedDict): + content: NotRequired[str | Sequence[ChatCompletionContentPartText]] + name: NotRequired[str] + role: Literal["system"] + + +class FunctionCall(TypedDict): + arguments: str + name: str + + +class ChatCompletionMessageParam4(TypedDict): + content: NotRequired[str | Sequence[ChatCompletionContentPartText]] + role: Literal["tool"] + tool_call_id: NotRequired[str] + + +class ChatCompletionMessageParam5(TypedDict): + content: str | None + name: str + role: Literal["function"] + + +class ChatCompletionMessageParam6(TypedDict): + content: NotRequired[str | Sequence[ChatCompletionContentPartText]] + name: NotRequired[str] + role: Literal["developer"] + + +class ChatCompletionMessageParam7(TypedDict): + content: NotRequired[str | None] + role: Literal["model"] + + +class ChatCompletionMessageReasoning(TypedDict): + content: NotRequired[str | None] + id: NotRequired[str | None] + + +class ChatCompletionMessageToolCallFunction(TypedDict): + arguments: str + name: str + + class Metadata(TypedDict): model: NotRequired[str | None] """ @@ -80,6 +164,23 @@ class FeedbackResponseSchema(TypedDict): The type of global function. Defaults to 'scorer'. """ +FunctionTypeEnumNullish: TypeAlias = ( + Literal[ + "llm", + "scorer", + "task", + "tool", + "custom_view", + "preprocessor", + "facet", + "classifier", + "tag", + "parameters", + "sandbox", + ] + | None +) + Ids: TypeAlias = str | Sequence[str] """ Filter search results to a particular set of object IDs. To specify a list of IDs, include the query param multiple times @@ -117,6 +218,53 @@ class InsertEventsResponse(TypedDict): """ +class FunctionCall1(TypedDict): + name: str + + +class ModelParams2(TypedDict): + max_tokens: float + max_tokens_to_sample: NotRequired[float] + """ + This is a legacy parameter that should not be used. + """ + reasoning_budget: NotRequired[float] + reasoning_enabled: NotRequired[bool] + stop_sequences: NotRequired[Sequence[str]] + temperature: float + top_k: NotRequired[float] + top_p: NotRequired[float] + use_cache: NotRequired[bool] + + +class ModelParams3(TypedDict): + maxOutputTokens: NotRequired[float] + reasoning_budget: NotRequired[float] + reasoning_enabled: NotRequired[bool] + temperature: NotRequired[float] + topK: NotRequired[float] + topP: NotRequired[float] + use_cache: NotRequired[bool] + + +class ModelParams4(TypedDict): + reasoning_budget: NotRequired[float] + reasoning_enabled: NotRequired[bool] + temperature: NotRequired[float] + topK: NotRequired[float] + use_cache: NotRequired[bool] + + +class ModelParams5(TypedDict): + reasoning_budget: NotRequired[float] + reasoning_enabled: NotRequired[bool] + use_cache: NotRequired[bool] + + +class ModelParamsToolChoiceFunction(TypedDict): + name: str + + class ObjectReferenceNullish(TypedDict): _xact_id: NotRequired[str | None] """ @@ -145,6 +293,38 @@ class ObjectReferenceNullish(TypedDict): Filter search results to within a particular organization """ + +class PreprocessorId1(TypedDict): + id: str + type: Literal["function"] + version: NotRequired[str] + """ + The version of the function + """ + + +class PreprocessorId2(TypedDict): + function_type: NotRequired[Literal["preprocessor"]] + """ + The type of global function. Defaults to 'preprocessor'. + """ + name: str + type: Literal["global"] + + +class PreprocessorId3(TypedDict): + code: str + """ + The complete JavaScript preprocessor implementation, including its handler. + """ + type: Literal["inline"] + + +PreprocessorId: TypeAlias = PreprocessorId1 | PreprocessorId2 | PreprocessorId3 | None +""" +For prompt-backed functions: the saved, global, or inline preprocessor to use for trace template variables. Set to null to disable preprocessing. If omitted, the traced project's default preprocessor will be used, falling back to the global 'thread' preprocessor. +""" + ProjectIdQuery: TypeAlias = str """ Project id @@ -156,6 +336,134 @@ class ObjectReferenceNullish(TypedDict): """ +class PromptBlockDataNullish2(TypedDict): + content: str + type: Literal["completion"] + + +class Mcp(TypedDict): + enabled_tools: NotRequired[Sequence[str] | None] + """ + If omitted, all tools are enabled + """ + id: str + is_disabled: NotRequired[bool] + type: Literal["id"] + + +class Mcp1(TypedDict): + enabled_tools: NotRequired[Sequence[str] | None] + """ + If omitted, all tools are enabled + """ + is_disabled: NotRequired[bool] + type: Literal["url"] + url: str + + +class Origin2(TypedDict): + project_id: NotRequired[str] + prompt_id: NotRequired[str] + prompt_version: NotRequired[str] + + +class ToolFunction1(TypedDict): + id: str + type: Literal["function"] + version: NotRequired[str] + """ + The version of the function + """ + + +class ToolFunction2(TypedDict): + function_type: NotRequired[FunctionTypeEnum] + name: str + type: Literal["global"] + + +class ToolFunction3(TypedDict): + pass + + +class ToolFunction4(ToolFunction1, ToolFunction3): + pass + + +class ToolFunction5(ToolFunction2, ToolFunction3): + pass + + +class ToolFunction6(ToolFunction1, ToolFunction3): + pass + + +class ToolFunction7(ToolFunction2, ToolFunction3): + pass + + +ToolFunction: TypeAlias = ToolFunction4 | ToolFunction5 | ToolFunction6 | ToolFunction7 + +PromptEnvironment: TypeAlias = str +""" +Filter by environment slug. Cannot be used together with `version`. + +For `GET /v1/prompt`, environment resolution currently requires the request to match a single prompt. If multiple prompts match, the endpoint returns `400` (for example when `limit=1` is not set). Use `limit=1` or other filters (for example `slug`, `project_id`) to narrow results. +""" + + +class PromptParserNullish(TypedDict): + allow_no_match: NotRequired[bool] + """ + If true, adds a 'No match' option. When selected, no tag is deposited. + """ + allow_skip: NotRequired[bool] + """ + If true, adds a 'Skip' option. When selected, the scorer returns null. + """ + choice: NotRequired[Sequence[str]] + """ + List of valid choices without score mapping. Used by classifiers that deposit output to tags. + """ + choice_scores: NotRequired[Mapping[str, float]] + """ + Map of choices to scores (0-1). Used by scorers. + """ + type: Literal["llm_classifier"] + use_cot: bool + + +PromptVersion: TypeAlias = str +""" +Retrieve prompt at a specific version. + +The version id can either be a transaction id (e.g. '1000192656880881099') or a version identifier (e.g. '81cd05ee665fdfb3'). +""" + + +class ResponseFormatJsonSchema(TypedDict): + description: NotRequired[str] + name: str + schema: NotRequired[Mapping[str, Any] | str] + strict: NotRequired[bool | None] + + +class ResponseFormatNullish1(TypedDict): + type: Literal["json_object"] + + +class ResponseFormatNullish2(TypedDict): + json_schema: ResponseFormatJsonSchema + type: Literal["json_schema"] + + +class ResponseFormatNullish3(TypedDict): + type: Literal["text"] + + +ResponseFormatNullish: TypeAlias = ResponseFormatNullish1 | ResponseFormatNullish2 | ResponseFormatNullish3 | None + + class SavedFunctionId1(TypedDict): id: str type: Literal["function"] @@ -176,6 +484,11 @@ class SavedFunctionId2(TypedDict): Optional function identifier that produced the classification """ +Slug: TypeAlias = str +""" +Retrieve prompt with a specific slug +""" + StartingAfter: TypeAlias = str """ Pagination cursor id. @@ -190,6 +503,24 @@ class SavedFunctionId2(TypedDict): The version id is essentially a filter on the latest event transaction id. You can use the `max_xact_id` returned by a past fetch as the version to reproduce that exact fetch. """ +ChatCompletionContentPart: TypeAlias = ( + ChatCompletionContentPartTextWithTitle + | ChatCompletionContentPartImageWithTitle + | ChatCompletionContentPartFileWithTitle +) + + +class ChatCompletionMessageParam2(TypedDict): + content: NotRequired[str | Sequence[ChatCompletionContentPart]] + name: NotRequired[str] + role: Literal["user"] + + +class ChatCompletionMessageToolCall(TypedDict): + function: ChatCompletionMessageToolCallFunction + id: str + type: Literal["function"] + class Classification(TypedDict): confidence: NotRequired[float | None] @@ -217,3 +548,81 @@ class FetchEventsRequest(TypedDict): max_root_span_id: NotRequired[MaxRootSpanId | None] max_xact_id: NotRequired[MaxXactId | None] version: NotRequired[Version | None] + + +class ToolChoice(TypedDict): + function: ModelParamsToolChoiceFunction + type: Literal["function"] + + +class ModelParams1(TypedDict): + frequency_penalty: NotRequired[float] + function_call: NotRequired[Literal["auto"] | Literal["none"] | FunctionCall1] + max_completion_tokens: NotRequired[float] + """ + The successor to max_tokens + """ + max_tokens: NotRequired[float] + n: NotRequired[float] + presence_penalty: NotRequired[float] + reasoning_budget: NotRequired[float] + reasoning_effort: NotRequired[Literal["none", "minimal", "low", "medium", "high"]] + reasoning_enabled: NotRequired[bool] + response_format: NotRequired[ResponseFormatNullish] + stop: NotRequired[Sequence[str]] + temperature: NotRequired[float] + tool_choice: NotRequired[Literal["auto"] | Literal["none"] | Literal["required"] | ToolChoice] + top_p: NotRequired[float] + use_cache: NotRequired[bool] + verbosity: NotRequired[Literal["low", "medium", "high"]] + + +ModelParams: TypeAlias = ModelParams1 | ModelParams2 | ModelParams3 | ModelParams4 | ModelParams5 + + +class PromptOptionsNullish(TypedDict): + endpoint_name: NotRequired[str | None] + model: NotRequired[str] + params: NotRequired[ModelParams] + position: NotRequired[str] + + +class ChatCompletionMessageParam3(TypedDict): + content: NotRequired[str | Sequence[ChatCompletionContentPartText] | None] + function_call: NotRequired[FunctionCall | None] + name: NotRequired[str | None] + reasoning: NotRequired[Sequence[ChatCompletionMessageReasoning] | None] + reasoning_signature: NotRequired[str | None] + role: Literal["assistant"] + tool_calls: NotRequired[Sequence[ChatCompletionMessageToolCall] | None] + + +ChatCompletionMessageParam: TypeAlias = ( + ChatCompletionMessageParam1 + | ChatCompletionMessageParam2 + | ChatCompletionMessageParam3 + | ChatCompletionMessageParam4 + | ChatCompletionMessageParam5 + | ChatCompletionMessageParam6 + | ChatCompletionMessageParam7 +) + + +class PromptBlockDataNullish1(TypedDict): + messages: Sequence[ChatCompletionMessageParam] + tools: NotRequired[str] + type: Literal["chat"] + + +PromptBlockDataNullish: TypeAlias = PromptBlockDataNullish1 | PromptBlockDataNullish2 | None + + +class PromptDataNullish(TypedDict): + mcp: NotRequired[Mapping[str, Mcp | Mcp1] | None] + options: NotRequired[PromptOptionsNullish | None] + origin: NotRequired[Origin2 | None] + parser: NotRequired[PromptParserNullish | None] + preprocessor: NotRequired[PreprocessorId] + prompt: NotRequired[PromptBlockDataNullish] + template_format: NotRequired[Literal["mustache", "nunjucks", "none"] | None] + tool_functions: NotRequired[Sequence[ToolFunction] | None] diff --git a/py/src/braintrust/api/_generated/models/functions.py b/py/src/braintrust/api/_generated/models/functions.py new file mode 100644 index 00000000..6c0fd634 --- /dev/null +++ b/py/src/braintrust/api/_generated/models/functions.py @@ -0,0 +1,753 @@ +# Generated by scripts/generate-api-client.py. DO NOT EDIT. +# OpenAPI commit: 8d6e24ce78d8e33cbf9ba5f35d2fbc71749306d7 +# OpenAPI spec SHA-256: ebac33c1422e5673f875a254b3f7a13e4d8b5f5ab9b5095fd6b48e836613d507 +# datamodel-code-generator: 0.72.4 +# ruff: 0.15.21 +# Generator Python: 3.14 +# Content SHA-256: 8f75af48b162ca2c86ab19fb6a4850e12eac3b9e2376887cad73109e42c833e5 + +from typing import Any, Literal, TypeAlias, TypedDict +from typing_extensions import NotRequired +from collections.abc import Mapping, Sequence + +from .common import ChatCompletionMessageParam, FunctionTypeEnum, FunctionTypeEnumNullish, PromptDataNullish + +AclObjectType: TypeAlias = Literal[ + "organization", + "project", + "experiment", + "dataset", + "prompt", + "prompt_session", + "group", + "role", + "org_member", + "project_log", + "org_project", + "org_audit_logs", + "project_group", + "ai_secret", + "org_ai_secret", +] +""" +The object type that the ACL applies to +""" + + +class Facet(TypedDict): + embedding_model: NotRequired[str] + """ + The embedding model to use for vectorizing facet results. + """ + model: NotRequired[str] + """ + The model to use for facet extraction + """ + name: str + """ + The name of the facet + """ + no_match_pattern: NotRequired[str] + """ + Regex pattern to identify outputs that do not match the facet. If the output matches, the facet will be saved as 'no_match' + """ + prompt: str + """ + The prompt to use for LLM extraction. The preprocessed text will be provided as context. + """ + + +class Position(TypedDict): + type: Literal["task"] + + +class Position1(TypedDict): + index: int + type: Literal["scorer"] + + +class Position2(TypedDict): + index: int + type: Literal["classifier"] + + +class Location(TypedDict): + eval_name: str + position: Position | Position1 | Position2 + type: Literal["experiment"] + + +class Location1(TypedDict): + index: int + type: Literal["function"] + + +class SandboxSpec(TypedDict): + provider: Literal["modal"] + snapshot_ref: str + """ + sandbox snapshot ref + """ + + +class SandboxSpec1(TypedDict): + provider: Literal["lambda"] + + +class Location2(TypedDict): + entrypoints: NotRequired[Sequence[str]] + """ + Which entrypoints to execute in the sandbox + """ + eval_name: str + evaluator_definition: NotRequired[Any | None] + """ + Definition of current evaluator with parameters + """ + parameters: NotRequired[Mapping[str, Any]] + """ + Parameter values for sandbox eval execution + """ + sandbox_spec: SandboxSpec | SandboxSpec1 + type: Literal["sandbox"] + + +class RuntimeContext(TypedDict): + runtime: Literal["node", "python", "browser", "quickjs"] + version: str + + +class CodeBundle(TypedDict): + bundle_id: NotRequired[str | None] + location: Location | Location1 | Location2 + preview: NotRequired[str | None] + """ + A preview of the code + """ + runtime_context: RuntimeContext + + +class FunctionSchema(TypedDict): + parameters: NotRequired[Any | None] + returns: NotRequired[Any | None] + + +class Origin(TypedDict): + internal: NotRequired[bool | None] + """ + The function exists for internal purposes and should not be displayed in the list of functions. + """ + object_id: str + """ + Id of the object the function is originating from + """ + object_type: AclObjectType + + +class FacetPreprocessorId1(TypedDict): + id: str + type: Literal["function"] + version: NotRequired[str] + """ + The version of the function + """ + + +class FacetPreprocessorId3(TypedDict): + code: str + """ + The complete JavaScript preprocessor implementation, including its handler. + """ + type: Literal["inline"] + + +class FunctionData1(TypedDict): + type: Literal["prompt"] + + +class Data(CodeBundle): + type: Literal["bundle"] + + +class Data1(TypedDict): + code: str + code_hash: NotRequired[str] + """ + SHA256 hash of the code, computed at save time + """ + runtime_context: RuntimeContext + type: Literal["inline"] + + +class FunctionData2(TypedDict): + data: Data | Data1 + type: Literal["code"] + + +class FunctionData3(TypedDict): + endpoint: str + eval_name: str + parameters: Mapping[str, Any] + parameters_version: NotRequired[str | None] + """ + The version (transaction ID) of the parameters being used + """ + type: Literal["remote_eval"] + + +class FieldSchema(TypedDict): + additionalProperties: NotRequired[bool] + properties: Mapping[str, Mapping[str, Any]] + required: NotRequired[Sequence[str]] + type: Literal["object"] + + +FunctionData5 = TypedDict( + "FunctionData5", + { + "__schema": FieldSchema, + "data": Mapping[str, Any], + "type": Literal["parameters"], + }, +) + + +class FunctionDataNullish1(TypedDict): + type: Literal["prompt"] + + +class Data2(CodeBundle): + type: Literal["bundle"] + + +class Data3(TypedDict): + code: str + code_hash: NotRequired[str] + """ + SHA256 hash of the code, computed at save time + """ + runtime_context: RuntimeContext + type: Literal["inline"] + + +class FunctionDataNullish2(TypedDict): + data: Data2 | Data3 + type: Literal["code"] + + +class FunctionDataNullish3(TypedDict): + endpoint: str + eval_name: str + parameters: Mapping[str, Any] + parameters_version: NotRequired[str | None] + """ + The version (transaction ID) of the parameters being used + """ + type: Literal["remote_eval"] + + +FunctionDataNullish5 = TypedDict( + "FunctionDataNullish5", + { + "__schema": FieldSchema, + "data": Mapping[str, Any], + "type": Literal["parameters"], + }, +) + +FunctionIdParam: TypeAlias = str +""" +Function id +""" + +FunctionIdRef: TypeAlias = Mapping[str, Any] + +FunctionName: TypeAlias = str +""" +Name of the function to search for +""" + + +class Source(TypedDict): + node: str + """ + The id of the node in the graph + """ + variable: str + + +class Target(TypedDict): + node: str + """ + The id of the node in the graph + """ + variable: str + + +class GraphEdge(TypedDict): + purpose: Literal["control", "data", "messages"] + """ + The purpose of the edge + """ + source: Source + target: Target + + +class Position3(TypedDict): + x: float + """ + The x position of the node + """ + y: float + """ + The y position of the node + """ + + +class GraphNode1(TypedDict): + description: NotRequired[str | None] + """ + The description of the node + """ + function: FunctionIdRef + position: NotRequired[Position3 | None] + """ + The position of the node + """ + type: Literal["function"] + + +class GraphNode2(TypedDict): + description: NotRequired[str | None] + """ + The description of the node + """ + position: NotRequired[Position3 | None] + """ + The position of the node + """ + type: Literal["input"] + """ + The input to the graph + """ + + +class GraphNode3(TypedDict): + description: NotRequired[str | None] + """ + The description of the node + """ + position: NotRequired[Position3 | None] + """ + The position of the node + """ + type: Literal["output"] + """ + The output of the graph + """ + + +class GraphNode4(TypedDict): + description: NotRequired[str | None] + """ + The description of the node + """ + position: NotRequired[Position3 | None] + """ + The position of the node + """ + type: Literal["literal"] + value: NotRequired[Any | None] + """ + A literal value to be returned + """ + + +class GraphNode5(TypedDict): + description: NotRequired[str | None] + """ + The description of the node + """ + expr: str + """ + A BTQL expression to be evaluated + """ + position: NotRequired[Position3 | None] + """ + The position of the node + """ + type: Literal["btql"] + + +class GraphNode6(TypedDict): + condition: NotRequired[str | None] + """ + A BTQL expression to be evaluated + """ + description: NotRequired[str | None] + """ + The description of the node + """ + position: NotRequired[Position3 | None] + """ + The position of the node + """ + type: Literal["gate"] + + +class GraphNode7(TypedDict): + description: NotRequired[str | None] + """ + The description of the node + """ + position: NotRequired[Position3 | None] + """ + The position of the node + """ + type: Literal["aggregator"] + + +class PromptBlockData2(TypedDict): + content: str + type: Literal["completion"] + + +class SourceFacetFunction1(TypedDict): + id: str + type: Literal["function"] + version: NotRequired[str] + """ + The version of the function + """ + + +class SourceFacetFunction2(TypedDict): + function_type: NotRequired[FunctionTypeEnum] + name: str + type: Literal["global"] + + +class SourceFacetFunction3(TypedDict): + pass + + +class SourceFacetFunction4(SourceFacetFunction1, SourceFacetFunction3): + pass + + +class SourceFacetFunction5(SourceFacetFunction2, SourceFacetFunction3): + pass + + +class SourceFacetFunction6(SourceFacetFunction1, SourceFacetFunction3): + pass + + +class SourceFacetFunction7(SourceFacetFunction2, SourceFacetFunction3): + pass + + +SourceFacetFunction: TypeAlias = ( + SourceFacetFunction4 | SourceFacetFunction5 | SourceFacetFunction6 | SourceFacetFunction7 +) + + +class TopicMapGenerationSettings(TypedDict): + algorithm: Literal["hdbscan", "kmeans", "community"] + dimension_reduction: Literal["umap", "pca", "none"] + hierarchy_threshold: NotRequired[int] + min_cluster_size: NotRequired[int] + min_samples: NotRequired[int] + n_clusters: NotRequired[int] + naming_model: NotRequired[str] + sample_size: NotRequired[int] + + +class FacetPreprocessorId2(TypedDict): + function_type: NotRequired[FunctionTypeEnum] + name: str + type: Literal["global"] + + +FacetPreprocessorId: TypeAlias = FacetPreprocessorId1 | FacetPreprocessorId2 | FacetPreprocessorId3 | None +""" +The saved, global, or inline preprocessor to use for facet extraction. If not provided, the project default preprocessor will be used, falling back to the global 'thread' preprocessor. +""" + + +class FunctionData4(TypedDict): + config: NotRequired[Mapping[str, Any] | None] + """ + Configuration options to pass to the global function (e.g., for preprocessor customization) + """ + function_type: NotRequired[FunctionTypeEnum] + name: str + type: Literal["global"] + + +class FunctionDataNullish4(TypedDict): + config: NotRequired[Mapping[str, Any] | None] + """ + Configuration options to pass to the global function (e.g., for preprocessor customization) + """ + function_type: NotRequired[FunctionTypeEnum] + name: str + type: Literal["global"] + + +class TopicMapData(TypedDict): + automation_btql_filter: NotRequired[str] + """ + Automation-level BTQL filter that was applied when this version was generated. Absent on versions generated before this was recorded. + """ + btql_filter: NotRequired[str] + """ + Per-topic-map BTQL filter that was applied when this version was generated. Absent on versions generated before this was recorded. + """ + bundle_key: NotRequired[str] + """ + Key of the topic map bundle in code_bundles bucket + """ + disable_reconciliation: NotRequired[bool] + """ + Whether new topic generation should ignore the previously saved report during reconciliation. Defaults to false when omitted. + """ + distance_threshold: NotRequired[float] + """ + Maximum distance to nearest centroid. If exceeded, returns no_match. + """ + embedding_model: str + """ + The embedding model to use for embedding facet values + """ + generation_settings: NotRequired[TopicMapGenerationSettings] + reconcile_mode: NotRequired[Literal["evolve", "names_only"]] + """ + How reconciliation carries the previous map forward: "evolve" re-routes new samples into the previous topics before naming; "names_only" keeps the fresh clustering and carries only topic ids/names. Defaults to "names_only" when omitted. + """ + report_key: NotRequired[str] + """ + Key of the clustering report in code_bundles bucket + """ + source_facet: str + """ + Materialized facet field name used when source_facet_function is absent + """ + source_facet_function: NotRequired[SourceFacetFunction] + topic_names: NotRequired[Mapping[str, str]] + """ + Mapping from topic_id to topic name + """ + type: Literal["topic_map"] + + +class TopicMap(TypedDict): + function_name: str + """ + The name of the topic map function + """ + topic_map_data: TopicMapData + topic_map_id: NotRequired[str] + """ + The id of the topic map function + """ + + +class BatchedFacetData(TypedDict): + facets: Sequence[Facet] + preprocessor: NotRequired[FacetPreprocessorId] + topic_maps: NotRequired[Mapping[str, Sequence[TopicMap]]] + """ + Topic maps that depend on facets in this batch, keyed by source facet name. Each source facet can have multiple topic maps. + """ + type: Literal["batched_facet"] + + +class FacetData(TypedDict): + embedding_model: NotRequired[str] + """ + The embedding model to use for vectorizing facet results. + """ + model: NotRequired[str] + """ + The model to use for facet extraction + """ + no_match_pattern: NotRequired[str] + """ + Regex pattern to identify outputs that do not match the facet. If the output matches, the facet will be saved as 'no_match' + """ + preprocessor: NotRequired[FacetPreprocessorId] + prompt: str + """ + The prompt to use for LLM extraction. The preprocessed text will be provided as context. + """ + type: Literal["facet"] + + +class PromptBlockData1(TypedDict): + messages: Sequence[ChatCompletionMessageParam] + tools: NotRequired[str] + type: Literal["chat"] + + +PromptBlockData: TypeAlias = PromptBlockData1 | PromptBlockData2 + + +class GraphNode8(TypedDict): + description: NotRequired[str | None] + """ + The description of the node + """ + position: NotRequired[Position3 | None] + """ + The position of the node + """ + prompt: PromptBlockData + type: Literal["prompt_template"] + + +GraphNode: TypeAlias = ( + GraphNode1 | GraphNode2 | GraphNode3 | GraphNode4 | GraphNode5 | GraphNode6 | GraphNode7 | GraphNode8 +) + + +class GraphData(TypedDict): + edges: Mapping[str, GraphEdge] + nodes: Mapping[str, GraphNode] + type: Literal["graph"] + + +FunctionData: TypeAlias = ( + FunctionData1 + | FunctionData2 + | GraphData + | FunctionData3 + | FunctionData4 + | FacetData + | BatchedFacetData + | FunctionData5 + | TopicMapData +) + +FunctionDataNullish: TypeAlias = ( + FunctionDataNullish1 + | FunctionDataNullish2 + | GraphData + | FunctionDataNullish3 + | FunctionDataNullish4 + | FacetData + | BatchedFacetData + | FunctionDataNullish5 + | TopicMapData + | None +) + + +class PatchFunction(TypedDict): + description: NotRequired[str | None] + """ + Textual description of the prompt + """ + function_data: NotRequired[FunctionDataNullish] + name: NotRequired[str | None] + """ + Name of the prompt + """ + prompt_data: NotRequired[PromptDataNullish | None] + tags: NotRequired[Sequence[str] | None] + """ + A list of tags for the prompt + """ + + +class CreateFunction(TypedDict): + description: NotRequired[str | None] + """ + Textual description of the prompt + """ + function_data: FunctionData + function_schema: NotRequired[FunctionSchema | None] + """ + JSON schema for the function's parameters and return type + """ + function_type: NotRequired[FunctionTypeEnumNullish | None] + name: str + """ + Name of the prompt + """ + origin: NotRequired[Origin | None] + project_id: str + """ + Unique identifier for the project that the prompt belongs under + """ + prompt_data: NotRequired[PromptDataNullish | None] + slug: str + """ + Unique identifier for the prompt + """ + tags: NotRequired[Sequence[str] | None] + """ + A list of tags for the prompt + """ + + +class Function(TypedDict): + _xact_id: str + """ + The transaction id of an event is unique to the network operation that processed the event insertion. Transaction ids are monotonically increasing over time and can be used to retrieve a versioned snapshot of the prompt (see the `version` parameter) + """ + created: NotRequired[str | None] + """ + Date of prompt creation + """ + description: NotRequired[str | None] + """ + Textual description of the prompt + """ + function_data: FunctionData + function_schema: NotRequired[FunctionSchema | None] + """ + JSON schema for the function's parameters and return type + """ + function_type: NotRequired[FunctionTypeEnumNullish | None] + id: str + """ + Unique identifier for the prompt + """ + log_id: Literal["p"] + """ + A literal 'p' which identifies the object as a project prompt + """ + metadata: NotRequired[Mapping[str, Any] | None] + """ + User-controlled metadata about the prompt + """ + name: str + """ + Name of the prompt + """ + org_id: str + """ + Unique identifier for the organization + """ + origin: NotRequired[Origin | None] + project_id: str + """ + Unique identifier for the project that the prompt belongs under + """ + prompt_data: NotRequired[PromptDataNullish | None] + slug: str + """ + Unique identifier for the prompt + """ + tags: NotRequired[Sequence[str] | None] + """ + A list of tags for the prompt + """ + + +class GetFunctionResponse(TypedDict): + objects: Sequence[Function] + """ + A list of function objects + """ diff --git a/py/src/braintrust/api/_generated/models/prompts.py b/py/src/braintrust/api/_generated/models/prompts.py index 850dc4f0..41426c7b 100644 --- a/py/src/braintrust/api/_generated/models/prompts.py +++ b/py/src/braintrust/api/_generated/models/prompts.py @@ -4,281 +4,13 @@ # datamodel-code-generator: 0.72.4 # ruff: 0.15.21 # Generator Python: 3.14 -# Content SHA-256: bacb6ce9b0a44df81cd41c75e92a79000c50c826bee226218b541ea94dd73636 +# Content SHA-256: 0ec7d11315d85b755a496e3e33ffe15ed9b3600a66bc2be20906b0ba311c76bc from typing import Any, Literal, TypeAlias, TypedDict from typing_extensions import NotRequired from collections.abc import Mapping, Sequence -from .common import FunctionTypeEnum - - -class ChatCompletionContentPartFileFile(TypedDict): - file_data: NotRequired[str] - file_id: NotRequired[str] - filename: NotRequired[str] - - -class CacheControl(TypedDict): - ttl: NotRequired[Literal["5m", "1h"]] - type: Literal["ephemeral"] - - -class ChatCompletionContentPartFileWithTitle(TypedDict): - cache_control: NotRequired[CacheControl] - file: ChatCompletionContentPartFileFile - type: Literal["file"] - - -class ImageUrl(TypedDict): - detail: NotRequired[Literal["auto"] | Literal["low"] | Literal["high"]] - url: str - - -class ChatCompletionContentPartImageWithTitle(TypedDict): - cache_control: NotRequired[CacheControl] - image_url: ImageUrl - type: Literal["image_url"] - - -class ChatCompletionContentPartText(TypedDict): - cache_control: NotRequired[CacheControl] - text: NotRequired[str] - type: Literal["text"] - - -class ChatCompletionContentPartTextWithTitle(TypedDict): - cache_control: NotRequired[CacheControl] - text: NotRequired[str] - type: Literal["text"] - - -class ChatCompletionMessageParam1(TypedDict): - content: NotRequired[str | Sequence[ChatCompletionContentPartText]] - name: NotRequired[str] - role: Literal["system"] - - -class FunctionCall(TypedDict): - arguments: str - name: str - - -class ChatCompletionMessageParam4(TypedDict): - content: NotRequired[str | Sequence[ChatCompletionContentPartText]] - role: Literal["tool"] - tool_call_id: NotRequired[str] - - -class ChatCompletionMessageParam5(TypedDict): - content: str | None - name: str - role: Literal["function"] - - -class ChatCompletionMessageParam6(TypedDict): - content: NotRequired[str | Sequence[ChatCompletionContentPartText]] - name: NotRequired[str] - role: Literal["developer"] - - -class ChatCompletionMessageParam7(TypedDict): - content: NotRequired[str | None] - role: Literal["model"] - - -class ChatCompletionMessageReasoning(TypedDict): - content: NotRequired[str | None] - id: NotRequired[str | None] - - -class Function(TypedDict): - arguments: str - name: str - - -class ChatCompletionMessageToolCall(TypedDict): - function: Function - id: str - type: Literal["function"] - - -FunctionTypeEnumNullish: TypeAlias = ( - Literal[ - "llm", - "scorer", - "task", - "tool", - "custom_view", - "preprocessor", - "facet", - "classifier", - "tag", - "parameters", - "sandbox", - ] - | None -) - - -class FunctionCall1(TypedDict): - name: str - - -class Function1(TypedDict): - name: str - - -class ToolChoice(TypedDict): - function: Function1 - type: Literal["function"] - - -class ModelParams2(TypedDict): - max_tokens: float - max_tokens_to_sample: NotRequired[float] - """ - This is a legacy parameter that should not be used. - """ - reasoning_budget: NotRequired[float] - reasoning_enabled: NotRequired[bool] - stop_sequences: NotRequired[Sequence[str]] - temperature: float - top_k: NotRequired[float] - top_p: NotRequired[float] - use_cache: NotRequired[bool] - - -class ModelParams3(TypedDict): - maxOutputTokens: NotRequired[float] - reasoning_budget: NotRequired[float] - reasoning_enabled: NotRequired[bool] - temperature: NotRequired[float] - topK: NotRequired[float] - topP: NotRequired[float] - use_cache: NotRequired[bool] - - -class ModelParams4(TypedDict): - reasoning_budget: NotRequired[float] - reasoning_enabled: NotRequired[bool] - temperature: NotRequired[float] - topK: NotRequired[float] - use_cache: NotRequired[bool] - - -class ModelParams5(TypedDict): - reasoning_budget: NotRequired[float] - reasoning_enabled: NotRequired[bool] - use_cache: NotRequired[bool] - - -class PreprocessorId1(TypedDict): - id: str - type: Literal["function"] - version: NotRequired[str] - """ - The version of the function - """ - - -class PreprocessorId2(TypedDict): - function_type: NotRequired[Literal["preprocessor"]] - """ - The type of global function. Defaults to 'preprocessor'. - """ - name: str - type: Literal["global"] - - -class PreprocessorId3(TypedDict): - code: str - """ - The complete JavaScript preprocessor implementation, including its handler. - """ - type: Literal["inline"] - - -PreprocessorId: TypeAlias = PreprocessorId1 | PreprocessorId2 | PreprocessorId3 | None -""" -For prompt-backed functions: the saved, global, or inline preprocessor to use for trace template variables. Set to null to disable preprocessing. If omitted, the traced project's default preprocessor will be used, falling back to the global 'thread' preprocessor. -""" - - -class PromptBlockDataNullish2(TypedDict): - content: str - type: Literal["completion"] - - -class Mcp(TypedDict): - enabled_tools: NotRequired[Sequence[str] | None] - """ - If omitted, all tools are enabled - """ - id: str - is_disabled: NotRequired[bool] - type: Literal["id"] - - -class Mcp1(TypedDict): - enabled_tools: NotRequired[Sequence[str] | None] - """ - If omitted, all tools are enabled - """ - is_disabled: NotRequired[bool] - type: Literal["url"] - url: str - - -class Origin(TypedDict): - project_id: NotRequired[str] - prompt_id: NotRequired[str] - prompt_version: NotRequired[str] - - -class ToolFunction1(TypedDict): - id: str - type: Literal["function"] - version: NotRequired[str] - """ - The version of the function - """ - - -class ToolFunction2(TypedDict): - function_type: NotRequired[FunctionTypeEnum] - name: str - type: Literal["global"] - - -class ToolFunction3(TypedDict): - pass - - -class ToolFunction4(ToolFunction1, ToolFunction3): - pass - - -class ToolFunction5(ToolFunction2, ToolFunction3): - pass - - -class ToolFunction6(ToolFunction1, ToolFunction3): - pass - - -class ToolFunction7(ToolFunction2, ToolFunction3): - pass - - -ToolFunction: TypeAlias = ToolFunction4 | ToolFunction5 | ToolFunction6 | ToolFunction7 - -PromptEnvironment: TypeAlias = str -""" -Filter by environment slug. Cannot be used together with `version`. - -For `GET /v1/prompt`, environment resolution currently requires the request to match a single prompt. If multiple prompts match, the endpoint returns `400` (for example when `limit=1` is not set). Use `limit=1` or other filters (for example `slug`, `project_id`) to narrow results. -""" +from .common import FunctionTypeEnumNullish, PromptDataNullish PromptIdParam: TypeAlias = str """ @@ -291,148 +23,6 @@ class ToolFunction7(ToolFunction2, ToolFunction3): """ -class PromptParserNullish(TypedDict): - allow_no_match: NotRequired[bool] - """ - If true, adds a 'No match' option. When selected, no tag is deposited. - """ - allow_skip: NotRequired[bool] - """ - If true, adds a 'Skip' option. When selected, the scorer returns null. - """ - choice: NotRequired[Sequence[str]] - """ - List of valid choices without score mapping. Used by classifiers that deposit output to tags. - """ - choice_scores: NotRequired[Mapping[str, float]] - """ - Map of choices to scores (0-1). Used by scorers. - """ - type: Literal["llm_classifier"] - use_cot: bool - - -PromptVersion: TypeAlias = str -""" -Retrieve prompt at a specific version. - -The version id can either be a transaction id (e.g. '1000192656880881099') or a version identifier (e.g. '81cd05ee665fdfb3'). -""" - - -class ResponseFormatJsonSchema(TypedDict): - description: NotRequired[str] - name: str - schema: NotRequired[Mapping[str, Any] | str] - strict: NotRequired[bool | None] - - -class ResponseFormatNullish1(TypedDict): - type: Literal["json_object"] - - -class ResponseFormatNullish2(TypedDict): - json_schema: ResponseFormatJsonSchema - type: Literal["json_schema"] - - -class ResponseFormatNullish3(TypedDict): - type: Literal["text"] - - -ResponseFormatNullish: TypeAlias = ResponseFormatNullish1 | ResponseFormatNullish2 | ResponseFormatNullish3 | None - -Slug: TypeAlias = str -""" -Retrieve prompt with a specific slug -""" - -ChatCompletionContentPart: TypeAlias = ( - ChatCompletionContentPartTextWithTitle - | ChatCompletionContentPartImageWithTitle - | ChatCompletionContentPartFileWithTitle -) - - -class ChatCompletionMessageParam2(TypedDict): - content: NotRequired[str | Sequence[ChatCompletionContentPart]] - name: NotRequired[str] - role: Literal["user"] - - -class ChatCompletionMessageParam3(TypedDict): - content: NotRequired[str | Sequence[ChatCompletionContentPartText] | None] - function_call: NotRequired[FunctionCall | None] - name: NotRequired[str | None] - reasoning: NotRequired[Sequence[ChatCompletionMessageReasoning] | None] - reasoning_signature: NotRequired[str | None] - role: Literal["assistant"] - tool_calls: NotRequired[Sequence[ChatCompletionMessageToolCall] | None] - - -ChatCompletionMessageParam: TypeAlias = ( - ChatCompletionMessageParam1 - | ChatCompletionMessageParam2 - | ChatCompletionMessageParam3 - | ChatCompletionMessageParam4 - | ChatCompletionMessageParam5 - | ChatCompletionMessageParam6 - | ChatCompletionMessageParam7 -) - - -class ModelParams1(TypedDict): - frequency_penalty: NotRequired[float] - function_call: NotRequired[Literal["auto"] | Literal["none"] | FunctionCall1] - max_completion_tokens: NotRequired[float] - """ - The successor to max_tokens - """ - max_tokens: NotRequired[float] - n: NotRequired[float] - presence_penalty: NotRequired[float] - reasoning_budget: NotRequired[float] - reasoning_effort: NotRequired[Literal["none", "minimal", "low", "medium", "high"]] - reasoning_enabled: NotRequired[bool] - response_format: NotRequired[ResponseFormatNullish] - stop: NotRequired[Sequence[str]] - temperature: NotRequired[float] - tool_choice: NotRequired[Literal["auto"] | Literal["none"] | Literal["required"] | ToolChoice] - top_p: NotRequired[float] - use_cache: NotRequired[bool] - verbosity: NotRequired[Literal["low", "medium", "high"]] - - -ModelParams: TypeAlias = ModelParams1 | ModelParams2 | ModelParams3 | ModelParams4 | ModelParams5 - - -class PromptBlockDataNullish1(TypedDict): - messages: Sequence[ChatCompletionMessageParam] - tools: NotRequired[str] - type: Literal["chat"] - - -PromptBlockDataNullish: TypeAlias = PromptBlockDataNullish1 | PromptBlockDataNullish2 | None - - -class PromptOptionsNullish(TypedDict): - endpoint_name: NotRequired[str | None] - model: NotRequired[str] - params: NotRequired[ModelParams] - position: NotRequired[str] - - -class PromptDataNullish(TypedDict): - mcp: NotRequired[Mapping[str, Mcp | Mcp1] | None] - options: NotRequired[PromptOptionsNullish | None] - origin: NotRequired[Origin | None] - parser: NotRequired[PromptParserNullish | None] - preprocessor: NotRequired[PreprocessorId] - prompt: NotRequired[PromptBlockDataNullish] - template_format: NotRequired[Literal["mustache", "nunjucks", "none"] | None] - tool_functions: NotRequired[Sequence[ToolFunction] | None] - - class CreatePrompt(TypedDict): description: NotRequired[str | None] """ diff --git a/py/src/braintrust/api/_generated/prompts.py b/py/src/braintrust/api/_generated/prompts.py index 816f240b..54b168ea 100644 --- a/py/src/braintrust/api/_generated/prompts.py +++ b/py/src/braintrust/api/_generated/prompts.py @@ -4,7 +4,7 @@ # datamodel-code-generator: 0.72.4 # ruff: 0.15.21 # Generator Python: 3.14 -# Content SHA-256: 2f8d43a3bacf5e304ec52fca1dd92b7c050bbdda4033df72c96c39d2a584380b +# Content SHA-256: f74abab6482c7f8968add98f0d9d92fd904ff8bcd882c95698778efc1c466ce3 """Generated Prompts REST operations and resource.""" @@ -12,18 +12,19 @@ from .._service import Operation, Parameter, ResourceAPI from ..policies import RetryMode -from .models.common import AppLimitParam, EndingBefore, Ids, OrgName, ProjectIdQuery, ProjectName, StartingAfter -from .models.prompts import ( - CreatePrompt, - GetPromptResponse, - PatchPrompt, - Prompt, +from .models.common import ( + AppLimitParam, + EndingBefore, + Ids, + OrgName, + ProjectIdQuery, + ProjectName, PromptEnvironment, - PromptIdParam, - PromptName, PromptVersion, Slug, + StartingAfter, ) +from .models.prompts import CreatePrompt, GetPromptResponse, PatchPrompt, Prompt, PromptIdParam, PromptName POST_PROMPT = Operation( diff --git a/py/src/braintrust/api/cassettes/test_functions_end_to_end_with_real_backend.yaml b/py/src/braintrust/api/cassettes/test_functions_end_to_end_with_real_backend.yaml new file mode 100644 index 00000000..3e830043 --- /dev/null +++ b/py/src/braintrust/api/cassettes/test_functions_end_to_end_with_real_backend.yaml @@ -0,0 +1,546 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - python-requests/2.34.2 + method: POST + uri: https://www.braintrust.dev/api/apikey/login + response: + body: + string: '{"org_info":[{"id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","name":"Braintrust + SDKs","api_url":"https://api.braintrust.dev","git_metadata":{"collect":"some","fields":["commit","branch","tag","dirty","author_name","author_email","commit_message","commit_time"]},"is_universal_api":null,"proxy_url":"https://api.braintrust.dev","realtime_url":"wss://realtime.braintrustapi.com"}]}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Headers: + - X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, + Content-Type, Date, X-Api-Version + Access-Control-Allow-Methods: + - GET,OPTIONS,PATCH,DELETE,POST,PUT + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - public, max-age=0, must-revalidate + Content-Length: + - '376' + Content-Security-Policy: + - 'script-src ''self'' ''unsafe-eval'' ''wasm-unsafe-eval'' ''strict-dynamic'' + ''nonce-OGM3ZTg1YWQtMTdiMS00NGJmLWI0ODktMDRiZDYyODQyN2E4'' *.js.stripe.com + js.stripe.com maps.googleapis.com ; style-src ''self'' ''unsafe-inline'' *.braintrust.dev + btcm6qilbbhv4yi1.public.blob.vercel-storage.com fonts.googleapis.com www.gstatic.com + d4tuoctqmanu0.cloudfront.net; font-src ''self'' data: fonts.gstatic.com btcm6qilbbhv4yi1.public.blob.vercel-storage.com + cdn.jsdelivr.net d4tuoctqmanu0.cloudfront.net fonts.googleapis.com mintlify-assets.b-cdn.net + fonts.cdnfonts.com; object-src ''none''; base-uri ''self''; form-action ''self'' + https://www.facebook.com; frame-ancestors ''self''; worker-src ''self'' blob:; + report-uri https://o4507221741076480.ingest.us.sentry.io/api/4507221754380288/security/?sentry_key=27fa5ac907cf7c6ce4a1ab2a03f805b4&sentry_environment=production&sentry_release=16; + report-to csp-endpoint-0' + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:18 GMT + Etag: + - '"13vsc5ye8flag"' + Reporting-Endpoints: + - csp-endpoint-0="https://o4507221741076480.ingest.us.sentry.io/api/4507221754380288/security/?sentry_key=27fa5ac907cf7c6ce4a1ab2a03f805b4&sentry_environment=production&sentry_release=16" + Server: + - Vercel + Strict-Transport-Security: + - max-age=63072000 + X-Bt-Was-Udf-Cached: + - 'true' + X-Clerk-Auth-Message: + - Invalid JWT form. A JWT consists of three parts separated by dots. (reason=token-invalid, + token-carrier=header) + X-Clerk-Auth-Reason: + - token-invalid + X-Clerk-Auth-Status: + - signed-out + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Matched-Path: + - /api/apikey/login + X-Nonce: + - OGM3ZTg1YWQtMTdiMS00NGJmLWI0ODktMDRiZDYyODQyN2E4 + X-Vercel-Cache: + - MISS + X-Vercel-Id: + - yul1::iad1::r7p8z-1789048998662-8107cc23334a + status: + code: 200 + message: OK +- request: + body: '{"name": "python-sdk-generated-functions-vcr", "org_name": "Braintrust + SDKs"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '77' + Content-Type: + - application/json + User-Agent: + - python-requests/2.34.2 + method: POST + uri: https://api.braintrust.dev/v1/project + response: + body: + string: '{"id":"08507b0c-d606-4609-aab2-935137072001","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","name":"python-sdk-generated-functions-vcr","description":null,"created":"2026-09-10T14:03:19.036Z","deleted_at":null,"user_id":"c1f71e19-b3ce-4f59-89a9-055901f7755b","settings":null}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:19 GMT + ETag: + - W/"114-IdOSjpJ3ntaa9kT95Tw94fuqONU" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 5e2f1ed3ba0ab1e08304bb3d134360de.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - a-rTy5ObAIIAuHnPbi0QNEf6bdVjNS7Wy0ONacM-98dAeg5EwEcMvQ== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '276' + status: + code: 200 + message: OK +- request: + body: '{"project_id": "08507b0c-d606-4609-aab2-935137072001", "name": "Generated + parameters API", "slug": "generated-parameters-api", "function_type": "parameters", + "function_data": {"type": "parameters", "data": {"prefix": "created"}, "__schema": + {"type": "object", "properties": {"prefix": {"type": "string"}}}}, "tags": ["python-sdk-vcr"]}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '335' + Content-Type: + - application/json + User-Agent: + - python-requests/2.34.2 + method: POST + uri: https://api.braintrust.dev/v1/function + response: + body: + string: '{"log_id":"p","project_id":"08507b0c-d606-4609-aab2-935137072001","slug":"generated-parameters-api","name":"Generated + parameters API","function_type":"parameters","function_data":{"type":"parameters","data":{"prefix":"created"},"__schema":{"type":"object","properties":{"prefix":{"type":"string"}}}},"tags":["python-sdk-vcr"],"id":"a1105df5-e620-4879-8639-6d0e88064b7e","created":"2026-09-10T14:03:19.646Z","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","_xact_id":"1000197839368165794"}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:19 GMT + ETag: + - W/"1e8-BY0ZAgesLRigq/bh42VeExhGf14" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 3340b5a392e45fce453c4d978abfd6be.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - u4QgMfNUdtj7HE0fSTDgMkipeu1RngJoFIcciT82-fFyTjhU8uF2MQ== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '488' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + User-Agent: + - python-requests/2.34.2 + method: GET + uri: https://api.braintrust.dev/v1/function?limit=1&project_id=08507b0c-d606-4609-aab2-935137072001&slug=generated-parameters-api + response: + body: + string: '{"objects":[{"_xact_id":"1000197839368165794","created":"2026-09-10T14:03:19.646Z","description":null,"function_data":{"data":{"prefix":"created"},"type":"parameters","__schema":{"type":"object","properties":{"prefix":{"type":"string"}}}},"function_schema":null,"function_type":"parameters","id":"a1105df5-e620-4879-8639-6d0e88064b7e","log_id":"p","metadata":null,"name":"Generated + parameters API","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","origin":null,"project_id":"08507b0c-d606-4609-aab2-935137072001","prompt_data":null,"slug":"generated-parameters-api","tags":["python-sdk-vcr"]}]}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:20 GMT + ETag: + - W/"251-tovtkSKxeFWJZhS5AFol14P4tS8" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 bc9d715161855640c4738aa7390d934e.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - YJky4TLSmEjp1gX4_9XbTSBfG3r6ZzyiCbLgJvlhbAQ7S3RCGK-cpA== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '593' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + User-Agent: + - python-requests/2.34.2 + method: GET + uri: https://api.braintrust.dev/v1/function/a1105df5-e620-4879-8639-6d0e88064b7e?version=1000197839368165794 + response: + body: + string: '{"_xact_id":"1000197839368165794","created":"2026-09-10T14:03:19.646Z","description":null,"function_data":{"data":{"prefix":"created"},"type":"parameters","__schema":{"type":"object","properties":{"prefix":{"type":"string"}}}},"function_schema":null,"function_type":"parameters","id":"a1105df5-e620-4879-8639-6d0e88064b7e","log_id":"p","metadata":null,"name":"Generated + parameters API","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","origin":null,"project_id":"08507b0c-d606-4609-aab2-935137072001","prompt_data":null,"slug":"generated-parameters-api","tags":["python-sdk-vcr"]}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:20 GMT + ETag: + - W/"243-XXeb1y2oCPX2Op1TahsZvNXG0MU" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 41c02c3f5acef4f58284b65a8f7a983a.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - iuN3WGbI_CSTqbqaLdL0CWLBc17qCiwJOVumbTIOsh2WlW5f5ac67w== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '579' + status: + code: 200 + message: OK +- request: + body: '{"description": "updated by the Python SDK VCR test"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '53' + Content-Type: + - application/json + User-Agent: + - python-requests/2.34.2 + method: PATCH + uri: https://api.braintrust.dev/v1/function/a1105df5-e620-4879-8639-6d0e88064b7e + response: + body: + string: '{"name":"Generated parameters API","slug":"generated-parameters-api","tags":["python-sdk-vcr"],"log_id":"p","project_id":"08507b0c-d606-4609-aab2-935137072001","function_data":{"data":{"prefix":"created"},"type":"parameters","__schema":{"type":"object","properties":{"prefix":{"type":"string"}}}},"function_type":"parameters","description":"updated + by the Python SDK VCR test","id":"a1105df5-e620-4879-8639-6d0e88064b7e","created":"2026-09-10T14:03:19.646Z","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","_xact_id":"1000197839368292659"}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:21 GMT + ETag: + - W/"21b-D0qHZDN7h+QbJLsOcN7cO2VnDks" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 4f3eaee3896fb5ad2377261bd0d773c8.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - vRovpELvNSnogKY7Aw2jBMh_r9AwT0aPdslF0BQYvuSXzL7W_VBrJg== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '539' + status: + code: 200 + message: OK +- request: + body: '{"project_id": "08507b0c-d606-4609-aab2-935137072001", "name": "Generated + parameters API", "slug": "generated-parameters-api", "function_type": "parameters", + "function_data": {"type": "parameters", "data": {"prefix": "replaced"}, "__schema": + {"type": "object", "properties": {"prefix": {"type": "string"}}}}, "tags": ["python-sdk-vcr"]}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '336' + Content-Type: + - application/json + User-Agent: + - python-requests/2.34.2 + method: PUT + uri: https://api.braintrust.dev/v1/function + response: + body: + string: '{"log_id":"p","project_id":"08507b0c-d606-4609-aab2-935137072001","slug":"generated-parameters-api","name":"Generated + parameters API","function_type":"parameters","function_data":{"type":"parameters","data":{"prefix":"replaced"},"__schema":{"type":"object","properties":{"prefix":{"type":"string"}}}},"tags":["python-sdk-vcr"],"id":"a1105df5-e620-4879-8639-6d0e88064b7e","created":"2026-09-10T14:03:21.493Z","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","_xact_id":"1000197839368294460"}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:21 GMT + ETag: + - W/"1e9-IVyvJrhCiwQDgAmkZuRVNE++q4M" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 41c02c3f5acef4f58284b65a8f7a983a.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - HiDFB_VaE39uMm_irn5D-Oj8LYSrz5TXlqdy7tHZUXYdU6foIGcecw== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '489' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - python-requests/2.34.2 + method: DELETE + uri: https://api.braintrust.dev/v1/function/a1105df5-e620-4879-8639-6d0e88064b7e + response: + body: + string: '{"log_id":"p","project_id":"08507b0c-d606-4609-aab2-935137072001","slug":"generated-parameters-api","id":"a1105df5-e620-4879-8639-6d0e88064b7e","created":"2026-09-10T14:03:21.720Z","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","_object_delete":true,"_xact_id":"1000197839368296140"}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:21 GMT + ETag: + - W/"11c-pgg0wzu1y6NXXOH+mJ/Gh0m+CV4" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 cb0c6226aa19d81a39519501df383968.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - ikHkJP1FKEVwVPHzs0mmcIro8qvyBzm3SuQxgcPw_tIp1laZp1ryNw== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '284' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - python-requests/2.34.2 + method: DELETE + uri: https://api.braintrust.dev/v1/project/08507b0c-d606-4609-aab2-935137072001 + response: + body: + string: '{"id":"08507b0c-d606-4609-aab2-935137072001","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","name":"python-sdk-generated-functions-vcr","description":null,"created":"2026-09-10T14:03:19.036Z","deleted_at":"2026-09-10T14:03:21.935Z","user_id":"c1f71e19-b3ce-4f59-89a9-055901f7755b","settings":null}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:21 GMT + ETag: + - W/"12a-edqlR7oYU1y36+zrRPNZcTgewbg" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 b734db9b28028c2ed717c3d72b3b45b8.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - wSyUNliryQsmF74V0JxVJlQRaM0x54qUwWmwB1A0yqu58vkZCkzFdA== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '298' + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/api/cassettes/test_high_level_load_parameters_uses_generated_resources.yaml b/py/src/braintrust/api/cassettes/test_high_level_load_parameters_uses_generated_resources.yaml new file mode 100644 index 00000000..6df09056 --- /dev/null +++ b/py/src/braintrust/api/cassettes/test_high_level_load_parameters_uses_generated_resources.yaml @@ -0,0 +1,505 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - python-requests/2.34.2 + method: POST + uri: https://www.braintrust.dev/api/apikey/login + response: + body: + string: '{"org_info":[{"id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","name":"Braintrust + SDKs","api_url":"https://api.braintrust.dev","git_metadata":{"collect":"some","fields":["commit","branch","tag","dirty","author_name","author_email","commit_message","commit_time"]},"is_universal_api":null,"proxy_url":"https://api.braintrust.dev","realtime_url":"wss://realtime.braintrustapi.com"}]}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Headers: + - X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, + Content-Type, Date, X-Api-Version + Access-Control-Allow-Methods: + - GET,OPTIONS,PATCH,DELETE,POST,PUT + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - public, max-age=0, must-revalidate + Content-Length: + - '376' + Content-Security-Policy: + - 'script-src ''self'' ''unsafe-eval'' ''wasm-unsafe-eval'' ''strict-dynamic'' + ''nonce-MmNmY2FkNWItYzg0MS00MDNkLWJmODMtOWY5N2JiZTljNDE3'' *.js.stripe.com + js.stripe.com maps.googleapis.com ; style-src ''self'' ''unsafe-inline'' *.braintrust.dev + btcm6qilbbhv4yi1.public.blob.vercel-storage.com fonts.googleapis.com www.gstatic.com + d4tuoctqmanu0.cloudfront.net; font-src ''self'' data: fonts.gstatic.com btcm6qilbbhv4yi1.public.blob.vercel-storage.com + cdn.jsdelivr.net d4tuoctqmanu0.cloudfront.net fonts.googleapis.com mintlify-assets.b-cdn.net + fonts.cdnfonts.com; object-src ''none''; base-uri ''self''; form-action ''self'' + https://www.facebook.com; frame-ancestors ''self''; worker-src ''self'' blob:; + report-uri https://o4507221741076480.ingest.us.sentry.io/api/4507221754380288/security/?sentry_key=27fa5ac907cf7c6ce4a1ab2a03f805b4&sentry_environment=production&sentry_release=16; + report-to csp-endpoint-0' + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:22 GMT + Etag: + - '"13vsc5ye8flag"' + Reporting-Endpoints: + - csp-endpoint-0="https://o4507221741076480.ingest.us.sentry.io/api/4507221754380288/security/?sentry_key=27fa5ac907cf7c6ce4a1ab2a03f805b4&sentry_environment=production&sentry_release=16" + Server: + - Vercel + Strict-Transport-Security: + - max-age=63072000 + X-Bt-Was-Udf-Cached: + - 'true' + X-Clerk-Auth-Message: + - Invalid JWT form. A JWT consists of three parts separated by dots. (reason=token-invalid, + token-carrier=header) + X-Clerk-Auth-Reason: + - token-invalid + X-Clerk-Auth-Status: + - signed-out + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Matched-Path: + - /api/apikey/login + X-Nonce: + - MmNmY2FkNWItYzg0MS00MDNkLWJmODMtOWY5N2JiZTljNDE3 + X-Vercel-Cache: + - MISS + X-Vercel-Id: + - yul1::iad1::lg4pc-1789049002106-43493da0fdf8 + status: + code: 200 + message: OK +- request: + body: '{"name": "python-sdk-high-level-parameters-vcr", "org_name": "Braintrust + SDKs"}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '79' + Content-Type: + - application/json + User-Agent: + - python-requests/2.34.2 + method: POST + uri: https://api.braintrust.dev/v1/project + response: + body: + string: '{"id":"220a46a8-95a5-4cd1-972d-b1f654a0ac52","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","name":"python-sdk-high-level-parameters-vcr","description":null,"created":"2026-09-10T14:03:22.397Z","deleted_at":null,"user_id":"c1f71e19-b3ce-4f59-89a9-055901f7755b","settings":null}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:22 GMT + ETag: + - W/"116-SLBYTvsMo521T13vSdIdpvJUj18" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 41c02c3f5acef4f58284b65a8f7a983a.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - ZI4IWIPhiSH5sE6bcMmChr4Q2Mr-HTZBP8gE7PSw9Efp37-9eQu8Kw== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '278' + status: + code: 200 + message: OK +- request: + body: '{"project_id": "220a46a8-95a5-4cd1-972d-b1f654a0ac52", "name": "Generated + parameters API", "slug": "generated-parameters-api", "function_type": "parameters", + "function_data": {"type": "parameters", "data": {"prefix": "loaded"}, "__schema": + {"type": "object", "properties": {"prefix": {"type": "string"}}}}, "tags": ["python-sdk-vcr"]}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '334' + Content-Type: + - application/json + User-Agent: + - python-requests/2.34.2 + method: POST + uri: https://api.braintrust.dev/v1/function + response: + body: + string: '{"log_id":"p","project_id":"220a46a8-95a5-4cd1-972d-b1f654a0ac52","slug":"generated-parameters-api","name":"Generated + parameters API","function_type":"parameters","function_data":{"type":"parameters","data":{"prefix":"loaded"},"__schema":{"type":"object","properties":{"prefix":{"type":"string"}}}},"tags":["python-sdk-vcr"],"id":"c860ec58-3532-489e-a57c-0895599cb0c0","created":"2026-09-10T14:03:22.680Z","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","_xact_id":"1000197839368361576"}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:22 GMT + ETag: + - W/"1e7-s3zmIkjd+I+ADesqUAS0CEpeNvY" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 777f4a7ed43b40353f84311869e119c8.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - A9sgPPHdaMhaQZf0tjmz4ehPoMRfFQBpEb1O2a-n37Q1RkkikC8ZgA== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '487' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - python-requests/2.34.2 + method: POST + uri: https://www.braintrust.dev/api/apikey/login + response: + body: + string: '{"org_info":[{"id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","name":"Braintrust + SDKs","api_url":"https://api.braintrust.dev","git_metadata":{"collect":"some","fields":["commit","branch","tag","dirty","author_name","author_email","commit_message","commit_time"]},"is_universal_api":null,"proxy_url":"https://api.braintrust.dev","realtime_url":"wss://realtime.braintrustapi.com"}]}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Headers: + - X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, + Content-Type, Date, X-Api-Version + Access-Control-Allow-Methods: + - GET,OPTIONS,PATCH,DELETE,POST,PUT + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - public, max-age=0, must-revalidate + Content-Length: + - '376' + Content-Security-Policy: + - 'script-src ''self'' ''unsafe-eval'' ''wasm-unsafe-eval'' ''strict-dynamic'' + ''nonce-NWVmY2QwZGUtZjJiMC00ZThmLTlhZjktN2MzM2EzZWM4YjU4'' *.js.stripe.com + js.stripe.com maps.googleapis.com ; style-src ''self'' ''unsafe-inline'' *.braintrust.dev + btcm6qilbbhv4yi1.public.blob.vercel-storage.com fonts.googleapis.com www.gstatic.com + d4tuoctqmanu0.cloudfront.net; font-src ''self'' data: fonts.gstatic.com btcm6qilbbhv4yi1.public.blob.vercel-storage.com + cdn.jsdelivr.net d4tuoctqmanu0.cloudfront.net fonts.googleapis.com mintlify-assets.b-cdn.net + fonts.cdnfonts.com; object-src ''none''; base-uri ''self''; form-action ''self'' + https://www.facebook.com; frame-ancestors ''self''; worker-src ''self'' blob:; + report-uri https://o4507221741076480.ingest.us.sentry.io/api/4507221754380288/security/?sentry_key=27fa5ac907cf7c6ce4a1ab2a03f805b4&sentry_environment=production&sentry_release=16; + report-to csp-endpoint-0' + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:22 GMT + Etag: + - '"13vsc5ye8flag"' + Reporting-Endpoints: + - csp-endpoint-0="https://o4507221741076480.ingest.us.sentry.io/api/4507221754380288/security/?sentry_key=27fa5ac907cf7c6ce4a1ab2a03f805b4&sentry_environment=production&sentry_release=16" + Server: + - Vercel + Strict-Transport-Security: + - max-age=63072000 + X-Bt-Was-Udf-Cached: + - 'true' + X-Clerk-Auth-Message: + - Invalid JWT form. A JWT consists of three parts separated by dots. (reason=token-invalid, + token-carrier=header) + X-Clerk-Auth-Reason: + - token-invalid + X-Clerk-Auth-Status: + - signed-out + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Matched-Path: + - /api/apikey/login + X-Nonce: + - NWVmY2QwZGUtZjJiMC00ZThmLTlhZjktN2MzM2EzZWM4YjU4 + X-Vercel-Cache: + - MISS + X-Vercel-Id: + - yul1::iad1::9j5tz-1789049002918-355f30d2ce51 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + User-Agent: + - python-requests/2.34.2 + method: GET + uri: https://api.braintrust.dev/v1/function?project_id=220a46a8-95a5-4cd1-972d-b1f654a0ac52&slug=generated-parameters-api&version=1000197839368361576 + response: + body: + string: '{"objects":[{"_xact_id":"1000197839368361576","created":"2026-09-10T14:03:22.680Z","description":null,"function_data":{"data":{"prefix":"loaded"},"type":"parameters","__schema":{"type":"object","properties":{"prefix":{"type":"string"}}}},"function_schema":null,"function_type":"parameters","id":"c860ec58-3532-489e-a57c-0895599cb0c0","log_id":"p","metadata":null,"name":"Generated + parameters API","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","origin":null,"project_id":"220a46a8-95a5-4cd1-972d-b1f654a0ac52","prompt_data":null,"slug":"generated-parameters-api","tags":["python-sdk-vcr"]}]}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:23 GMT + ETag: + - W/"250-5Ap7sLQ/Cj+82XqhxHesx2nv7bw" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 5a2f8eb373b5a17b769c0fee9b0725a6.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - TCGaCj2zhknP9JjUEtm1uSuwKEBUX5ZWM87cJAKwYF8hWhwpXp-aSA== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '592' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + User-Agent: + - python-requests/2.34.2 + method: GET + uri: https://api.braintrust.dev/v1/function/c860ec58-3532-489e-a57c-0895599cb0c0 + response: + body: + string: '{"_xact_id":"1000197839368361576","created":"2026-09-10T14:03:22.680Z","description":null,"function_data":{"data":{"prefix":"loaded"},"type":"parameters","__schema":{"type":"object","properties":{"prefix":{"type":"string"}}}},"function_schema":null,"function_type":"parameters","id":"c860ec58-3532-489e-a57c-0895599cb0c0","log_id":"p","metadata":null,"name":"Generated + parameters API","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","origin":null,"project_id":"220a46a8-95a5-4cd1-972d-b1f654a0ac52","prompt_data":null,"slug":"generated-parameters-api","tags":["python-sdk-vcr"]}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:24 GMT + ETag: + - W/"242-9w/rUjU/oOGWhUWcByY/bjBVpuw" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 6889869bf680fe34cca722f0a05e1106.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - zeKH8u93s1c7RxIGvwD-L9sdTMip-qHfOzcseloucxWEC76MylL7MQ== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '578' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - python-requests/2.34.2 + method: DELETE + uri: https://api.braintrust.dev/v1/function/c860ec58-3532-489e-a57c-0895599cb0c0 + response: + body: + string: '{"log_id":"p","project_id":"220a46a8-95a5-4cd1-972d-b1f654a0ac52","slug":"generated-parameters-api","id":"c860ec58-3532-489e-a57c-0895599cb0c0","created":"2026-09-10T14:03:25.311Z","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","_object_delete":true,"_xact_id":"1000197839368554840"}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:25 GMT + ETag: + - W/"11c-5lFiX8bRE1G3RLnRgtoX9l6jQjI" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 12aa3fefbdb5e80269e58f34f94a99e8.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - bk6P3WmUX2y9jw4TupTVJhT1KG5DjHIDSmNJf7bVYWdH2hlmqHnofg== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '284' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - python-requests/2.34.2 + method: DELETE + uri: https://api.braintrust.dev/v1/project/220a46a8-95a5-4cd1-972d-b1f654a0ac52 + response: + body: + string: '{"id":"220a46a8-95a5-4cd1-972d-b1f654a0ac52","org_id":"5abfae3a-7aa7-4653-a9c8-b3efcb18f584","name":"python-sdk-high-level-parameters-vcr","description":null,"created":"2026-09-10T14:03:22.397Z","deleted_at":"2026-09-10T14:03:25.525Z","user_id":"c1f71e19-b3ce-4f59-89a9-055901f7755b","settings":null}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Expose-Headers: + - x-bt-cursor,x-bt-found-existing,x-bt-query-plan,x-bt-api-duration-ms,x-bt-brainstore-duration-ms,x-bt-internal-trace-id,x-bt-error-origin,x-bt-used-endpoint,x-bt-overflow-url + Cache-Control: + - no-store, no-cache, must-revalidate, proxy-revalidate + Connection: + - keep-alive + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 10 Sep 2026 14:03:25 GMT + ETag: + - W/"12c-rsmGPAJrGkwumgrSkASM1dzJBKI" + Expires: + - '0' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Surrogate-Control: + - no-store + Transfer-Encoding: + - chunked + Vary: + - Origin, Accept-Encoding + Via: + - 1.1 4ec5f8da969dc981ba2067c9dad5dad8.cloudfront.net (CloudFront) + X-Amz-Cf-Id: + - t08r3co2VexePeTvCvr2UNABWHY99dmwRuqD7SISr89TSBHkvLFniw== + X-Amz-Cf-Pop: + - YTO50-P2 + X-Cache: + - Miss from cloudfront + content-length: + - '300' + status: + code: 200 + message: OK +version: 1 diff --git a/py/src/braintrust/api/client.py b/py/src/braintrust/api/client.py index 1206f420..46e535a2 100644 --- a/py/src/braintrust/api/client.py +++ b/py/src/braintrust/api/client.py @@ -157,12 +157,14 @@ def from_transport( def _initialize_services(self, api_key: str) -> None: from ._generated.datasets import DatasetsAPI from ._generated.experiments import ExperimentsAPI + from ._generated.functions import FunctionsAPI from ._generated.projects import ProjectsAPI from ._generated.prompts import PromptsAPI self.api_key = api_key self.datasets = DatasetsAPI(self.transport, self.router, api_key) self.experiments = ExperimentsAPI(self.transport, self.router, api_key) + self.functions = FunctionsAPI(self.transport, self.router, api_key) self.projects = ProjectsAPI(self.transport, self.router, api_key) self.prompts = PromptsAPI(self.transport, self.router, api_key) diff --git a/py/src/braintrust/api/test_functions.py b/py/src/braintrust/api/test_functions.py new file mode 100644 index 00000000..4e789d82 --- /dev/null +++ b/py/src/braintrust/api/test_functions.py @@ -0,0 +1,149 @@ +import contextlib + +import braintrust +import pytest +from braintrust.api import BraintrustClient, BraintrustOpenApiClient +from braintrust.api._generated.functions import OPERATIONS, FunctionsAPI +from braintrust.api._test_server import scripted_server +from braintrust.api.policies import RetryMode + + +def _create_function_body(project_id: str, prefix: str) -> dict: + return { + "project_id": project_id, + "name": "Generated parameters API", + "slug": "generated-parameters-api", + "function_type": "parameters", + "function_data": { + "type": "parameters", + "data": {"prefix": prefix}, + "__schema": { + "type": "object", + "properties": {"prefix": {"type": "string"}}, + }, + }, + "tags": ["python-sdk-vcr"], + } + + +def test_all_function_metadata_operations_have_complete_retry_classification(): + assert {name: operation.retry_mode for name, operation in OPERATIONS.items()} == { + "postFunction": RetryMode.NONE, + "putFunction": RetryMode.NONE, + "getFunction": RetryMode.SAFE_READ, + "getFunctionId": RetryMode.SAFE_READ, + "patchFunctionId": RetryMode.NONE, + "deleteFunctionId": RetryMode.NONE, + } + assert "postFunctionIdInvoke" not in OPERATIONS + assert not hasattr(FunctionsAPI, "post_function_id_invoke") + + +def test_get_function_preserves_exact_wire_query_and_additive_response_fields(): + response = b'{"objects":[{"id":"function-id","future_field":{"preserved":true}}]}' + with scripted_server([(200, {"Content-Type": "application/json"}, response)]) as (api_url, handler): + with BraintrustOpenApiClient(api_key="test-key", api_url=api_url) as client: + functions = client.functions.get_function( + limit=1, + project_name="test project", + slug="function/slug", + version="123", + ) + + assert handler.requests[0][:2] == ( + "GET", + "/v1/function?limit=1&project_name=test%20project&slug=function%2Fslug&version=123", + ) + assert functions["objects"][0]["future_field"] == {"preserved": True} + + +@pytest.mark.vcr +def test_functions_end_to_end_with_real_backend(api_key): + project_name = "python-sdk-generated-functions-vcr" + cleanup_project_id = None + cleanup_function_id = None + + with BraintrustClient(api_key=api_key) as client: + try: + discovery = client.auth.login() + project = client.openapi.projects.post_project( + body={"name": project_name, "org_name": discovery.organization.name} + ) + cleanup_project_id = project["id"] + created = client.openapi.functions.post_function( + body=_create_function_body(project["id"], prefix="created") + ) + cleanup_function_id = created["id"] + listed = client.openapi.functions.get_function( + project_id=project["id"], + slug=created["slug"], + limit=1, + ) + fetched = client.openapi.functions.get_function_id(created["id"], version=created["_xact_id"]) + updated = client.openapi.functions.patch_function_id( + created["id"], body={"description": "updated by the Python SDK VCR test"} + ) + replaced = client.openapi.functions.put_function( + body=_create_function_body(project["id"], prefix="replaced") + ) + deleted = client.openapi.functions.delete_function_id(created["id"]) + cleanup_function_id = None + client.openapi.projects.delete_project_id(project["id"]) + cleanup_project_id = None + finally: + if cleanup_function_id is not None: + with contextlib.suppress(Exception): + client.openapi.functions.delete_function_id(cleanup_function_id) + if cleanup_project_id is not None: + with contextlib.suppress(Exception): + client.openapi.projects.delete_project_id(cleanup_project_id) + + assert created["function_data"]["data"] == {"prefix": "created"} + assert [function["id"] for function in listed["objects"]] == [created["id"]] + assert fetched["id"] == created["id"] + assert updated["description"] == "updated by the Python SDK VCR test" + assert replaced["id"] == created["id"] + assert replaced["function_data"]["data"] == {"prefix": "replaced"} + assert deleted["id"] == created["id"] + + +@pytest.mark.vcr +def test_high_level_load_parameters_uses_generated_resources(api_key): + project_name = "python-sdk-high-level-parameters-vcr" + cleanup_project_id = None + cleanup_function_id = None + + with BraintrustClient(api_key=api_key) as client: + try: + discovery = client.auth.login() + project = client.openapi.projects.post_project( + body={"name": project_name, "org_name": discovery.organization.name} + ) + cleanup_project_id = project["id"] + created = client.openapi.functions.post_function( + body=_create_function_body(project["id"], prefix="loaded") + ) + cleanup_function_id = created["id"] + + by_slug = braintrust.load_parameters( + project_id=project["id"], + slug=created["slug"], + version=created["_xact_id"], + api_key=api_key, + org_name=discovery.organization.name, + ) + by_id = braintrust.load_parameters( + id=created["id"], + api_key=api_key, + org_name=discovery.organization.name, + ) + + assert by_slug.data == {"prefix": "loaded"} + assert by_id.id == created["id"] + finally: + if cleanup_function_id is not None: + with contextlib.suppress(Exception): + client.openapi.functions.delete_function_id(cleanup_function_id) + if cleanup_project_id is not None: + with contextlib.suppress(Exception): + client.openapi.projects.delete_project_id(cleanup_project_id) diff --git a/py/src/braintrust/api/test_generated_models.py b/py/src/braintrust/api/test_generated_models.py index afc6f778..0f518614 100644 --- a/py/src/braintrust/api/test_generated_models.py +++ b/py/src/braintrust/api/test_generated_models.py @@ -21,20 +21,24 @@ def test_import_braintrust_is_lazy_about_generated_api_modules(): def test_generated_models_import_on_supported_python(): from braintrust.api._generated import datasets as dataset_bindings from braintrust.api._generated import experiments as experiment_bindings + from braintrust.api._generated import functions as function_bindings from braintrust.api._generated import models from braintrust.api._generated import projects as project_bindings from braintrust.api._generated import prompts as prompt_bindings assert is_typeddict(models.Dataset) assert is_typeddict(models.Experiment) + assert is_typeddict(models.Function) assert is_typeddict(models.Project) assert is_typeddict(models.Prompt) assert models.DatasetIdParam is str assert models.ExperimentIdParam is str + assert models.FunctionIdParam is str assert models.ProjectIdParam is str assert models.PromptIdParam is str assert get_type_hints(dataset_bindings.DatasetsAPI.get_dataset)["return"] is models.GetDatasetResponse assert get_type_hints(experiment_bindings.ExperimentsAPI.get_experiment)["return"] is models.GetExperimentResponse + assert get_type_hints(function_bindings.FunctionsAPI.get_function)["return"] is models.GetFunctionResponse assert get_type_hints(project_bindings.ProjectsAPI.get_project)["return"] is models.GetProjectResponse assert get_type_hints(prompt_bindings.PromptsAPI.get_prompt)["return"] is models.GetPromptResponse @@ -47,10 +51,12 @@ def test_generated_package_content_is_installed(): assert generated.joinpath("models", "common.py").is_file() assert generated.joinpath("models", "datasets.py").is_file() assert generated.joinpath("models", "experiments.py").is_file() + assert generated.joinpath("models", "functions.py").is_file() assert generated.joinpath("models", "projects.py").is_file() assert generated.joinpath("models", "prompts.py").is_file() assert generated.joinpath("datasets.py").is_file() assert generated.joinpath("experiments.py").is_file() + assert generated.joinpath("functions.py").is_file() assert generated.joinpath("projects.py").is_file() assert generated.joinpath("prompts.py").is_file() @@ -61,4 +67,4 @@ def test_rest_and_logging_type_surfaces_have_reviewed_overlap(): overlap = set(generated_types.__all__) & set(types.__all__) - assert overlap == {"Dataset", "Experiment", "Project", "Prompt"} + assert overlap == {"Dataset", "Experiment", "Function", "Project", "Prompt"} diff --git a/py/src/braintrust/api/types/__init__.py b/py/src/braintrust/api/types/__init__.py index f12b1066..98b3cde4 100644 --- a/py/src/braintrust/api/types/__init__.py +++ b/py/src/braintrust/api/types/__init__.py @@ -3,6 +3,7 @@ from .._generated.models import ( CreateDataset, CreateExperiment, + CreateFunction, CreateProject, CreatePrompt, Dataset, @@ -13,8 +14,10 @@ FetchDatasetEventsResponse, FetchEventsRequest, FetchExperimentEventsResponse, + Function, GetDatasetResponse, GetExperimentResponse, + GetFunctionResponse, GetProjectResponse, GetPromptResponse, InsertDatasetEventRequest, @@ -22,6 +25,7 @@ InsertExperimentEventRequest, PatchDataset, PatchExperiment, + PatchFunction, PatchProject, PatchPrompt, Project, @@ -34,6 +38,7 @@ __all__ = [ "CreateDataset", "CreateExperiment", + "CreateFunction", "CreateProject", "CreatePrompt", "Dataset", @@ -44,8 +49,10 @@ "FetchDatasetEventsResponse", "FetchEventsRequest", "FetchExperimentEventsResponse", + "Function", "GetDatasetResponse", "GetExperimentResponse", + "GetFunctionResponse", "GetProjectResponse", "GetPromptResponse", "InsertDatasetEventRequest", @@ -53,6 +60,7 @@ "InsertExperimentEventRequest", "PatchDataset", "PatchExperiment", + "PatchFunction", "PatchProject", "PatchPrompt", "Project", diff --git a/py/src/braintrust/logger.py b/py/src/braintrust/logger.py index f5ffdb28..e44b9cfe 100644 --- a/py/src/braintrust/logger.py +++ b/py/src/braintrust/logger.py @@ -451,7 +451,7 @@ class _LoaderLoginOptions: cache_namespace: str -_LoaderResource = TypeVar("_LoaderResource", HTTPConnection, BraintrustClient) +_LoaderResource = TypeVar("_LoaderResource", bound=BraintrustClient) class _LoaderLoginEntry(Generic[_LoaderResource]): @@ -502,10 +502,6 @@ def _close(self) -> None: class BraintrustState: def __init__(self): self.id = str(uuid.uuid4()) - self._loader_login_cache: LRUCache[str, _LoaderLoginEntry[HTTPConnection]] = LRUCache( - max_size=16, - on_remove=self._evict_loader_login_entry, - ) self._loader_api_client_cache: LRUCache[str, _LoaderLoginEntry[BraintrustClient]] = LRUCache( max_size=16, on_remove=self._evict_loader_login_entry, @@ -591,7 +587,6 @@ def default_get_api_conn(): self._otel_flush_callback: Any | None = None def reset_login_info(self): - self._loader_login_cache.clear() self._loader_api_client_cache.clear() self.app_url: str | None = None @@ -673,7 +668,6 @@ async def flush_otel(self) -> None: def copy_state(self, other: "BraintrustState"): """Copy login information from another BraintrustState instance.""" - self._loader_login_cache.clear() self._loader_api_client_cache.clear() self.__dict__.update( { @@ -692,7 +686,6 @@ def copy_state(self, other: "BraintrustState"): "_last_otel_setting", "_context_manager_lock", "_client_lock", - "_loader_login_cache", "_loader_api_client_cache", ) } @@ -797,21 +790,6 @@ def _cached_loader_resource( finally: entry.release() - @contextlib.contextmanager - def loader_conn(self, options: "_LoaderLoginOptions") -> "Iterator[HTTPConnection]": - """Yield the API connection for one loader call, releasing it on exit.""" - - if self._uses_active_loader_login(options): - yield self.api_conn() - return - - with self._cached_loader_resource( - self._loader_login_cache, - options.cache_namespace, - lambda: _login_loader_conn(options), - ) as conn: - yield conn - @contextlib.contextmanager def loader_api_client(self, options: "_LoaderLoginOptions") -> "Iterator[BraintrustOpenApiClient]": """Yield the generated API client for one loader call, releasing it on exit.""" @@ -880,7 +858,6 @@ def set_http_adapter(adapter: HTTPAdapter) -> None: # Per-credential loader resources may have been created with the previous # adapter. Eviction closes them once any active requests release their lease; # subsequent loads recreate them with the new global adapter. - _state._loader_login_cache.clear() _state._loader_api_client_cache.clear() @@ -2253,7 +2230,6 @@ def load_parameters( effective_environment = None if version is not None else environment should_fall_back_to_cache = version is None and effective_environment is None - query_args = _populate_args({}, version=version, environment=effective_environment) login_options = _resolve_loader_login_options( app_url=app_url, api_key=api_key, @@ -2262,20 +2238,22 @@ def load_parameters( cache_namespace = login_options.cache_namespace try: - with _state.loader_conn(login_options) as conn: + with _state.loader_api_client(login_options) as api_client: if id: - response = conn.get_json(f"/v1/function/{id}", query_args) - if response is not None: - response = {"objects": [response]} + function = api_client.functions.get_function_id( + id, + version=str(version) if version is not None else None, + environment=effective_environment, + ) + response = {"objects": [function]} if function is not None else None else: - args = _populate_args( - {"function_type": "parameters"}, + response = api_client.functions.get_function( project_name=project, project_id=project_id, slug=slug, - **query_args, + version=str(version) if version is not None else None, + environment=effective_environment, ) - response = conn.get_json("/v1/function", args) except Exception as server_error: if not _is_loader_cache_fallback_error(server_error): raise @@ -2302,6 +2280,16 @@ def load_parameters( f"Parameters {slug} not found in {project or project_id} (not found on server or in local cache): {cache_error}" ) from server_error + if response is not None and "objects" in response: + response = { + **response, + "objects": [ + function + for function in response["objects"] + if function.get("function_data", {}).get("type") == "parameters" + ], + } + if response is None or "objects" not in response or len(response["objects"]) == 0: if id: raise ValueError(f"Parameters with id {id} not found.") @@ -2437,16 +2425,6 @@ def _login_loader_client(options: _LoaderLoginOptions) -> BraintrustClient: return client -def _login_loader_conn(options: _LoaderLoginOptions) -> HTTPConnection: - client, login_result = _login_with_api_key( - app_url=options.app_url, api_key=options.api_key, org_name=options.org_name - ) - try: - return _authenticated_api_conn(login_result.api_url, options.api_key) - finally: - client.close() - - def login_to_state( app_url: str | None = None, api_key: str | None = None, diff --git a/py/src/braintrust/test_logger.py b/py/src/braintrust/test_logger.py index 7f85eef3..7356a273 100644 --- a/py/src/braintrust/test_logger.py +++ b/py/src/braintrust/test_logger.py @@ -81,25 +81,6 @@ def _loader_options(api_key: str, cache_namespace: str) -> logger._LoaderLoginOp ) -def test_loader_request_state_closes_connections_on_eviction_and_reset(): - state = BraintrustState() - state._loader_login_cache = LRUCache(max_size=1, on_remove=state._evict_loader_login_entry) - first_conn = MagicMock() - second_conn = MagicMock() - with patch.object(logger, "_login_loader_conn", side_effect=[first_conn, second_conn]): - with state.loader_conn(_loader_options("first-api-key", "first")): - pass - with state.loader_conn(_loader_options("second-api-key", "second")): - pass - - first_conn.close.assert_called_once() - second_conn.close.assert_not_called() - - state.reset_login_info() - - second_conn.close.assert_called_once() - - def test_loader_request_state_closes_openapi_clients_on_eviction_and_reset(): state = BraintrustState() state._loader_api_client_cache = LRUCache(max_size=1, on_remove=state._evict_loader_login_entry) @@ -120,57 +101,6 @@ def test_loader_request_state_closes_openapi_clients_on_eviction_and_reset(): second_client.close.assert_called_once() -def test_loader_request_state_closes_state_evicted_while_login_is_pending(): - state = BraintrustState() - state._loader_login_cache = LRUCache(max_size=1, on_remove=state._evict_loader_login_entry) - pending_conn = MagicMock() - login_started = threading.Event() - release_login = threading.Event() - - def login(options): - if options.api_key == "slow-api-key": - login_started.set() - assert release_login.wait(5) - return pending_conn - return MagicMock() - - def run_slow_login(): - with state.loader_conn(_loader_options("slow-api-key", "first")): - pass - - with patch.object(logger, "_login_loader_conn", side_effect=login): - thread = threading.Thread(target=run_slow_login) - thread.start() - try: - assert login_started.wait(5) - # Evicts "first" while its login is still in flight, so the cache can no - # longer close whatever that login resolves into. - with state.loader_conn(_loader_options("fast-api-key", "second")): - pass - finally: - release_login.set() - thread.join(5) - - assert not thread.is_alive() - pending_conn.close.assert_called_once() - - -def test_loader_request_state_defers_close_until_last_holder_releases(): - state = BraintrustState() - state._loader_login_cache = LRUCache(max_size=1, on_remove=state._evict_loader_login_entry) - conn = MagicMock() - - with patch.object(logger, "_login_loader_conn", side_effect=[conn, MagicMock()]): - with state.loader_conn(_loader_options("first-api-key", "first")): - # Evicting while the caller is still issuing its request must not close - # the connection out from under it. - with state.loader_conn(_loader_options("second-api-key", "second")): - pass - conn.close.assert_not_called() - - conn.close.assert_called_once() - - class TestInit(TestCase): @staticmethod def _mock_api_client(): @@ -543,12 +473,12 @@ def test_load_parameters_uses_explicit_api_key_without_changing_global_login(): simulate_login() original_login_token = logger._state.login_token parameters_cache = ParametersCache(memory_cache=LRUCache(max_size=10)) - request_conn = MagicMock() - request_conn.get_json.return_value = _parameters_response("saved-parameters") + request_client = MagicMock() + request_client.openapi.functions.get_function.return_value = _parameters_response("saved-parameters") with ( patch.object(logger._state, "_parameters_cache", parameters_cache), - patch.object(logger, "_login_loader_conn", return_value=request_conn) as mock_login_conn, + patch.object(logger, "_login_loader_client", return_value=request_client) as mock_login_client, ): parameters = braintrust.load_parameters( project="test-project", @@ -557,13 +487,50 @@ def test_load_parameters_uses_explicit_api_key_without_changing_global_login(): ) assert parameters.data == {"prefix": "saved-parameters"} - (called_options,) = mock_login_conn.call_args.args + request_client.openapi.functions.get_function.assert_called_once_with( + project_name="test-project", + project_id=None, + slug="saved-parameters", + version=None, + environment=None, + ) + (called_options,) = mock_login_client.call_args.args assert called_options.app_url == logger._state.app_url assert called_options.api_key == "parameters-api-key" assert called_options.org_name is None assert logger._state.login_token == original_login_token +def test_load_parameters_filters_non_parameter_functions(): + simulate_login() + mock_api_client = MagicMock() + parameter = _parameters_response("saved-parameters")["objects"][0] + mock_api_client.functions.get_function.return_value = { + "objects": [ + {"id": "scorer-123", "function_data": {"type": "global"}}, + parameter, + ] + } + + with patch.object(logger._state, "api_client", return_value=mock_api_client): + parameters = braintrust.load_parameters(project="test-project", slug="saved-parameters") + + assert parameters.id == "parameters-saved-parameters" + assert parameters.data == {"prefix": "saved-parameters"} + + +def test_load_parameters_rejects_non_parameter_function(): + simulate_login() + mock_api_client = MagicMock() + mock_api_client.functions.get_function.return_value = { + "objects": [{"id": "scorer-123", "function_data": {"type": "global"}}] + } + + with patch.object(logger._state, "api_client", return_value=mock_api_client): + with pytest.raises(ValueError, match="Parameters saved-parameters not found"): + braintrust.load_parameters(project="test-project", slug="saved-parameters") + + @pytest.mark.parametrize( "server_error", [ @@ -809,8 +776,8 @@ def test_load_prompt_prefers_version_over_environment_for_id(self): ) def test_load_parameters_returns_remote_object(self): - mock_api_conn = MagicMock() - mock_api_conn.get_json.return_value = { + mock_api_client = MagicMock() + mock_api_client.functions.get_function.return_value = { "objects": [ { "id": "params-123", @@ -834,7 +801,7 @@ def test_load_parameters_returns_remote_object(self): } simulate_login() - with patch.object(logger._state, "api_conn", return_value=mock_api_conn): + with patch.object(logger._state, "api_client", return_value=mock_api_client): parameters = braintrust.load_parameters(project="test-project", slug="saved-parameters") assert isinstance(parameters, RemoteEvalParameters) @@ -857,8 +824,8 @@ def test_load_parameters_returns_remote_object(self): ) def test_load_parameters_prefers_version_over_environment_for_project_slug(self): - mock_api_conn = MagicMock() - mock_api_conn.get_json.return_value = { + mock_api_client = MagicMock() + mock_api_client.functions.get_function.return_value = { "objects": [ { "id": "params-123", @@ -882,7 +849,7 @@ def test_load_parameters_prefers_version_over_environment_for_project_slug(self) } simulate_login() - with patch.object(logger._state, "api_conn", return_value=mock_api_conn): + with patch.object(logger._state, "api_client", return_value=mock_api_client): parameters = braintrust.load_parameters( project="test-project", slug="saved-parameters", @@ -891,17 +858,17 @@ def test_load_parameters_prefers_version_over_environment_for_project_slug(self) ) assert parameters.version == "v1" - mock_api_conn.get_json.assert_called_once() - assert mock_api_conn.get_json.call_args.args[0] == "/v1/function" - assert mock_api_conn.get_json.call_args.args[1]["project_name"] == "test-project" - assert mock_api_conn.get_json.call_args.args[1]["slug"] == "saved-parameters" - assert mock_api_conn.get_json.call_args.args[1]["version"] == "v1" - assert mock_api_conn.get_json.call_args.args[1]["function_type"] == "parameters" - assert "environment" not in mock_api_conn.get_json.call_args.args[1] + mock_api_client.functions.get_function.assert_called_once_with( + project_name="test-project", + project_id=None, + slug="saved-parameters", + version="v1", + environment=None, + ) def test_load_parameters_prefers_version_over_environment_for_id(self): - mock_api_conn = MagicMock() - mock_api_conn.get_json.return_value = { + mock_api_client = MagicMock() + mock_api_client.functions.get_function_id.return_value = { "id": "params-123", "project_id": "project-123", "name": "Saved parameters", @@ -921,7 +888,7 @@ def test_load_parameters_prefers_version_over_environment_for_id(self): } simulate_login() - with patch.object(logger._state, "api_conn", return_value=mock_api_conn): + with patch.object(logger._state, "api_client", return_value=mock_api_client): parameters = braintrust.load_parameters( id="params-123", version="v1", @@ -929,10 +896,11 @@ def test_load_parameters_prefers_version_over_environment_for_id(self): ) assert parameters.id == "params-123" - mock_api_conn.get_json.assert_called_once() - assert mock_api_conn.get_json.call_args.args[0] == "/v1/function/params-123" - assert mock_api_conn.get_json.call_args.args[1]["version"] == "v1" - assert "environment" not in mock_api_conn.get_json.call_args.args[1] + mock_api_client.functions.get_function_id.assert_called_once_with( + "params-123", + version="v1", + environment=None, + ) def test_extract_attachments_no_op(self): attachments: list[BaseAttachment] = [] diff --git a/py/src/braintrust/type_tests/test_api_client.py b/py/src/braintrust/type_tests/test_api_client.py index 3388e014..3342a732 100644 --- a/py/src/braintrust/type_tests/test_api_client.py +++ b/py/src/braintrust/type_tests/test_api_client.py @@ -6,6 +6,7 @@ from braintrust.api.types import ( CreateDataset, CreateExperiment, + CreateFunction, CreateProject, CreatePrompt, Dataset, @@ -13,13 +14,16 @@ FetchDatasetEventsResponse, FetchEventsRequest, FetchExperimentEventsResponse, + Function, GetDatasetResponse, GetExperimentResponse, + GetFunctionResponse, GetProjectResponse, GetPromptResponse, InsertDatasetEventRequest, PatchDataset, PatchExperiment, + PatchFunction, PatchProject, PatchPrompt, Project, @@ -68,6 +72,27 @@ updated_prompt: Prompt = openapi_client.prompts.patch_prompt_id(prompt["id"], body=patch_prompt) deleted_prompt: Prompt = openapi_client.prompts.delete_prompt_id(prompt["id"]) + create_function: CreateFunction = { + "project_id": project["id"], + "name": "Typed parameters", + "slug": "typed-parameters", + "function_type": "parameters", + "function_data": { + "type": "parameters", + "data": {"prefix": "hello"}, + "__schema": {"type": "object", "properties": {}}, + }, + } + function: Function = openapi_client.functions.post_function(body=create_function) + replaced_function: Function = openapi_client.functions.put_function(body=create_function) + functions: GetFunctionResponse = openapi_client.functions.get_function( + project_id=project["id"], slug=function["slug"], limit=1 + ) + fetched_function: Function = openapi_client.functions.get_function_id(function["id"], version=function["_xact_id"]) + patch_function: PatchFunction = {"description": "updated"} + updated_function: Function = openapi_client.functions.patch_function_id(function["id"], body=patch_function) + deleted_function: Function = openapi_client.functions.delete_function_id(function["id"]) + create_dataset: CreateDataset = {"project_id": project["id"], "name": "typed-dataset"} dataset: Dataset = openapi_client.datasets.post_dataset(body=create_dataset) datasets: GetDatasetResponse = openapi_client.datasets.get_dataset(ids=[dataset["id"]], project_id=project["id"]) diff --git a/py/tests/api_codegen/conftest.py b/py/tests/api_codegen/conftest.py index c3bbf88d..cf8b48fc 100644 --- a/py/tests/api_codegen/conftest.py +++ b/py/tests/api_codegen/conftest.py @@ -16,6 +16,7 @@ def codegen_config(): config["endpoint_generator"]["generated_tags"] = ["Widgets"] config["endpoint_generator"]["safe_reads"] = [] config["endpoint_generator"]["idempotent_writes"] = [] + config["endpoint_generator"]["specialized_operations"] = [] return config diff --git a/py/tests/api_codegen/test_generation.py b/py/tests/api_codegen/test_generation.py index 680bff77..f6b2bd5f 100644 --- a/py/tests/api_codegen/test_generation.py +++ b/py/tests/api_codegen/test_generation.py @@ -1,6 +1,7 @@ import ast import copy import re +import runpy import pytest from openapi_codegen import ( @@ -46,13 +47,17 @@ def test_pinned_selected_spec_operations_match_generated_registries(): config = load_config(CONFIG_PATH) spec = read_and_verify_spec(config, SPEC_PATH) selected_tags = config["endpoint_generator"]["generated_tags"] + specialized_operations = set(config["endpoint_generator"]["specialized_operations"]) for tag in selected_tags: expected = { operation["operationId"] for path_item in spec["paths"].values() for method, operation in path_item.items() - if method != "options" and isinstance(operation, dict) and tag in operation.get("tags", []) + if method != "options" + and isinstance(operation, dict) + and tag in operation.get("tags", []) + and operation["operationId"] not in specialized_operations } tree = ast.parse((GENERATED_ROOT / f"{_snake_case(tag)}.py").read_text()) registry = next( @@ -86,6 +91,98 @@ def test_pinned_selected_spec_operations_match_generated_registries(): ) +def test_inline_models_do_not_take_component_names(tmp_path, codegen_config, minimal_spec): + minimal_spec["components"]["schemas"].update( + { + "Function": { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + "ToolCall": { + "type": "object", + "properties": { + "function": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + }, + "required": ["function"], + }, + } + ) + minimal_spec["components"]["schemas"]["Widget"]["properties"].update( + { + "saved_function": {"$ref": "#/components/schemas/Function"}, + "tool_call": {"$ref": "#/components/schemas/ToolCall"}, + } + ) + + generated = _generate(tmp_path, "inline-model-collision", codegen_config, minimal_spec) + models = _models_text(generated) + + assert "class Function(TypedDict):" in models + assert "class ToolCallFunction(TypedDict):" in models + assert "function: ToolCallFunction" in models + + +def test_contextual_inline_model_names_cannot_replace_components(tmp_path, codegen_config, minimal_spec): + minimal_spec["components"]["schemas"].update( + { + "Function": {"type": "object", "properties": {"id": {"type": "string"}}}, + "ToolCallFunction": { + "type": "object", + "properties": {"existing": {"type": "boolean"}}, + }, + "ToolCall": { + "type": "object", + "properties": { + "function": { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + }, + }, + } + ) + minimal_spec["components"]["schemas"]["Widget"]["properties"].update( + { + "saved_function": {"$ref": "#/components/schemas/Function"}, + "tool_call": {"$ref": "#/components/schemas/ToolCall"}, + "existing": {"$ref": "#/components/schemas/ToolCallFunction"}, + } + ) + + with pytest.raises( + CodegenError, + match="Contextual inline model 'ToolCallFunction'.*component schema 'ToolCallFunction'", + ): + _generate(tmp_path, "contextual-name-component-collision", codegen_config, minimal_spec) + + +def test_specialized_operations_are_not_generated(tmp_path, codegen_config, minimal_spec): + minimal_spec["paths"]["/widgets"] = { + "get": { + "operationId": "getWidgets", + "tags": ["Widgets"], + "responses": { + "200": { + "description": "OK", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Widget"}}}, + } + }, + } + } + codegen_config["endpoint_generator"]["specialized_operations"] = ["getWidget"] + + generated = _generate(tmp_path, "specialized-operation", codegen_config, minimal_spec) + bindings = (generated / "widgets.py").read_text() + + assert "def get_widgets(" in bindings + assert "def get_widget(" not in bindings + + def test_declarative_post_reads_use_safe_read_retry_mode(tmp_path, codegen_config, minimal_spec): minimal_spec["paths"]["/widgets"] = { "post": { @@ -300,19 +397,25 @@ def test_leading_underscore_model_fields_preserve_wire_names(tmp_path, codegen_c "_pagination_key", "_parent_id", "_xact_id", + "__schema", ) widget = minimal_spec["components"]["schemas"]["Widget"] widget["properties"].update({name: {"type": "string"} for name in wire_names}) - widget["required"].append("_xact_id") + widget["required"].extend(["_xact_id", "__schema"]) generated = _generate(tmp_path, "leading-underscore-fields", codegen_config, minimal_spec) models = _models_text(generated) for name in wire_names: - assert f" {name}:" in models - assert " _xact_id: str" in models + assert f' "{name}":' in models + assert ' "_xact_id": str,' in models + assert ' "__schema": str,' in models assert "field_" not in models + generated_types = runpy.run_path(str(generated / "models" / "widgets.py")) + assert "__schema" in generated_types["Widget"].__required_keys__ + assert "_Widget__schema" not in generated_types["Widget"].__required_keys__ + def test_nullable_and_missing_fields_remain_distinct(tmp_path, codegen_config, minimal_spec): spec = copy.deepcopy(minimal_spec) diff --git a/py/tests/api_codegen/test_validation.py b/py/tests/api_codegen/test_validation.py index 3bec3288..428a559a 100644 --- a/py/tests/api_codegen/test_validation.py +++ b/py/tests/api_codegen/test_validation.py @@ -129,6 +129,13 @@ def test_media_types_and_success_statuses_are_validated(minimal_spec, codegen_co validate_spec(spec, codegen_config) +def test_specialized_operations_must_belong_to_generated_tags(minimal_spec, codegen_config): + codegen_config["endpoint_generator"]["specialized_operations"] = ["missingOperation"] + + with pytest.raises(CodegenError, match="specialized_operations.*missingOperation"): + validate_spec(minimal_spec, codegen_config) + + def test_safe_reads_must_reference_generated_post_operations(minimal_spec, codegen_config): codegen_config["endpoint_generator"]["safe_reads"] = ["missingOperation"] @@ -264,6 +271,7 @@ def test_malformed_specs_and_configs_raise_actionable_errors(minimal_spec, codeg for key in ( "safe_reads", "idempotent_writes", + "specialized_operations", "supported_request_media_types", "supported_response_media_types", "supported_success_statuses", @@ -272,7 +280,7 @@ def test_malformed_specs_and_configs_raise_actionable_errors(minimal_spec, codeg del broken["endpoint_generator"][key] message = ( f"endpoint_generator.{key} must be a unique list" - if key in {"safe_reads", "idempotent_writes"} + if key in {"safe_reads", "idempotent_writes", "specialized_operations"} else f"endpoint_generator.{key} must be a non-empty list" ) with pytest.raises(CodegenError, match=message):