diff --git a/baseten/client/managementapi/__init__.py b/baseten/client/managementapi/__init__.py index 096442a..40f0b04 100644 --- a/baseten/client/managementapi/__init__.py +++ b/baseten/client/managementapi/__init__.py @@ -113,7 +113,6 @@ Chains, ChainTombstone, CheckpointFile, - ClassifierEnvironmentName, CleanupImagesParams, CleanupSandboxImagesResponse, Component, @@ -149,6 +148,10 @@ CreateRouteConnectionRequest, CreateRouteRequest, CreateSandboxParams, + CreateSandboxPreviewParams, + CreateSandboxPreviewRequest, + CreateSandboxPreviewTokenParams, + CreateSandboxPreviewTokenRequest, CreateSandboxRequest, CreateTokenRequest, CreateTrainingJob, @@ -177,6 +180,8 @@ DeleteImageParams, DeleteImageTagParams, DeleteSandboxParams, + DeleteSandboxPreviewParams, + DeleteSandboxPreviewTokenParams, DeleteVolumeRequest, DeleteVolumeResponse, DeleteVolumeTagRequest, @@ -298,6 +303,7 @@ GetSandboxLogsParams, GetSandboxMetricsParams, GetSandboxParams, + GetSandboxPreviewParams, GetTeamsLoopsRunsParams, GetTeamsLoopsSamplersParams, GetTeamsModelsParams, @@ -356,6 +362,10 @@ ListSandboxImagesResponse, ListSandboxImageTagsResponse, ListSandboxLibraryImagesResponse, + ListSandboxPreviewsParams, + ListSandboxPreviewsResponse, + ListSandboxPreviewTokensParams, + ListSandboxPreviewTokensResponse, ListTrainingJobsResponse, ListTrainingProjectsResponse, ListVolumeNamespacesResponse, @@ -452,6 +462,7 @@ RetryDeploymentResponse, RollingDeployConfig, Route, + RouteClassifierListingRef, RouteConnection, RouteConnectionConfigAnthropic, RouteConnectionConfigOpenAI, @@ -469,7 +480,7 @@ RoutesUsageResponse, RoutesUsageResult, RouteTargetBasetenModelAPI, - RouteTargetClassifierModelBased, + RouteTargetClassifier, RouteTargetConnection, RouteTeamSettings, RouteTeamSpendLimitSetting, @@ -498,6 +509,9 @@ SandboxMetricValueSet, SandboxNetwork, SandboxPort, + SandboxPreview, + SandboxPreviewConfiguration, + SandboxPreviewToken, SandboxProxyConfig, SandboxProxyTarget, SandboxRegion, @@ -596,6 +610,8 @@ UpdateRouteTeamSpendLimitSetting, UpdateRouteUserSettingsRequest, UpdateSandboxParams, + UpdateSandboxPreviewParams, + UpdateSandboxPreviewRequest, UpdateSandboxRequest, UpdateTrainingJobRequest, UpdateTrainingJobResponse, @@ -603,7 +619,7 @@ UpsertRouteConnectionConfigOpenAI, UpsertRouteConnectionConfigXAI, UpsertRouteTargetBasetenModelAPI, - UpsertRouteTargetClassifierModelBased, + UpsertRouteTargetClassifier, UpsertRouteTargetConnection, UpsertSecretRequest, UpsertTrainingProject, @@ -743,7 +759,6 @@ "ChainletEnvironmentSettingsRequest", "Chains", "CheckpointFile", - "ClassifierEnvironmentName", "CleanupImagesParams", "CleanupSandboxImagesResponse", "Component", @@ -778,6 +793,10 @@ "CreateRouteConnectionRequest", "CreateRouteRequest", "CreateSandboxParams", + "CreateSandboxPreviewParams", + "CreateSandboxPreviewRequest", + "CreateSandboxPreviewTokenParams", + "CreateSandboxPreviewTokenRequest", "CreateSandboxRequest", "CreateTokenRequest", "CreateTrainingJob", @@ -807,6 +826,8 @@ "DeleteImageParams", "DeleteImageTagParams", "DeleteSandboxParams", + "DeleteSandboxPreviewParams", + "DeleteSandboxPreviewTokenParams", "DeleteVolumeRequest", "DeleteVolumeResponse", "DeleteVolumeTagRequest", @@ -928,6 +949,7 @@ "GetSandboxLogsParams", "GetSandboxMetricsParams", "GetSandboxParams", + "GetSandboxPreviewParams", "GetTeamsLoopsRunsParams", "GetTeamsLoopsSamplersParams", "GetTeamsModelsParams", @@ -986,6 +1008,10 @@ "ListSandboxImageTagsResponse", "ListSandboxImagesResponse", "ListSandboxLibraryImagesResponse", + "ListSandboxPreviewTokensParams", + "ListSandboxPreviewTokensResponse", + "ListSandboxPreviewsParams", + "ListSandboxPreviewsResponse", "ListSandboxesParams", "ListSandboxesResponse", "ListTrainingJobsResponse", @@ -1083,6 +1109,7 @@ "RetryDeploymentResponse", "RollingDeployConfig", "Route", + "RouteClassifierListingRef", "RouteConnection", "RouteConnectionConfigAnthropic", "RouteConnectionConfigOpenAI", @@ -1096,7 +1123,7 @@ "RouteSpendLimitSetting", "RouteSpendLimitTeamDefault", "RouteTargetBasetenModelAPI", - "RouteTargetClassifierModelBased", + "RouteTargetClassifier", "RouteTargetConnection", "RouteTeamSettings", "RouteTeamSpendLimitSetting", @@ -1129,6 +1156,9 @@ "SandboxMetrics", "SandboxNetwork", "SandboxPort", + "SandboxPreview", + "SandboxPreviewConfiguration", + "SandboxPreviewToken", "SandboxProxyConfig", "SandboxProxyTarget", "SandboxRegion", @@ -1227,6 +1257,8 @@ "UpdateRouteTeamSpendLimitSetting", "UpdateRouteUserSettingsRequest", "UpdateSandboxParams", + "UpdateSandboxPreviewParams", + "UpdateSandboxPreviewRequest", "UpdateSandboxRequest", "UpdateTrainingJobRequest", "UpdateTrainingJobResponse", @@ -1234,7 +1266,7 @@ "UpsertRouteConnectionConfigOpenAI", "UpsertRouteConnectionConfigXAI", "UpsertRouteTargetBasetenModelAPI", - "UpsertRouteTargetClassifierModelBased", + "UpsertRouteTargetClassifier", "UpsertRouteTargetConnection", "UpsertSecretRequest", "UpsertTrainingProject", diff --git a/baseten/client/managementapi/_client.py b/baseten/client/managementapi/_client.py index 13acdd1..8ed4149 100644 --- a/baseten/client/managementapi/_client.py +++ b/baseten/client/managementapi/_client.py @@ -50,6 +50,10 @@ CreateRouteConnectionRequest, CreateRouteRequest, CreateSandboxParams, + CreateSandboxPreviewParams, + CreateSandboxPreviewRequest, + CreateSandboxPreviewTokenParams, + CreateSandboxPreviewTokenRequest, CreateSandboxRequest, CreateTokenRequest, CreateTrainingJobRequest, @@ -64,6 +68,8 @@ DeleteImageParams, DeleteImageTagParams, DeleteSandboxParams, + DeleteSandboxPreviewParams, + DeleteSandboxPreviewTokenParams, DeleteVolumeRequest, DeleteVolumeResponse, DeleteVolumeTagRequest, @@ -144,6 +150,7 @@ GetSandboxLogsParams, GetSandboxMetricsParams, GetSandboxParams, + GetSandboxPreviewParams, GetTeamsLoopsRunsParams, GetTeamsLoopsSamplersParams, GetTeamsModelsParams, @@ -188,6 +195,10 @@ ListSandboxImagesResponse, ListSandboxImageTagsResponse, ListSandboxLibraryImagesResponse, + ListSandboxPreviewsParams, + ListSandboxPreviewsResponse, + ListSandboxPreviewTokensParams, + ListSandboxPreviewTokensResponse, ListTrainingJobsResponse, ListTrainingProjectsResponse, ListVolumeNamespacesResponse, @@ -245,6 +256,8 @@ SandboxImageBuildLogsResponse, SandboxLogs, SandboxMetrics, + SandboxPreview, + SandboxPreviewToken, SearchTrainingJobsRequest, SearchTrainingJobsResponse, Secret, @@ -290,6 +303,8 @@ UpdateRouteTeamSettingsRequest, UpdateRouteUserSettingsRequest, UpdateSandboxParams, + UpdateSandboxPreviewParams, + UpdateSandboxPreviewRequest, UpdateSandboxRequest, UpdateTrainingJobRequest, UpdateTrainingJobResponse, @@ -405,6 +420,49 @@ def create_sandbox( ), ) + def create_sandbox_preview( + self, + *, + sandbox_name: str, + params: CreateSandboxPreviewParams | None = None, + request: CreateSandboxPreviewRequest, + ) -> SandboxPreview: + """Create a sandbox preview""" + return self._do_json( + SandboxPreview, + _ApiRequest( + method="POST", + path_fmt="/v1/sandboxes/instances/{}/previews", + path_args=[sandbox_name], + body=request, + query=params, + success_codes=[201], + error_codes=None, + ), + ) + + def create_sandbox_preview_token( + self, + *, + sandbox_name: str, + preview_name: str, + params: CreateSandboxPreviewTokenParams | None = None, + request: CreateSandboxPreviewTokenRequest, + ) -> SandboxPreviewToken: + """Create a preview token""" + return self._do_json( + SandboxPreviewToken, + _ApiRequest( + method="POST", + path_fmt="/v1/sandboxes/instances/{}/previews/{}/tokens", + path_args=[sandbox_name, preview_name], + body=request, + query=params, + success_codes=[201], + error_codes=None, + ), + ) + def delete_api_keys(self, *, api_key_prefix: str) -> APIKeyTombstone: """Deletes an API key by prefix""" return self._do_json( @@ -654,6 +712,48 @@ def delete_sandbox( ), ) + def delete_sandbox_preview( + self, + *, + sandbox_name: str, + preview_name: str, + params: DeleteSandboxPreviewParams | None = None, + ) -> SandboxPreview: + """Delete a sandbox preview""" + return self._do_json( + SandboxPreview, + _ApiRequest( + method="DELETE", + path_fmt="/v1/sandboxes/instances/{}/previews/{}", + path_args=[sandbox_name, preview_name], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + + def delete_sandbox_preview_token( + self, + *, + sandbox_name: str, + preview_name: str, + token_name: str, + params: DeleteSandboxPreviewTokenParams | None = None, + ) -> None: + """Delete a preview token""" + self._do_no_response( + _ApiRequest( + method="DELETE", + path_fmt="/v1/sandboxes/instances/{}/previews/{}/tokens/{}", + path_args=[sandbox_name, preview_name, token_name], + body=None, + query=params, + success_codes=[204], + error_codes=None, + ) + ) + def delete_secrets(self, *, secret_name: str) -> SecretTombstone: """Deletes a secret by name""" return self._do_json( @@ -2192,6 +2292,27 @@ def get_sandbox_metrics( ), ) + def get_sandbox_preview( + self, + *, + sandbox_name: str, + preview_name: str, + params: GetSandboxPreviewParams | None = None, + ) -> SandboxPreview: + """Get a sandbox preview""" + return self._do_json( + SandboxPreview, + _ApiRequest( + method="GET", + path_fmt="/v1/sandboxes/instances/{}/previews/{}", + path_args=[sandbox_name, preview_name], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + def get_secrets(self) -> Secrets: """Gets all secrets (metadata only, no plain text keys)""" return self._do_json( @@ -2792,6 +2913,44 @@ def list_sandbox_library_images(self) -> ListSandboxLibraryImagesResponse: ), ) + def list_sandbox_preview_tokens( + self, + *, + sandbox_name: str, + preview_name: str, + params: ListSandboxPreviewTokensParams | None = None, + ) -> ListSandboxPreviewTokensResponse: + """List preview tokens""" + return self._do_json( + ListSandboxPreviewTokensResponse, + _ApiRequest( + method="GET", + path_fmt="/v1/sandboxes/instances/{}/previews/{}/tokens", + path_args=[sandbox_name, preview_name], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + + def list_sandbox_previews( + self, *, sandbox_name: str, params: ListSandboxPreviewsParams | None = None + ) -> ListSandboxPreviewsResponse: + """List sandbox previews""" + return self._do_json( + ListSandboxPreviewsResponse, + _ApiRequest( + method="GET", + path_fmt="/v1/sandboxes/instances/{}/previews", + path_args=[sandbox_name], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + def list_sandboxes( self, *, params: ListSandboxesParams | None = None ) -> ListSandboxesResponse: @@ -4574,6 +4733,28 @@ def update_sandbox( ), ) + def update_sandbox_preview( + self, + *, + sandbox_name: str, + preview_name: str, + params: UpdateSandboxPreviewParams | None = None, + request: UpdateSandboxPreviewRequest, + ) -> SandboxPreview: + """Update a sandbox preview""" + return self._do_json( + SandboxPreview, + _ApiRequest( + method="PATCH", + path_fmt="/v1/sandboxes/instances/{}/previews/{}", + path_args=[sandbox_name, preview_name], + body=request, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + def _build_request(self, request: _ApiRequest) -> httpx.Request: path = request.path_fmt.format( *[urllib.parse.quote(a, safe="") for a in request.path_args] @@ -4644,6 +4825,9 @@ def _do_json(self, response_type: type[_T], request: _ApiRequest) -> _T: ) return response_type.model_validate_json(response.content) + def _do_no_response(self, request: _ApiRequest) -> None: + self._do(request) + class AsyncApiClient: """Generated HTTP client for the Baseten API. @@ -4718,6 +4902,49 @@ async def create_sandbox( ), ) + async def create_sandbox_preview( + self, + *, + sandbox_name: str, + params: CreateSandboxPreviewParams | None = None, + request: CreateSandboxPreviewRequest, + ) -> SandboxPreview: + """Create a sandbox preview""" + return await self._do_json( + SandboxPreview, + _ApiRequest( + method="POST", + path_fmt="/v1/sandboxes/instances/{}/previews", + path_args=[sandbox_name], + body=request, + query=params, + success_codes=[201], + error_codes=None, + ), + ) + + async def create_sandbox_preview_token( + self, + *, + sandbox_name: str, + preview_name: str, + params: CreateSandboxPreviewTokenParams | None = None, + request: CreateSandboxPreviewTokenRequest, + ) -> SandboxPreviewToken: + """Create a preview token""" + return await self._do_json( + SandboxPreviewToken, + _ApiRequest( + method="POST", + path_fmt="/v1/sandboxes/instances/{}/previews/{}/tokens", + path_args=[sandbox_name, preview_name], + body=request, + query=params, + success_codes=[201], + error_codes=None, + ), + ) + async def delete_api_keys(self, *, api_key_prefix: str) -> APIKeyTombstone: """Deletes an API key by prefix""" return await self._do_json( @@ -4967,6 +5194,48 @@ async def delete_sandbox( ), ) + async def delete_sandbox_preview( + self, + *, + sandbox_name: str, + preview_name: str, + params: DeleteSandboxPreviewParams | None = None, + ) -> SandboxPreview: + """Delete a sandbox preview""" + return await self._do_json( + SandboxPreview, + _ApiRequest( + method="DELETE", + path_fmt="/v1/sandboxes/instances/{}/previews/{}", + path_args=[sandbox_name, preview_name], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + + async def delete_sandbox_preview_token( + self, + *, + sandbox_name: str, + preview_name: str, + token_name: str, + params: DeleteSandboxPreviewTokenParams | None = None, + ) -> None: + """Delete a preview token""" + await self._do_no_response( + _ApiRequest( + method="DELETE", + path_fmt="/v1/sandboxes/instances/{}/previews/{}/tokens/{}", + path_args=[sandbox_name, preview_name, token_name], + body=None, + query=params, + success_codes=[204], + error_codes=None, + ) + ) + async def delete_secrets(self, *, secret_name: str) -> SecretTombstone: """Deletes a secret by name""" return await self._do_json( @@ -6511,6 +6780,27 @@ async def get_sandbox_metrics( ), ) + async def get_sandbox_preview( + self, + *, + sandbox_name: str, + preview_name: str, + params: GetSandboxPreviewParams | None = None, + ) -> SandboxPreview: + """Get a sandbox preview""" + return await self._do_json( + SandboxPreview, + _ApiRequest( + method="GET", + path_fmt="/v1/sandboxes/instances/{}/previews/{}", + path_args=[sandbox_name, preview_name], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + async def get_secrets(self) -> Secrets: """Gets all secrets (metadata only, no plain text keys)""" return await self._do_json( @@ -7113,6 +7403,44 @@ async def list_sandbox_library_images(self) -> ListSandboxLibraryImagesResponse: ), ) + async def list_sandbox_preview_tokens( + self, + *, + sandbox_name: str, + preview_name: str, + params: ListSandboxPreviewTokensParams | None = None, + ) -> ListSandboxPreviewTokensResponse: + """List preview tokens""" + return await self._do_json( + ListSandboxPreviewTokensResponse, + _ApiRequest( + method="GET", + path_fmt="/v1/sandboxes/instances/{}/previews/{}/tokens", + path_args=[sandbox_name, preview_name], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + + async def list_sandbox_previews( + self, *, sandbox_name: str, params: ListSandboxPreviewsParams | None = None + ) -> ListSandboxPreviewsResponse: + """List sandbox previews""" + return await self._do_json( + ListSandboxPreviewsResponse, + _ApiRequest( + method="GET", + path_fmt="/v1/sandboxes/instances/{}/previews", + path_args=[sandbox_name], + body=None, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + async def list_sandboxes( self, *, params: ListSandboxesParams | None = None ) -> ListSandboxesResponse: @@ -8911,6 +9239,28 @@ async def update_sandbox( ), ) + async def update_sandbox_preview( + self, + *, + sandbox_name: str, + preview_name: str, + params: UpdateSandboxPreviewParams | None = None, + request: UpdateSandboxPreviewRequest, + ) -> SandboxPreview: + """Update a sandbox preview""" + return await self._do_json( + SandboxPreview, + _ApiRequest( + method="PATCH", + path_fmt="/v1/sandboxes/instances/{}/previews/{}", + path_args=[sandbox_name, preview_name], + body=request, + query=params, + success_codes=[200], + error_codes=None, + ), + ) + def _build_request(self, request: _ApiRequest) -> httpx.Request: path = request.path_fmt.format( *[urllib.parse.quote(a, safe="") for a in request.path_args] @@ -8980,3 +9330,6 @@ async def _do_json(self, response_type: type[_T], request: _ApiRequest) -> _T: f"unexpected content type {content_type!r}, expected application/json" ) return response_type.model_validate_json(response.content) + + async def _do_no_response(self, request: _ApiRequest) -> None: + await self._do(request) diff --git a/baseten/client/managementapi/_models.py b/baseten/client/managementapi/_models.py index 316a47f..b6a1eba 100644 --- a/baseten/client/managementapi/_models.py +++ b/baseten/client/managementapi/_models.py @@ -1007,6 +1007,128 @@ class DeleteSandboxParams(BaseModel): ] = None +class ListSandboxPreviewsParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + cursor: Annotated[ + str | None, + Field( + description="Opaque cursor from the previous page; omit for the first page.", + examples=[ + "eyJ2IjoxLCJsYXN0X2tleSI6ImJhc2V0ZW4tYXBpLXJldmlldy0wOTE2Iiwic29ydCI6ImRlc2MifQ" + ], + ), + ] = None + limit: Annotated[ + int, + Field( + description="Maximum number of items to return.", + examples=[20], + ge=1, + le=100, + ), + ] = 20 + + +class CreateSandboxPreviewParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + + +class GetSandboxPreviewParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + show_secrets: Annotated[ + bool, + Field( + description="Reveal request header values for workspace administrators, for example to change one header and send the full set back in a PATCH. Defaults to false. Callers without the admin role receive masked values even when true." + ), + ] = False + + +class UpdateSandboxPreviewParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + + +class DeleteSandboxPreviewParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + + +class ListSandboxPreviewTokensParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + cursor: Annotated[ + str | None, + Field( + description="Opaque cursor from the previous page; omit for the first page.", + examples=[ + "eyJ2IjoxLCJsYXN0X2tleSI6ImJhc2V0ZW4tYXBpLXJldmlldy0wOTE2Iiwic29ydCI6ImRlc2MifQ" + ], + ), + ] = None + limit: Annotated[ + int, + Field( + description="Maximum number of items to return.", + examples=[20], + ge=1, + le=100, + ), + ] = 20 + + +class CreateSandboxPreviewTokenParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + + +class DeleteSandboxPreviewTokenParams(BaseModel): + team_id: Annotated[ + str | None, + Field( + description="Optional team ID. Must match X-Team-Id when both are supplied. If neither selector is supplied, defaults to the caller's only accessible team. Callers with multiple accessible teams must select a team. Requests without access to any team are forbidden.", + min_length=1, + ), + ] = None + + class ListImagesParams(BaseModel): team_id: Annotated[ str | None, @@ -8235,6 +8357,25 @@ class ExploreMetadataAPIFormats(BaseModel): ] = False +class RouteClassifierListingRef(BaseModel): + id: Annotated[ + str, + Field( + description="Stable classifier identifier.", + examples=["general-v1"], + title="Id", + ), + ] + name: Annotated[ + str, + Field( + description="Display name of the classifier.", + examples=["General"], + title="Name", + ), + ] + + class RouteRef(BaseModel): id: Annotated[str, Field(description="Stable route identifier.", title="Id")] slug: Annotated[ @@ -8257,27 +8398,15 @@ class RouteTargetBasetenModelAPI(BaseModel): ] -class RouteTargetClassifierModelBased(BaseModel): +class RouteTargetClassifier(BaseModel): type: Annotated[ - Literal["CLASSIFIER_MODEL_BASED"], - Field( - description="Target kind for a classifier-model-based route.", title="Type" - ), - ] - classifier_model_id: Annotated[ - str, - Field( - description="ID of the Baseten model that picks a route for each request.", - examples=["abcd123"], - title="Classifier Model Id", - ), + Literal["CLASSIFIER"], + Field(description="Target kind for a classifier-based route.", title="Type"), ] - classifier_environment_name: Annotated[ - str | None, + classifier: Annotated[ + RouteClassifierListingRef | None, Field( - description="Environment of the classifier model. Null for production.", - examples=["production"], - title="Classifier Environment Name", + description="Classifier that picks a route for each request. Null for routers created before classifiers could be selected." ), ] default_route: Annotated[ @@ -8329,46 +8458,23 @@ class UpsertRouteTargetBasetenModelAPI(BaseModel): ] -class ClassifierEnvironmentName(RootModel[str]): - root: Annotated[ - str | None, - Field( - description="Environment of the classifier model. Omit for production, which is returned as null.", - examples=["production"], - min_length=1, - title="Classifier Environment Name", - ), - ] = None - - -class UpsertRouteTargetClassifierModelBased(BaseModel): +class UpsertRouteTargetClassifier(BaseModel): model_config = ConfigDict( extra="forbid", ) type: Annotated[ - Literal["CLASSIFIER_MODEL_BASED"], - Field( - description="Target kind for a classifier-model-based route. Not intended for general use: classifiers are deployed by Baseten's post-training team.", - title="Type", - ), + Literal["CLASSIFIER"], + Field(description="Target kind for a classifier-based route.", title="Type"), ] - classifier_model_id: Annotated[ + classifier_id: Annotated[ str, Field( - description="ID of the Baseten model that picks a route for each request. Only classifiers deployed by Baseten's post-training team are supported.", - examples=["abcd123"], + description="ID of the classifier that picks a route for each request.", + examples=["general-v1"], min_length=1, - title="Classifier Model Id", + title="Classifier Id", ), ] - classifier_environment_name: Annotated[ - ClassifierEnvironmentName | None, - Field( - description="Environment of the classifier model. Omit for production, which is returned as null.", - examples=["production"], - title="Classifier Environment Name", - ), - ] = None allowed_route_ids: Annotated[ list[str], Field( @@ -8476,7 +8582,7 @@ class CreateRouteRequest(BaseModel): Annotated[ UpsertRouteTargetBasetenModelAPI | UpsertRouteTargetConnection - | UpsertRouteTargetClassifierModelBased, + | UpsertRouteTargetClassifier, Field( description="Upstream target for the route. CONNECTION targets use the provider and credentials of a connection owned by the route's team.", discriminator="type", @@ -8605,7 +8711,7 @@ class RouteTeamSpendLimitSetting(BaseModel): per_member_monthly_limit_usd: Annotated[ str | None, Field( - description="Spend limit in USD for each UTC calendar month that applies to each member's Code spend in this team, unless the member has a limit of their own. Returned as an exact decimal string. Null when the team has no per-member limit.", + description="Spend limit in USD for each UTC calendar month that applies to each member's Grid spend in this team, unless the member has a limit of their own. Returned as an exact decimal string. Null when the team has no per-member limit.", examples=["200"], title="Per Member Monthly Limit Usd", ), @@ -8613,7 +8719,7 @@ class RouteTeamSpendLimitSetting(BaseModel): team_wide_monthly_limit_usd: Annotated[ str | None, Field( - description="Spend limit in USD for each UTC calendar month on the Code spend of all members in this team together. Once it is reached, every member's requests in the team are rejected. Returned as an exact decimal string. Null when the team has no team-wide limit.", + description="Spend limit in USD for each UTC calendar month on the Grid spend of all members in this team together. Once it is reached, every member's requests in the team are rejected. Returned as an exact decimal string. Null when the team has no team-wide limit.", examples=["5000"], title="Team Wide Monthly Limit Usd", ), @@ -8717,7 +8823,7 @@ class UpdateRouteTeamSpendLimitSetting(BaseModel): per_member_monthly_limit_usd: Annotated[ str | None, Field( - description="Spend limit in USD for each UTC calendar month that applies to each member's Code spend in this team, unless the member has a limit of their own. Send null to remove it; omit to leave it unchanged.", + description="Spend limit in USD for each UTC calendar month that applies to each member's Grid spend in this team, unless the member has a limit of their own. Send null to remove it; omit to leave it unchanged.", examples=["200"], title="Per Member Monthly Limit Usd", ), @@ -8725,7 +8831,7 @@ class UpdateRouteTeamSpendLimitSetting(BaseModel): team_wide_monthly_limit_usd: Annotated[ str | None, Field( - description="Spend limit in USD for each UTC calendar month on the Code spend of all members in this team together. Send null to remove it; omit to leave it unchanged.", + description="Spend limit in USD for each UTC calendar month on the Grid spend of all members in this team together. Send null to remove it; omit to leave it unchanged.", examples=["5000"], title="Team Wide Monthly Limit Usd", ), @@ -9064,10 +9170,10 @@ class UpdateRouteRequest(BaseModel): target: Annotated[ UpsertRouteTargetBasetenModelAPI | UpsertRouteTargetConnection - | UpsertRouteTargetClassifierModelBased + | UpsertRouteTargetClassifier | None, Field( - description="Complete new target configuration. Omit to keep the current configuration; null is not accepted. Only the CLASSIFIER_MODEL_BASED configuration of a router route is mutable.", + description="Complete new target configuration. Omit to keep the current configuration; null is not accepted. Only the CLASSIFIER configuration of a router route is mutable.", title="Target", ), ] = None @@ -9917,6 +10023,164 @@ class CleanupSandboxImagesResponse(BaseModel): ] +class SandboxPreviewConfiguration(BaseModel): + public: Annotated[ + bool, + Field( + description="Whether the URL is reachable without a preview token. Defaults to false.", + examples=[True], + ), + ] = False + response_headers: Annotated[ + dict[str, str] | None, + Field( + description="Headers added to every response served by the preview, for example CORS headers." + ), + ] = None + request_headers: Annotated[ + dict[str, str] | None, + Field( + description='Headers added to every request forwarded to the sandbox, for example an Authorization header expected by the application. Responses return each name with the value "****", except a GET with show_secrets=true by a workspace administrator. Sending "****" back as a value returns 400. A PATCH replaces the whole set.', + examples=[{"Authorization": "Bearer app-internal-token"}], + ), + ] = None + ttl: Annotated[ + str | None, + Field( + description="Lifetime counted from the request, using seconds, minutes, hours or composite durations such as 1h30m. Whole days are also supported, for example 7d; days cannot be combined with other units. The preview is deleted when it elapses.", + examples=["24h"], + pattern="^([0-9]+d|(([0-9]+(\\.[0-9]*)?|\\.[0-9]+)(ns|us|µs|μs|ms|s|m|h))+)$", + ), + ] = None + expires_at: Annotated[ + AwareDatetime | None, + Field( + description="Time at which the preview is deleted. Must be in the future.", + examples=["2026-10-19T12:00:00Z"], + ), + ] = None + + +class CreateSandboxPreviewRequest(SandboxPreviewConfiguration): + create_if_not_exists: Annotated[ + bool | None, + Field( + description="When true, return the existing preview with this name instead of failing with a conflict. Without name, the name is public- or private-, so the same port and visibility return the same preview. Existing configuration is preserved. Defaults to false when omitted." + ), + ] = None + name: Annotated[ + str | None, + Field( + description="Preview name, unique within the sandbox. When omitted, generated from the sandbox name and port, or public- or private- when create_if_not_exists is true.", + examples=["web"], + max_length=49, + min_length=1, + pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + ), + ] = None + port: Annotated[ + int, + Field(description="Sandbox port to expose.", examples=[3000], ge=1, le=65535), + ] + + +class UpdateSandboxPreviewRequest(SandboxPreviewConfiguration): + pass + + +class SandboxPreview(SandboxPreviewConfiguration): + name: Annotated[ + str, + Field( + description="Preview name, used in preview_name path parameters.", + examples=["web"], + ), + ] + port: Annotated[int, Field(description="Exposed sandbox port.", examples=[3000])] + url: Annotated[ + AnyUrl, + Field( + description="HTTPS URL serving the port, including the region label. Use this exact URL. Requests to a private preview need a token.", + examples=["https://a1b2c3d4e5f6g7h8.us-pdx-1.preview.b10.co"], + ), + ] + status: Annotated[ + Literal["DEPLOYING", "DEPLOYED", "FAILED", "DELETING", "TERMINATED"] | str, + Field(description="Preview status, always uppercase.", examples=["DEPLOYED"]), + ] + region: Annotated[ + str | None, + Field( + description="Region serving the preview, the sandbox's region.", + examples=["us-pdx-1"], + ), + ] = None + created_at: Annotated[ + AwareDatetime | None, Field(examples=["2026-10-12T12:00:00Z"]) + ] = None + updated_at: Annotated[ + AwareDatetime | None, Field(examples=["2026-10-12T12:00:00Z"]) + ] = None + created_by: Annotated[str | None, Field(examples=["sandbox-automation"])] = None + updated_by: Annotated[str | None, Field(examples=["sandbox-automation"])] = None + public: Annotated[ + bool, + Field( + description="Whether the URL is reachable without a preview token. Defaults to false.", + examples=[True], + ), + ] + + +class ListSandboxPreviewsResponse(BaseModel): + items: Annotated[list[SandboxPreview], Field(description="Resources on this page.")] + pagination: SandboxApiPagination + + +class CreateSandboxPreviewTokenRequest(BaseModel): + create_if_not_exists: Annotated[ + bool | None, + Field( + description="When true, return an existing unexpired token of this preview that stays valid at least until expires_at instead of creating a new one. Defaults to false when omitted." + ), + ] = None + expires_at: Annotated[ + AwareDatetime, + Field( + description="Time at which the token stops working. Must be in the future.", + examples=["2026-10-13T12:00:00Z"], + ), + ] + + +class SandboxPreviewToken(BaseModel): + name: Annotated[ + str, + Field( + description="Token name, used in token_name path parameters.", + examples=["7f3k2m9q1x8c4v6b0n5z"], + ), + ] + token: Annotated[ + str, + Field( + description="Value to send as the bl_preview_token query parameter.", + examples=["7f3k2m9q1x8c4v6b0n5z"], + ), + ] + expires_at: Annotated[AwareDatetime, Field(examples=["2026-10-13T12:00:00Z"])] + expired: Annotated[ + bool, Field(description="Whether expires_at has passed.", examples=[False]) + ] + + +class ListSandboxPreviewTokensResponse(BaseModel): + items: Annotated[ + list[SandboxPreviewToken], Field(description="Resources on this page.") + ] + pagination: SandboxApiPagination + + class LoadCheckpointConfig(BaseModel): enabled: Annotated[ bool, @@ -13307,9 +13571,7 @@ class Route(BaseModel): ] target: Annotated[ Annotated[ - RouteTargetBasetenModelAPI - | RouteTargetConnection - | RouteTargetClassifierModelBased, + RouteTargetBasetenModelAPI | RouteTargetConnection | RouteTargetClassifier, Field( description="Configured upstream target.", discriminator="type", @@ -13450,13 +13712,13 @@ class RouteTeamUserSettings(BaseModel): spend_limit: Annotated[ RouteSpendLimitSetting, Field( - description="Spend limit for Baseten Code in the team. Applies to requests with Routes keys the user created in the team; personal API keys are not limited." + description="Spend limit for Baseten Grid in the team. Applies to requests with Routes keys the user created in the team; personal API keys are not limited." ), ] month_spend_usd: Annotated[ str, Field( - description="The user's metered Code spend in USD in the team this UTC calendar month, returned as an exact decimal string.", + description="The user's metered Grid spend in USD in the team this UTC calendar month, returned as an exact decimal string.", examples=["12.5"], title="Month Spend Usd", ), @@ -13480,7 +13742,7 @@ class RouteUserTeamSettingsList(BaseModel): teams: Annotated[ list[RouteTeamUserSettings], Field( - description="The user's settings in each team they have a role in and in any team they have Code spend in this month, alphabetical by team name.", + description="The user's settings in each team they have a role in and in any team they have Grid spend in this month, alphabetical by team name.", title="Teams", ), ] @@ -13501,7 +13763,7 @@ class RouteTeamUserSettingsList(BaseModel): users: Annotated[ list[RouteTeamUserSettings], Field( - description="Settings of each user with a role in the team and of anyone with Code spend in the team this month.", + description="Settings of each user with a role in the team and of anyone with Grid spend in the team this month.", title="Users", ), ] @@ -14378,7 +14640,7 @@ class RouteTeamSettings(BaseModel): spend_limit: Annotated[ RouteTeamSpendLimitSetting, Field( - description="Spend limits for Baseten Code for members' spend in this team." + description="Spend limits for Baseten Grid for members' spend in this team." ), ] diff --git a/baseten/sandbox/__init__.py b/baseten/sandbox/__init__.py index 158d7bf..5bf29d0 100644 --- a/baseten/sandbox/__init__.py +++ b/baseten/sandbox/__init__.py @@ -93,6 +93,13 @@ SandboxPortProtocol, SandboxStatus, ) +from baseten.sandbox._previews import ( + AsyncSandboxPreviews, + SandboxPreviewInfo, + SandboxPreviews, + SandboxPreviewStatus, + SandboxPreviewTokenInfo, +) from baseten.sandbox._process import ( AsyncSandboxProcess, SandboxProcess, @@ -115,6 +122,7 @@ "AsyncSandboxClient", "AsyncSandboxCreateResult", "AsyncSandboxFileSystem", + "AsyncSandboxPreviews", "AsyncSandboxProcess", "AsyncSandboxStream", "AsyncSandboxTokenProvider", @@ -168,6 +176,10 @@ "SandboxNetworkProxyRoute", "SandboxPort", "SandboxPortProtocol", + "SandboxPreviewInfo", + "SandboxPreviewStatus", + "SandboxPreviewTokenInfo", + "SandboxPreviews", "SandboxProcess", "SandboxProcessExecEvent", "SandboxProcessExecEventExit", diff --git a/baseten/sandbox/_client.py b/baseten/sandbox/_client.py index 937833c..d990a87 100644 --- a/baseten/sandbox/_client.py +++ b/baseten/sandbox/_client.py @@ -522,6 +522,8 @@ def _sandbox_context(self, name: str, url: str) -> SandboxContext: auth=self._auth, headers=self._request_headers, ), + management_api=self._raw_api, + team_id=self._options.team_id, retry=self._retry, executor=self._executor, ) @@ -837,6 +839,8 @@ def _sandbox_context(self, name: str, url: str) -> AsyncSandboxContext: auth=self._auth, headers=self._request_headers, ), + management_api=self._raw_api, + team_id=self._options.team_id, retry=self._retry, ) diff --git a/baseten/sandbox/_common.py b/baseten/sandbox/_common.py index 27649c0..fba8107 100644 --- a/baseten/sandbox/_common.py +++ b/baseten/sandbox/_common.py @@ -21,6 +21,7 @@ import httpx +import baseten.client.managementapi import baseten.client.sandboxapi from baseten.sandbox._errors import SandboxError, converted_errors from baseten.sandbox._retry import ( @@ -114,6 +115,9 @@ class SandboxContext: headers: Mapping[str, str] raw_api: baseten.client.sandboxapi.ApiClient """The sandbox's execution API, authenticated.""" + management_api: baseten.client.managementapi.ApiClient + """The client's management API, authenticated.""" + team_id: str | None retry: ResolvedRetryOptions executor: ThreadPoolExecutor | None """The caller's thread pool for upload parts, if given.""" @@ -124,6 +128,12 @@ def api(self) -> Iterator[baseten.client.sandboxapi.ApiClient]: with converted_errors("exec"): yield self.raw_api + @contextmanager + def control_api(self) -> Iterator[baseten.client.managementapi.ApiClient]: + """Give the generated management client, raising its error responses as ours.""" + with converted_errors("control"): + yield self.management_api + @contextmanager def api_with_timeout( self, timeout: timedelta @@ -195,6 +205,9 @@ class AsyncSandboxContext: headers: Mapping[str, str] raw_api: baseten.client.sandboxapi.AsyncApiClient """The sandbox's execution API, authenticated.""" + management_api: baseten.client.managementapi.AsyncApiClient + """The client's management API, authenticated.""" + team_id: str | None retry: ResolvedRetryOptions @contextmanager @@ -204,6 +217,12 @@ def api(self) -> Iterator[baseten.client.sandboxapi.AsyncApiClient]: with converted_errors("exec"): yield self.raw_api + @contextmanager + def control_api(self) -> Iterator[baseten.client.managementapi.AsyncApiClient]: + """Give the generated management client, raising its error responses as ours.""" + with converted_errors("control"): + yield self.management_api + @contextmanager def api_with_timeout( self, timeout: timedelta diff --git a/baseten/sandbox/_previews.py b/baseten/sandbox/_previews.py new file mode 100644 index 0000000..55ca791 --- /dev/null +++ b/baseten/sandbox/_previews.py @@ -0,0 +1,698 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator, Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Literal, TypeAlias + +import baseten.client.managementapi +from baseten.sandbox._common import ( + AsyncSandboxContext, + SandboxContext, + format_duration, + paginate, + paginate_async, + parse_duration, +) +from baseten.sandbox._info import set_fields + +SandboxPreviewStatus: TypeAlias = ( + Literal["DEPLOYING", "DEPLOYED", "FAILED", "DELETING", "TERMINATED"] | str +) +"""Deployment status of a preview. + +Other values may be added, so do not treat this list as exhaustive. +""" + + +@dataclass(kw_only=True) +class SandboxPreviewInfo: + """A preview as last reported by the control plane.""" + + name: str + """Name of the preview, unique within the sandbox.""" + + port: int + """Sandbox port the preview exposes.""" + + url: str + """HTTPS URL serving the port. + + Requests to a private preview need a token from + :meth:`SandboxPreviews.create_token`. + """ + + status: SandboxPreviewStatus + + public: bool + """Whether the URL is reachable without a token.""" + + response_headers: dict[str, str] + """Headers added to every response the preview serves.""" + + request_headers: dict[str, str] + """Headers added to every request forwarded to the sandbox. + + Values come back as ``"****"`` unless read with *show_secrets*. + """ + + ttl: timedelta | None + """Lifetime set by the last create or update that had one, counted from that request. + + :attr:`expires_at` is when the preview is deleted. + """ + + expires_at: datetime | None + """When the preview is deleted, when it expires.""" + + region: str | None + """Region serving the preview, the sandbox's region.""" + + created_at: datetime | None + + updated_at: datetime | None + + created_by: str | None + + updated_by: str | None + + +@dataclass(kw_only=True) +class SandboxPreviewTokenInfo: + """An access token for a private preview.""" + + name: str + """Name of the token.""" + + token: str + """Value granting access. + + Append it to the preview URL as ``?preview_token=``; the first + request sets a cookie so a browser can keep navigating without it. + """ + + expires_at: datetime + """When the token stops working.""" + + expired: bool + """Whether :attr:`expires_at` has passed.""" + + +class SandboxPreviews: + """A sandbox's previews, which expose its ports on HTTPS URLs. + + Obtained from :attr:`Sandbox.previews`. Previews need the sandbox's name, + so every call fails on a sandbox from :meth:`SandboxClient.sandbox_from_url`. + """ + + def __init__(self, context: SandboxContext) -> None: + """Internal. Use :attr:`Sandbox.previews` instead. + + :meta private: + """ + self._context = context + + def create( + self, + *, + port: int, + name: str | None = None, + create_if_not_exists: bool | None = None, + public: bool | None = None, + response_headers: Mapping[str, str] | None = None, + request_headers: Mapping[str, str] | None = None, + ttl: timedelta | None = None, + expires_at: datetime | None = None, + ) -> SandboxPreviewInfo: + """Create a preview exposing a sandbox port. + + Args: + port: Sandbox port to expose. + name: Name of the preview, unique within the sandbox. Assigned by + the server when ``None``. + create_if_not_exists: When true, return the existing preview with + this name, unchanged, instead of failing. + public: Make the URL reachable without a token. Defaults to false, + a private preview. + response_headers: Headers added to every response the preview + serves, such as CORS headers. + request_headers: Headers added to every request forwarded to the + sandbox, such as an Authorization header the application + expects. + ttl: Delete the preview this long after the request. Cannot be + combined with *expires_at*. + expires_at: Delete the preview at this time. Cannot be combined + with *ttl*. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + preview = api.create_sandbox_preview( + sandbox_name=sandbox_name, + params=baseten.client.managementapi.CreateSandboxPreviewParams( + **set_fields(team_id=self._context.team_id) + ), + request=baseten.client.managementapi.CreateSandboxPreviewRequest( + port=port, + **set_fields( + name=name, + create_if_not_exists=create_if_not_exists, + public=public, + response_headers=_optional_dict(response_headers), + request_headers=_optional_dict(request_headers), + ttl=None if ttl is None else format_duration(ttl), + expires_at=expires_at, + ), + ), + ) + return _preview_info_from_api(preview) + + def get_info( + self, *, name: str, show_secrets: bool | None = None + ) -> SandboxPreviewInfo: + """Get a preview's current record. + + Args: + name: Name of the preview. + show_secrets: Reveal request header values for workspace + administrators. Defaults to false. Callers without the admin + role receive masked values even when true. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + preview = api.get_sandbox_preview( + sandbox_name=sandbox_name, + preview_name=name, + params=baseten.client.managementapi.GetSandboxPreviewParams( + **set_fields( + team_id=self._context.team_id, show_secrets=show_secrets + ) + ), + ) + return _preview_info_from_api(preview) + + def list(self, *, page_size: int | None = None) -> Iterator[SandboxPreviewInfo]: + """List the sandbox's previews, fetching further pages as iteration reaches them. + + Args: + page_size: How many previews to fetch per underlying request. + """ + sandbox_name = _sandbox_name(self._context.name) + + def fetch_page( + cursor: str | None, + ) -> baseten.client.managementapi.ListSandboxPreviewsResponse: + with self._context.control_api() as api: + return api.list_sandbox_previews( + sandbox_name=sandbox_name, + params=baseten.client.managementapi.ListSandboxPreviewsParams( + **set_fields( + team_id=self._context.team_id, + cursor=cursor, + limit=page_size, + ) + ), + ) + + for preview in paginate("preview list", fetch_page): + yield _preview_info_from_api(preview) + + def update( + self, + *, + name: str, + public: bool | None = None, + response_headers: Mapping[str, str] | None = None, + request_headers: Mapping[str, str] | None = None, + ttl: timedelta | None = None, + expires_at: datetime | None = None, + ) -> SandboxPreviewInfo: + """Update a preview, returning its record after the change. + + Arguments left ``None`` stay unchanged. The URL does not change, and + the name and port cannot be changed. + + Args: + name: Name of the preview. + public: Whether the URL is reachable without a token. + response_headers: Headers added to every response, replacing the + whole set: headers left out are removed, and an empty mapping + removes them all. + request_headers: Headers added to every forwarded request, + replacing the whole set: headers left out are removed, and an + empty mapping removes them all. Sending back a ``"****"`` value + from :attr:`SandboxPreviewInfo.request_headers` is rejected, so + to change one header, get the others with *show_secrets* and + send them all. + ttl: Delete the preview this long after the request, replacing any + previous expiration. Cannot be combined with *expires_at*. + expires_at: Delete the preview at this time, replacing any previous + expiration. Cannot be combined with *ttl*. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + preview = api.update_sandbox_preview( + sandbox_name=sandbox_name, + preview_name=name, + params=baseten.client.managementapi.UpdateSandboxPreviewParams( + **set_fields(team_id=self._context.team_id) + ), + request=baseten.client.managementapi.UpdateSandboxPreviewRequest( + **set_fields( + public=public, + response_headers=_optional_dict(response_headers), + request_headers=_optional_dict(request_headers), + ttl=None if ttl is None else format_duration(ttl), + expires_at=expires_at, + ) + ), + ) + return _preview_info_from_api(preview) + + def delete(self, *, name: str) -> SandboxPreviewInfo: + """Delete a preview and its tokens, returning its record. + + The URL stops serving at once, but the record may show the preview as + deleting while the server finishes; this does not wait for that. + + Args: + name: Name of the preview. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + preview = api.delete_sandbox_preview( + sandbox_name=sandbox_name, + preview_name=name, + params=baseten.client.managementapi.DeleteSandboxPreviewParams( + **set_fields(team_id=self._context.team_id) + ), + ) + return _preview_info_from_api(preview) + + def create_token( + self, + *, + preview_name: str, + expires_at: datetime, + create_if_not_exists: bool | None = None, + ) -> SandboxPreviewTokenInfo: + """Create a token granting access to a private preview. + + Args: + preview_name: Name of the private preview. + expires_at: When the token stops working. + create_if_not_exists: When true, return an existing unexpired token + of the preview that stays valid at least until *expires_at*, + instead of creating one. A token created moments before may not + be found yet, so a new one may be created anyway. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + token = api.create_sandbox_preview_token( + sandbox_name=sandbox_name, + preview_name=preview_name, + params=baseten.client.managementapi.CreateSandboxPreviewTokenParams( + **set_fields(team_id=self._context.team_id) + ), + request=baseten.client.managementapi.CreateSandboxPreviewTokenRequest( + expires_at=expires_at, + **set_fields(create_if_not_exists=create_if_not_exists), + ), + ) + return _preview_token_info_from_api(token) + + def list_tokens( + self, *, preview_name: str, page_size: int | None = None + ) -> Iterator[SandboxPreviewTokenInfo]: + """List a private preview's tokens, fetching further pages as iteration reaches them. + + Expired tokens are included until the server removes them. A public + preview has no tokens, and the server rejects the call. + + Args: + preview_name: Name of the private preview. + page_size: How many tokens to fetch per underlying request. + """ + sandbox_name = _sandbox_name(self._context.name) + + def fetch_page( + cursor: str | None, + ) -> baseten.client.managementapi.ListSandboxPreviewTokensResponse: + with self._context.control_api() as api: + return api.list_sandbox_preview_tokens( + sandbox_name=sandbox_name, + preview_name=preview_name, + params=baseten.client.managementapi.ListSandboxPreviewTokensParams( + **set_fields( + team_id=self._context.team_id, + cursor=cursor, + limit=page_size, + ) + ), + ) + + for token in paginate("preview token list", fetch_page): + yield _preview_token_info_from_api(token) + + def delete_token(self, *, preview_name: str, token_name: str) -> None: + """Revoke a preview token. Requests using it are rejected at once. + + Args: + preview_name: Name of the private preview. + token_name: Name of the token. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + api.delete_sandbox_preview_token( + sandbox_name=sandbox_name, + preview_name=preview_name, + token_name=token_name, + params=baseten.client.managementapi.DeleteSandboxPreviewTokenParams( + **set_fields(team_id=self._context.team_id) + ), + ) + + +class AsyncSandboxPreviews: + """A sandbox's previews, which expose its ports on HTTPS URLs. + + Obtained from :attr:`AsyncSandbox.previews`. Previews need the sandbox's + name, so every call fails on a sandbox from + :meth:`AsyncSandboxClient.sandbox_from_url`. + """ + + def __init__(self, context: AsyncSandboxContext) -> None: + """Internal. Use :attr:`AsyncSandbox.previews` instead. + + :meta private: + """ + self._context = context + + async def create( + self, + *, + port: int, + name: str | None = None, + create_if_not_exists: bool | None = None, + public: bool | None = None, + response_headers: Mapping[str, str] | None = None, + request_headers: Mapping[str, str] | None = None, + ttl: timedelta | None = None, + expires_at: datetime | None = None, + ) -> SandboxPreviewInfo: + """Create a preview exposing a sandbox port. + + Args: + port: Sandbox port to expose. + name: Name of the preview, unique within the sandbox. Assigned by + the server when ``None``. + create_if_not_exists: When true, return the existing preview with + this name, unchanged, instead of failing. + public: Make the URL reachable without a token. Defaults to false, + a private preview. + response_headers: Headers added to every response the preview + serves, such as CORS headers. + request_headers: Headers added to every request forwarded to the + sandbox, such as an Authorization header the application + expects. + ttl: Delete the preview this long after the request. Cannot be + combined with *expires_at*. + expires_at: Delete the preview at this time. Cannot be combined + with *ttl*. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + preview = await api.create_sandbox_preview( + sandbox_name=sandbox_name, + params=baseten.client.managementapi.CreateSandboxPreviewParams( + **set_fields(team_id=self._context.team_id) + ), + request=baseten.client.managementapi.CreateSandboxPreviewRequest( + port=port, + **set_fields( + name=name, + create_if_not_exists=create_if_not_exists, + public=public, + response_headers=_optional_dict(response_headers), + request_headers=_optional_dict(request_headers), + ttl=None if ttl is None else format_duration(ttl), + expires_at=expires_at, + ), + ), + ) + return _preview_info_from_api(preview) + + async def get_info( + self, *, name: str, show_secrets: bool | None = None + ) -> SandboxPreviewInfo: + """Get a preview's current record. + + Args: + name: Name of the preview. + show_secrets: Reveal request header values for workspace + administrators. Defaults to false. Callers without the admin + role receive masked values even when true. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + preview = await api.get_sandbox_preview( + sandbox_name=sandbox_name, + preview_name=name, + params=baseten.client.managementapi.GetSandboxPreviewParams( + **set_fields( + team_id=self._context.team_id, show_secrets=show_secrets + ) + ), + ) + return _preview_info_from_api(preview) + + async def list( + self, *, page_size: int | None = None + ) -> AsyncIterator[SandboxPreviewInfo]: + """List the sandbox's previews, fetching further pages as iteration reaches them. + + Args: + page_size: How many previews to fetch per underlying request. + """ + sandbox_name = _sandbox_name(self._context.name) + + async def fetch_page( + cursor: str | None, + ) -> baseten.client.managementapi.ListSandboxPreviewsResponse: + with self._context.control_api() as api: + return await api.list_sandbox_previews( + sandbox_name=sandbox_name, + params=baseten.client.managementapi.ListSandboxPreviewsParams( + **set_fields( + team_id=self._context.team_id, + cursor=cursor, + limit=page_size, + ) + ), + ) + + async for preview in paginate_async("preview list", fetch_page): + yield _preview_info_from_api(preview) + + async def update( + self, + *, + name: str, + public: bool | None = None, + response_headers: Mapping[str, str] | None = None, + request_headers: Mapping[str, str] | None = None, + ttl: timedelta | None = None, + expires_at: datetime | None = None, + ) -> SandboxPreviewInfo: + """Update a preview, returning its record after the change. + + Arguments left ``None`` stay unchanged. The URL does not change, and + the name and port cannot be changed. + + Args: + name: Name of the preview. + public: Whether the URL is reachable without a token. + response_headers: Headers added to every response, replacing the + whole set: headers left out are removed, and an empty mapping + removes them all. + request_headers: Headers added to every forwarded request, + replacing the whole set: headers left out are removed, and an + empty mapping removes them all. Sending back a ``"****"`` value + from :attr:`SandboxPreviewInfo.request_headers` is rejected, so + to change one header, get the others with *show_secrets* and + send them all. + ttl: Delete the preview this long after the request, replacing any + previous expiration. Cannot be combined with *expires_at*. + expires_at: Delete the preview at this time, replacing any previous + expiration. Cannot be combined with *ttl*. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + preview = await api.update_sandbox_preview( + sandbox_name=sandbox_name, + preview_name=name, + params=baseten.client.managementapi.UpdateSandboxPreviewParams( + **set_fields(team_id=self._context.team_id) + ), + request=baseten.client.managementapi.UpdateSandboxPreviewRequest( + **set_fields( + public=public, + response_headers=_optional_dict(response_headers), + request_headers=_optional_dict(request_headers), + ttl=None if ttl is None else format_duration(ttl), + expires_at=expires_at, + ) + ), + ) + return _preview_info_from_api(preview) + + async def delete(self, *, name: str) -> SandboxPreviewInfo: + """Delete a preview and its tokens, returning its record. + + The URL stops serving at once, but the record may show the preview as + deleting while the server finishes; this does not wait for that. + + Args: + name: Name of the preview. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + preview = await api.delete_sandbox_preview( + sandbox_name=sandbox_name, + preview_name=name, + params=baseten.client.managementapi.DeleteSandboxPreviewParams( + **set_fields(team_id=self._context.team_id) + ), + ) + return _preview_info_from_api(preview) + + async def create_token( + self, + *, + preview_name: str, + expires_at: datetime, + create_if_not_exists: bool | None = None, + ) -> SandboxPreviewTokenInfo: + """Create a token granting access to a private preview. + + Args: + preview_name: Name of the private preview. + expires_at: When the token stops working. + create_if_not_exists: When true, return an existing unexpired token + of the preview that stays valid at least until *expires_at*, + instead of creating one. A token created moments before may not + be found yet, so a new one may be created anyway. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + token = await api.create_sandbox_preview_token( + sandbox_name=sandbox_name, + preview_name=preview_name, + params=baseten.client.managementapi.CreateSandboxPreviewTokenParams( + **set_fields(team_id=self._context.team_id) + ), + request=baseten.client.managementapi.CreateSandboxPreviewTokenRequest( + expires_at=expires_at, + **set_fields(create_if_not_exists=create_if_not_exists), + ), + ) + return _preview_token_info_from_api(token) + + async def list_tokens( + self, *, preview_name: str, page_size: int | None = None + ) -> AsyncIterator[SandboxPreviewTokenInfo]: + """List a private preview's tokens, fetching further pages as iteration reaches them. + + Expired tokens are included until the server removes them. A public + preview has no tokens, and the server rejects the call. + + Args: + preview_name: Name of the private preview. + page_size: How many tokens to fetch per underlying request. + """ + sandbox_name = _sandbox_name(self._context.name) + + async def fetch_page( + cursor: str | None, + ) -> baseten.client.managementapi.ListSandboxPreviewTokensResponse: + with self._context.control_api() as api: + return await api.list_sandbox_preview_tokens( + sandbox_name=sandbox_name, + preview_name=preview_name, + params=baseten.client.managementapi.ListSandboxPreviewTokensParams( + **set_fields( + team_id=self._context.team_id, + cursor=cursor, + limit=page_size, + ) + ), + ) + + async for token in paginate_async("preview token list", fetch_page): + yield _preview_token_info_from_api(token) + + async def delete_token(self, *, preview_name: str, token_name: str) -> None: + """Revoke a preview token. Requests using it are rejected at once. + + Args: + preview_name: Name of the private preview. + token_name: Name of the token. + """ + sandbox_name = _sandbox_name(self._context.name) + with self._context.control_api() as api: + await api.delete_sandbox_preview_token( + sandbox_name=sandbox_name, + preview_name=preview_name, + token_name=token_name, + params=baseten.client.managementapi.DeleteSandboxPreviewTokenParams( + **set_fields(team_id=self._context.team_id) + ), + ) + + +def _sandbox_name(name: str) -> str: + """Return the sandbox's name, failing for a sandbox from a URL alone.""" + if name == "": + raise ValueError( + "previews need the sandbox's name, which a sandbox from " + "sandbox_from_url does not have; use SandboxClient.get" + ) + return name + + +def _optional_dict(values: Mapping[str, str] | None) -> dict[str, str] | None: + """Copy a mapping to a dict, keeping ``None``.""" + return None if values is None else dict(values) + + +def _preview_info_from_api( + preview: baseten.client.managementapi.SandboxPreview, +) -> SandboxPreviewInfo: + """Convert the control plane's preview record.""" + return SandboxPreviewInfo( + name=preview.name, + port=preview.port, + url=str(preview.url), + status=preview.status, + public=preview.public, + response_headers=dict(preview.response_headers or {}), + request_headers=dict(preview.request_headers or {}), + ttl=None if not preview.ttl else parse_duration(preview.ttl, "preview TTL"), + expires_at=preview.expires_at, + region=preview.region, + created_at=preview.created_at, + updated_at=preview.updated_at, + created_by=preview.created_by, + updated_by=preview.updated_by, + ) + + +def _preview_token_info_from_api( + token: baseten.client.managementapi.SandboxPreviewToken, +) -> SandboxPreviewTokenInfo: + """Convert the control plane's preview token record.""" + return SandboxPreviewTokenInfo( + name=token.name, + token=token.token, + expires_at=token.expires_at, + expired=token.expired, + ) diff --git a/baseten/sandbox/_sandbox.py b/baseten/sandbox/_sandbox.py index 7128cb6..0e591e1 100644 --- a/baseten/sandbox/_sandbox.py +++ b/baseten/sandbox/_sandbox.py @@ -3,6 +3,7 @@ import baseten.client.sandboxapi from baseten.sandbox._common import AsyncSandboxContext, SandboxContext from baseten.sandbox._fs import AsyncSandboxFileSystem, SandboxFileSystem +from baseten.sandbox._previews import AsyncSandboxPreviews, SandboxPreviews from baseten.sandbox._process import AsyncSandboxProcess, SandboxProcess @@ -21,6 +22,7 @@ def __init__(self, context: SandboxContext) -> None: self._context = context self._process = SandboxProcess(context) self._fs = SandboxFileSystem(context, self._process) + self._previews = SandboxPreviews(context) @property def name(self) -> str: @@ -42,6 +44,15 @@ def process(self) -> SandboxProcess: """Processes in the sandbox.""" return self._process + @property + def previews(self) -> SandboxPreviews: + """The sandbox's previews, which expose its ports on HTTPS URLs. + + Every call fails on a sandbox from :meth:`SandboxClient.sandbox_from_url`, + which has no name. + """ + return self._previews + @property def raw_api(self) -> baseten.client.sandboxapi.ApiClient: """The generated client for this sandbox's execution API, authenticated. @@ -68,6 +79,7 @@ def __init__(self, context: AsyncSandboxContext) -> None: self._context = context self._process = AsyncSandboxProcess(context) self._fs = AsyncSandboxFileSystem(context, self._process) + self._previews = AsyncSandboxPreviews(context) @property def name(self) -> str: @@ -89,6 +101,15 @@ def process(self) -> AsyncSandboxProcess: """Processes in the sandbox.""" return self._process + @property + def previews(self) -> AsyncSandboxPreviews: + """The sandbox's previews, which expose its ports on HTTPS URLs. + + Every call fails on a sandbox from + :meth:`AsyncSandboxClient.sandbox_from_url`, which has no name. + """ + return self._previews + @property def raw_api(self) -> baseten.client.sandboxapi.AsyncApiClient: """The generated client for this sandbox's execution API, authenticated. diff --git a/scripts/apigen/specs/management.json b/scripts/apigen/specs/management.json index 044dc35..fcccec2 100644 --- a/scripts/apigen/specs/management.json +++ b/scripts/apigen/specs/management.json @@ -83,8 +83,7 @@ } } } - }, - "x-stability": "unstable" + } } }, "/v1/volumes/token": { @@ -122,8 +121,7 @@ } } } - }, - "x-stability": "unstable" + } } }, "/v1/volumes/namespaces": { @@ -184,8 +182,7 @@ } } } - }, - "x-stability": "unstable" + } } }, "/v1/volumes/syncs": { @@ -264,8 +261,7 @@ } } } - }, - "x-stability": "unstable" + } }, "post": { "summary": "Starts a volume sync", @@ -301,8 +297,7 @@ } } } - }, - "x-stability": "unstable" + } } }, "/v1/volumes/syncs/{volume_sync_id}": { @@ -329,8 +324,7 @@ } } } - }, - "x-stability": "unstable" + } }, "parameters": [ { @@ -363,8 +357,7 @@ } } } - }, - "x-stability": "unstable" + } }, "parameters": [ { @@ -407,8 +400,7 @@ } } } - }, - "x-stability": "unstable" + } }, "parameters": [ { @@ -442,8 +434,7 @@ } } } - }, - "x-stability": "unstable" + } }, "patch": { "summary": "Updates a volume", @@ -479,8 +470,7 @@ } } } - }, - "x-stability": "unstable" + } } }, "/v1/volumes/{volume_namespace}/{volume_name}/versions": { @@ -521,8 +511,7 @@ } } } - }, - "x-stability": "unstable" + } }, "parameters": [ { @@ -568,8 +557,7 @@ } } } - }, - "x-stability": "unstable" + } }, "parameters": [ { @@ -606,8 +594,7 @@ } } } - }, - "x-stability": "unstable" + } } }, "/v1/volumes/{volume_namespace}/{volume_name}/versions/{volume_version}/restore": { @@ -645,8 +632,7 @@ } } } - }, - "x-stability": "unstable" + } }, "parameters": [ { @@ -695,8 +681,7 @@ } } } - }, - "x-stability": "unstable" + } }, "parameters": [ { @@ -742,8 +727,7 @@ } } } - }, - "x-stability": "unstable" + } }, "parameters": [ { @@ -10472,7 +10456,7 @@ "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/routes/settings/users/{user_id}/teams\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"GET\",\n url,\n headers=headers,\n json={}\n)\n\nprint(response.text)" } ], - "description": "Returns the user's settings in each team they belong to (every team, for organization admins), alphabetical by team name: their spend limit for Baseten Code in the team, their spend in the team this month, and whether that spend reached the limit. Members can read their own settings; organization admins can read anyone's.", + "description": "Returns the user's settings in each team they belong to (every team, for organization admins), alphabetical by team name: their spend limit for Baseten Grid in the team, their spend in the team this month, and whether that spend reached the limit. Members can read their own settings; organization admins can read anyone's.", "responses": { "200": { "description": "", @@ -10577,7 +10561,7 @@ "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/routes/settings/teams/{team_id}/users\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"GET\",\n url,\n headers=headers,\n json={}\n)\n\nprint(response.text)" } ], - "description": "Returns the settings in the team of each member of the team, including organization admins and Code service accounts. Requires team admin for the specified team or organization admin.", + "description": "Returns the settings in the team of each member of the team, including organization admins and Grid service accounts. Requires team admin for the specified team or organization admin.", "responses": { "200": { "description": "", @@ -10611,7 +10595,7 @@ "source": "import requests\nimport os\nAPI_KEY = os.environ.get(\"BASETEN_API_KEY\", \"\")\nurl = \"https://api.baseten.co/v1/routes/settings/teams/{team_id}/users/{user_id}\"\n\nheaders = {\"Authorization\": f\"Bearer {API_KEY}\"}\n\nresponse = requests.request(\n \"GET\",\n url,\n headers=headers,\n json={}\n)\n\nprint(response.text)" } ], - "description": "Returns the user's spend limit for Baseten Code in the team, their spend in the team this month, and whether that spend reached the limit. Members can read their own settings; team admins can read members' settings in teams they administer, and organization admins can read anyone's.", + "description": "Returns the user's spend limit for Baseten Grid in the team, their spend in the team this month, and whether that spend reached the limit. Members can read their own settings; team admins can read members' settings in teams they administer, and organization admins can read anyone's.", "responses": { "200": { "description": "", @@ -11389,6 +11373,55 @@ } ] }, + "/v1/sandboxes/configuration": { + "get": { + "operationId": "GetSandboxConfiguration", + "summary": "Get sandbox configuration", + "description": "Return available Carbon-compatible sandbox regions. Configuration is the same for every team, so no team is selected. Only enabled regions supporting Carbon are returned. This describes regional capability, not the runtime selected for a particular team or sandbox. Region configuration is cached for up to 30 seconds.", + "tags": [ + "Sandboxes" + ], + "responses": { + "200": { + "description": "Successful operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSandboxConfigurationResponseV1" + }, + "example": { + "regions": [ + { + "name": "us-was-1", + "country": "us", + "continent": "na", + "location": "Washington", + "info_generation": "CARBON" + } + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/SandboxError400" + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + } + }, "/v1/sandboxes/instances/{sandbox_name}/metrics": { "get": { "operationId": "GetSandboxMetrics", @@ -11545,55 +11578,6 @@ "x-stability": "unstable" } }, - "/v1/sandboxes/configuration": { - "get": { - "operationId": "GetSandboxConfiguration", - "summary": "Get sandbox configuration", - "description": "Return available Carbon-compatible sandbox regions. Configuration is the same for every team, so no team is selected. Only enabled regions supporting Carbon are returned. This describes regional capability, not the runtime selected for a particular team or sandbox. Region configuration is cached for up to 30 seconds.", - "tags": [ - "Sandboxes" - ], - "responses": { - "200": { - "description": "Successful operation.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetSandboxConfigurationResponseV1" - }, - "example": { - "regions": [ - { - "name": "us-was-1", - "country": "us", - "continent": "na", - "location": "Washington", - "info_generation": "CARBON" - } - ] - } - } - } - }, - "400": { - "$ref": "#/components/responses/SandboxError400" - }, - "401": { - "$ref": "#/components/responses/SandboxError401" - }, - "403": { - "$ref": "#/components/responses/SandboxError403" - }, - "429": { - "$ref": "#/components/responses/SandboxError429" - }, - "500": { - "$ref": "#/components/responses/SandboxError500" - } - }, - "x-stability": "unstable" - } - }, "/v1/sandboxes/instances": { "get": { "operationId": "ListSandboxes", @@ -11970,6 +11954,521 @@ "x-stability": "unstable" } }, + "/v1/sandboxes/instances/{sandbox_name}/previews": { + "parameters": [ + { + "$ref": "#/components/parameters/SandboxName" + } + ], + "get": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + }, + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Limit" + } + ], + "operationId": "ListSandboxPreviews", + "summary": "List sandbox previews", + "description": "Return the previews of the sandbox in the paginated list format. cursor and limit are accepted but not applied yet; all previews fit on one page, so has_more is false. Follow pagination.cursor while has_more is true to stay compatible when they are applied.", + "tags": [ + "Previews" + ], + "responses": { + "200": { + "description": "Successful operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSandboxPreviewsResponseV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxPreviewsList" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + }, + "post": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + } + ], + "operationId": "CreateSandboxPreview", + "summary": "Create a sandbox preview", + "description": "Expose a port of the sandbox on an HTTPS URL. A public preview is reachable by anyone with the URL. A private preview requires a preview token, created with the tokens endpoint and sent as the bl_preview_token query parameter; the first request sets a bl_preview_token cookie so a browser can keep navigating without it. Port 80 and the sandbox's reserved system ports are rejected, and port 443 must stay private. Without a name, the name is generated from the sandbox name and port, or is public- or private- when create_if_not_exists is true.", + "tags": [ + "Previews" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSandboxPreviewRequestV1" + }, + "examples": { + "public": { + "$ref": "#/components/examples/CreateSandboxPreviewPublic" + }, + "private": { + "$ref": "#/components/examples/CreateSandboxPreviewPrivate" + } + } + } + } + }, + "responses": { + "201": { + "description": "Preview created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxPreviewV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxPreviewReady" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/SandboxError400" + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "409": { + "$ref": "#/components/responses/SandboxError409" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + } + }, + "/v1/sandboxes/instances/{sandbox_name}/previews/{preview_name}": { + "parameters": [ + { + "$ref": "#/components/parameters/SandboxName" + }, + { + "$ref": "#/components/parameters/PreviewName" + } + ], + "get": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + }, + { + "name": "show_secrets", + "in": "query", + "description": "Reveal request header values for workspace administrators, for example to change one header and send the full set back in a PATCH. Defaults to false. Callers without the admin role receive masked values even when true.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "operationId": "GetSandboxPreview", + "summary": "Get a sandbox preview", + "description": "Return the preview configuration, URL and status.", + "tags": [ + "Previews" + ], + "responses": { + "200": { + "description": "Successful operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxPreviewV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxPreviewReady" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + }, + "patch": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + } + ], + "operationId": "UpdateSandboxPreview", + "summary": "Update a sandbox preview", + "description": "Partially update the preview. Omitted fields remain unchanged; supplied header maps replace their previous values. The URL does not change. Setting ttl or expires_at replaces any previous expiration and restarts it from now. The name and port are immutable; supplying either returns 400.", + "tags": [ + "Previews" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSandboxPreviewRequestV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/UpdateSandboxPreview" + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxPreviewV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxPreviewReady" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/SandboxError400" + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "409": { + "$ref": "#/components/responses/SandboxError409" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + }, + "delete": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + } + ], + "operationId": "DeleteSandboxPreview", + "summary": "Delete a sandbox preview", + "description": "Delete the preview and revoke its tokens. The URL stops serving immediately. Return the deleted preview, with status TERMINATED, or DELETING while a preview with many tokens finishes in the background.", + "tags": [ + "Previews" + ], + "responses": { + "200": { + "description": "Successful operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxPreviewV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxPreviewDeleted" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + } + }, + "/v1/sandboxes/instances/{sandbox_name}/previews/{preview_name}/tokens": { + "parameters": [ + { + "$ref": "#/components/parameters/SandboxName" + }, + { + "$ref": "#/components/parameters/PreviewName" + } + ], + "get": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + }, + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Limit" + } + ], + "operationId": "ListSandboxPreviewTokens", + "summary": "List preview tokens", + "description": "Return the tokens of a private preview in the paginated list format, including expired tokens until they are removed. cursor and limit are accepted but not applied yet; all tokens fit on one page, so has_more is false. Follow pagination.cursor while has_more is true to stay compatible when they are applied.", + "tags": [ + "Previews" + ], + "responses": { + "200": { + "description": "Successful operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSandboxPreviewTokensResponseV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxPreviewTokensList" + } + } + } + } + }, + "400": { + "description": "The preview is public, so it has no tokens. Returns a JSON error with code, message, and optional details.", + "content": { + "application/json": { + "example": { + "code": "INVALID_REQUEST", + "message": "Public preview cannot have tokens." + } + } + } + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + }, + "post": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + } + ], + "operationId": "CreateSandboxPreviewToken", + "summary": "Create a preview token", + "description": "Create a token granting access to a private preview until expires_at. Append it to the preview URL as ?bl_preview_token=.", + "tags": [ + "Previews" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSandboxPreviewTokenRequestV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/CreateSandboxPreviewToken" + } + } + } + } + }, + "responses": { + "201": { + "description": "Token created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboxPreviewTokenV1" + }, + "examples": { + "complete": { + "$ref": "#/components/examples/SandboxPreviewToken" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/SandboxError400" + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + } + }, + "/v1/sandboxes/instances/{sandbox_name}/previews/{preview_name}/tokens/{token_name}": { + "parameters": [ + { + "$ref": "#/components/parameters/SandboxName" + }, + { + "$ref": "#/components/parameters/PreviewName" + }, + { + "$ref": "#/components/parameters/PreviewTokenName" + } + ], + "delete": { + "parameters": [ + { + "$ref": "#/components/parameters/TeamId" + }, + { + "$ref": "#/components/parameters/TeamIdHeader" + } + ], + "operationId": "DeleteSandboxPreviewToken", + "summary": "Delete a preview token", + "description": "Revoke the token. Requests using it are rejected immediately.", + "tags": [ + "Previews" + ], + "responses": { + "204": { + "description": "Token deleted." + }, + "401": { + "$ref": "#/components/responses/SandboxError401" + }, + "403": { + "$ref": "#/components/responses/SandboxError403" + }, + "404": { + "$ref": "#/components/responses/SandboxError404" + }, + "429": { + "$ref": "#/components/responses/SandboxError429" + }, + "500": { + "$ref": "#/components/responses/SandboxError500" + } + }, + "x-stability": "unstable" + } + }, "/v1/sandboxes/library_images": { "get": { "operationId": "ListSandboxLibraryImages", @@ -33843,6 +34342,32 @@ "title": "ExploreMetadataRequestV1", "type": "object" }, + "RouteClassifierListingRefV1": { + "properties": { + "id": { + "description": "Stable classifier identifier.", + "examples": [ + "general-v1" + ], + "title": "Id", + "type": "string" + }, + "name": { + "description": "Display name of the classifier.", + "examples": [ + "General" + ], + "title": "Name", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "title": "RouteClassifierListingRefV1", + "type": "object" + }, "RouteRefV1": { "properties": { "id": { @@ -33885,36 +34410,24 @@ "title": "RouteTargetBasetenModelAPIV1", "type": "object" }, - "RouteTargetClassifierModelBasedV1": { + "RouteTargetClassifierV1": { "properties": { "type": { - "const": "CLASSIFIER_MODEL_BASED", - "description": "Target kind for a classifier-model-based route.", + "const": "CLASSIFIER", + "description": "Target kind for a classifier-based route.", "title": "Type", "type": "string" }, - "classifier_model_id": { - "description": "ID of the Baseten model that picks a route for each request.", - "examples": [ - "abcd123" - ], - "title": "Classifier Model Id", - "type": "string" - }, - "classifier_environment_name": { + "classifier": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/RouteClassifierListingRefV1" }, { "type": "null" } ], - "description": "Environment of the classifier model. Null for production.", - "examples": [ - "production" - ], - "title": "Classifier Environment Name" + "description": "Classifier that picks a route for each request. Null for routers created before classifiers could be selected." }, "default_route": { "$ref": "#/components/schemas/RouteRefV1", @@ -33931,12 +34444,11 @@ }, "required": [ "type", - "classifier_model_id", - "classifier_environment_name", + "classifier", "default_route", "allowed_routes" ], - "title": "RouteTargetClassifierModelBasedV1", + "title": "RouteTargetClassifierV1", "type": "object" }, "RouteTargetConnectionV1": { @@ -34004,7 +34516,7 @@ "discriminator": { "mapping": { "BASETEN_MODEL_API": "#/components/schemas/RouteTargetBasetenModelAPIV1", - "CLASSIFIER_MODEL_BASED": "#/components/schemas/RouteTargetClassifierModelBasedV1", + "CLASSIFIER": "#/components/schemas/RouteTargetClassifierV1", "CONNECTION": "#/components/schemas/RouteTargetConnectionV1" }, "propertyName": "type" @@ -34017,7 +34529,7 @@ "$ref": "#/components/schemas/RouteTargetConnectionV1" }, { - "$ref": "#/components/schemas/RouteTargetClassifierModelBasedV1" + "$ref": "#/components/schemas/RouteTargetClassifierV1" } ], "title": "Target" @@ -34158,41 +34670,24 @@ "title": "UpsertRouteTargetBasetenModelAPIV1", "type": "object" }, - "UpsertRouteTargetClassifierModelBasedV1": { + "UpsertRouteTargetClassifierV1": { "additionalProperties": false, "properties": { "type": { - "const": "CLASSIFIER_MODEL_BASED", - "description": "Target kind for a classifier-model-based route. Not intended for general use: classifiers are deployed by Baseten's post-training team.", + "const": "CLASSIFIER", + "description": "Target kind for a classifier-based route.", "title": "Type", "type": "string" }, - "classifier_model_id": { - "description": "ID of the Baseten model that picks a route for each request. Only classifiers deployed by Baseten's post-training team are supported.", + "classifier_id": { + "description": "ID of the classifier that picks a route for each request.", "examples": [ - "abcd123" + "general-v1" ], "minLength": 1, - "title": "Classifier Model Id", + "title": "Classifier Id", "type": "string" }, - "classifier_environment_name": { - "anyOf": [ - { - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Environment of the classifier model. Omit for production, which is returned as null.", - "examples": [ - "production" - ], - "title": "Classifier Environment Name" - }, "allowed_route_ids": { "description": "IDs of the routes the classifier may pick. All must belong to the route's team and must not be routers themselves.", "examples": [ @@ -34218,11 +34713,11 @@ }, "required": [ "type", - "classifier_model_id", + "classifier_id", "allowed_route_ids", "default_route_id" ], - "title": "UpsertRouteTargetClassifierModelBasedV1", + "title": "UpsertRouteTargetClassifierV1", "type": "object" }, "UpsertRouteTargetConnectionV1": { @@ -34301,7 +34796,7 @@ "discriminator": { "mapping": { "BASETEN_MODEL_API": "#/components/schemas/UpsertRouteTargetBasetenModelAPIV1", - "CLASSIFIER_MODEL_BASED": "#/components/schemas/UpsertRouteTargetClassifierModelBasedV1", + "CLASSIFIER": "#/components/schemas/UpsertRouteTargetClassifierV1", "CONNECTION": "#/components/schemas/UpsertRouteTargetConnectionV1" }, "propertyName": "type" @@ -34320,7 +34815,7 @@ "$ref": "#/components/schemas/UpsertRouteTargetConnectionV1" }, { - "$ref": "#/components/schemas/UpsertRouteTargetClassifierModelBasedV1" + "$ref": "#/components/schemas/UpsertRouteTargetClassifierV1" } ], "title": "Target" @@ -34824,10 +35319,10 @@ }, "spend_limit": { "$ref": "#/components/schemas/RouteSpendLimitSettingV1", - "description": "Spend limit for Baseten Code in the team. Applies to requests with Routes keys the user created in the team; personal API keys are not limited." + "description": "Spend limit for Baseten Grid in the team. Applies to requests with Routes keys the user created in the team; personal API keys are not limited." }, "month_spend_usd": { - "description": "The user's metered Code spend in USD in the team this UTC calendar month, returned as an exact decimal string.", + "description": "The user's metered Grid spend in USD in the team this UTC calendar month, returned as an exact decimal string.", "examples": [ "12.5" ], @@ -34867,7 +35362,7 @@ "RouteUserTeamSettingsListV1": { "properties": { "teams": { - "description": "The user's settings in each team they have a role in and in any team they have Code spend in this month, alphabetical by team name.", + "description": "The user's settings in each team they have a role in and in any team they have Grid spend in this month, alphabetical by team name.", "items": { "$ref": "#/components/schemas/RouteTeamUserSettingsV1" }, @@ -34990,7 +35485,7 @@ "type": "null" } ], - "description": "Spend limit in USD for each UTC calendar month that applies to each member's Code spend in this team, unless the member has a limit of their own. Returned as an exact decimal string. Null when the team has no per-member limit.", + "description": "Spend limit in USD for each UTC calendar month that applies to each member's Grid spend in this team, unless the member has a limit of their own. Returned as an exact decimal string. Null when the team has no per-member limit.", "examples": [ "200" ], @@ -35005,7 +35500,7 @@ "type": "null" } ], - "description": "Spend limit in USD for each UTC calendar month on the Code spend of all members in this team together. Once it is reached, every member's requests in the team are rejected. Returned as an exact decimal string. Null when the team has no team-wide limit.", + "description": "Spend limit in USD for each UTC calendar month on the Grid spend of all members in this team together. Once it is reached, every member's requests in the team are rejected. Returned as an exact decimal string. Null when the team has no team-wide limit.", "examples": [ "5000" ], @@ -35035,7 +35530,7 @@ }, "spend_limit": { "$ref": "#/components/schemas/RouteTeamSpendLimitSettingV1", - "description": "Spend limits for Baseten Code for members' spend in this team." + "description": "Spend limits for Baseten Grid for members' spend in this team." } }, "required": [ @@ -35190,7 +35685,7 @@ } ], "default": null, - "description": "Spend limit in USD for each UTC calendar month that applies to each member's Code spend in this team, unless the member has a limit of their own. Send null to remove it; omit to leave it unchanged.", + "description": "Spend limit in USD for each UTC calendar month that applies to each member's Grid spend in this team, unless the member has a limit of their own. Send null to remove it; omit to leave it unchanged.", "examples": [ "200" ], @@ -35207,7 +35702,7 @@ } ], "default": null, - "description": "Spend limit in USD for each UTC calendar month on the Code spend of all members in this team together. Send null to remove it; omit to leave it unchanged.", + "description": "Spend limit in USD for each UTC calendar month on the Grid spend of all members in this team together. Send null to remove it; omit to leave it unchanged.", "examples": [ "5000" ], @@ -35255,7 +35750,7 @@ "RouteTeamUserSettingsListV1": { "properties": { "users": { - "description": "Settings of each user with a role in the team and of anyone with Code spend in the team this month.", + "description": "Settings of each user with a role in the team and of anyone with Grid spend in the team this month.", "items": { "$ref": "#/components/schemas/RouteTeamUserSettingsV1" }, @@ -35776,7 +36271,7 @@ "discriminator": { "mapping": { "BASETEN_MODEL_API": "#/components/schemas/UpsertRouteTargetBasetenModelAPIV1", - "CLASSIFIER_MODEL_BASED": "#/components/schemas/UpsertRouteTargetClassifierModelBasedV1", + "CLASSIFIER": "#/components/schemas/UpsertRouteTargetClassifierV1", "CONNECTION": "#/components/schemas/UpsertRouteTargetConnectionV1" }, "propertyName": "type" @@ -35789,7 +36284,7 @@ "$ref": "#/components/schemas/UpsertRouteTargetConnectionV1" }, { - "$ref": "#/components/schemas/UpsertRouteTargetClassifierModelBasedV1" + "$ref": "#/components/schemas/UpsertRouteTargetClassifierV1" } ] }, @@ -35797,7 +36292,7 @@ "type": "null" } ], - "description": "Complete new target configuration. Omit to keep the current configuration; null is not accepted. Only the CLASSIFIER_MODEL_BASED configuration of a router route is mutable.", + "description": "Complete new target configuration. Omit to keep the current configuration; null is not accepted. Only the CLASSIFIER configuration of a router route is mutable.", "title": "Target", "x-null-distinct": true } @@ -39162,6 +39657,278 @@ "deleted": 3, "message": "Removed 3 unused image versions. Image versions used by active sandboxes were retained." } + }, + "SandboxPreviewStatusV1": { + "type": "string", + "description": "Preview status, always uppercase.", + "enum": [ + "DEPLOYING", + "DEPLOYED", + "FAILED", + "DELETING", + "TERMINATED" + ], + "readOnly": true, + "example": "DEPLOYED" + }, + "SandboxPreviewTtlV1": { + "type": "string", + "description": "Lifetime counted from the request, using seconds, minutes, hours or composite durations such as 1h30m. Whole days are also supported, for example 7d; days cannot be combined with other units. The preview is deleted when it elapses.", + "pattern": "^([0-9]+d|(([0-9]+(\\.[0-9]*)?|\\.[0-9]+)(ns|us|\u00b5s|\u03bcs|ms|s|m|h))+)$", + "example": "24h" + }, + "SandboxPreviewHeadersV1": { + "type": "object", + "description": "HTTP headers as name-value pairs.", + "additionalProperties": { + "type": "string" + }, + "example": { + "Access-Control-Allow-Origin": "*" + } + }, + "SandboxPreviewConfigurationV1": { + "type": "object", + "description": "Mutable preview configuration. ttl and expires_at are mutually exclusive; supplying both returns 400.", + "properties": { + "public": { + "type": "boolean", + "description": "Whether the URL is reachable without a preview token. Defaults to false.", + "default": false, + "example": true + }, + "response_headers": { + "description": "Headers added to every response served by the preview, for example CORS headers.", + "allOf": [ + { + "$ref": "#/components/schemas/SandboxPreviewHeadersV1" + } + ] + }, + "request_headers": { + "description": "Headers added to every request forwarded to the sandbox, for example an Authorization header expected by the application. Responses return each name with the value \"****\", except a GET with show_secrets=true by a workspace administrator. Sending \"****\" back as a value returns 400. A PATCH replaces the whole set.", + "allOf": [ + { + "$ref": "#/components/schemas/SandboxPreviewHeadersV1" + } + ], + "example": { + "Authorization": "Bearer app-internal-token" + } + }, + "ttl": { + "$ref": "#/components/schemas/SandboxPreviewTtlV1" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "Time at which the preview is deleted. Must be in the future.", + "example": "2026-10-19T12:00:00Z" + } + } + }, + "CreateSandboxPreviewRequestV1": { + "description": "Preview to create. Only port is required.", + "allOf": [ + { + "$ref": "#/components/schemas/SandboxPreviewConfigurationV1" + }, + { + "type": "object", + "properties": { + "create_if_not_exists": { + "type": "boolean", + "description": "When true, return the existing preview with this name instead of failing with a conflict. Without name, the name is public- or private-, so the same port and visibility return the same preview. Existing configuration is preserved. Defaults to false when omitted." + }, + "name": { + "type": "string", + "description": "Preview name, unique within the sandbox. When omitted, generated from the sandbox name and port, or public- or private- when create_if_not_exists is true.", + "minLength": 1, + "maxLength": 49, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "example": "web" + }, + "port": { + "type": "integer", + "description": "Sandbox port to expose.", + "minimum": 1, + "maximum": 65535, + "example": 3000 + } + }, + "required": [ + "port" + ] + } + ] + }, + "UpdateSandboxPreviewRequestV1": { + "description": "Preview fields to change. name and port cannot be changed.", + "allOf": [ + { + "$ref": "#/components/schemas/SandboxPreviewConfigurationV1" + } + ] + }, + "SandboxPreviewV1": { + "description": "Sandbox preview with configuration and server-managed fields at the root.", + "allOf": [ + { + "$ref": "#/components/schemas/SandboxPreviewConfigurationV1" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Preview name, used in preview_name path parameters.", + "readOnly": true, + "example": "web" + }, + "port": { + "type": "integer", + "description": "Exposed sandbox port.", + "readOnly": true, + "example": 3000 + }, + "url": { + "type": "string", + "format": "uri", + "description": "HTTPS URL serving the port, including the region label. Use this exact URL. Requests to a private preview need a token.", + "readOnly": true, + "example": "https://a1b2c3d4e5f6g7h8.us-pdx-1.preview.b10.co" + }, + "status": { + "$ref": "#/components/schemas/SandboxPreviewStatusV1" + }, + "region": { + "type": "string", + "description": "Region serving the preview, the sandbox's region.", + "readOnly": true, + "example": "us-pdx-1" + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "example": "2026-10-12T12:00:00Z" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "example": "2026-10-12T12:00:00Z" + }, + "created_by": { + "type": "string", + "readOnly": true, + "example": "sandbox-automation" + }, + "updated_by": { + "type": "string", + "readOnly": true, + "example": "sandbox-automation" + } + }, + "required": [ + "name", + "port", + "url", + "status", + "public" + ] + } + ] + }, + "ListSandboxPreviewsResponseV1": { + "type": "object", + "description": "One page of previews of a sandbox.", + "properties": { + "items": { + "type": "array", + "description": "Resources on this page.", + "items": { + "$ref": "#/components/schemas/SandboxPreviewV1" + } + }, + "pagination": { + "$ref": "#/components/schemas/SandboxApiPaginationV1" + } + }, + "required": [ + "items", + "pagination" + ] + }, + "CreateSandboxPreviewTokenRequestV1": { + "type": "object", + "properties": { + "create_if_not_exists": { + "type": "boolean", + "description": "When true, return an existing unexpired token of this preview that stays valid at least until expires_at instead of creating a new one. Defaults to false when omitted." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "Time at which the token stops working. Must be in the future.", + "example": "2026-10-13T12:00:00Z" + } + }, + "required": [ + "expires_at" + ] + }, + "SandboxPreviewTokenV1": { + "type": "object", + "description": "Access token for a private preview.", + "properties": { + "name": { + "type": "string", + "description": "Token name, used in token_name path parameters.", + "example": "7f3k2m9q1x8c4v6b0n5z" + }, + "token": { + "type": "string", + "description": "Value to send as the bl_preview_token query parameter.", + "example": "7f3k2m9q1x8c4v6b0n5z" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "example": "2026-10-13T12:00:00Z" + }, + "expired": { + "type": "boolean", + "description": "Whether expires_at has passed.", + "example": false + } + }, + "required": [ + "name", + "token", + "expires_at", + "expired" + ] + }, + "ListSandboxPreviewTokensResponseV1": { + "type": "object", + "description": "One page of tokens of a preview.", + "properties": { + "items": { + "type": "array", + "description": "Resources on this page.", + "items": { + "$ref": "#/components/schemas/SandboxPreviewTokenV1" + } + }, + "pagination": { + "$ref": "#/components/schemas/SandboxApiPaginationV1" + } + }, + "required": [ + "items", + "pagination" + ] } }, "parameters": { @@ -39472,6 +40239,28 @@ "example": "baseten-api-review-0916" } }, + "PreviewName": { + "name": "preview_name", + "in": "path", + "required": true, + "description": "Preview name returned by creation.", + "schema": { + "type": "string", + "minLength": 1, + "example": "web" + } + }, + "PreviewTokenName": { + "name": "token_name", + "in": "path", + "required": true, + "description": "Token name returned by creation.", + "schema": { + "type": "string", + "minLength": 1, + "example": "7f3k2m9q1x8c4v6b0n5z" + } + }, "SandboxImageName": { "name": "image_name", "in": "path", @@ -40377,6 +41166,115 @@ "deleted": 3, "message": "Removed 3 unused image versions. Image versions used by active sandboxes were retained." } + }, + "CreateSandboxPreviewPublic": { + "summary": "Public preview of a web server", + "value": { + "name": "web", + "port": 3000, + "public": true, + "response_headers": { + "Access-Control-Allow-Origin": "*" + } + } + }, + "CreateSandboxPreviewPrivate": { + "summary": "Private preview deleted after one day", + "value": { + "port": 8080, + "ttl": "24h" + } + }, + "UpdateSandboxPreview": { + "summary": "Make a preview private and extend it", + "value": { + "public": false, + "ttl": "7d" + } + }, + "SandboxPreviewReady": { + "summary": "Public preview", + "value": { + "name": "web", + "port": 3000, + "public": true, + "url": "https://a1b2c3d4e5f6g7h8.us-pdx-1.preview.b10.co", + "status": "DEPLOYED", + "region": "us-pdx-1", + "response_headers": { + "Access-Control-Allow-Origin": "*" + }, + "created_at": "2026-10-12T12:00:00Z", + "updated_at": "2026-10-12T12:00:00Z", + "created_by": "sandbox-automation", + "updated_by": "sandbox-automation" + } + }, + "SandboxPreviewDeleted": { + "summary": "Deleted preview", + "value": { + "name": "web", + "port": 3000, + "public": true, + "url": "https://a1b2c3d4e5f6g7h8.us-pdx-1.preview.b10.co", + "status": "TERMINATED", + "region": "us-pdx-1", + "created_at": "2026-10-12T12:00:00Z", + "updated_at": "2026-10-12T12:00:00Z", + "created_by": "sandbox-automation", + "updated_by": "sandbox-automation" + } + }, + "SandboxPreviewsList": { + "summary": "Previews of a sandbox", + "value": { + "items": [ + { + "name": "web", + "port": 3000, + "public": true, + "url": "https://a1b2c3d4e5f6g7h8.us-pdx-1.preview.b10.co", + "status": "DEPLOYED", + "region": "us-pdx-1", + "created_at": "2026-10-12T12:00:00Z", + "updated_at": "2026-10-12T12:00:00Z" + } + ], + "pagination": { + "has_more": false + } + } + }, + "CreateSandboxPreviewToken": { + "summary": "Token valid for one day", + "value": { + "expires_at": "2026-10-13T12:00:00Z" + } + }, + "SandboxPreviewToken": { + "summary": "Created token", + "value": { + "name": "7f3k2m9q1x8c4v6b0n5z", + "token": "7f3k2m9q1x8c4v6b0n5z", + "expires_at": "2026-10-13T12:00:00Z", + "expired": false + } + }, + "SandboxPreviewTokensList": { + "summary": "Tokens of a preview", + "value": { + "items": [ + { + "name": "7f3k2m9q1x8c4v6b0n5z", + "token": "7f3k2m9q1x8c4v6b0n5z", + "expires_at": "2026-10-13T12:00:00Z", + "expired": false + } + ], + "pagination": { + "has_more": false + } + } } } } diff --git a/tests/sandbox/test_e2e.py b/tests/sandbox/test_e2e.py index 92e680d..0e61aaf 100644 --- a/tests/sandbox/test_e2e.py +++ b/tests/sandbox/test_e2e.py @@ -1191,6 +1191,212 @@ async def test_image_sandbox_has_its_files_and_sandbox_api( assert re.match(r"/usr/local/bin/sandbox-api\b", processes[1].stdout) +# Answers every GET with "hello " plus the X-E2E-Request header it received, so +# a test can see the headers a preview adds to requests. Formatted with the +# port. +_PREVIEW_SERVER = """import http.server +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = ("hello " + self.headers.get("X-E2E-Request", "")).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) +http.server.ThreadingHTTPServer(("0.0.0.0", {port}), Handler).serve_forever() +""" + + +@pytest.mark.asyncio +async def test_previews(sandbox: Sandbox | AsyncSandbox) -> None: + started = datetime.now(UTC) + directory = f"/tmp/e2e-preview/{_unique()}" + server, name = _unique(), _unique() + # Each client's run has its own port, so one run's server ending never + # races the next one's starting. + port = 8123 if isinstance(sandbox, Sandbox) else 8124 + create: dict[str, Any] = { + "port": port, + "name": name, + "response_headers": {"X-E2E-Response": "added"}, + "request_headers": {"X-E2E-Request": "injected"}, + "ttl": timedelta(hours=1), + } + server_options: dict[str, Any] = { + "command": "python3 server.py", + "working_dir": directory, + "name": server, + "wait_for_ports": [port], + } + script = _PREVIEW_SERVER.replace("{port}", str(port)) + try: + if isinstance(sandbox, Sandbox): + await asyncio.to_thread(sandbox.fs.mkdir, path=directory) + await asyncio.to_thread( + sandbox.fs.write, path=f"{directory}/server.py", content=script + ) + await asyncio.to_thread(lambda: sandbox.process.exec(**server_options)) + created = await asyncio.to_thread(lambda: sandbox.previews.create(**create)) + info = await asyncio.to_thread(sandbox.previews.get_info, name=name) + listed = await asyncio.to_thread( + lambda: [preview.name for preview in sandbox.previews.list()] + ) + existing = await asyncio.to_thread( + lambda: sandbox.previews.create( + port=port, name=name, create_if_not_exists=True, public=True + ) + ) + else: + await sandbox.fs.mkdir(path=directory) + await sandbox.fs.write(path=f"{directory}/server.py", content=script) + await sandbox.process.exec(**server_options) + created = await sandbox.previews.create(**create) + info = await sandbox.previews.get_info(name=name) + listed = [preview.name async for preview in sandbox.previews.list()] + existing = await sandbox.previews.create( + port=port, name=name, create_if_not_exists=True, public=True + ) + assert (created.name, created.port, created.public) == (name, port, False) + assert created.url.startswith("https://") + assert created.created_at is not None + _assert_within_run(started, created.created_at) + # The TTL counts from the request, so expires_at is an hour after it. + assert created.expires_at is not None + _assert_within_run(started, created.expires_at - timedelta(hours=1)) + assert info.url == created.url + assert info.request_headers == {"X-E2E-Request": "****"} + assert info.response_headers == {"X-E2E-Response": "added"} + assert name in listed + assert (existing.url, existing.public) == (created.url, False) + + await _fetch_preview(created.url, "rejected without a token", _rejected) + + expires_at = datetime.now(UTC) + timedelta(hours=1) + if isinstance(sandbox, Sandbox): + token = await asyncio.to_thread( + sandbox.previews.create_token, preview_name=name, expires_at=expires_at + ) + reused = await asyncio.to_thread( + sandbox.previews.create_token, + preview_name=name, + expires_at=expires_at - timedelta(minutes=30), + create_if_not_exists=True, + ) + token_names = await asyncio.to_thread( + lambda: [ + t.name for t in sandbox.previews.list_tokens(preview_name=name) + ] + ) + else: + token = await sandbox.previews.create_token( + preview_name=name, expires_at=expires_at + ) + reused = await sandbox.previews.create_token( + preview_name=name, + expires_at=expires_at - timedelta(minutes=30), + create_if_not_exists=True, + ) + token_names = [ + t.name async for t in sandbox.previews.list_tokens(preview_name=name) + ] + assert not token.expired + # The server may not find the token just created yet, so the result may + # be that token or a new one, either valid at least as long as asked. + assert not reused.expired + assert reused.expires_at >= expires_at - timedelta(minutes=30) - _CLOCK_SKEW + assert token.name in token_names + assert reused.name in token_names + + # The preview adds its response header, and the app sees the request + # header the preview adds. + with_token = f"{created.url}?preview_token={token.token}" + response = await _fetch_preview( + with_token, + "served with the token and the request header", + lambda status, body: status == 200 and body == "hello injected", + ) + assert response.headers["X-E2E-Response"] == "added" + + if isinstance(sandbox, Sandbox): + await asyncio.to_thread( + sandbox.previews.delete_token, preview_name=name, token_name=token.name + ) + else: + await sandbox.previews.delete_token( + preview_name=name, token_name=token.name + ) + await _fetch_preview(with_token, "rejected with the revoked token", _rejected) + + if isinstance(sandbox, Sandbox): + updated = await asyncio.to_thread( + sandbox.previews.update, name=name, public=True, request_headers={} + ) + else: + updated = await sandbox.previews.update( + name=name, public=True, request_headers={} + ) + assert (updated.public, updated.url) == (True, created.url) + assert updated.request_headers == {} + assert updated.response_headers == {"X-E2E-Response": "added"} + # Served without a token. + # TODO: Also require body == "hello " once the server stops adding + # request headers cleared with an empty mapping; it still adds them. + await _fetch_preview( + created.url, "served without a token", lambda status, body: status == 200 + ) + + # A public preview has no tokens, so listing them is rejected. + with pytest.raises(SandboxAPIError) as raised: + if isinstance(sandbox, Sandbox): + await asyncio.to_thread( + lambda: list(sandbox.previews.list_tokens(preview_name=name)) + ) + else: + [t async for t in sandbox.previews.list_tokens(preview_name=name)] + assert raised.value.status == 400 + + if isinstance(sandbox, Sandbox): + deleted = await asyncio.to_thread(sandbox.previews.delete, name=name) + else: + deleted = await sandbox.previews.delete(name=name) + assert deleted.status in ("DELETING", "TERMINATED") + await _fetch_preview( + created.url, "no longer served", lambda status, body: status != 200 + ) + finally: + with contextlib.suppress(Exception): + if isinstance(sandbox, Sandbox): + await asyncio.to_thread(sandbox.previews.delete, name=name) + else: + await sandbox.previews.delete(name=name) + await _kill_quietly(sandbox, server) + + +def _rejected(status: int, body: str) -> bool: + return status in (401, 403) + + +async def _fetch_preview( + url: str, want: str, accept: Callable[[int, str], bool] +) -> httpx.Response: + # The edge applies preview changes shortly after the API returns, so other + # responses and connection errors are retried, but only for a few seconds. + deadline = time.monotonic() + 3 + while True: + # A fresh client per attempt, so the cookie a token request sets never + # stands in for the token. + async with httpx.AsyncClient(timeout=10) as http: + try: + response = await http.get(url) + if accept(response.status_code, response.text): + return response + last = f"status {response.status_code}: {response.text}" + except httpx.HTTPError as err: + last = repr(err) + if time.monotonic() > deadline: + pytest.fail(f"preview {url}: want {want}, got {last}") + await asyncio.sleep(0.2) + + def test_uses_http2_by_default(shared: _Shared) -> None: versions: list[str] = [] with _client() as client: diff --git a/tests/sandbox/test_parity.py b/tests/sandbox/test_parity.py index 8fad135..15159d8 100644 --- a/tests/sandbox/test_parity.py +++ b/tests/sandbox/test_parity.py @@ -16,6 +16,7 @@ AsyncSandboxClient, AsyncSandboxCreateResult, AsyncSandboxFileSystem, + AsyncSandboxPreviews, AsyncSandboxProcess, AsyncSandboxStream, ImageCleanupResult, @@ -45,6 +46,9 @@ SandboxNetworkProxy, SandboxNetworkProxyRoute, SandboxPort, + SandboxPreviewInfo, + SandboxPreviews, + SandboxPreviewTokenInfo, SandboxProcess, SandboxProcessInfo, SandboxProcessLogs, @@ -63,6 +67,7 @@ (SandboxCreateResult, AsyncSandboxCreateResult), (SandboxProcess, AsyncSandboxProcess), (SandboxFileSystem, AsyncSandboxFileSystem), + (SandboxPreviews, AsyncSandboxPreviews), (ImageClient, AsyncImageClient), (SandboxStream, AsyncSandboxStream), ], @@ -183,6 +188,26 @@ def _kwargs(method: Callable[..., Any]) -> set[str]: ours=_kwargs(SandboxClient.update) - {"name"}, generated=baseten.client.managementapi.UpdateSandboxRequest, ), + "SandboxPreviewInfo": _Parity( + ours=_dataclass_fields(SandboxPreviewInfo), + generated=baseten.client.managementapi.SandboxPreview, + ), + "SandboxPreviewTokenInfo": _Parity( + ours=_dataclass_fields(SandboxPreviewTokenInfo), + generated=baseten.client.managementapi.SandboxPreviewToken, + ), + "SandboxPreviews.create": _Parity( + ours=_kwargs(SandboxPreviews.create), + generated=baseten.client.managementapi.CreateSandboxPreviewRequest, + ), + "SandboxPreviews.update": _Parity( + ours=_kwargs(SandboxPreviews.update) - {"name"}, + generated=baseten.client.managementapi.UpdateSandboxPreviewRequest, + ), + "SandboxPreviews.create_token": _Parity( + ours=_kwargs(SandboxPreviews.create_token) - {"preview_name"}, + generated=baseten.client.managementapi.CreateSandboxPreviewTokenRequest, + ), "SandboxProcessInfo": _Parity( ours=_dataclass_fields(SandboxProcessInfo), generated=baseten.client.sandboxapi.ProcessResponse, diff --git a/tests/sandbox/test_previews.py b/tests/sandbox/test_previews.py new file mode 100644 index 0000000..151b39f --- /dev/null +++ b/tests/sandbox/test_previews.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx +import pytest + +from baseten.sandbox import ( + AsyncSandbox, + AsyncSandboxClient, + Sandbox, + SandboxAPIError, + SandboxClient, + SandboxPreviewInfo, + SandboxPreviewTokenInfo, +) + +_PREVIEWS = "/v1/sandboxes/instances/sbx/previews" + +_PREVIEW = { + "name": "web", + "port": 3000, + "url": "https://p.example", + "status": "DEPLOYED", + "public": False, + "region": "us-was-1", + "ttl": "7d", + "expires_at": "2026-10-20T12:00:00Z", + "response_headers": {"A": "1"}, + "request_headers": {"Authorization": "****"}, + "created_at": "2026-10-13T12:00:00Z", + "created_by": "me", +} + +_TOKEN = { + "name": "t1", + "token": "secret", + "expires_at": "2026-10-14T12:00:00Z", + "expired": False, +} + + +class _Api: + """Fake control plane and token exchange, recording requests.""" + + def __init__(self) -> None: + self.requests: list[httpx.Request] = [] + self.routes: dict[ + tuple[str, str], Callable[[httpx.Request], httpx.Response] + ] = {} + + def route(self, method: str, path: str, body: Any, *, status: int = 200) -> None: + self.routes[(method, path)] = lambda request: httpx.Response(status, json=body) + + def handle(self, request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/token": + return httpx.Response( + 200, + json={ + "token": "token", + "expires_at": (datetime.now(UTC) + timedelta(hours=2)).isoformat(), + }, + ) + self.requests.append(request) + return self.routes[(request.method, request.url.path)](request) + + def client(self) -> SandboxClient: + return SandboxClient( + api_key="key", + team_id="team-1", + base_url_override="https://api.test", + http_client_override=httpx.Client( + transport=httpx.MockTransport(self.handle) + ), + ) + + def sandbox(self) -> Sandbox: + return self.client().sandbox_from_url(url="https://sbx.example") + + def named_sandbox(self) -> Sandbox: + self.route( + "GET", + "/v1/sandboxes/instances/sbx", + { + "name": "sbx", + "url": "https://sbx.example", + "status": "DEPLOYED", + "created_at": "2026-10-13T12:00:00Z", + }, + ) + sandbox = self.client().get(name="sbx") + self.requests.clear() + return sandbox + + async def async_named_sandbox(self) -> AsyncSandbox: + self.route( + "GET", + "/v1/sandboxes/instances/sbx", + { + "name": "sbx", + "url": "https://sbx.example", + "status": "DEPLOYED", + "created_at": "2026-10-13T12:00:00Z", + }, + ) + + async def handle(request: httpx.Request) -> httpx.Response: + return self.handle(request) + + client = AsyncSandboxClient( + api_key="key", + team_id="team-1", + base_url_override="https://api.test", + http_client_override=httpx.AsyncClient( + transport=httpx.MockTransport(handle) + ), + ) + sandbox = await client.get(name="sbx") + self.requests.clear() + return sandbox + + +def _body(request: httpx.Request) -> Any: + return json.loads(request.content) + + +class TestSandboxPreviews: + def test_creates_sending_only_set_fields_and_maps_record(self) -> None: + api = _Api() + api.route("POST", _PREVIEWS, _PREVIEW, status=201) + info = api.named_sandbox().previews.create( + port=3000, name="web", response_headers={}, ttl=timedelta(minutes=90) + ) + request = api.requests[0] + assert request.url.params["team_id"] == "team-1" + assert _body(request) == { + "port": 3000, + "name": "web", + "response_headers": {}, + "ttl": "5400000ms", + } + assert info == SandboxPreviewInfo( + name="web", + port=3000, + url="https://p.example/", + status="DEPLOYED", + public=False, + response_headers={"A": "1"}, + request_headers={"Authorization": "****"}, + ttl=timedelta(days=7), + expires_at=datetime(2026, 10, 20, 12, tzinfo=UTC), + region="us-was-1", + created_at=datetime(2026, 10, 13, 12, tzinfo=UTC), + updated_at=None, + created_by="me", + updated_by=None, + ) + + def test_creates_with_every_field(self) -> None: + api = _Api() + api.route("POST", _PREVIEWS, _PREVIEW, status=201) + api.named_sandbox().previews.create( + port=3000, + create_if_not_exists=True, + public=True, + request_headers={"Authorization": "Bearer x"}, + expires_at=datetime(2026, 10, 20, 12, tzinfo=UTC), + ) + assert _body(api.requests[0]) == { + "port": 3000, + "create_if_not_exists": True, + "public": True, + "request_headers": {"Authorization": "Bearer x"}, + "expires_at": "2026-10-20T12:00:00Z", + } + + def test_raises_api_errors(self) -> None: + api = _Api() + api.route( + "POST", _PREVIEWS, {"code": "CONFLICT", "message": "exists"}, status=409 + ) + with pytest.raises(SandboxAPIError) as raised: + api.named_sandbox().previews.create(port=3000) + assert raised.value.code == "CONFLICT" + + def test_gets_info_with_show_secrets(self) -> None: + api = _Api() + api.route("GET", f"{_PREVIEWS}/web", _PREVIEW) + info = api.named_sandbox().previews.get_info(name="web", show_secrets=True) + assert info.name == "web" + assert api.requests[0].url.params["show_secrets"] == "true" + + def test_lists_across_pages(self) -> None: + api = _Api() + pages = iter( + [ + { + "items": [{**_PREVIEW, "name": "a"}], + "pagination": {"has_more": True, "cursor": "c1"}, + }, + { + "items": [{**_PREVIEW, "name": "b"}], + "pagination": {"has_more": False}, + }, + ] + ) + api.routes[("GET", _PREVIEWS)] = lambda request: httpx.Response( + 200, json=next(pages) + ) + names = [info.name for info in api.named_sandbox().previews.list(page_size=1)] + assert names == ["a", "b"] + assert [r.url.params.get("cursor") for r in api.requests] == [None, "c1"] + assert api.requests[0].url.params["limit"] == "1" + + def test_updates_sending_only_set_fields(self) -> None: + api = _Api() + api.route("PATCH", f"{_PREVIEWS}/web", _PREVIEW) + previews = api.named_sandbox().previews + previews.update(name="web", public=False, request_headers={}) + previews.update(name="web") + # False is sent, since it differs from unchanged, and an empty mapping + # clears the set; the generated model's default false is never sent. + assert _body(api.requests[0]) == {"public": False, "request_headers": {}} + assert _body(api.requests[1]) == {} + + def test_deletes_returning_record(self) -> None: + api = _Api() + api.route("DELETE", f"{_PREVIEWS}/web", {**_PREVIEW, "status": "DELETING"}) + assert api.named_sandbox().previews.delete(name="web").status == "DELETING" + + def test_creates_lists_and_deletes_tokens(self) -> None: + api = _Api() + api.route("POST", f"{_PREVIEWS}/web/tokens", _TOKEN, status=201) + api.route( + "GET", + f"{_PREVIEWS}/web/tokens", + {"items": [_TOKEN], "pagination": {"has_more": False}}, + ) + api.routes[("DELETE", f"{_PREVIEWS}/web/tokens/t1")] = lambda request: ( + httpx.Response(204) + ) + previews = api.named_sandbox().previews + token = previews.create_token( + preview_name="web", + expires_at=datetime(2026, 10, 14, 12, tzinfo=UTC), + create_if_not_exists=True, + ) + assert _body(api.requests[0]) == { + "expires_at": "2026-10-14T12:00:00Z", + "create_if_not_exists": True, + } + assert token == SandboxPreviewTokenInfo( + name="t1", + token="secret", + expires_at=datetime(2026, 10, 14, 12, tzinfo=UTC), + expired=False, + ) + assert [t.name for t in previews.list_tokens(preview_name="web")] == ["t1"] + previews.delete_token(preview_name="web", token_name="t1") + assert api.requests[2].method == "DELETE" + + def test_fails_every_call_on_sandbox_from_url(self) -> None: + api = _Api() + previews = api.sandbox().previews + calls: dict[str, Callable[[], object]] = { + "create": lambda: previews.create(port=3000), + "get_info": lambda: previews.get_info(name="web"), + "list": lambda: next(previews.list()), + "update": lambda: previews.update(name="web"), + "delete": lambda: previews.delete(name="web"), + "create_token": lambda: previews.create_token( + preview_name="web", expires_at=datetime.now(UTC) + ), + "list_tokens": lambda: next(previews.list_tokens(preview_name="web")), + "delete_token": lambda: previews.delete_token( + preview_name="web", token_name="t1" + ), + } + for name, call in calls.items(): + with pytest.raises(ValueError, match="sandbox_from_url"): + call() + assert api.requests == [], name + + +class TestAsyncSandboxPreviews: + @pytest.mark.asyncio + async def test_creates_lists_and_deletes_tokens(self) -> None: + api = _Api() + api.route("POST", _PREVIEWS, _PREVIEW, status=201) + api.route( + "GET", + f"{_PREVIEWS}/web/tokens", + {"items": [_TOKEN], "pagination": {"has_more": False}}, + ) + api.routes[("DELETE", f"{_PREVIEWS}/web/tokens/t1")] = lambda request: ( + httpx.Response(204) + ) + previews = (await api.async_named_sandbox()).previews + info = await previews.create(port=3000, public=True) + assert info.name == "web" + assert _body(api.requests[0]) == {"port": 3000, "public": True} + names = [t.name async for t in previews.list_tokens(preview_name="web")] + assert names == ["t1"] + await previews.delete_token(preview_name="web", token_name="t1") + assert api.requests[2].url.path == f"{_PREVIEWS}/web/tokens/t1"