From 9a8c60eb51daba174c0a4761612b3dd51fb5bee5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 19:14:04 +0000 Subject: [PATCH 01/30] feat(api): add cluster config/OIDC/add-ons params, project filtering, update storage types --- .stats.yml | 4 +- api.md | 4 +- .../resources/beta/clusters/clusters.py | 194 +++++++++- .../resources/beta/clusters/storage.py | 78 ++-- src/together/types/beta/__init__.py | 1 + src/together/types/beta/cluster.py | 349 +++++++++++++++++- .../types/beta/cluster_create_params.py | 224 ++++++++++- .../types/beta/cluster_list_params.py | 16 + .../types/beta/cluster_update_params.py | 122 +++++- src/together/types/beta/clusters/__init__.py | 1 + .../types/beta/clusters/cluster_storage.py | 8 +- .../beta/clusters/storage_create_params.py | 5 +- .../beta/clusters/storage_list_params.py | 16 + .../beta/clusters/storage_update_params.py | 8 +- .../beta/clusters/test_storage.py | 64 +++- tests/api_resources/beta/test_clusters.py | 182 ++++++++- 16 files changed, 1188 insertions(+), 88 deletions(-) create mode 100644 src/together/types/beta/cluster_list_params.py create mode 100644 src/together/types/beta/clusters/storage_list_params.py diff --git a/.stats.yml b/.stats.yml index 498815517..409398869 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-5f05c9669c67c3f4b0ebfe2317d2768cd96317424965ebb2acf06a7757a7d0ca.yml -openapi_spec_hash: 84f45151f4d0eed68551b5ffda61595a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-e398785d31ad51e94f158b377be469e74748e0229a964d3602149c6bf964581e.yml +openapi_spec_hash: 3e4db6f27d100314926d87c9ae24cba2 config_hash: ec427df08d61d8888138f15cd53c6454 diff --git a/api.md b/api.md index 1ea19fb05..c32e307a7 100644 --- a/api.md +++ b/api.md @@ -87,7 +87,7 @@ Methods: - client.beta.clusters.create(\*\*params) -> Cluster - client.beta.clusters.retrieve(cluster_id) -> Cluster - client.beta.clusters.update(cluster_id, \*\*params) -> Cluster -- client.beta.clusters.list() -> ClusterListResponse +- client.beta.clusters.list(\*\*params) -> ClusterListResponse - client.beta.clusters.delete(cluster_id) -> ClusterDeleteResponse - client.beta.clusters.list_regions() -> ClusterListRegionsResponse @@ -104,7 +104,7 @@ Methods: - client.beta.clusters.storage.create(\*\*params) -> ClusterStorage - client.beta.clusters.storage.retrieve(volume_id) -> ClusterStorage - client.beta.clusters.storage.update(\*\*params) -> ClusterStorage -- client.beta.clusters.storage.list() -> StorageListResponse +- client.beta.clusters.storage.list(\*\*params) -> StorageListResponse - client.beta.clusters.storage.delete(volume_id) -> StorageDeleteResponse # Chat diff --git a/src/together/resources/beta/clusters/clusters.py b/src/together/resources/beta/clusters/clusters.py index 5014087ac..935d71153 100644 --- a/src/together/resources/beta/clusters/clusters.py +++ b/src/together/resources/beta/clusters/clusters.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Union +from typing import Union, Iterable from datetime import datetime from typing_extensions import Literal @@ -26,7 +26,7 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) -from ....types.beta import cluster_create_params, cluster_update_params +from ....types.beta import cluster_list_params, cluster_create_params, cluster_update_params from ...._base_client import make_request_options from ....types.beta.cluster import Cluster from ....types.beta.cluster_list_response import ClusterListResponse @@ -66,17 +66,26 @@ def create( billing_type: Literal["RESERVED", "ON_DEMAND", "SCHEDULED_CAPACITY"], cluster_name: str, cuda_version: str, + duration_days: int, gpu_type: Literal["H100_SXM", "H200_SXM", "RTX_6000_PCI", "L40_PCIE", "B200_SXM", "H100_SXM_INF"], num_gpus: int, nvidia_driver_version: str, region: str, + acceptance_tests_params: cluster_create_params.AcceptanceTestsParams | Omit = omit, + add_ons: Iterable[cluster_create_params.AddOn] | Omit = omit, + auto_scale: bool | Omit = omit, auto_scale_max_gpus: int | Omit = omit, auto_scaled: bool | Omit = omit, capacity_pool_id: str | Omit = omit, + cluster_config: cluster_create_params.ClusterConfig | Omit = omit, cluster_type: Literal["KUBERNETES", "SLURM"] | Omit = omit, - duration_days: int | Omit = omit, gpu_node_failover_enabled: bool | Omit = omit, install_traefik: bool | Omit = omit, + num_capacity_pool_gpus: int | Omit = omit, + num_preemptible_gpus: int | Omit = omit, + num_reserved_gpus: int | Omit = omit, + oidc_config: cluster_create_params.OidcConfig | Omit = omit, + project_id: str | Omit = omit, reservation_end_time: Union[str, datetime] | Omit = omit, reservation_start_time: Union[str, datetime] | Omit = omit, shared_volume: cluster_create_params.SharedVolume | Omit = omit, @@ -109,6 +118,8 @@ def create( cuda_version: CUDA version for this cluster. For example, 12.5 + duration_days: Duration in days to keep the cluster running. + gpu_type: Type of GPU to use in the cluster num_gpus: Number of GPUs to allocate in the cluster. This must be multiple of 8. For @@ -120,6 +131,15 @@ def create( region: Region to create the GPU cluster in. Usable regions can be found from `client.clusters.list_regions()` + acceptance_tests_params: AcceptanceTestsParams groups all GPU acceptance test options when enabled is + true. + + add_ons: Add-ons to enable on the cluster at creation time. + + auto_scale: Whether to enable auto-scaling for the cluster. If true, the cluster will + automatically scale the number of GPU worker nodes between num_gpus and + auto_scale_max_gpus based on the workload. + auto_scale_max_gpus: Maximum number of GPUs to which the cluster can be auto-scaled up. This field is required if auto_scaled is true. @@ -131,14 +151,26 @@ def create( cluster_type: Type of cluster to create. - duration_days: Duration in days to keep the cluster running. - gpu_node_failover_enabled: Whether automated GPU node failover should be enabled for this cluster. By default, it is disabled. install_traefik: Whether to install Traefik ingress controller in the cluster. This field is only applicable for Kubernetes clusters and is false by default. + num_capacity_pool_gpus: Number of GPUs to allocate from the capacity pool. Must be a multiple of 8 and + not exceed num_gpus. + + num_preemptible_gpus: Number of preemptible GPUs to request alongside on-demand capacity. Must be a + multiple of 8. Preemptible nodes are cheaper but may be reclaimed when on-demand + capacity is needed elsewhere; the system fulfills this asynchronously and + surfaces the actual count in allocated_preemptible_gpus. + + num_reserved_gpus: Number of prepaid (PLG) reserved GPUs for this cluster. When omitted for + RESERVED billing on create, the server defaults this to num_gpus. + + project_id: Project ID for the cluster. If not set, the project from the request context is + used. + reservation_end_time: Reservation end time of the cluster. This field is required for SCHEDULED billing to specify the reservation end time for the cluster. @@ -170,17 +202,26 @@ def create( "billing_type": billing_type, "cluster_name": cluster_name, "cuda_version": cuda_version, + "duration_days": duration_days, "gpu_type": gpu_type, "num_gpus": num_gpus, "nvidia_driver_version": nvidia_driver_version, "region": region, + "acceptance_tests_params": acceptance_tests_params, + "add_ons": add_ons, + "auto_scale": auto_scale, "auto_scale_max_gpus": auto_scale_max_gpus, "auto_scaled": auto_scaled, "capacity_pool_id": capacity_pool_id, + "cluster_config": cluster_config, "cluster_type": cluster_type, - "duration_days": duration_days, "gpu_node_failover_enabled": gpu_node_failover_enabled, "install_traefik": install_traefik, + "num_capacity_pool_gpus": num_capacity_pool_gpus, + "num_preemptible_gpus": num_preemptible_gpus, + "num_reserved_gpus": num_reserved_gpus, + "oidc_config": oidc_config, + "project_id": project_id, "reservation_end_time": reservation_end_time, "reservation_start_time": reservation_start_time, "shared_volume": shared_volume, @@ -235,8 +276,12 @@ def update( self, cluster_id: str, *, + add_ons: Iterable[cluster_update_params.AddOn] | Omit = omit, + cluster_config: cluster_update_params.ClusterConfig | Omit = omit, cluster_type: Literal["KUBERNETES", "SLURM"] | Omit = omit, num_gpus: int | Omit = omit, + num_preemptible_gpus: int | Omit = omit, + num_reserved_gpus: int | Omit = omit, reservation_end_time: Union[str, datetime] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -251,10 +296,20 @@ def update( Args: cluster_id: The ID of the cluster to update + add_ons: Add-ons to update on the cluster. Each entry identifies an existing add-on by + name and provides the new external config to merge. + cluster_type: Type of cluster to update. - num_gpus: Number of GPUs to allocate in the cluster. This must be multiple of 8. For - example, 8, 16 or 24 + num_gpus: Target GPU count for the cluster. When omitted, the server keeps the current GPU + count from cluster metadata (use for config-only or decommission-time-only + updates). + + num_preemptible_gpus: Updated desired number of preemptible GPUs for the cluster. When omitted, the + current value is preserved. Must be a multiple of 8. + + num_reserved_gpus: Number of reserved GPUs to update to. This field is only applicable for clusters + with RESERVED billing type. reservation_end_time: Timestamp at which the cluster should be decommissioned. Only accepted for prepaid clusters. @@ -273,8 +328,12 @@ def update( path_template("/compute/clusters/{cluster_id}", cluster_id=cluster_id), body=maybe_transform( { + "add_ons": add_ons, + "cluster_config": cluster_config, "cluster_type": cluster_type, "num_gpus": num_gpus, + "num_preemptible_gpus": num_preemptible_gpus, + "num_reserved_gpus": num_reserved_gpus, "reservation_end_time": reservation_end_time, }, cluster_update_params.ClusterUpdateParams, @@ -288,6 +347,7 @@ def update( def list( self, *, + project_id: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -295,11 +355,30 @@ def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ClusterListResponse: - """List all GPU clusters.""" + """ + List all GPU clusters. + + Args: + project_id: Optional UMS project ID to filter clusters by. When set, only clusters belonging + to this project are returned. The caller must be a member of the project; + otherwise the result set will be empty. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ return self._get( "/compute/clusters", options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"project_id": project_id}, cluster_list_params.ClusterListParams), ), cast_to=ClusterListResponse, ) @@ -389,17 +468,26 @@ async def create( billing_type: Literal["RESERVED", "ON_DEMAND", "SCHEDULED_CAPACITY"], cluster_name: str, cuda_version: str, + duration_days: int, gpu_type: Literal["H100_SXM", "H200_SXM", "RTX_6000_PCI", "L40_PCIE", "B200_SXM", "H100_SXM_INF"], num_gpus: int, nvidia_driver_version: str, region: str, + acceptance_tests_params: cluster_create_params.AcceptanceTestsParams | Omit = omit, + add_ons: Iterable[cluster_create_params.AddOn] | Omit = omit, + auto_scale: bool | Omit = omit, auto_scale_max_gpus: int | Omit = omit, auto_scaled: bool | Omit = omit, capacity_pool_id: str | Omit = omit, + cluster_config: cluster_create_params.ClusterConfig | Omit = omit, cluster_type: Literal["KUBERNETES", "SLURM"] | Omit = omit, - duration_days: int | Omit = omit, gpu_node_failover_enabled: bool | Omit = omit, install_traefik: bool | Omit = omit, + num_capacity_pool_gpus: int | Omit = omit, + num_preemptible_gpus: int | Omit = omit, + num_reserved_gpus: int | Omit = omit, + oidc_config: cluster_create_params.OidcConfig | Omit = omit, + project_id: str | Omit = omit, reservation_end_time: Union[str, datetime] | Omit = omit, reservation_start_time: Union[str, datetime] | Omit = omit, shared_volume: cluster_create_params.SharedVolume | Omit = omit, @@ -432,6 +520,8 @@ async def create( cuda_version: CUDA version for this cluster. For example, 12.5 + duration_days: Duration in days to keep the cluster running. + gpu_type: Type of GPU to use in the cluster num_gpus: Number of GPUs to allocate in the cluster. This must be multiple of 8. For @@ -443,6 +533,15 @@ async def create( region: Region to create the GPU cluster in. Usable regions can be found from `client.clusters.list_regions()` + acceptance_tests_params: AcceptanceTestsParams groups all GPU acceptance test options when enabled is + true. + + add_ons: Add-ons to enable on the cluster at creation time. + + auto_scale: Whether to enable auto-scaling for the cluster. If true, the cluster will + automatically scale the number of GPU worker nodes between num_gpus and + auto_scale_max_gpus based on the workload. + auto_scale_max_gpus: Maximum number of GPUs to which the cluster can be auto-scaled up. This field is required if auto_scaled is true. @@ -454,14 +553,26 @@ async def create( cluster_type: Type of cluster to create. - duration_days: Duration in days to keep the cluster running. - gpu_node_failover_enabled: Whether automated GPU node failover should be enabled for this cluster. By default, it is disabled. install_traefik: Whether to install Traefik ingress controller in the cluster. This field is only applicable for Kubernetes clusters and is false by default. + num_capacity_pool_gpus: Number of GPUs to allocate from the capacity pool. Must be a multiple of 8 and + not exceed num_gpus. + + num_preemptible_gpus: Number of preemptible GPUs to request alongside on-demand capacity. Must be a + multiple of 8. Preemptible nodes are cheaper but may be reclaimed when on-demand + capacity is needed elsewhere; the system fulfills this asynchronously and + surfaces the actual count in allocated_preemptible_gpus. + + num_reserved_gpus: Number of prepaid (PLG) reserved GPUs for this cluster. When omitted for + RESERVED billing on create, the server defaults this to num_gpus. + + project_id: Project ID for the cluster. If not set, the project from the request context is + used. + reservation_end_time: Reservation end time of the cluster. This field is required for SCHEDULED billing to specify the reservation end time for the cluster. @@ -493,17 +604,26 @@ async def create( "billing_type": billing_type, "cluster_name": cluster_name, "cuda_version": cuda_version, + "duration_days": duration_days, "gpu_type": gpu_type, "num_gpus": num_gpus, "nvidia_driver_version": nvidia_driver_version, "region": region, + "acceptance_tests_params": acceptance_tests_params, + "add_ons": add_ons, + "auto_scale": auto_scale, "auto_scale_max_gpus": auto_scale_max_gpus, "auto_scaled": auto_scaled, "capacity_pool_id": capacity_pool_id, + "cluster_config": cluster_config, "cluster_type": cluster_type, - "duration_days": duration_days, "gpu_node_failover_enabled": gpu_node_failover_enabled, "install_traefik": install_traefik, + "num_capacity_pool_gpus": num_capacity_pool_gpus, + "num_preemptible_gpus": num_preemptible_gpus, + "num_reserved_gpus": num_reserved_gpus, + "oidc_config": oidc_config, + "project_id": project_id, "reservation_end_time": reservation_end_time, "reservation_start_time": reservation_start_time, "shared_volume": shared_volume, @@ -558,8 +678,12 @@ async def update( self, cluster_id: str, *, + add_ons: Iterable[cluster_update_params.AddOn] | Omit = omit, + cluster_config: cluster_update_params.ClusterConfig | Omit = omit, cluster_type: Literal["KUBERNETES", "SLURM"] | Omit = omit, num_gpus: int | Omit = omit, + num_preemptible_gpus: int | Omit = omit, + num_reserved_gpus: int | Omit = omit, reservation_end_time: Union[str, datetime] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -574,10 +698,20 @@ async def update( Args: cluster_id: The ID of the cluster to update + add_ons: Add-ons to update on the cluster. Each entry identifies an existing add-on by + name and provides the new external config to merge. + cluster_type: Type of cluster to update. - num_gpus: Number of GPUs to allocate in the cluster. This must be multiple of 8. For - example, 8, 16 or 24 + num_gpus: Target GPU count for the cluster. When omitted, the server keeps the current GPU + count from cluster metadata (use for config-only or decommission-time-only + updates). + + num_preemptible_gpus: Updated desired number of preemptible GPUs for the cluster. When omitted, the + current value is preserved. Must be a multiple of 8. + + num_reserved_gpus: Number of reserved GPUs to update to. This field is only applicable for clusters + with RESERVED billing type. reservation_end_time: Timestamp at which the cluster should be decommissioned. Only accepted for prepaid clusters. @@ -596,8 +730,12 @@ async def update( path_template("/compute/clusters/{cluster_id}", cluster_id=cluster_id), body=await async_maybe_transform( { + "add_ons": add_ons, + "cluster_config": cluster_config, "cluster_type": cluster_type, "num_gpus": num_gpus, + "num_preemptible_gpus": num_preemptible_gpus, + "num_reserved_gpus": num_reserved_gpus, "reservation_end_time": reservation_end_time, }, cluster_update_params.ClusterUpdateParams, @@ -611,6 +749,7 @@ async def update( async def list( self, *, + project_id: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -618,11 +757,30 @@ async def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ClusterListResponse: - """List all GPU clusters.""" + """ + List all GPU clusters. + + Args: + project_id: Optional UMS project ID to filter clusters by. When set, only clusters belonging + to this project are returned. The caller must be a member of the project; + otherwise the result set will be empty. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ return await self._get( "/compute/clusters", options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"project_id": project_id}, cluster_list_params.ClusterListParams), ), cast_to=ClusterListResponse, ) diff --git a/src/together/resources/beta/clusters/storage.py b/src/together/resources/beta/clusters/storage.py index c6abf44e8..c295061f3 100644 --- a/src/together/resources/beta/clusters/storage.py +++ b/src/together/resources/beta/clusters/storage.py @@ -15,7 +15,7 @@ async_to_streamed_response_wrapper, ) from ...._base_client import make_request_options -from ....types.beta.clusters import storage_create_params, storage_update_params +from ....types.beta.clusters import storage_list_params, storage_create_params, storage_update_params from ....types.beta.clusters.cluster_storage import ClusterStorage from ....types.beta.clusters.storage_list_response import StorageListResponse from ....types.beta.clusters.storage_delete_response import StorageDeleteResponse @@ -49,6 +49,7 @@ def create( region: str, size_tib: int, volume_name: str, + is_lifecycle_independent: bool | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -64,11 +65,9 @@ def create( performance for shared storage. Args: - region: Region name. Usable regions can be found from `client.clusters.list_regions()` - size_tib: Volume size in whole tebibytes (TiB). - volume_name: Customizable name of the volume to create. + is_lifecycle_independent: When true, the shared volume is not deleted when the cluster is decommissioned. extra_headers: Send extra headers @@ -85,6 +84,7 @@ def create( "region": region, "size_tib": size_tib, "volume_name": volume_name, + "is_lifecycle_independent": is_lifecycle_independent, }, storage_create_params.StorageCreateParams, ), @@ -132,8 +132,8 @@ def retrieve( def update( self, *, - size_tib: int | Omit = omit, - volume_id: str | Omit = omit, + size_tib: int, + volume_id: str, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -145,10 +145,6 @@ def update( Update the configuration of an existing shared volume. Args: - size_tib: Size of the volume in whole tebibytes (TiB). - - volume_id: ID of the volume to update. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -175,6 +171,7 @@ def update( def list( self, *, + project_id: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -182,11 +179,30 @@ def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> StorageListResponse: - """List all shared volumes.""" + """ + List all shared volumes. + + Args: + project_id: Optional UMS project ID to filter volumes by. When set, only volumes belonging + to this project are returned. The caller must be a member of the project; + otherwise the result set will be empty. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ return self._get( "/compute/clusters/storage/volumes", options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"project_id": project_id}, storage_list_params.StorageListParams), ), cast_to=StorageListResponse, ) @@ -255,6 +271,7 @@ async def create( region: str, size_tib: int, volume_name: str, + is_lifecycle_independent: bool | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -270,11 +287,9 @@ async def create( performance for shared storage. Args: - region: Region name. Usable regions can be found from `client.clusters.list_regions()` - size_tib: Volume size in whole tebibytes (TiB). - volume_name: Customizable name of the volume to create. + is_lifecycle_independent: When true, the shared volume is not deleted when the cluster is decommissioned. extra_headers: Send extra headers @@ -291,6 +306,7 @@ async def create( "region": region, "size_tib": size_tib, "volume_name": volume_name, + "is_lifecycle_independent": is_lifecycle_independent, }, storage_create_params.StorageCreateParams, ), @@ -338,8 +354,8 @@ async def retrieve( async def update( self, *, - size_tib: int | Omit = omit, - volume_id: str | Omit = omit, + size_tib: int, + volume_id: str, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -351,10 +367,6 @@ async def update( Update the configuration of an existing shared volume. Args: - size_tib: Size of the volume in whole tebibytes (TiB). - - volume_id: ID of the volume to update. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -381,6 +393,7 @@ async def update( async def list( self, *, + project_id: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -388,11 +401,30 @@ async def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> StorageListResponse: - """List all shared volumes.""" + """ + List all shared volumes. + + Args: + project_id: Optional UMS project ID to filter volumes by. When set, only volumes belonging + to this project are returned. The caller must be a member of the project; + otherwise the result set will be empty. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ return await self._get( "/compute/clusters/storage/volumes", options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"project_id": project_id}, storage_list_params.StorageListParams), ), cast_to=StorageListResponse, ) diff --git a/src/together/types/beta/__init__.py b/src/together/types/beta/__init__.py index ab66b141e..e7c1d8d94 100644 --- a/src/together/types/beta/__init__.py +++ b/src/together/types/beta/__init__.py @@ -8,6 +8,7 @@ from .jig_deploy_params import JigDeployParams as JigDeployParams from .jig_list_response import JigListResponse as JigListResponse from .jig_update_params import JigUpdateParams as JigUpdateParams +from .cluster_list_params import ClusterListParams as ClusterListParams from .cluster_create_params import ClusterCreateParams as ClusterCreateParams from .cluster_list_response import ClusterListResponse as ClusterListResponse from .cluster_update_params import ClusterUpdateParams as ClusterUpdateParams diff --git a/src/together/types/beta/cluster.py b/src/together/types/beta/cluster.py index 9d5f254c7..400108cfb 100644 --- a/src/together/types/beta/cluster.py +++ b/src/together/types/beta/cluster.py @@ -6,7 +6,85 @@ from ..._models import BaseModel -__all__ = ["Cluster", "ControlPlaneNode", "GPUWorkerNode", "Volume"] +__all__ = [ + "Cluster", + "AddOn", + "AddOnConfig", + "AddOnConfigDashboard", + "AddOnConfigIngress", + "AddOnState", + "AddOnStateDashboard", + "AddOnStateIngress", + "ControlPlaneNode", + "ControlPlaneNodePhaseTransition", + "GPUWorkerNode", + "GPUWorkerNodePhaseTransition", + "GPUWorkerNodeLatestRemediation", + "PhaseTransition", + "Volume", + "ClusterConfig", + "ClusterConfigIngress", + "ClusterConfigObservability", + "ClusterConfigSlurmStartupScripts", + "OidcConfig", +] + + +class AddOnConfigDashboard(BaseModel): + enabled: Optional[bool] = None + + +class AddOnConfigIngress(BaseModel): + enabled: Optional[bool] = None + + +class AddOnConfig(BaseModel): + dashboard: Optional[AddOnConfigDashboard] = None + + ingress: Optional[AddOnConfigIngress] = None + + +class AddOnStateDashboard(BaseModel): + pass + + +class AddOnStateIngress(BaseModel): + pass + + +class AddOnState(BaseModel): + dashboard: Optional[AddOnStateDashboard] = None + + ingress: Optional[AddOnStateIngress] = None + + +class AddOn(BaseModel): + """AddOnInfo is returned in cluster responses and add-on CRUD operations.""" + + add_on_type: str + + config: AddOnConfig + + name: str + + state: AddOnState + + +class ControlPlaneNodePhaseTransition(BaseModel): + phase: Literal[ + "NODE_PHASE_PENDING", + "NODE_PHASE_SCHEDULING", + "NODE_PHASE_BOOTING", + "NODE_PHASE_BOOTSTRAPPING", + "NODE_PHASE_RUNNING", + "NODE_PHASE_SUCCEEDED", + "NODE_PHASE_FAILED", + "NODE_PHASE_PAUSED", + ] + """Node phase.""" + + transition_time: datetime + """Timestamp when the phase transition occurred.""" class ControlPlaneNode(BaseModel): @@ -22,9 +100,114 @@ class ControlPlaneNode(BaseModel): num_cpu_cores: int + phase_transitions: List[ControlPlaneNodePhaseTransition] + """Phase transition history for this control plane node.""" + status: str +class GPUWorkerNodePhaseTransition(BaseModel): + phase: Literal[ + "NODE_PHASE_PENDING", + "NODE_PHASE_SCHEDULING", + "NODE_PHASE_BOOTING", + "NODE_PHASE_BOOTSTRAPPING", + "NODE_PHASE_RUNNING", + "NODE_PHASE_SUCCEEDED", + "NODE_PHASE_FAILED", + "NODE_PHASE_PAUSED", + ] + """Node phase.""" + + transition_time: datetime + """Timestamp when the phase transition occurred.""" + + +class GPUWorkerNodeLatestRemediation(BaseModel): + """ + Remediation represents a node remediation request for an instance. + An instance can have multiple remediations over time (e.g., failed attempts followed by retries). + """ + + id: str + + cluster_id: str + + instance_id: str + + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + """Remediation mode specifies how the remediation should be performed. + + - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any + available host. + - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and + provisions a new one on a different host. + """ + + state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] + """RemediationState represents the lifecycle state of a remediation. + + - `PENDING_APPROVAL`: Awaiting approval before processing can begin. + - `PENDING`: Approved and queued for processing. + - `RUNNING`: Actively being processed. + - `SUCCEEDED`: Successfully completed. + - `FAILED`: Failed with an error. + - `CANCELLED`: Cancelled by user or system. + - `AUTO_RESOLVED`: The underlying issue was automatically resolved before + processing. + """ + + trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] + """RemediationTrigger specifies how the remediation was triggered. + + - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI + or API call). + - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires + approval. + """ + + active_health_check_run_id: Optional[str] = None + """Active health check run ID (UUID) that triggered this remediation.""" + + create_time: Optional[datetime] = None + """When the remediation was created.""" + + end_time: Optional[datetime] = None + """When the remediation completed.""" + + error_message: Optional[str] = None + """Error message if the remediation failed.""" + + passive_health_check_event_id: Optional[str] = None + """Passive health check event ID that triggered this remediation.""" + + reason: Optional[str] = None + """User-provided reason for the remediation.""" + + requested_by: Optional[str] = None + """Who requested the remediation.""" + + review_comment: Optional[str] = None + """Review comment.""" + + review_time: Optional[datetime] = None + """When the remediation was reviewed.""" + + reviewed_by: Optional[str] = None + """Who reviewed the remediation.""" + + start_time: Optional[datetime] = None + """When processing started.""" + + update_time: Optional[datetime] = None + """When the remediation was last updated.""" + + class GPUWorkerNode(BaseModel): host_name: str @@ -40,10 +223,48 @@ class GPUWorkerNode(BaseModel): num_gpus: int + phase_transitions: List[GPUWorkerNodePhaseTransition] + """Phase transition history for this GPU worker node.""" + status: str instance_id: Optional[str] = None + latest_remediation: Optional[GPUWorkerNodeLatestRemediation] = None + """ + Remediation represents a node remediation request for an instance. An instance + can have multiple remediations over time (e.g., failed attempts followed by + retries). + """ + + slurm_worker_hostname: Optional[str] = None + + +class PhaseTransition(BaseModel): + phase: Literal[ + "CLUSTER_PHASE_QUEUED", + "CLUSTER_PHASE_SCHEDULED", + "CLUSTER_PHASE_WAITING_FOR_CONTROL_PLANE_NODES", + "CLUSTER_PHASE_WAITING_FOR_DATA_PLANE_NODES", + "CLUSTER_PHASE_WAITING_FOR_SUBNET", + "CLUSTER_PHASE_WAITING_FOR_SHARED_VOLUME", + "CLUSTER_PHASE_WAITING_FOR_AUTO_SCALER", + "CLUSTER_PHASE_INSTALLING_DRIVERS", + "CLUSTER_PHASE_RUNNING_ACCEPTANCE_TESTS", + "CLUSTER_PHASE_ACCEPTANCE_TESTS_FAILED", + "CLUSTER_PHASE_RUNNING_NCCL_TESTS", + "CLUSTER_PHASE_NCCL_TESTS_FAILED", + "CLUSTER_PHASE_READY", + "CLUSTER_PHASE_PAUSED", + "CLUSTER_PHASE_ON_DEMAND_COMPUTE_PAUSED", + "CLUSTER_PHASE_DEGRADED", + "CLUSTER_PHASE_DELETING", + ] + """Cluster phase.""" + + transition_time: datetime + """Timestamp when the phase transition occurred.""" + class Volume(BaseModel): size_tib: int @@ -55,7 +276,115 @@ class Volume(BaseModel): volume_name: str +class ClusterConfigIngress(BaseModel): + enabled: Optional[bool] = None + + +class ClusterConfigObservability(BaseModel): + enabled: Optional[bool] = None + + +class ClusterConfigSlurmStartupScripts(BaseModel): + """ + SlurmStartupScripts carries optional Slurm lifecycle scripts (prolog/epilog, init, extra conf). + """ + + controller_epilog: Optional[str] = None + """Slurm controller epilog script.""" + + controller_prolog: Optional[str] = None + """Slurm controller prolog script.""" + + extra_slurm_conf: Optional[str] = None + """Additional slurm.conf fragments.""" + + login_init_script: Optional[str] = None + """Script run on Slurm login node init.""" + + nodeset_init_script: Optional[str] = None + """Script run on Slurm nodeset init.""" + + worker_epilog: Optional[str] = None + """Slurm worker node epilog script.""" + + worker_prolog: Optional[str] = None + """Slurm worker node prolog script.""" + + +class ClusterConfig(BaseModel): + load_balancer: Literal["NONE", "TRAEFIK", "NGINX", "ISTIO"] + + gpu_operator_version: Optional[str] = None + """NVIDIA GPU Operator chart/version for the tenant cluster (e.g. + + v24.6.2). When omitted, a service default is applied. + """ + + ingress: Optional[ClusterConfigIngress] = None + + jumphost_enabled: Optional[bool] = None + + kubernetes_dashboard_enabled: Optional[bool] = None + + observability: Optional[ClusterConfigObservability] = None + + slurm_startup_scripts: Optional[ClusterConfigSlurmStartupScripts] = None + """ + SlurmStartupScripts carries optional Slurm lifecycle scripts (prolog/epilog, + init, extra conf). + """ + + +class OidcConfig(BaseModel): + client_id: str + """OIDC client ID for authentication.""" + + group_claim: str + """JWT claim to use for user groups. For example, 'groups'""" + + group_prefix: str + """Prefix to add to the group claim to form the final group name. + + For example, 'oidc:' + """ + + issuer_url: str + """OIDC issuer URL for authentication. For example, https://accounts.google.com""" + + username_claim: str + """JWT claim to use as the username. For example, 'sub' or 'email'""" + + username_prefix: str + """Prefix to add to the username claim to form the final username. + + For example, 'oidc:' + """ + + ca_cert: Optional[str] = None + """CA certificate in PEM format to validate the OIDC issuer's TLS certificate. + + This field is optional but recommended if the issuer uses a private CA or + self-signed certificate. + """ + + class Cluster(BaseModel): + add_ons: List[AddOn] + """Enabled add-ons on this cluster. + + Only add-ons with enabled=true in their config are returned. + """ + + allocated_preemptible_gpus: int + """Actual number of preemptible GPUs currently allocated to the cluster. + + Updated asynchronously by the fulfillment and reclamation workers; may be less + than desired_preemptible_gpus when capacity is constrained. + """ + + billing_type: Literal["RESERVED", "ON_DEMAND", "SCHEDULED_CAPACITY"] + """Billing type for the cluster (RESERVED, ON_DEMAND, or SCHEDULED_CAPACITY).""" + cluster_id: str cluster_name: str @@ -67,16 +396,30 @@ class Cluster(BaseModel): cuda_version: str + desired_preemptible_gpus: int + """Customer's requested number of preemptible GPUs. + + Set on cluster create or update; persists until changed. + """ + gpu_type: Literal["H100_SXM", "H200_SXM", "RTX_6000_PCI", "L40_PCIE", "B200_SXM", "H100_SXM_INF"] gpu_worker_nodes: List[GPUWorkerNode] kube_config: str + num_cpu_workers: int + """Number of CPU-only worker nodes in the cluster.""" + num_gpus: int nvidia_driver_version: str + phase_transitions: List[PhaseTransition] + """Cluster-level phase transition history.""" + + project_id: str + region: str status: Literal[ @@ -98,12 +441,16 @@ class Cluster(BaseModel): capacity_pool_id: Optional[str] = None + cluster_config: Optional[ClusterConfig] = None + created_at: Optional[datetime] = None duration_hours: Optional[int] = None install_traefik: Optional[bool] = None + oidc_config: Optional[OidcConfig] = None + reservation_end_time: Optional[datetime] = None reservation_start_time: Optional[datetime] = None diff --git a/src/together/types/beta/cluster_create_params.py b/src/together/types/beta/cluster_create_params.py index e961052b7..4d9482da5 100644 --- a/src/together/types/beta/cluster_create_params.py +++ b/src/together/types/beta/cluster_create_params.py @@ -2,13 +2,26 @@ from __future__ import annotations -from typing import Union +from typing import Union, Iterable from datetime import datetime from typing_extensions import Literal, Required, Annotated, TypedDict from ..._utils import PropertyInfo -__all__ = ["ClusterCreateParams", "SharedVolume"] +__all__ = [ + "ClusterCreateParams", + "AcceptanceTestsParams", + "AddOn", + "AddOnConfig", + "AddOnConfigDashboard", + "AddOnConfigIngress", + "ClusterConfig", + "ClusterConfigIngress", + "ClusterConfigObservability", + "ClusterConfigSlurmStartupScripts", + "OidcConfig", + "SharedVolume", +] class ClusterCreateParams(TypedDict, total=False): @@ -27,6 +40,9 @@ class ClusterCreateParams(TypedDict, total=False): cuda_version: Required[str] """CUDA version for this cluster. For example, 12.5""" + duration_days: Required[int] + """Duration in days to keep the cluster running.""" + gpu_type: Required[Literal["H100_SXM", "H200_SXM", "RTX_6000_PCI", "L40_PCIE", "B200_SXM", "H100_SXM_INF"]] """Type of GPU to use in the cluster""" @@ -49,6 +65,22 @@ class ClusterCreateParams(TypedDict, total=False): Usable regions can be found from `client.clusters.list_regions()` """ + acceptance_tests_params: AcceptanceTestsParams + """ + AcceptanceTestsParams groups all GPU acceptance test options when enabled is + true. + """ + + add_ons: Iterable[AddOn] + """Add-ons to enable on the cluster at creation time.""" + + auto_scale: bool + """Whether to enable auto-scaling for the cluster. + + If true, the cluster will automatically scale the number of GPU worker nodes + between num_gpus and auto_scale_max_gpus based on the workload. + """ + auto_scale_max_gpus: int """Maximum number of GPUs to which the cluster can be auto-scaled up. @@ -68,12 +100,11 @@ class ClusterCreateParams(TypedDict, total=False): capacity pool. """ + cluster_config: ClusterConfig + cluster_type: Literal["KUBERNETES", "SLURM"] """Type of cluster to create.""" - duration_days: int - """Duration in days to keep the cluster running.""" - gpu_node_failover_enabled: bool """Whether automated GPU node failover should be enabled for this cluster. @@ -86,6 +117,35 @@ class ClusterCreateParams(TypedDict, total=False): This field is only applicable for Kubernetes clusters and is false by default. """ + num_capacity_pool_gpus: int + """Number of GPUs to allocate from the capacity pool. + + Must be a multiple of 8 and not exceed num_gpus. + """ + + num_preemptible_gpus: int + """Number of preemptible GPUs to request alongside on-demand capacity. + + Must be a multiple of 8. Preemptible nodes are cheaper but may be reclaimed when + on-demand capacity is needed elsewhere; the system fulfills this asynchronously + and surfaces the actual count in allocated_preemptible_gpus. + """ + + num_reserved_gpus: int + """Number of prepaid (PLG) reserved GPUs for this cluster. + + When omitted for RESERVED billing on create, the server defaults this to + num_gpus. + """ + + oidc_config: OidcConfig + + project_id: str + """Project ID for the cluster. + + If not set, the project from the request context is used. + """ + reservation_end_time: Annotated[Union[str, datetime], PropertyInfo(format="iso8601")] """Reservation end time of the cluster. @@ -116,14 +176,164 @@ class ClusterCreateParams(TypedDict, total=False): """ID of an existing volume to use with the cluster creation.""" +class AcceptanceTestsParams(TypedDict, total=False): + """ + AcceptanceTestsParams groups all GPU acceptance test options when enabled is true. + """ + + dcgm_diag_level: Literal[ + "DCGM_DIAG_LEVEL_SHORT", "DCGM_DIAG_LEVEL_MEDIUM", "DCGM_DIAG_LEVEL_LONG", "DCGM_DIAG_LEVEL_EXTENDED" + ] + """DCGM diagnostic depth. + + SHORT = readiness; MEDIUM = default; LONG = system validation; EXTENDED = + memtest. An omitted value selects MEDIUM when enabled. + """ + + dcgm_diag_skipped: bool + """Skip DCGM diagnostics acceptance test.""" + + enabled: bool + """Whether to run GPU acceptance tests during cluster bring-up.""" + + gpu_burn_duration: int + """GPU burn duration in seconds; 0 means use the default when enabled.""" + + gpu_burn_skipped: bool + """Skip GPU burn acceptance test.""" + + nccl_multi_node_skipped: bool + """Skip NCCL multi-node acceptance test.""" + + nccl_single_node_skipped: bool + """Skip NCCL single-node acceptance test.""" + + +class AddOnConfigDashboard(TypedDict, total=False): + enabled: bool + + +class AddOnConfigIngress(TypedDict, total=False): + enabled: bool + + +class AddOnConfig(TypedDict, total=False): + dashboard: AddOnConfigDashboard + + ingress: AddOnConfigIngress + + +class AddOn(TypedDict, total=False): + add_on_type: Required[str] + """Type of add-on. Valid values: 'dashboard', 'ingress'.""" + + name: Required[str] + """Human-readable name for this add-on instance.""" + + config: AddOnConfig + + +class ClusterConfigIngress(TypedDict, total=False): + enabled: bool + + +class ClusterConfigObservability(TypedDict, total=False): + enabled: bool + + +class ClusterConfigSlurmStartupScripts(TypedDict, total=False): + """ + SlurmStartupScripts carries optional Slurm lifecycle scripts (prolog/epilog, init, extra conf). + """ + + controller_epilog: str + """Slurm controller epilog script.""" + + controller_prolog: str + """Slurm controller prolog script.""" + + extra_slurm_conf: str + """Additional slurm.conf fragments.""" + + login_init_script: str + """Script run on Slurm login node init.""" + + nodeset_init_script: str + """Script run on Slurm nodeset init.""" + + worker_epilog: str + """Slurm worker node epilog script.""" + + worker_prolog: str + """Slurm worker node prolog script.""" + + +class ClusterConfig(TypedDict, total=False): + load_balancer: Required[Literal["NONE", "TRAEFIK", "NGINX", "ISTIO"]] + + gpu_operator_version: str + """NVIDIA GPU Operator chart/version for the tenant cluster (e.g. + + v24.6.2). When omitted, a service default is applied. + """ + + ingress: ClusterConfigIngress + + jumphost_enabled: bool + + kubernetes_dashboard_enabled: bool + + observability: ClusterConfigObservability + + slurm_startup_scripts: ClusterConfigSlurmStartupScripts + """ + SlurmStartupScripts carries optional Slurm lifecycle scripts (prolog/epilog, + init, extra conf). + """ + + +class OidcConfig(TypedDict, total=False): + client_id: Required[str] + """OIDC client ID for authentication.""" + + group_claim: Required[str] + """JWT claim to use for user groups. For example, 'groups'""" + + group_prefix: Required[str] + """Prefix to add to the group claim to form the final group name. + + For example, 'oidc:' + """ + + issuer_url: Required[str] + """OIDC issuer URL for authentication. For example, https://accounts.google.com""" + + username_claim: Required[str] + """JWT claim to use as the username. For example, 'sub' or 'email'""" + + username_prefix: Required[str] + """Prefix to add to the username claim to form the final username. + + For example, 'oidc:' + """ + + ca_cert: str + """CA certificate in PEM format to validate the OIDC issuer's TLS certificate. + + This field is optional but recommended if the issuer uses a private CA or + self-signed certificate. + """ + + class SharedVolume(TypedDict, total=False): """Inline configuration to create a shared volume with the cluster creation.""" region: Required[str] - """Region name. Usable regions can be found from `client.clusters.list_regions()`""" size_tib: Required[int] """Volume size in whole tebibytes (TiB).""" volume_name: Required[str] - """Customizable name of the volume to create.""" + + is_lifecycle_independent: bool + """When true, the shared volume is not deleted when the cluster is decommissioned.""" diff --git a/src/together/types/beta/cluster_list_params.py b/src/together/types/beta/cluster_list_params.py new file mode 100644 index 000000000..69fa8e9e4 --- /dev/null +++ b/src/together/types/beta/cluster_list_params.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["ClusterListParams"] + + +class ClusterListParams(TypedDict, total=False): + project_id: str + """Optional UMS project ID to filter clusters by. + + When set, only clusters belonging to this project are returned. The caller must + be a member of the project; otherwise the result set will be empty. + """ diff --git a/src/together/types/beta/cluster_update_params.py b/src/together/types/beta/cluster_update_params.py index a98f77653..46cf388aa 100644 --- a/src/together/types/beta/cluster_update_params.py +++ b/src/together/types/beta/cluster_update_params.py @@ -2,23 +2,55 @@ from __future__ import annotations -from typing import Union +from typing import Union, Iterable from datetime import datetime -from typing_extensions import Literal, Annotated, TypedDict +from typing_extensions import Literal, Required, Annotated, TypedDict from ..._utils import PropertyInfo -__all__ = ["ClusterUpdateParams"] +__all__ = [ + "ClusterUpdateParams", + "AddOn", + "AddOnConfig", + "AddOnConfigDashboard", + "AddOnConfigIngress", + "ClusterConfig", + "ClusterConfigIngress", + "ClusterConfigObservability", + "ClusterConfigSlurmStartupScripts", +] class ClusterUpdateParams(TypedDict, total=False): + add_ons: Iterable[AddOn] + """Add-ons to update on the cluster. + + Each entry identifies an existing add-on by name and provides the new external + config to merge. + """ + + cluster_config: ClusterConfig + cluster_type: Literal["KUBERNETES", "SLURM"] """Type of cluster to update.""" num_gpus: int - """Number of GPUs to allocate in the cluster. + """Target GPU count for the cluster. + + When omitted, the server keeps the current GPU count from cluster metadata (use + for config-only or decommission-time-only updates). + """ - This must be multiple of 8. For example, 8, 16 or 24 + num_preemptible_gpus: int + """Updated desired number of preemptible GPUs for the cluster. + + When omitted, the current value is preserved. Must be a multiple of 8. + """ + + num_reserved_gpus: int + """Number of reserved GPUs to update to. + + This field is only applicable for clusters with RESERVED billing type. """ reservation_end_time: Annotated[Union[str, datetime], PropertyInfo(format="iso8601")] @@ -26,3 +58,83 @@ class ClusterUpdateParams(TypedDict, total=False): Only accepted for prepaid clusters. """ + + +class AddOnConfigDashboard(TypedDict, total=False): + enabled: bool + + +class AddOnConfigIngress(TypedDict, total=False): + enabled: bool + + +class AddOnConfig(TypedDict, total=False): + dashboard: AddOnConfigDashboard + + ingress: AddOnConfigIngress + + +class AddOn(TypedDict, total=False): + name: Required[str] + """Name of the add-on to update. Must match an existing add-on on the cluster.""" + + config: AddOnConfig + + +class ClusterConfigIngress(TypedDict, total=False): + enabled: bool + + +class ClusterConfigObservability(TypedDict, total=False): + enabled: bool + + +class ClusterConfigSlurmStartupScripts(TypedDict, total=False): + """ + SlurmStartupScripts carries optional Slurm lifecycle scripts (prolog/epilog, init, extra conf). + """ + + controller_epilog: str + """Slurm controller epilog script.""" + + controller_prolog: str + """Slurm controller prolog script.""" + + extra_slurm_conf: str + """Additional slurm.conf fragments.""" + + login_init_script: str + """Script run on Slurm login node init.""" + + nodeset_init_script: str + """Script run on Slurm nodeset init.""" + + worker_epilog: str + """Slurm worker node epilog script.""" + + worker_prolog: str + """Slurm worker node prolog script.""" + + +class ClusterConfig(TypedDict, total=False): + load_balancer: Required[Literal["NONE", "TRAEFIK", "NGINX", "ISTIO"]] + + gpu_operator_version: str + """NVIDIA GPU Operator chart/version for the tenant cluster (e.g. + + v24.6.2). When omitted, a service default is applied. + """ + + ingress: ClusterConfigIngress + + jumphost_enabled: bool + + kubernetes_dashboard_enabled: bool + + observability: ClusterConfigObservability + + slurm_startup_scripts: ClusterConfigSlurmStartupScripts + """ + SlurmStartupScripts carries optional Slurm lifecycle scripts (prolog/epilog, + init, extra conf). + """ diff --git a/src/together/types/beta/clusters/__init__.py b/src/together/types/beta/clusters/__init__.py index a85f4c49a..9c4f18a76 100644 --- a/src/together/types/beta/clusters/__init__.py +++ b/src/together/types/beta/clusters/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from .cluster_storage import ClusterStorage as ClusterStorage +from .storage_list_params import StorageListParams as StorageListParams from .storage_create_params import StorageCreateParams as StorageCreateParams from .storage_list_response import StorageListResponse as StorageListResponse from .storage_update_params import StorageUpdateParams as StorageUpdateParams diff --git a/src/together/types/beta/clusters/cluster_storage.py b/src/together/types/beta/clusters/cluster_storage.py index 6d7a0bfe4..22392753f 100644 --- a/src/together/types/beta/clusters/cluster_storage.py +++ b/src/together/types/beta/clusters/cluster_storage.py @@ -1,7 +1,5 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing_extensions import Literal - from ...._models import BaseModel __all__ = ["ClusterStorage"] @@ -9,13 +7,9 @@ class ClusterStorage(BaseModel): size_tib: int - """Size of the volume in whole tebibytes (TiB).""" - status: Literal["available", "bound", "provisioning"] - """Deployment status of the volume.""" + status: str volume_id: str - """ID of the volume.""" volume_name: str - """Provided name of the volume.""" diff --git a/src/together/types/beta/clusters/storage_create_params.py b/src/together/types/beta/clusters/storage_create_params.py index 5629cb113..2dfb9218f 100644 --- a/src/together/types/beta/clusters/storage_create_params.py +++ b/src/together/types/beta/clusters/storage_create_params.py @@ -9,10 +9,11 @@ class StorageCreateParams(TypedDict, total=False): region: Required[str] - """Region name. Usable regions can be found from `client.clusters.list_regions()`""" size_tib: Required[int] """Volume size in whole tebibytes (TiB).""" volume_name: Required[str] - """Customizable name of the volume to create.""" + + is_lifecycle_independent: bool + """When true, the shared volume is not deleted when the cluster is decommissioned.""" diff --git a/src/together/types/beta/clusters/storage_list_params.py b/src/together/types/beta/clusters/storage_list_params.py new file mode 100644 index 000000000..2dddfca79 --- /dev/null +++ b/src/together/types/beta/clusters/storage_list_params.py @@ -0,0 +1,16 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["StorageListParams"] + + +class StorageListParams(TypedDict, total=False): + project_id: str + """Optional UMS project ID to filter volumes by. + + When set, only volumes belonging to this project are returned. The caller must + be a member of the project; otherwise the result set will be empty. + """ diff --git a/src/together/types/beta/clusters/storage_update_params.py b/src/together/types/beta/clusters/storage_update_params.py index 449a62661..c87e4cd61 100644 --- a/src/together/types/beta/clusters/storage_update_params.py +++ b/src/together/types/beta/clusters/storage_update_params.py @@ -2,14 +2,12 @@ from __future__ import annotations -from typing_extensions import TypedDict +from typing_extensions import Required, TypedDict __all__ = ["StorageUpdateParams"] class StorageUpdateParams(TypedDict, total=False): - size_tib: int - """Size of the volume in whole tebibytes (TiB).""" + size_tib: Required[int] - volume_id: str - """ID of the volume to update.""" + volume_id: Required[str] diff --git a/tests/api_resources/beta/clusters/test_storage.py b/tests/api_resources/beta/clusters/test_storage.py index 7a78f0d7a..8403af1ae 100644 --- a/tests/api_resources/beta/clusters/test_storage.py +++ b/tests/api_resources/beta/clusters/test_storage.py @@ -30,6 +30,16 @@ def test_method_create(self, client: Together) -> None: ) assert_matches_type(ClusterStorage, storage, path=["response"]) + @parametrize + def test_method_create_with_all_params(self, client: Together) -> None: + storage = client.beta.clusters.storage.create( + region="region", + size_tib=0, + volume_name="volume_name", + is_lifecycle_independent=True, + ) + assert_matches_type(ClusterStorage, storage, path=["response"]) + @parametrize def test_raw_response_create(self, client: Together) -> None: response = client.beta.clusters.storage.with_raw_response.create( @@ -98,11 +108,6 @@ def test_path_params_retrieve(self, client: Together) -> None: @parametrize def test_method_update(self, client: Together) -> None: - storage = client.beta.clusters.storage.update() - assert_matches_type(ClusterStorage, storage, path=["response"]) - - @parametrize - def test_method_update_with_all_params(self, client: Together) -> None: storage = client.beta.clusters.storage.update( size_tib=0, volume_id="volume_id", @@ -111,7 +116,10 @@ def test_method_update_with_all_params(self, client: Together) -> None: @parametrize def test_raw_response_update(self, client: Together) -> None: - response = client.beta.clusters.storage.with_raw_response.update() + response = client.beta.clusters.storage.with_raw_response.update( + size_tib=0, + volume_id="volume_id", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -120,7 +128,10 @@ def test_raw_response_update(self, client: Together) -> None: @parametrize def test_streaming_response_update(self, client: Together) -> None: - with client.beta.clusters.storage.with_streaming_response.update() as response: + with client.beta.clusters.storage.with_streaming_response.update( + size_tib=0, + volume_id="volume_id", + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -134,6 +145,13 @@ def test_method_list(self, client: Together) -> None: storage = client.beta.clusters.storage.list() assert_matches_type(StorageListResponse, storage, path=["response"]) + @parametrize + def test_method_list_with_all_params(self, client: Together) -> None: + storage = client.beta.clusters.storage.list( + project_id="project_id", + ) + assert_matches_type(StorageListResponse, storage, path=["response"]) + @parametrize def test_raw_response_list(self, client: Together) -> None: response = client.beta.clusters.storage.with_raw_response.list() @@ -207,6 +225,16 @@ async def test_method_create(self, async_client: AsyncTogether) -> None: ) assert_matches_type(ClusterStorage, storage, path=["response"]) + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncTogether) -> None: + storage = await async_client.beta.clusters.storage.create( + region="region", + size_tib=0, + volume_name="volume_name", + is_lifecycle_independent=True, + ) + assert_matches_type(ClusterStorage, storage, path=["response"]) + @parametrize async def test_raw_response_create(self, async_client: AsyncTogether) -> None: response = await async_client.beta.clusters.storage.with_raw_response.create( @@ -275,11 +303,6 @@ async def test_path_params_retrieve(self, async_client: AsyncTogether) -> None: @parametrize async def test_method_update(self, async_client: AsyncTogether) -> None: - storage = await async_client.beta.clusters.storage.update() - assert_matches_type(ClusterStorage, storage, path=["response"]) - - @parametrize - async def test_method_update_with_all_params(self, async_client: AsyncTogether) -> None: storage = await async_client.beta.clusters.storage.update( size_tib=0, volume_id="volume_id", @@ -288,7 +311,10 @@ async def test_method_update_with_all_params(self, async_client: AsyncTogether) @parametrize async def test_raw_response_update(self, async_client: AsyncTogether) -> None: - response = await async_client.beta.clusters.storage.with_raw_response.update() + response = await async_client.beta.clusters.storage.with_raw_response.update( + size_tib=0, + volume_id="volume_id", + ) assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -297,7 +323,10 @@ async def test_raw_response_update(self, async_client: AsyncTogether) -> None: @parametrize async def test_streaming_response_update(self, async_client: AsyncTogether) -> None: - async with async_client.beta.clusters.storage.with_streaming_response.update() as response: + async with async_client.beta.clusters.storage.with_streaming_response.update( + size_tib=0, + volume_id="volume_id", + ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -311,6 +340,13 @@ async def test_method_list(self, async_client: AsyncTogether) -> None: storage = await async_client.beta.clusters.storage.list() assert_matches_type(StorageListResponse, storage, path=["response"]) + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncTogether) -> None: + storage = await async_client.beta.clusters.storage.list( + project_id="project_id", + ) + assert_matches_type(StorageListResponse, storage, path=["response"]) + @parametrize async def test_raw_response_list(self, async_client: AsyncTogether) -> None: response = await async_client.beta.clusters.storage.with_raw_response.list() diff --git a/tests/api_resources/beta/test_clusters.py b/tests/api_resources/beta/test_clusters.py index e1f452619..119b86f46 100644 --- a/tests/api_resources/beta/test_clusters.py +++ b/tests/api_resources/beta/test_clusters.py @@ -29,6 +29,7 @@ def test_method_create(self, client: Together) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", + duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -42,23 +43,74 @@ def test_method_create_with_all_params(self, client: Together) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", + duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", region="region", + acceptance_tests_params={ + "dcgm_diag_level": "DCGM_DIAG_LEVEL_SHORT", + "dcgm_diag_skipped": True, + "enabled": True, + "gpu_burn_duration": 0, + "gpu_burn_skipped": True, + "nccl_multi_node_skipped": True, + "nccl_single_node_skipped": True, + }, + add_ons=[ + { + "add_on_type": "add_on_type", + "name": "name", + "config": { + "dashboard": {"enabled": True}, + "ingress": {"enabled": True}, + }, + } + ], + auto_scale=True, auto_scale_max_gpus=0, auto_scaled=True, capacity_pool_id="capacity_pool_id", + cluster_config={ + "load_balancer": "NONE", + "gpu_operator_version": "gpu_operator_version", + "ingress": {"enabled": True}, + "jumphost_enabled": True, + "kubernetes_dashboard_enabled": True, + "observability": {"enabled": True}, + "slurm_startup_scripts": { + "controller_epilog": "controller_epilog", + "controller_prolog": "controller_prolog", + "extra_slurm_conf": "extra_slurm_conf", + "login_init_script": "login_init_script", + "nodeset_init_script": "nodeset_init_script", + "worker_epilog": "worker_epilog", + "worker_prolog": "worker_prolog", + }, + }, cluster_type="KUBERNETES", - duration_days=0, gpu_node_failover_enabled=True, install_traefik=True, + num_capacity_pool_gpus=0, + num_preemptible_gpus=0, + num_reserved_gpus=0, + oidc_config={ + "client_id": "client_id", + "group_claim": "group_claim", + "group_prefix": "group_prefix", + "issuer_url": "issuer_url", + "username_claim": "username_claim", + "username_prefix": "username_prefix", + "ca_cert": "ca_cert", + }, + project_id="project_id", reservation_end_time=parse_datetime("2019-12-27T18:11:19.117Z"), reservation_start_time=parse_datetime("2019-12-27T18:11:19.117Z"), shared_volume={ "region": "region", "size_tib": 0, "volume_name": "volume_name", + "is_lifecycle_independent": True, }, slurm_image="slurm_image", slurm_shm_size_gib=0, @@ -72,6 +124,7 @@ def test_raw_response_create(self, client: Together) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", + duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -89,6 +142,7 @@ def test_streaming_response_create(self, client: Together) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", + duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -151,8 +205,36 @@ def test_method_update(self, client: Together) -> None: def test_method_update_with_all_params(self, client: Together) -> None: cluster = client.beta.clusters.update( cluster_id="cluster_id", + add_ons=[ + { + "name": "name", + "config": { + "dashboard": {"enabled": True}, + "ingress": {"enabled": True}, + }, + } + ], + cluster_config={ + "load_balancer": "NONE", + "gpu_operator_version": "gpu_operator_version", + "ingress": {"enabled": True}, + "jumphost_enabled": True, + "kubernetes_dashboard_enabled": True, + "observability": {"enabled": True}, + "slurm_startup_scripts": { + "controller_epilog": "controller_epilog", + "controller_prolog": "controller_prolog", + "extra_slurm_conf": "extra_slurm_conf", + "login_init_script": "login_init_script", + "nodeset_init_script": "nodeset_init_script", + "worker_epilog": "worker_epilog", + "worker_prolog": "worker_prolog", + }, + }, cluster_type="KUBERNETES", num_gpus=0, + num_preemptible_gpus=0, + num_reserved_gpus=0, reservation_end_time=parse_datetime("2019-12-27T18:11:19.117Z"), ) assert_matches_type(Cluster, cluster, path=["response"]) @@ -193,6 +275,13 @@ def test_method_list(self, client: Together) -> None: cluster = client.beta.clusters.list() assert_matches_type(ClusterListResponse, cluster, path=["response"]) + @parametrize + def test_method_list_with_all_params(self, client: Together) -> None: + cluster = client.beta.clusters.list( + project_id="project_id", + ) + assert_matches_type(ClusterListResponse, cluster, path=["response"]) + @parametrize def test_raw_response_list(self, client: Together) -> None: response = client.beta.clusters.with_raw_response.list() @@ -288,6 +377,7 @@ async def test_method_create(self, async_client: AsyncTogether) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", + duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -301,23 +391,74 @@ async def test_method_create_with_all_params(self, async_client: AsyncTogether) billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", + duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", region="region", + acceptance_tests_params={ + "dcgm_diag_level": "DCGM_DIAG_LEVEL_SHORT", + "dcgm_diag_skipped": True, + "enabled": True, + "gpu_burn_duration": 0, + "gpu_burn_skipped": True, + "nccl_multi_node_skipped": True, + "nccl_single_node_skipped": True, + }, + add_ons=[ + { + "add_on_type": "add_on_type", + "name": "name", + "config": { + "dashboard": {"enabled": True}, + "ingress": {"enabled": True}, + }, + } + ], + auto_scale=True, auto_scale_max_gpus=0, auto_scaled=True, capacity_pool_id="capacity_pool_id", + cluster_config={ + "load_balancer": "NONE", + "gpu_operator_version": "gpu_operator_version", + "ingress": {"enabled": True}, + "jumphost_enabled": True, + "kubernetes_dashboard_enabled": True, + "observability": {"enabled": True}, + "slurm_startup_scripts": { + "controller_epilog": "controller_epilog", + "controller_prolog": "controller_prolog", + "extra_slurm_conf": "extra_slurm_conf", + "login_init_script": "login_init_script", + "nodeset_init_script": "nodeset_init_script", + "worker_epilog": "worker_epilog", + "worker_prolog": "worker_prolog", + }, + }, cluster_type="KUBERNETES", - duration_days=0, gpu_node_failover_enabled=True, install_traefik=True, + num_capacity_pool_gpus=0, + num_preemptible_gpus=0, + num_reserved_gpus=0, + oidc_config={ + "client_id": "client_id", + "group_claim": "group_claim", + "group_prefix": "group_prefix", + "issuer_url": "issuer_url", + "username_claim": "username_claim", + "username_prefix": "username_prefix", + "ca_cert": "ca_cert", + }, + project_id="project_id", reservation_end_time=parse_datetime("2019-12-27T18:11:19.117Z"), reservation_start_time=parse_datetime("2019-12-27T18:11:19.117Z"), shared_volume={ "region": "region", "size_tib": 0, "volume_name": "volume_name", + "is_lifecycle_independent": True, }, slurm_image="slurm_image", slurm_shm_size_gib=0, @@ -331,6 +472,7 @@ async def test_raw_response_create(self, async_client: AsyncTogether) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", + duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -348,6 +490,7 @@ async def test_streaming_response_create(self, async_client: AsyncTogether) -> N billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", + duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -410,8 +553,36 @@ async def test_method_update(self, async_client: AsyncTogether) -> None: async def test_method_update_with_all_params(self, async_client: AsyncTogether) -> None: cluster = await async_client.beta.clusters.update( cluster_id="cluster_id", + add_ons=[ + { + "name": "name", + "config": { + "dashboard": {"enabled": True}, + "ingress": {"enabled": True}, + }, + } + ], + cluster_config={ + "load_balancer": "NONE", + "gpu_operator_version": "gpu_operator_version", + "ingress": {"enabled": True}, + "jumphost_enabled": True, + "kubernetes_dashboard_enabled": True, + "observability": {"enabled": True}, + "slurm_startup_scripts": { + "controller_epilog": "controller_epilog", + "controller_prolog": "controller_prolog", + "extra_slurm_conf": "extra_slurm_conf", + "login_init_script": "login_init_script", + "nodeset_init_script": "nodeset_init_script", + "worker_epilog": "worker_epilog", + "worker_prolog": "worker_prolog", + }, + }, cluster_type="KUBERNETES", num_gpus=0, + num_preemptible_gpus=0, + num_reserved_gpus=0, reservation_end_time=parse_datetime("2019-12-27T18:11:19.117Z"), ) assert_matches_type(Cluster, cluster, path=["response"]) @@ -452,6 +623,13 @@ async def test_method_list(self, async_client: AsyncTogether) -> None: cluster = await async_client.beta.clusters.list() assert_matches_type(ClusterListResponse, cluster, path=["response"]) + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncTogether) -> None: + cluster = await async_client.beta.clusters.list( + project_id="project_id", + ) + assert_matches_type(ClusterListResponse, cluster, path=["response"]) + @parametrize async def test_raw_response_list(self, async_client: AsyncTogether) -> None: response = await async_client.beta.clusters.with_raw_response.list() From 2109f0a0c897a7d5659f700042ec97b0843b3228 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 00:12:06 +0000 Subject: [PATCH 02/30] fix(types): correct status field to enum in cluster_storage model --- .stats.yml | 4 ++-- src/together/types/beta/clusters/cluster_storage.py | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index 409398869..1e6d5f98c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-e398785d31ad51e94f158b377be469e74748e0229a964d3602149c6bf964581e.yml -openapi_spec_hash: 3e4db6f27d100314926d87c9ae24cba2 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-5ba14fb08cb2129befa3b72c8bd20b67a863c4be6e010368e2184fbe1b18321b.yml +openapi_spec_hash: 5b1c21199bda07c852b8b48c87859784 config_hash: ec427df08d61d8888138f15cd53c6454 diff --git a/src/together/types/beta/clusters/cluster_storage.py b/src/together/types/beta/clusters/cluster_storage.py index 22392753f..024b79c04 100644 --- a/src/together/types/beta/clusters/cluster_storage.py +++ b/src/together/types/beta/clusters/cluster_storage.py @@ -1,5 +1,7 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. +from typing_extensions import Literal + from ...._models import BaseModel __all__ = ["ClusterStorage"] @@ -8,7 +10,10 @@ class ClusterStorage(BaseModel): size_tib: int - status: str + status: Literal[ + "scheduled", "available", "bound", "provisioning", "deleting", "failed", "access_revoked", "unknown" + ] + """Current status of the shared volume.""" volume_id: str From 0f34ea4e1441a08b014f19d99588902b17eda1be Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 15:25:37 +0000 Subject: [PATCH 03/30] feat(api): add h200-140gb gpu_type to jig deploy/update methods --- .stats.yml | 4 ++-- src/together/resources/beta/jig/jig.py | 8 ++++---- src/together/types/beta/deployment.py | 2 +- src/together/types/beta/jig_deploy_params.py | 2 +- src/together/types/beta/jig_update_params.py | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.stats.yml b/.stats.yml index 1e6d5f98c..88217d8a8 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-5ba14fb08cb2129befa3b72c8bd20b67a863c4be6e010368e2184fbe1b18321b.yml -openapi_spec_hash: 5b1c21199bda07c852b8b48c87859784 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-8f89a16f91cc70112c8f61ccfa8e22d08f3d898afdcbb92d3dec0ad6efba8fb2.yml +openapi_spec_hash: c4dd7519d9865e6e2fcf5b9414b06bab config_hash: ec427df08d61d8888138f15cd53c6454 diff --git a/src/together/resources/beta/jig/jig.py b/src/together/resources/beta/jig/jig.py index 64ba1862f..fd20c3995 100644 --- a/src/together/resources/beta/jig/jig.py +++ b/src/together/resources/beta/jig/jig.py @@ -128,7 +128,7 @@ def update( description: str | Omit = omit, environment_variables: Iterable[jig_update_params.EnvironmentVariable] | Omit = omit, gpu_count: int | Omit = omit, - gpu_type: Literal["h100-80gb", "h100-40gb-mig", "b200-192gb"] | Omit = omit, + gpu_type: Literal["h100-80gb", "h100-40gb-mig", "h200-140gb", "b200-192gb"] | Omit = omit, health_check_path: str | Omit = omit, image: str | Omit = omit, max_replicas: int | Omit = omit, @@ -262,7 +262,7 @@ def list( def deploy( self, *, - gpu_type: Literal["h100-80gb", "h100-40gb-mig", "b200-192gb"], + gpu_type: Literal["h100-80gb", "h100-40gb-mig", "h200-140gb", "b200-192gb"], image: str, name: str, args: SequenceNotStr[str] | Omit = omit, @@ -538,7 +538,7 @@ async def update( description: str | Omit = omit, environment_variables: Iterable[jig_update_params.EnvironmentVariable] | Omit = omit, gpu_count: int | Omit = omit, - gpu_type: Literal["h100-80gb", "h100-40gb-mig", "b200-192gb"] | Omit = omit, + gpu_type: Literal["h100-80gb", "h100-40gb-mig", "h200-140gb", "b200-192gb"] | Omit = omit, health_check_path: str | Omit = omit, image: str | Omit = omit, max_replicas: int | Omit = omit, @@ -672,7 +672,7 @@ async def list( async def deploy( self, *, - gpu_type: Literal["h100-80gb", "h100-40gb-mig", "b200-192gb"], + gpu_type: Literal["h100-80gb", "h100-40gb-mig", "h200-140gb", "b200-192gb"], image: str, name: str, args: SequenceNotStr[str] | Omit = omit, diff --git a/src/together/types/beta/deployment.py b/src/together/types/beta/deployment.py index f7e36e0af..2ff5b0589 100644 --- a/src/together/types/beta/deployment.py +++ b/src/together/types/beta/deployment.py @@ -196,7 +196,7 @@ class Deployment(BaseModel): gpu_count: Optional[int] = None """GPUCount is the number of GPUs allocated to each replica in this deployment""" - gpu_type: Optional[Literal["h100-80gb", "h100-40gb-mig", "b200-192gb"]] = None + gpu_type: Optional[Literal["h100-80gb", "h100-40gb-mig", "h200-140gb", "b200-192gb"]] = None """GPUType specifies the type of GPU requested (if any) for this deployment""" health_check_path: Optional[str] = None diff --git a/src/together/types/beta/jig_deploy_params.py b/src/together/types/beta/jig_deploy_params.py index c0bdc9c54..9682a7a3a 100644 --- a/src/together/types/beta/jig_deploy_params.py +++ b/src/together/types/beta/jig_deploy_params.py @@ -19,7 +19,7 @@ class JigDeployParams(TypedDict, total=False): - gpu_type: Required[Literal["h100-80gb", "h100-40gb-mig", "b200-192gb"]] + gpu_type: Required[Literal["h100-80gb", "h100-40gb-mig", "h200-140gb", "b200-192gb"]] """GPUType specifies the GPU hardware to use (e.g., "h100-80gb").""" image: Required[str] diff --git a/src/together/types/beta/jig_update_params.py b/src/together/types/beta/jig_update_params.py index c91e46d92..56ad5d58c 100644 --- a/src/together/types/beta/jig_update_params.py +++ b/src/together/types/beta/jig_update_params.py @@ -52,7 +52,7 @@ class JigUpdateParams(TypedDict, total=False): gpu_count: int """GPUCount is the number of GPUs to allocate per container instance""" - gpu_type: Literal["h100-80gb", "h100-40gb-mig", "b200-192gb"] + gpu_type: Literal["h100-80gb", "h100-40gb-mig", "h200-140gb", "b200-192gb"] """GPUType specifies the GPU hardware to use (e.g., "h100-80gb")""" health_check_path: str From 899752dbebed9a75433b9ab95245c3bf15237eb3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 16:11:23 +0000 Subject: [PATCH 04/30] fix(api): make duration_days optional in clusters create, size_tib optional in storage update --- .stats.yml | 4 ++-- .../resources/beta/clusters/clusters.py | 16 +++++++------- .../resources/beta/clusters/storage.py | 8 +++---- .../types/beta/cluster_create_params.py | 6 ++--- .../beta/clusters/storage_update_params.py | 4 ++-- .../beta/clusters/test_storage.py | 22 ++++++++++++++----- tests/api_resources/beta/test_clusters.py | 10 ++------- 7 files changed, 37 insertions(+), 33 deletions(-) diff --git a/.stats.yml b/.stats.yml index 88217d8a8..c39ae67d5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-8f89a16f91cc70112c8f61ccfa8e22d08f3d898afdcbb92d3dec0ad6efba8fb2.yml -openapi_spec_hash: c4dd7519d9865e6e2fcf5b9414b06bab +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-e070ac8df7cf6579d5f9f5f7e2896a85de93f46f0ab4aed2f6b48aa4af092204.yml +openapi_spec_hash: 4603775ed88b25a4307e5d87299a23bb config_hash: ec427df08d61d8888138f15cd53c6454 diff --git a/src/together/resources/beta/clusters/clusters.py b/src/together/resources/beta/clusters/clusters.py index 935d71153..7b8f2f67a 100644 --- a/src/together/resources/beta/clusters/clusters.py +++ b/src/together/resources/beta/clusters/clusters.py @@ -66,7 +66,6 @@ def create( billing_type: Literal["RESERVED", "ON_DEMAND", "SCHEDULED_CAPACITY"], cluster_name: str, cuda_version: str, - duration_days: int, gpu_type: Literal["H100_SXM", "H200_SXM", "RTX_6000_PCI", "L40_PCIE", "B200_SXM", "H100_SXM_INF"], num_gpus: int, nvidia_driver_version: str, @@ -79,6 +78,7 @@ def create( capacity_pool_id: str | Omit = omit, cluster_config: cluster_create_params.ClusterConfig | Omit = omit, cluster_type: Literal["KUBERNETES", "SLURM"] | Omit = omit, + duration_days: int | Omit = omit, gpu_node_failover_enabled: bool | Omit = omit, install_traefik: bool | Omit = omit, num_capacity_pool_gpus: int | Omit = omit, @@ -118,8 +118,6 @@ def create( cuda_version: CUDA version for this cluster. For example, 12.5 - duration_days: Duration in days to keep the cluster running. - gpu_type: Type of GPU to use in the cluster num_gpus: Number of GPUs to allocate in the cluster. This must be multiple of 8. For @@ -151,6 +149,8 @@ def create( cluster_type: Type of cluster to create. + duration_days: Duration in days to keep the cluster running. + gpu_node_failover_enabled: Whether automated GPU node failover should be enabled for this cluster. By default, it is disabled. @@ -202,7 +202,6 @@ def create( "billing_type": billing_type, "cluster_name": cluster_name, "cuda_version": cuda_version, - "duration_days": duration_days, "gpu_type": gpu_type, "num_gpus": num_gpus, "nvidia_driver_version": nvidia_driver_version, @@ -215,6 +214,7 @@ def create( "capacity_pool_id": capacity_pool_id, "cluster_config": cluster_config, "cluster_type": cluster_type, + "duration_days": duration_days, "gpu_node_failover_enabled": gpu_node_failover_enabled, "install_traefik": install_traefik, "num_capacity_pool_gpus": num_capacity_pool_gpus, @@ -468,7 +468,6 @@ async def create( billing_type: Literal["RESERVED", "ON_DEMAND", "SCHEDULED_CAPACITY"], cluster_name: str, cuda_version: str, - duration_days: int, gpu_type: Literal["H100_SXM", "H200_SXM", "RTX_6000_PCI", "L40_PCIE", "B200_SXM", "H100_SXM_INF"], num_gpus: int, nvidia_driver_version: str, @@ -481,6 +480,7 @@ async def create( capacity_pool_id: str | Omit = omit, cluster_config: cluster_create_params.ClusterConfig | Omit = omit, cluster_type: Literal["KUBERNETES", "SLURM"] | Omit = omit, + duration_days: int | Omit = omit, gpu_node_failover_enabled: bool | Omit = omit, install_traefik: bool | Omit = omit, num_capacity_pool_gpus: int | Omit = omit, @@ -520,8 +520,6 @@ async def create( cuda_version: CUDA version for this cluster. For example, 12.5 - duration_days: Duration in days to keep the cluster running. - gpu_type: Type of GPU to use in the cluster num_gpus: Number of GPUs to allocate in the cluster. This must be multiple of 8. For @@ -553,6 +551,8 @@ async def create( cluster_type: Type of cluster to create. + duration_days: Duration in days to keep the cluster running. + gpu_node_failover_enabled: Whether automated GPU node failover should be enabled for this cluster. By default, it is disabled. @@ -604,7 +604,6 @@ async def create( "billing_type": billing_type, "cluster_name": cluster_name, "cuda_version": cuda_version, - "duration_days": duration_days, "gpu_type": gpu_type, "num_gpus": num_gpus, "nvidia_driver_version": nvidia_driver_version, @@ -617,6 +616,7 @@ async def create( "capacity_pool_id": capacity_pool_id, "cluster_config": cluster_config, "cluster_type": cluster_type, + "duration_days": duration_days, "gpu_node_failover_enabled": gpu_node_failover_enabled, "install_traefik": install_traefik, "num_capacity_pool_gpus": num_capacity_pool_gpus, diff --git a/src/together/resources/beta/clusters/storage.py b/src/together/resources/beta/clusters/storage.py index c295061f3..48f6ccba7 100644 --- a/src/together/resources/beta/clusters/storage.py +++ b/src/together/resources/beta/clusters/storage.py @@ -132,8 +132,8 @@ def retrieve( def update( self, *, - size_tib: int, volume_id: str, + size_tib: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -157,8 +157,8 @@ def update( "/compute/clusters/storage/volumes", body=maybe_transform( { - "size_tib": size_tib, "volume_id": volume_id, + "size_tib": size_tib, }, storage_update_params.StorageUpdateParams, ), @@ -354,8 +354,8 @@ async def retrieve( async def update( self, *, - size_tib: int, volume_id: str, + size_tib: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -379,8 +379,8 @@ async def update( "/compute/clusters/storage/volumes", body=await async_maybe_transform( { - "size_tib": size_tib, "volume_id": volume_id, + "size_tib": size_tib, }, storage_update_params.StorageUpdateParams, ), diff --git a/src/together/types/beta/cluster_create_params.py b/src/together/types/beta/cluster_create_params.py index 4d9482da5..b950a246d 100644 --- a/src/together/types/beta/cluster_create_params.py +++ b/src/together/types/beta/cluster_create_params.py @@ -40,9 +40,6 @@ class ClusterCreateParams(TypedDict, total=False): cuda_version: Required[str] """CUDA version for this cluster. For example, 12.5""" - duration_days: Required[int] - """Duration in days to keep the cluster running.""" - gpu_type: Required[Literal["H100_SXM", "H200_SXM", "RTX_6000_PCI", "L40_PCIE", "B200_SXM", "H100_SXM_INF"]] """Type of GPU to use in the cluster""" @@ -105,6 +102,9 @@ class ClusterCreateParams(TypedDict, total=False): cluster_type: Literal["KUBERNETES", "SLURM"] """Type of cluster to create.""" + duration_days: int + """Duration in days to keep the cluster running.""" + gpu_node_failover_enabled: bool """Whether automated GPU node failover should be enabled for this cluster. diff --git a/src/together/types/beta/clusters/storage_update_params.py b/src/together/types/beta/clusters/storage_update_params.py index c87e4cd61..116645fa7 100644 --- a/src/together/types/beta/clusters/storage_update_params.py +++ b/src/together/types/beta/clusters/storage_update_params.py @@ -8,6 +8,6 @@ class StorageUpdateParams(TypedDict, total=False): - size_tib: Required[int] - volume_id: Required[str] + + size_tib: int diff --git a/tests/api_resources/beta/clusters/test_storage.py b/tests/api_resources/beta/clusters/test_storage.py index 8403af1ae..de120cc40 100644 --- a/tests/api_resources/beta/clusters/test_storage.py +++ b/tests/api_resources/beta/clusters/test_storage.py @@ -109,15 +109,21 @@ def test_path_params_retrieve(self, client: Together) -> None: @parametrize def test_method_update(self, client: Together) -> None: storage = client.beta.clusters.storage.update( - size_tib=0, volume_id="volume_id", ) assert_matches_type(ClusterStorage, storage, path=["response"]) + @parametrize + def test_method_update_with_all_params(self, client: Together) -> None: + storage = client.beta.clusters.storage.update( + volume_id="volume_id", + size_tib=0, + ) + assert_matches_type(ClusterStorage, storage, path=["response"]) + @parametrize def test_raw_response_update(self, client: Together) -> None: response = client.beta.clusters.storage.with_raw_response.update( - size_tib=0, volume_id="volume_id", ) @@ -129,7 +135,6 @@ def test_raw_response_update(self, client: Together) -> None: @parametrize def test_streaming_response_update(self, client: Together) -> None: with client.beta.clusters.storage.with_streaming_response.update( - size_tib=0, volume_id="volume_id", ) as response: assert not response.is_closed @@ -304,15 +309,21 @@ async def test_path_params_retrieve(self, async_client: AsyncTogether) -> None: @parametrize async def test_method_update(self, async_client: AsyncTogether) -> None: storage = await async_client.beta.clusters.storage.update( - size_tib=0, volume_id="volume_id", ) assert_matches_type(ClusterStorage, storage, path=["response"]) + @parametrize + async def test_method_update_with_all_params(self, async_client: AsyncTogether) -> None: + storage = await async_client.beta.clusters.storage.update( + volume_id="volume_id", + size_tib=0, + ) + assert_matches_type(ClusterStorage, storage, path=["response"]) + @parametrize async def test_raw_response_update(self, async_client: AsyncTogether) -> None: response = await async_client.beta.clusters.storage.with_raw_response.update( - size_tib=0, volume_id="volume_id", ) @@ -324,7 +335,6 @@ async def test_raw_response_update(self, async_client: AsyncTogether) -> None: @parametrize async def test_streaming_response_update(self, async_client: AsyncTogether) -> None: async with async_client.beta.clusters.storage.with_streaming_response.update( - size_tib=0, volume_id="volume_id", ) as response: assert not response.is_closed diff --git a/tests/api_resources/beta/test_clusters.py b/tests/api_resources/beta/test_clusters.py index 119b86f46..4d84e3b2b 100644 --- a/tests/api_resources/beta/test_clusters.py +++ b/tests/api_resources/beta/test_clusters.py @@ -29,7 +29,6 @@ def test_method_create(self, client: Together) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", - duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -43,7 +42,6 @@ def test_method_create_with_all_params(self, client: Together) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", - duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -89,6 +87,7 @@ def test_method_create_with_all_params(self, client: Together) -> None: }, }, cluster_type="KUBERNETES", + duration_days=0, gpu_node_failover_enabled=True, install_traefik=True, num_capacity_pool_gpus=0, @@ -124,7 +123,6 @@ def test_raw_response_create(self, client: Together) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", - duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -142,7 +140,6 @@ def test_streaming_response_create(self, client: Together) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", - duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -377,7 +374,6 @@ async def test_method_create(self, async_client: AsyncTogether) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", - duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -391,7 +387,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncTogether) billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", - duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -437,6 +432,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncTogether) }, }, cluster_type="KUBERNETES", + duration_days=0, gpu_node_failover_enabled=True, install_traefik=True, num_capacity_pool_gpus=0, @@ -472,7 +468,6 @@ async def test_raw_response_create(self, async_client: AsyncTogether) -> None: billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", - duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", @@ -490,7 +485,6 @@ async def test_streaming_response_create(self, async_client: AsyncTogether) -> N billing_type="RESERVED", cluster_name="cluster_name", cuda_version="cuda_version", - duration_days=0, gpu_type="H100_SXM", num_gpus=0, nvidia_driver_version="nvidia_driver_version", From 1caa5fa4c41dff79164d8edd7436e66747eab712 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 17:37:16 +0000 Subject: [PATCH 05/30] feat: Sync deployments OpenAPI spec --- .stats.yml | 4 +- api.md | 2 +- src/together/resources/beta/jig/jig.py | 30 ++++++++++- src/together/resources/beta/jig/volumes.py | 20 +++++-- src/together/types/beta/deployment.py | 6 +++ src/together/types/beta/jig/__init__.py | 1 + .../types/beta/jig/volume_retrieve_params.py | 12 +++++ .../types/beta/jig_retrieve_logs_params.py | 9 ++++ tests/api_resources/beta/jig/test_volumes.py | 53 +++++++++++++------ tests/api_resources/beta/test_jig.py | 4 ++ 10 files changed, 116 insertions(+), 25 deletions(-) create mode 100644 src/together/types/beta/jig/volume_retrieve_params.py diff --git a/.stats.yml b/.stats.yml index c39ae67d5..bd80968e7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-e070ac8df7cf6579d5f9f5f7e2896a85de93f46f0ab4aed2f6b48aa4af092204.yml -openapi_spec_hash: 4603775ed88b25a4307e5d87299a23bb +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-a5de4cd2b7b07333c8566d7dc805319e5b1727078b57e5236f8076a7ab4925d2.yml +openapi_spec_hash: d087f1b24b5623db2c9a8d4a2d987a2f config_hash: ec427df08d61d8888138f15cd53c6454 diff --git a/api.md b/api.md index c32e307a7..abdf1cddd 100644 --- a/api.md +++ b/api.md @@ -48,7 +48,7 @@ from together.types.beta.jig import Volume, VolumeListResponse Methods: - client.beta.jig.volumes.create(\*\*params) -> Volume -- client.beta.jig.volumes.retrieve(id) -> Volume +- client.beta.jig.volumes.retrieve(id, \*\*params) -> Volume - client.beta.jig.volumes.update(id, \*\*params) -> Volume - client.beta.jig.volumes.list() -> VolumeListResponse - client.beta.jig.volumes.delete(id) -> object diff --git a/src/together/resources/beta/jig/jig.py b/src/together/resources/beta/jig/jig.py index fd20c3995..8beda0e29 100644 --- a/src/together/resources/beta/jig/jig.py +++ b/src/together/resources/beta/jig/jig.py @@ -422,6 +422,8 @@ def retrieve_logs( id: str, *, replica_id: str | Omit = omit, + revision: str | Omit = omit, + version: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -437,6 +439,11 @@ def retrieve_logs( replica_id: Replica ID to filter logs + revision: Deployment revision (UUID) to filter logs + + version: Deployment image version (tag or last 4 characters of image digest) to filter + logs + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -454,7 +461,14 @@ def retrieve_logs( extra_query=extra_query, extra_body=extra_body, timeout=timeout, - query=maybe_transform({"replica_id": replica_id}, jig_retrieve_logs_params.JigRetrieveLogsParams), + query=maybe_transform( + { + "replica_id": replica_id, + "revision": revision, + "version": version, + }, + jig_retrieve_logs_params.JigRetrieveLogsParams, + ), ), cast_to=DeploymentLogs, ) @@ -832,6 +846,8 @@ async def retrieve_logs( id: str, *, replica_id: str | Omit = omit, + revision: str | Omit = omit, + version: str | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -847,6 +863,11 @@ async def retrieve_logs( replica_id: Replica ID to filter logs + revision: Deployment revision (UUID) to filter logs + + version: Deployment image version (tag or last 4 characters of image digest) to filter + logs + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -865,7 +886,12 @@ async def retrieve_logs( extra_body=extra_body, timeout=timeout, query=await async_maybe_transform( - {"replica_id": replica_id}, jig_retrieve_logs_params.JigRetrieveLogsParams + { + "replica_id": replica_id, + "revision": revision, + "version": version, + }, + jig_retrieve_logs_params.JigRetrieveLogsParams, ), ), cast_to=DeploymentLogs, diff --git a/src/together/resources/beta/jig/volumes.py b/src/together/resources/beta/jig/volumes.py index 3b94490b7..c70d3ae85 100644 --- a/src/together/resources/beta/jig/volumes.py +++ b/src/together/resources/beta/jig/volumes.py @@ -17,7 +17,7 @@ async_to_streamed_response_wrapper, ) from ...._base_client import make_request_options -from ....types.beta.jig import volume_create_params, volume_update_params +from ....types.beta.jig import volume_create_params, volume_update_params, volume_retrieve_params from ....types.beta.jig.volume import Volume from ....types.beta.jig.volume_list_response import VolumeListResponse @@ -95,6 +95,7 @@ def retrieve( self, id: str, *, + version: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -108,6 +109,8 @@ def retrieve( Args: id: Volume ID or name + version: Volume version to describe (defaults to current version) + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -121,7 +124,11 @@ def retrieve( return self._get( path_template("/deployments/storage/volumes/{id}", id=id), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"version": version}, volume_retrieve_params.VolumeRetrieveParams), ), cast_to=Volume, ) @@ -304,6 +311,7 @@ async def retrieve( self, id: str, *, + version: int | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -317,6 +325,8 @@ async def retrieve( Args: id: Volume ID or name + version: Volume version to describe (defaults to current version) + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -330,7 +340,11 @@ async def retrieve( return await self._get( path_template("/deployments/storage/volumes/{id}", id=id), options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"version": version}, volume_retrieve_params.VolumeRetrieveParams), ), cast_to=Volume, ) diff --git a/src/together/types/beta/deployment.py b/src/together/types/beta/deployment.py index 2ff5b0589..3ce2f599a 100644 --- a/src/together/types/beta/deployment.py +++ b/src/together/types/beta/deployment.py @@ -244,6 +244,12 @@ class Deployment(BaseModel): allocated to each replica """ + termination_grace_period_seconds: Optional[int] = None + """ + TerminationGracePeriodSeconds is the time in seconds to wait for graceful + shutdown before forcefully terminating the replica + """ + updated_at: Optional[datetime] = None """UpdatedAt is the ISO8601 timestamp when this deployment was last updated""" diff --git a/src/together/types/beta/jig/__init__.py b/src/together/types/beta/jig/__init__.py index ef03b5652..f8c024918 100644 --- a/src/together/types/beta/jig/__init__.py +++ b/src/together/types/beta/jig/__init__.py @@ -17,4 +17,5 @@ from .queue_retrieve_params import QueueRetrieveParams as QueueRetrieveParams from .queue_submit_response import QueueSubmitResponse as QueueSubmitResponse from .queue_metrics_response import QueueMetricsResponse as QueueMetricsResponse +from .volume_retrieve_params import VolumeRetrieveParams as VolumeRetrieveParams from .queue_retrieve_response import QueueRetrieveResponse as QueueRetrieveResponse diff --git a/src/together/types/beta/jig/volume_retrieve_params.py b/src/together/types/beta/jig/volume_retrieve_params.py new file mode 100644 index 000000000..7b194db4d --- /dev/null +++ b/src/together/types/beta/jig/volume_retrieve_params.py @@ -0,0 +1,12 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import TypedDict + +__all__ = ["VolumeRetrieveParams"] + + +class VolumeRetrieveParams(TypedDict, total=False): + version: int + """Volume version to describe (defaults to current version)""" diff --git a/src/together/types/beta/jig_retrieve_logs_params.py b/src/together/types/beta/jig_retrieve_logs_params.py index 8afbea2d8..59e0ca3cb 100644 --- a/src/together/types/beta/jig_retrieve_logs_params.py +++ b/src/together/types/beta/jig_retrieve_logs_params.py @@ -10,3 +10,12 @@ class JigRetrieveLogsParams(TypedDict, total=False): replica_id: str """Replica ID to filter logs""" + + revision: str + """Deployment revision (UUID) to filter logs""" + + version: str + """ + Deployment image version (tag or last 4 characters of image digest) to filter + logs + """ diff --git a/tests/api_resources/beta/jig/test_volumes.py b/tests/api_resources/beta/jig/test_volumes.py index bc547d755..a630f5a83 100644 --- a/tests/api_resources/beta/jig/test_volumes.py +++ b/tests/api_resources/beta/jig/test_volumes.py @@ -9,7 +9,10 @@ from together import Together, AsyncTogether from tests.utils import assert_matches_type -from together.types.beta.jig import Volume, VolumeListResponse +from together.types.beta.jig import ( + Volume, + VolumeListResponse, +) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -21,7 +24,7 @@ class TestVolumes: def test_method_create(self, client: Together) -> None: volume = client.beta.jig.volumes.create( content={}, - name="name", + name="x", type="readOnly", ) assert_matches_type(Volume, volume, path=["response"]) @@ -33,7 +36,7 @@ def test_method_create_with_all_params(self, client: Together) -> None: "source_prefix": "models/", "type": "files", }, - name="name", + name="x", type="readOnly", ) assert_matches_type(Volume, volume, path=["response"]) @@ -42,7 +45,7 @@ def test_method_create_with_all_params(self, client: Together) -> None: def test_raw_response_create(self, client: Together) -> None: response = client.beta.jig.volumes.with_raw_response.create( content={}, - name="name", + name="x", type="readOnly", ) @@ -55,7 +58,7 @@ def test_raw_response_create(self, client: Together) -> None: def test_streaming_response_create(self, client: Together) -> None: with client.beta.jig.volumes.with_streaming_response.create( content={}, - name="name", + name="x", type="readOnly", ) as response: assert not response.is_closed @@ -69,14 +72,22 @@ def test_streaming_response_create(self, client: Together) -> None: @parametrize def test_method_retrieve(self, client: Together) -> None: volume = client.beta.jig.volumes.retrieve( - "id", + id="id", + ) + assert_matches_type(Volume, volume, path=["response"]) + + @parametrize + def test_method_retrieve_with_all_params(self, client: Together) -> None: + volume = client.beta.jig.volumes.retrieve( + id="id", + version=0, ) assert_matches_type(Volume, volume, path=["response"]) @parametrize def test_raw_response_retrieve(self, client: Together) -> None: response = client.beta.jig.volumes.with_raw_response.retrieve( - "id", + id="id", ) assert response.is_closed is True @@ -87,7 +98,7 @@ def test_raw_response_retrieve(self, client: Together) -> None: @parametrize def test_streaming_response_retrieve(self, client: Together) -> None: with client.beta.jig.volumes.with_streaming_response.retrieve( - "id", + id="id", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -101,7 +112,7 @@ def test_streaming_response_retrieve(self, client: Together) -> None: def test_path_params_retrieve(self, client: Together) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): client.beta.jig.volumes.with_raw_response.retrieve( - "", + id="", ) @parametrize @@ -228,7 +239,7 @@ class TestAsyncVolumes: async def test_method_create(self, async_client: AsyncTogether) -> None: volume = await async_client.beta.jig.volumes.create( content={}, - name="name", + name="x", type="readOnly", ) assert_matches_type(Volume, volume, path=["response"]) @@ -240,7 +251,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncTogether) "source_prefix": "models/", "type": "files", }, - name="name", + name="x", type="readOnly", ) assert_matches_type(Volume, volume, path=["response"]) @@ -249,7 +260,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncTogether) async def test_raw_response_create(self, async_client: AsyncTogether) -> None: response = await async_client.beta.jig.volumes.with_raw_response.create( content={}, - name="name", + name="x", type="readOnly", ) @@ -262,7 +273,7 @@ async def test_raw_response_create(self, async_client: AsyncTogether) -> None: async def test_streaming_response_create(self, async_client: AsyncTogether) -> None: async with async_client.beta.jig.volumes.with_streaming_response.create( content={}, - name="name", + name="x", type="readOnly", ) as response: assert not response.is_closed @@ -276,14 +287,22 @@ async def test_streaming_response_create(self, async_client: AsyncTogether) -> N @parametrize async def test_method_retrieve(self, async_client: AsyncTogether) -> None: volume = await async_client.beta.jig.volumes.retrieve( - "id", + id="id", + ) + assert_matches_type(Volume, volume, path=["response"]) + + @parametrize + async def test_method_retrieve_with_all_params(self, async_client: AsyncTogether) -> None: + volume = await async_client.beta.jig.volumes.retrieve( + id="id", + version=0, ) assert_matches_type(Volume, volume, path=["response"]) @parametrize async def test_raw_response_retrieve(self, async_client: AsyncTogether) -> None: response = await async_client.beta.jig.volumes.with_raw_response.retrieve( - "id", + id="id", ) assert response.is_closed is True @@ -294,7 +313,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncTogether) -> None: @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncTogether) -> None: async with async_client.beta.jig.volumes.with_streaming_response.retrieve( - "id", + id="id", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -308,7 +327,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncTogether) -> async def test_path_params_retrieve(self, async_client: AsyncTogether) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"): await async_client.beta.jig.volumes.with_raw_response.retrieve( - "", + id="", ) @parametrize diff --git a/tests/api_resources/beta/test_jig.py b/tests/api_resources/beta/test_jig.py index 30f5e8303..dabf106ca 100644 --- a/tests/api_resources/beta/test_jig.py +++ b/tests/api_resources/beta/test_jig.py @@ -290,6 +290,8 @@ def test_method_retrieve_logs_with_all_params(self, client: Together) -> None: jig = client.beta.jig.retrieve_logs( id="id", replica_id="replica_id", + revision="revision", + version="version", ) assert_matches_type(DeploymentLogs, jig, path=["response"]) @@ -599,6 +601,8 @@ async def test_method_retrieve_logs_with_all_params(self, async_client: AsyncTog jig = await async_client.beta.jig.retrieve_logs( id="id", replica_id="replica_id", + revision="revision", + version="version", ) assert_matches_type(DeploymentLogs, jig, path=["response"]) From 8c35457b06dc6a58f0c1343accb8db09ad91b845 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 17:39:39 +0000 Subject: [PATCH 06/30] docs(api): add parameter descriptions to storage methods and types --- .stats.yml | 4 ++-- src/together/resources/beta/clusters/storage.py | 16 ++++++++++++++++ src/together/types/beta/cluster.py | 6 +++++- src/together/types/beta/cluster_create_params.py | 2 ++ .../types/beta/clusters/cluster_storage.py | 3 +++ .../types/beta/clusters/storage_create_params.py | 2 ++ .../types/beta/clusters/storage_update_params.py | 2 ++ 7 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index bd80968e7..232dc112a 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-a5de4cd2b7b07333c8566d7dc805319e5b1727078b57e5236f8076a7ab4925d2.yml -openapi_spec_hash: d087f1b24b5623db2c9a8d4a2d987a2f +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-ae3cfb67611b42139b6d37809a00cf0c26b4803f974a3defd3ce64394fa08e1b.yml +openapi_spec_hash: 50c3ce80ecc7d25283c96e3544567995 config_hash: ec427df08d61d8888138f15cd53c6454 diff --git a/src/together/resources/beta/clusters/storage.py b/src/together/resources/beta/clusters/storage.py index 48f6ccba7..2493f96cf 100644 --- a/src/together/resources/beta/clusters/storage.py +++ b/src/together/resources/beta/clusters/storage.py @@ -65,8 +65,12 @@ def create( performance for shared storage. Args: + region: Region name. Usable regions can be found from `clusters.list_regions()` + size_tib: Volume size in whole tebibytes (TiB). + volume_name: User provided name of the volume. + is_lifecycle_independent: When true, the shared volume is not deleted when the cluster is decommissioned. extra_headers: Send extra headers @@ -145,6 +149,10 @@ def update( Update the configuration of an existing shared volume. Args: + volume_id: ID of the volume. + + size_tib: Size of the volume in TiB. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -287,8 +295,12 @@ async def create( performance for shared storage. Args: + region: Region name. Usable regions can be found from `clusters.list_regions()` + size_tib: Volume size in whole tebibytes (TiB). + volume_name: User provided name of the volume. + is_lifecycle_independent: When true, the shared volume is not deleted when the cluster is decommissioned. extra_headers: Send extra headers @@ -367,6 +379,10 @@ async def update( Update the configuration of an existing shared volume. Args: + volume_id: ID of the volume. + + size_tib: Size of the volume in TiB. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request diff --git a/src/together/types/beta/cluster.py b/src/together/types/beta/cluster.py index 400108cfb..e78c9a07a 100644 --- a/src/together/types/beta/cluster.py +++ b/src/together/types/beta/cluster.py @@ -126,7 +126,7 @@ class GPUWorkerNodePhaseTransition(BaseModel): class GPUWorkerNodeLatestRemediation(BaseModel): """ Remediation represents a node remediation request for an instance. - An instance can have multiple remediations over time (e.g., failed attempts followed by retries). + An instance can have multiple remediations over time (e.g., failed attempts followed by retries). """ id: str @@ -268,12 +268,16 @@ class PhaseTransition(BaseModel): class Volume(BaseModel): size_tib: int + """Size of the volume in TiB.""" status: str + """Current status of the volume.""" volume_id: str + """ID of the volume.""" volume_name: str + """User provided name of the volume.""" class ClusterConfigIngress(BaseModel): diff --git a/src/together/types/beta/cluster_create_params.py b/src/together/types/beta/cluster_create_params.py index b950a246d..9ac607436 100644 --- a/src/together/types/beta/cluster_create_params.py +++ b/src/together/types/beta/cluster_create_params.py @@ -329,11 +329,13 @@ class SharedVolume(TypedDict, total=False): """Inline configuration to create a shared volume with the cluster creation.""" region: Required[str] + """Region name. Usable regions can be found from `clusters.list_regions()`""" size_tib: Required[int] """Volume size in whole tebibytes (TiB).""" volume_name: Required[str] + """User provided name of the volume.""" is_lifecycle_independent: bool """When true, the shared volume is not deleted when the cluster is decommissioned.""" diff --git a/src/together/types/beta/clusters/cluster_storage.py b/src/together/types/beta/clusters/cluster_storage.py index 024b79c04..8daaefc14 100644 --- a/src/together/types/beta/clusters/cluster_storage.py +++ b/src/together/types/beta/clusters/cluster_storage.py @@ -9,6 +9,7 @@ class ClusterStorage(BaseModel): size_tib: int + """Size of the volume in TiB.""" status: Literal[ "scheduled", "available", "bound", "provisioning", "deleting", "failed", "access_revoked", "unknown" @@ -16,5 +17,7 @@ class ClusterStorage(BaseModel): """Current status of the shared volume.""" volume_id: str + """ID of the volume.""" volume_name: str + """User provided name of the volume.""" diff --git a/src/together/types/beta/clusters/storage_create_params.py b/src/together/types/beta/clusters/storage_create_params.py index 2dfb9218f..f368b0e8e 100644 --- a/src/together/types/beta/clusters/storage_create_params.py +++ b/src/together/types/beta/clusters/storage_create_params.py @@ -9,11 +9,13 @@ class StorageCreateParams(TypedDict, total=False): region: Required[str] + """Region name. Usable regions can be found from `clusters.list_regions()`""" size_tib: Required[int] """Volume size in whole tebibytes (TiB).""" volume_name: Required[str] + """User provided name of the volume.""" is_lifecycle_independent: bool """When true, the shared volume is not deleted when the cluster is decommissioned.""" diff --git a/src/together/types/beta/clusters/storage_update_params.py b/src/together/types/beta/clusters/storage_update_params.py index 116645fa7..6e6a0162a 100644 --- a/src/together/types/beta/clusters/storage_update_params.py +++ b/src/together/types/beta/clusters/storage_update_params.py @@ -9,5 +9,7 @@ class StorageUpdateParams(TypedDict, total=False): volume_id: Required[str] + """ID of the volume.""" size_tib: int + """Size of the volume in TiB.""" From 029c3fd79c22f130dab4a46bc62a6e1410908da4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 17:53:42 +0000 Subject: [PATCH 07/30] feat(api): Add node remediation APIs to clusters sdks --- .stats.yml | 6 +- api.md | 22 + .../resources/beta/clusters/__init__.py | 14 + .../resources/beta/clusters/clusters.py | 32 + .../resources/beta/clusters/remediations.py | 849 ++++++++++++++++++ src/together/types/beta/clusters/__init__.py | 9 + .../clusters/remediation_approve_params.py | 18 + .../clusters/remediation_approve_response.py | 94 ++ .../clusters/remediation_cancel_response.py | 94 ++ .../clusters/remediation_create_params.py | 34 + .../clusters/remediation_create_response.py | 94 ++ .../beta/clusters/remediation_list_params.py | 47 + .../clusters/remediation_list_response.py | 107 +++ .../clusters/remediation_reject_params.py | 18 + .../clusters/remediation_reject_response.py | 94 ++ .../beta/clusters/test_remediations.py | 682 ++++++++++++++ 16 files changed, 2211 insertions(+), 3 deletions(-) create mode 100644 src/together/resources/beta/clusters/remediations.py create mode 100644 src/together/types/beta/clusters/remediation_approve_params.py create mode 100644 src/together/types/beta/clusters/remediation_approve_response.py create mode 100644 src/together/types/beta/clusters/remediation_cancel_response.py create mode 100644 src/together/types/beta/clusters/remediation_create_params.py create mode 100644 src/together/types/beta/clusters/remediation_create_response.py create mode 100644 src/together/types/beta/clusters/remediation_list_params.py create mode 100644 src/together/types/beta/clusters/remediation_list_response.py create mode 100644 src/together/types/beta/clusters/remediation_reject_params.py create mode 100644 src/together/types/beta/clusters/remediation_reject_response.py create mode 100644 tests/api_resources/beta/clusters/test_remediations.py diff --git a/.stats.yml b/.stats.yml index 232dc112a..3b8397a01 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 75 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-ae3cfb67611b42139b6d37809a00cf0c26b4803f974a3defd3ce64394fa08e1b.yml +configured_endpoints: 80 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-496df09bdf8bfe14dddc5ba6dc70e219336f51295d2f0671285b4056ec756f21.yml openapi_spec_hash: 50c3ce80ecc7d25283c96e3544567995 -config_hash: ec427df08d61d8888138f15cd53c6454 +config_hash: 2ab62260e2c5e9527b47bef3f36e545d diff --git a/api.md b/api.md index abdf1cddd..1493164db 100644 --- a/api.md +++ b/api.md @@ -91,6 +91,28 @@ Methods: - client.beta.clusters.delete(cluster_id) -> ClusterDeleteResponse - client.beta.clusters.list_regions() -> ClusterListRegionsResponse +### Remediations + +Types: + +```python +from together.types.beta.clusters import ( + RemediationCreateResponse, + RemediationListResponse, + RemediationApproveResponse, + RemediationCancelResponse, + RemediationRejectResponse, +) +``` + +Methods: + +- client.beta.clusters.remediations.create(instance_id, \*, cluster_id, \*\*params) -> RemediationCreateResponse +- client.beta.clusters.remediations.list(instance_id, \*, cluster_id, \*\*params) -> RemediationListResponse +- client.beta.clusters.remediations.approve(remediation_id, \*, cluster_id, instance_id, \*\*params) -> RemediationApproveResponse +- client.beta.clusters.remediations.cancel(remediation_id, \*, cluster_id, instance_id) -> RemediationCancelResponse +- client.beta.clusters.remediations.reject(remediation_id, \*, cluster_id, instance_id, \*\*params) -> RemediationRejectResponse + ### Storage Types: diff --git a/src/together/resources/beta/clusters/__init__.py b/src/together/resources/beta/clusters/__init__.py index a428a2a51..58a0f2342 100644 --- a/src/together/resources/beta/clusters/__init__.py +++ b/src/together/resources/beta/clusters/__init__.py @@ -16,8 +16,22 @@ ClustersResourceWithStreamingResponse, AsyncClustersResourceWithStreamingResponse, ) +from .remediations import ( + RemediationsResource, + AsyncRemediationsResource, + RemediationsResourceWithRawResponse, + AsyncRemediationsResourceWithRawResponse, + RemediationsResourceWithStreamingResponse, + AsyncRemediationsResourceWithStreamingResponse, +) __all__ = [ + "RemediationsResource", + "AsyncRemediationsResource", + "RemediationsResourceWithRawResponse", + "AsyncRemediationsResourceWithRawResponse", + "RemediationsResourceWithStreamingResponse", + "AsyncRemediationsResourceWithStreamingResponse", "StorageResource", "AsyncStorageResource", "StorageResourceWithRawResponse", diff --git a/src/together/resources/beta/clusters/clusters.py b/src/together/resources/beta/clusters/clusters.py index 7b8f2f67a..75efca2fe 100644 --- a/src/together/resources/beta/clusters/clusters.py +++ b/src/together/resources/beta/clusters/clusters.py @@ -26,6 +26,14 @@ async_to_raw_response_wrapper, async_to_streamed_response_wrapper, ) +from .remediations import ( + RemediationsResource, + AsyncRemediationsResource, + RemediationsResourceWithRawResponse, + AsyncRemediationsResourceWithRawResponse, + RemediationsResourceWithStreamingResponse, + AsyncRemediationsResourceWithStreamingResponse, +) from ....types.beta import cluster_list_params, cluster_create_params, cluster_update_params from ...._base_client import make_request_options from ....types.beta.cluster import Cluster @@ -37,6 +45,10 @@ class ClustersResource(SyncAPIResource): + @cached_property + def remediations(self) -> RemediationsResource: + return RemediationsResource(self._client) + @cached_property def storage(self) -> StorageResource: return StorageResource(self._client) @@ -439,6 +451,10 @@ def list_regions( class AsyncClustersResource(AsyncAPIResource): + @cached_property + def remediations(self) -> AsyncRemediationsResource: + return AsyncRemediationsResource(self._client) + @cached_property def storage(self) -> AsyncStorageResource: return AsyncStorageResource(self._client) @@ -863,6 +879,10 @@ def __init__(self, clusters: ClustersResource) -> None: clusters.list_regions, ) + @cached_property + def remediations(self) -> RemediationsResourceWithRawResponse: + return RemediationsResourceWithRawResponse(self._clusters.remediations) + @cached_property def storage(self) -> StorageResourceWithRawResponse: return StorageResourceWithRawResponse(self._clusters.storage) @@ -891,6 +911,10 @@ def __init__(self, clusters: AsyncClustersResource) -> None: clusters.list_regions, ) + @cached_property + def remediations(self) -> AsyncRemediationsResourceWithRawResponse: + return AsyncRemediationsResourceWithRawResponse(self._clusters.remediations) + @cached_property def storage(self) -> AsyncStorageResourceWithRawResponse: return AsyncStorageResourceWithRawResponse(self._clusters.storage) @@ -919,6 +943,10 @@ def __init__(self, clusters: ClustersResource) -> None: clusters.list_regions, ) + @cached_property + def remediations(self) -> RemediationsResourceWithStreamingResponse: + return RemediationsResourceWithStreamingResponse(self._clusters.remediations) + @cached_property def storage(self) -> StorageResourceWithStreamingResponse: return StorageResourceWithStreamingResponse(self._clusters.storage) @@ -947,6 +975,10 @@ def __init__(self, clusters: AsyncClustersResource) -> None: clusters.list_regions, ) + @cached_property + def remediations(self) -> AsyncRemediationsResourceWithStreamingResponse: + return AsyncRemediationsResourceWithStreamingResponse(self._clusters.remediations) + @cached_property def storage(self) -> AsyncStorageResourceWithStreamingResponse: return AsyncStorageResourceWithStreamingResponse(self._clusters.storage) diff --git a/src/together/resources/beta/clusters/remediations.py b/src/together/resources/beta/clusters/remediations.py new file mode 100644 index 000000000..c130fb213 --- /dev/null +++ b/src/together/resources/beta/clusters/remediations.py @@ -0,0 +1,849 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List +from typing_extensions import Literal + +import httpx + +from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from ...._utils import path_template, maybe_transform, async_maybe_transform +from ...._compat import cached_property +from ...._resource import SyncAPIResource, AsyncAPIResource +from ...._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from ...._base_client import make_request_options +from ....types.beta.clusters import ( + remediation_list_params, + remediation_create_params, + remediation_reject_params, + remediation_approve_params, +) +from ....types.beta.clusters.remediation_list_response import RemediationListResponse +from ....types.beta.clusters.remediation_cancel_response import RemediationCancelResponse +from ....types.beta.clusters.remediation_create_response import RemediationCreateResponse +from ....types.beta.clusters.remediation_reject_response import RemediationRejectResponse +from ....types.beta.clusters.remediation_approve_response import RemediationApproveResponse + +__all__ = ["RemediationsResource", "AsyncRemediationsResource"] + + +class RemediationsResource(SyncAPIResource): + @cached_property + def with_raw_response(self) -> RemediationsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/togethercomputer/together-py#accessing-raw-response-data-eg-headers + """ + return RemediationsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> RemediationsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/togethercomputer/together-py#with_streaming_response + """ + return RemediationsResourceWithStreamingResponse(self) + + def create( + self, + instance_id: str, + *, + cluster_id: str, + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ], + remediation_id: str | Omit = omit, + reason: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationCreateResponse: + """ + Creates a new remediation for an instance. + + If mode is unspecified, it defaults to VM_ONLY. If trigger is unspecified, it + defaults to MANUAL. + + For MANUAL triggers: The remediation goes directly to PENDING state. + + For AUTOMATED triggers: The remediation is created with PENDING_APPROVAL state. + The caller must then use ApproveRemediation to start the remediation process. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + mode: Remediation mode specifies how the remediation should be performed. + + - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any + available host. + - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and + provisions a new one on a different host. + + remediation_id: Optional. Client-specified ID for idempotency. + + reason: User-provided reason for the remediation. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + return self._post( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations", + cluster_id=cluster_id, + instance_id=instance_id, + ), + body=maybe_transform( + { + "mode": mode, + "reason": reason, + }, + remediation_create_params.RemediationCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + {"remediation_id": remediation_id}, remediation_create_params.RemediationCreateParams + ), + ), + cast_to=RemediationCreateResponse, + ) + + def list( + self, + instance_id: str, + *, + cluster_id: str, + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + | Omit = omit, + order_by: str | Omit = omit, + page_size: int | Omit = omit, + page_token: str | Omit = omit, + state: List[ + Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] + ] + | Omit = omit, + trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationListResponse: + """Lists remediations for an instance or cluster. + + Use instances/- as wildcard to + list all remediations in a cluster. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + mode: Optional. Filter by remediation mode. Returns only remediations matching the + specified mode. + + order_by: Optional. Order by expression. + + page_size: Optional. Maximum results to return. + + page_token: Optional. Pagination token from previous request. + + state: Optional. Filter by state(s). Returns remediations matching any of the specified + states. + + trigger: Optional. Filter by trigger type. Returns only remediations matching the + specified trigger. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + return self._get( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations", + cluster_id=cluster_id, + instance_id=instance_id, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform( + { + "mode": mode, + "order_by": order_by, + "page_size": page_size, + "page_token": page_token, + "state": state, + "trigger": trigger, + }, + remediation_list_params.RemediationListParams, + ), + ), + cast_to=RemediationListResponse, + ) + + def approve( + self, + remediation_id: str, + *, + cluster_id: str, + instance_id: str, + comment: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationApproveResponse: + """ + Approves a pending remediation. + + Only remediations with state PENDING_APPROVAL can be approved. + + On APPROVE: state changes to PENDING and the remediation process begins. The + reviewed_by, review_time, and review_comment fields are populated on the + remediation after approval. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + remediation_id: The remediation ID. + + comment: Comment explaining the action. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + if not remediation_id: + raise ValueError(f"Expected a non-empty value for `remediation_id` but received {remediation_id!r}") + return self._post( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations/{remediation_id}/approve", + cluster_id=cluster_id, + instance_id=instance_id, + remediation_id=remediation_id, + ), + body=maybe_transform({"comment": comment}, remediation_approve_params.RemediationApproveParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=RemediationApproveResponse, + ) + + def cancel( + self, + remediation_id: str, + *, + cluster_id: str, + instance_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationCancelResponse: + """ + Cancels a pending remediation. + + Only remediations in PENDING_APPROVAL or PENDING state can be cancelled. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + remediation_id: The remediation ID. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + if not remediation_id: + raise ValueError(f"Expected a non-empty value for `remediation_id` but received {remediation_id!r}") + return self._post( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations/{remediation_id}/cancel", + cluster_id=cluster_id, + instance_id=instance_id, + remediation_id=remediation_id, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=RemediationCancelResponse, + ) + + def reject( + self, + remediation_id: str, + *, + cluster_id: str, + instance_id: str, + comment: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationRejectResponse: + """ + Rejects a pending remediation. + + Only remediations with state PENDING_APPROVAL can be rejected. + + On REJECT: state changes to CANCELLED. The reviewed_by, review_time, and + review_comment fields are populated on the remediation after rejection. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + remediation_id: The remediation ID. + + comment: Comment explaining the action. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + if not remediation_id: + raise ValueError(f"Expected a non-empty value for `remediation_id` but received {remediation_id!r}") + return self._post( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations/{remediation_id}/reject", + cluster_id=cluster_id, + instance_id=instance_id, + remediation_id=remediation_id, + ), + body=maybe_transform({"comment": comment}, remediation_reject_params.RemediationRejectParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=RemediationRejectResponse, + ) + + +class AsyncRemediationsResource(AsyncAPIResource): + @cached_property + def with_raw_response(self) -> AsyncRemediationsResourceWithRawResponse: + """ + This property can be used as a prefix for any HTTP method call to return + the raw response object instead of the parsed content. + + For more information, see https://www.github.com/togethercomputer/together-py#accessing-raw-response-data-eg-headers + """ + return AsyncRemediationsResourceWithRawResponse(self) + + @cached_property + def with_streaming_response(self) -> AsyncRemediationsResourceWithStreamingResponse: + """ + An alternative to `.with_raw_response` that doesn't eagerly read the response body. + + For more information, see https://www.github.com/togethercomputer/together-py#with_streaming_response + """ + return AsyncRemediationsResourceWithStreamingResponse(self) + + async def create( + self, + instance_id: str, + *, + cluster_id: str, + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ], + remediation_id: str | Omit = omit, + reason: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationCreateResponse: + """ + Creates a new remediation for an instance. + + If mode is unspecified, it defaults to VM_ONLY. If trigger is unspecified, it + defaults to MANUAL. + + For MANUAL triggers: The remediation goes directly to PENDING state. + + For AUTOMATED triggers: The remediation is created with PENDING_APPROVAL state. + The caller must then use ApproveRemediation to start the remediation process. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + mode: Remediation mode specifies how the remediation should be performed. + + - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any + available host. + - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and + provisions a new one on a different host. + + remediation_id: Optional. Client-specified ID for idempotency. + + reason: User-provided reason for the remediation. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + return await self._post( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations", + cluster_id=cluster_id, + instance_id=instance_id, + ), + body=await async_maybe_transform( + { + "mode": mode, + "reason": reason, + }, + remediation_create_params.RemediationCreateParams, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + {"remediation_id": remediation_id}, remediation_create_params.RemediationCreateParams + ), + ), + cast_to=RemediationCreateResponse, + ) + + async def list( + self, + instance_id: str, + *, + cluster_id: str, + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + | Omit = omit, + order_by: str | Omit = omit, + page_size: int | Omit = omit, + page_token: str | Omit = omit, + state: List[ + Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] + ] + | Omit = omit, + trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationListResponse: + """Lists remediations for an instance or cluster. + + Use instances/- as wildcard to + list all remediations in a cluster. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + mode: Optional. Filter by remediation mode. Returns only remediations matching the + specified mode. + + order_by: Optional. Order by expression. + + page_size: Optional. Maximum results to return. + + page_token: Optional. Pagination token from previous request. + + state: Optional. Filter by state(s). Returns remediations matching any of the specified + states. + + trigger: Optional. Filter by trigger type. Returns only remediations matching the + specified trigger. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + return await self._get( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations", + cluster_id=cluster_id, + instance_id=instance_id, + ), + options=make_request_options( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform( + { + "mode": mode, + "order_by": order_by, + "page_size": page_size, + "page_token": page_token, + "state": state, + "trigger": trigger, + }, + remediation_list_params.RemediationListParams, + ), + ), + cast_to=RemediationListResponse, + ) + + async def approve( + self, + remediation_id: str, + *, + cluster_id: str, + instance_id: str, + comment: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationApproveResponse: + """ + Approves a pending remediation. + + Only remediations with state PENDING_APPROVAL can be approved. + + On APPROVE: state changes to PENDING and the remediation process begins. The + reviewed_by, review_time, and review_comment fields are populated on the + remediation after approval. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + remediation_id: The remediation ID. + + comment: Comment explaining the action. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + if not remediation_id: + raise ValueError(f"Expected a non-empty value for `remediation_id` but received {remediation_id!r}") + return await self._post( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations/{remediation_id}/approve", + cluster_id=cluster_id, + instance_id=instance_id, + remediation_id=remediation_id, + ), + body=await async_maybe_transform({"comment": comment}, remediation_approve_params.RemediationApproveParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=RemediationApproveResponse, + ) + + async def cancel( + self, + remediation_id: str, + *, + cluster_id: str, + instance_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationCancelResponse: + """ + Cancels a pending remediation. + + Only remediations in PENDING_APPROVAL or PENDING state can be cancelled. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + remediation_id: The remediation ID. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + if not remediation_id: + raise ValueError(f"Expected a non-empty value for `remediation_id` but received {remediation_id!r}") + return await self._post( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations/{remediation_id}/cancel", + cluster_id=cluster_id, + instance_id=instance_id, + remediation_id=remediation_id, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=RemediationCancelResponse, + ) + + async def reject( + self, + remediation_id: str, + *, + cluster_id: str, + instance_id: str, + comment: str | Omit = omit, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationRejectResponse: + """ + Rejects a pending remediation. + + Only remediations with state PENDING_APPROVAL can be rejected. + + On REJECT: state changes to CANCELLED. The reviewed_by, review_time, and + review_comment fields are populated on the remediation after rejection. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + remediation_id: The remediation ID. + + comment: Comment explaining the action. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + if not remediation_id: + raise ValueError(f"Expected a non-empty value for `remediation_id` but received {remediation_id!r}") + return await self._post( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations/{remediation_id}/reject", + cluster_id=cluster_id, + instance_id=instance_id, + remediation_id=remediation_id, + ), + body=await async_maybe_transform({"comment": comment}, remediation_reject_params.RemediationRejectParams), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=RemediationRejectResponse, + ) + + +class RemediationsResourceWithRawResponse: + def __init__(self, remediations: RemediationsResource) -> None: + self._remediations = remediations + + self.create = to_raw_response_wrapper( + remediations.create, + ) + self.list = to_raw_response_wrapper( + remediations.list, + ) + self.approve = to_raw_response_wrapper( + remediations.approve, + ) + self.cancel = to_raw_response_wrapper( + remediations.cancel, + ) + self.reject = to_raw_response_wrapper( + remediations.reject, + ) + + +class AsyncRemediationsResourceWithRawResponse: + def __init__(self, remediations: AsyncRemediationsResource) -> None: + self._remediations = remediations + + self.create = async_to_raw_response_wrapper( + remediations.create, + ) + self.list = async_to_raw_response_wrapper( + remediations.list, + ) + self.approve = async_to_raw_response_wrapper( + remediations.approve, + ) + self.cancel = async_to_raw_response_wrapper( + remediations.cancel, + ) + self.reject = async_to_raw_response_wrapper( + remediations.reject, + ) + + +class RemediationsResourceWithStreamingResponse: + def __init__(self, remediations: RemediationsResource) -> None: + self._remediations = remediations + + self.create = to_streamed_response_wrapper( + remediations.create, + ) + self.list = to_streamed_response_wrapper( + remediations.list, + ) + self.approve = to_streamed_response_wrapper( + remediations.approve, + ) + self.cancel = to_streamed_response_wrapper( + remediations.cancel, + ) + self.reject = to_streamed_response_wrapper( + remediations.reject, + ) + + +class AsyncRemediationsResourceWithStreamingResponse: + def __init__(self, remediations: AsyncRemediationsResource) -> None: + self._remediations = remediations + + self.create = async_to_streamed_response_wrapper( + remediations.create, + ) + self.list = async_to_streamed_response_wrapper( + remediations.list, + ) + self.approve = async_to_streamed_response_wrapper( + remediations.approve, + ) + self.cancel = async_to_streamed_response_wrapper( + remediations.cancel, + ) + self.reject = async_to_streamed_response_wrapper( + remediations.reject, + ) diff --git a/src/together/types/beta/clusters/__init__.py b/src/together/types/beta/clusters/__init__.py index 9c4f18a76..0ac6cbb1b 100644 --- a/src/together/types/beta/clusters/__init__.py +++ b/src/together/types/beta/clusters/__init__.py @@ -7,4 +7,13 @@ from .storage_create_params import StorageCreateParams as StorageCreateParams from .storage_list_response import StorageListResponse as StorageListResponse from .storage_update_params import StorageUpdateParams as StorageUpdateParams +from .remediation_list_params import RemediationListParams as RemediationListParams from .storage_delete_response import StorageDeleteResponse as StorageDeleteResponse +from .remediation_create_params import RemediationCreateParams as RemediationCreateParams +from .remediation_list_response import RemediationListResponse as RemediationListResponse +from .remediation_reject_params import RemediationRejectParams as RemediationRejectParams +from .remediation_approve_params import RemediationApproveParams as RemediationApproveParams +from .remediation_cancel_response import RemediationCancelResponse as RemediationCancelResponse +from .remediation_create_response import RemediationCreateResponse as RemediationCreateResponse +from .remediation_reject_response import RemediationRejectResponse as RemediationRejectResponse +from .remediation_approve_response import RemediationApproveResponse as RemediationApproveResponse diff --git a/src/together/types/beta/clusters/remediation_approve_params.py b/src/together/types/beta/clusters/remediation_approve_params.py new file mode 100644 index 000000000..cb86457e7 --- /dev/null +++ b/src/together/types/beta/clusters/remediation_approve_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["RemediationApproveParams"] + + +class RemediationApproveParams(TypedDict, total=False): + cluster_id: Required[str] + """The cluster ID.""" + + instance_id: Required[str] + """The instance ID.""" + + comment: str + """Comment explaining the action.""" diff --git a/src/together/types/beta/clusters/remediation_approve_response.py b/src/together/types/beta/clusters/remediation_approve_response.py new file mode 100644 index 000000000..040e6dab2 --- /dev/null +++ b/src/together/types/beta/clusters/remediation_approve_response.py @@ -0,0 +1,94 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from ...._models import BaseModel + +__all__ = ["RemediationApproveResponse"] + + +class RemediationApproveResponse(BaseModel): + """ + Remediation represents a node remediation request for an instance. + An instance can have multiple remediations over time (e.g., failed attempts followed by retries). + """ + + id: str + + cluster_id: str + + instance_id: str + + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + """Remediation mode specifies how the remediation should be performed. + + - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any + available host. + - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and + provisions a new one on a different host. + """ + + state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] + """RemediationState represents the lifecycle state of a remediation. + + - `PENDING_APPROVAL`: Awaiting approval before processing can begin. + - `PENDING`: Approved and queued for processing. + - `RUNNING`: Actively being processed. + - `SUCCEEDED`: Successfully completed. + - `FAILED`: Failed with an error. + - `CANCELLED`: Cancelled by user or system. + - `AUTO_RESOLVED`: The underlying issue was automatically resolved before + processing. + """ + + trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] + """RemediationTrigger specifies how the remediation was triggered. + + - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI + or API call). + - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires + approval. + """ + + active_health_check_run_id: Optional[str] = None + """Active health check run ID (UUID) that triggered this remediation.""" + + create_time: Optional[datetime] = None + """When the remediation was created.""" + + end_time: Optional[datetime] = None + """When the remediation completed.""" + + error_message: Optional[str] = None + """Error message if the remediation failed.""" + + passive_health_check_event_id: Optional[str] = None + """Passive health check event ID that triggered this remediation.""" + + reason: Optional[str] = None + """User-provided reason for the remediation.""" + + requested_by: Optional[str] = None + """Who requested the remediation.""" + + review_comment: Optional[str] = None + """Review comment.""" + + review_time: Optional[datetime] = None + """When the remediation was reviewed.""" + + reviewed_by: Optional[str] = None + """Who reviewed the remediation.""" + + start_time: Optional[datetime] = None + """When processing started.""" + + update_time: Optional[datetime] = None + """When the remediation was last updated.""" diff --git a/src/together/types/beta/clusters/remediation_cancel_response.py b/src/together/types/beta/clusters/remediation_cancel_response.py new file mode 100644 index 000000000..ec8975c9b --- /dev/null +++ b/src/together/types/beta/clusters/remediation_cancel_response.py @@ -0,0 +1,94 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from ...._models import BaseModel + +__all__ = ["RemediationCancelResponse"] + + +class RemediationCancelResponse(BaseModel): + """ + Remediation represents a node remediation request for an instance. + An instance can have multiple remediations over time (e.g., failed attempts followed by retries). + """ + + id: str + + cluster_id: str + + instance_id: str + + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + """Remediation mode specifies how the remediation should be performed. + + - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any + available host. + - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and + provisions a new one on a different host. + """ + + state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] + """RemediationState represents the lifecycle state of a remediation. + + - `PENDING_APPROVAL`: Awaiting approval before processing can begin. + - `PENDING`: Approved and queued for processing. + - `RUNNING`: Actively being processed. + - `SUCCEEDED`: Successfully completed. + - `FAILED`: Failed with an error. + - `CANCELLED`: Cancelled by user or system. + - `AUTO_RESOLVED`: The underlying issue was automatically resolved before + processing. + """ + + trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] + """RemediationTrigger specifies how the remediation was triggered. + + - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI + or API call). + - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires + approval. + """ + + active_health_check_run_id: Optional[str] = None + """Active health check run ID (UUID) that triggered this remediation.""" + + create_time: Optional[datetime] = None + """When the remediation was created.""" + + end_time: Optional[datetime] = None + """When the remediation completed.""" + + error_message: Optional[str] = None + """Error message if the remediation failed.""" + + passive_health_check_event_id: Optional[str] = None + """Passive health check event ID that triggered this remediation.""" + + reason: Optional[str] = None + """User-provided reason for the remediation.""" + + requested_by: Optional[str] = None + """Who requested the remediation.""" + + review_comment: Optional[str] = None + """Review comment.""" + + review_time: Optional[datetime] = None + """When the remediation was reviewed.""" + + reviewed_by: Optional[str] = None + """Who reviewed the remediation.""" + + start_time: Optional[datetime] = None + """When processing started.""" + + update_time: Optional[datetime] = None + """When the remediation was last updated.""" diff --git a/src/together/types/beta/clusters/remediation_create_params.py b/src/together/types/beta/clusters/remediation_create_params.py new file mode 100644 index 000000000..84374cec9 --- /dev/null +++ b/src/together/types/beta/clusters/remediation_create_params.py @@ -0,0 +1,34 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["RemediationCreateParams"] + + +class RemediationCreateParams(TypedDict, total=False): + cluster_id: Required[str] + """The cluster ID.""" + + mode: Required[ + Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + ] + """Remediation mode specifies how the remediation should be performed. + + - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any + available host. + - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and + provisions a new one on a different host. + """ + + remediation_id: str + """Optional. Client-specified ID for idempotency.""" + + reason: str + """User-provided reason for the remediation.""" diff --git a/src/together/types/beta/clusters/remediation_create_response.py b/src/together/types/beta/clusters/remediation_create_response.py new file mode 100644 index 000000000..2b148f932 --- /dev/null +++ b/src/together/types/beta/clusters/remediation_create_response.py @@ -0,0 +1,94 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from ...._models import BaseModel + +__all__ = ["RemediationCreateResponse"] + + +class RemediationCreateResponse(BaseModel): + """ + Remediation represents a node remediation request for an instance. + An instance can have multiple remediations over time (e.g., failed attempts followed by retries). + """ + + id: str + + cluster_id: str + + instance_id: str + + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + """Remediation mode specifies how the remediation should be performed. + + - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any + available host. + - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and + provisions a new one on a different host. + """ + + state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] + """RemediationState represents the lifecycle state of a remediation. + + - `PENDING_APPROVAL`: Awaiting approval before processing can begin. + - `PENDING`: Approved and queued for processing. + - `RUNNING`: Actively being processed. + - `SUCCEEDED`: Successfully completed. + - `FAILED`: Failed with an error. + - `CANCELLED`: Cancelled by user or system. + - `AUTO_RESOLVED`: The underlying issue was automatically resolved before + processing. + """ + + trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] + """RemediationTrigger specifies how the remediation was triggered. + + - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI + or API call). + - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires + approval. + """ + + active_health_check_run_id: Optional[str] = None + """Active health check run ID (UUID) that triggered this remediation.""" + + create_time: Optional[datetime] = None + """When the remediation was created.""" + + end_time: Optional[datetime] = None + """When the remediation completed.""" + + error_message: Optional[str] = None + """Error message if the remediation failed.""" + + passive_health_check_event_id: Optional[str] = None + """Passive health check event ID that triggered this remediation.""" + + reason: Optional[str] = None + """User-provided reason for the remediation.""" + + requested_by: Optional[str] = None + """Who requested the remediation.""" + + review_comment: Optional[str] = None + """Review comment.""" + + review_time: Optional[datetime] = None + """When the remediation was reviewed.""" + + reviewed_by: Optional[str] = None + """Who reviewed the remediation.""" + + start_time: Optional[datetime] = None + """When processing started.""" + + update_time: Optional[datetime] = None + """When the remediation was last updated.""" diff --git a/src/together/types/beta/clusters/remediation_list_params.py b/src/together/types/beta/clusters/remediation_list_params.py new file mode 100644 index 000000000..18e6b884e --- /dev/null +++ b/src/together/types/beta/clusters/remediation_list_params.py @@ -0,0 +1,47 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List +from typing_extensions import Literal, Required, TypedDict + +__all__ = ["RemediationListParams"] + + +class RemediationListParams(TypedDict, total=False): + cluster_id: Required[str] + """The cluster ID.""" + + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + """Optional. + + Filter by remediation mode. Returns only remediations matching the specified + mode. + """ + + order_by: str + """Optional. Order by expression.""" + + page_size: int + """Optional. Maximum results to return.""" + + page_token: str + """Optional. Pagination token from previous request.""" + + state: List[Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"]] + """Optional. + + Filter by state(s). Returns remediations matching any of the specified states. + """ + + trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] + """Optional. + + Filter by trigger type. Returns only remediations matching the specified + trigger. + """ diff --git a/src/together/types/beta/clusters/remediation_list_response.py b/src/together/types/beta/clusters/remediation_list_response.py new file mode 100644 index 000000000..aaf15a9fb --- /dev/null +++ b/src/together/types/beta/clusters/remediation_list_response.py @@ -0,0 +1,107 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import List, Optional +from datetime import datetime +from typing_extensions import Literal + +from ...._models import BaseModel + +__all__ = ["RemediationListResponse", "Remediation"] + + +class Remediation(BaseModel): + """ + Remediation represents a node remediation request for an instance. + An instance can have multiple remediations over time (e.g., failed attempts followed by retries). + """ + + id: str + + cluster_id: str + + instance_id: str + + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + """Remediation mode specifies how the remediation should be performed. + + - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any + available host. + - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and + provisions a new one on a different host. + """ + + state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] + """RemediationState represents the lifecycle state of a remediation. + + - `PENDING_APPROVAL`: Awaiting approval before processing can begin. + - `PENDING`: Approved and queued for processing. + - `RUNNING`: Actively being processed. + - `SUCCEEDED`: Successfully completed. + - `FAILED`: Failed with an error. + - `CANCELLED`: Cancelled by user or system. + - `AUTO_RESOLVED`: The underlying issue was automatically resolved before + processing. + """ + + trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] + """RemediationTrigger specifies how the remediation was triggered. + + - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI + or API call). + - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires + approval. + """ + + active_health_check_run_id: Optional[str] = None + """Active health check run ID (UUID) that triggered this remediation.""" + + create_time: Optional[datetime] = None + """When the remediation was created.""" + + end_time: Optional[datetime] = None + """When the remediation completed.""" + + error_message: Optional[str] = None + """Error message if the remediation failed.""" + + passive_health_check_event_id: Optional[str] = None + """Passive health check event ID that triggered this remediation.""" + + reason: Optional[str] = None + """User-provided reason for the remediation.""" + + requested_by: Optional[str] = None + """Who requested the remediation.""" + + review_comment: Optional[str] = None + """Review comment.""" + + review_time: Optional[datetime] = None + """When the remediation was reviewed.""" + + reviewed_by: Optional[str] = None + """Who reviewed the remediation.""" + + start_time: Optional[datetime] = None + """When processing started.""" + + update_time: Optional[datetime] = None + """When the remediation was last updated.""" + + +class RemediationListResponse(BaseModel): + """ListRemediationsResponse is the response for ListRemediations.""" + + has_next: bool + """Indicates if there are more results available.""" + + next_page_token: str + """Token for the next page.""" + + remediations: List[Remediation] + """The list of remediations.""" diff --git a/src/together/types/beta/clusters/remediation_reject_params.py b/src/together/types/beta/clusters/remediation_reject_params.py new file mode 100644 index 000000000..de91e59f9 --- /dev/null +++ b/src/together/types/beta/clusters/remediation_reject_params.py @@ -0,0 +1,18 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing_extensions import Required, TypedDict + +__all__ = ["RemediationRejectParams"] + + +class RemediationRejectParams(TypedDict, total=False): + cluster_id: Required[str] + """The cluster ID.""" + + instance_id: Required[str] + """The instance ID.""" + + comment: str + """Comment explaining the action.""" diff --git a/src/together/types/beta/clusters/remediation_reject_response.py b/src/together/types/beta/clusters/remediation_reject_response.py new file mode 100644 index 000000000..e90306bb6 --- /dev/null +++ b/src/together/types/beta/clusters/remediation_reject_response.py @@ -0,0 +1,94 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from ...._models import BaseModel + +__all__ = ["RemediationRejectResponse"] + + +class RemediationRejectResponse(BaseModel): + """ + Remediation represents a node remediation request for an instance. + An instance can have multiple remediations over time (e.g., failed attempts followed by retries). + """ + + id: str + + cluster_id: str + + instance_id: str + + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + """Remediation mode specifies how the remediation should be performed. + + - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any + available host. + - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and + provisions a new one on a different host. + """ + + state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] + """RemediationState represents the lifecycle state of a remediation. + + - `PENDING_APPROVAL`: Awaiting approval before processing can begin. + - `PENDING`: Approved and queued for processing. + - `RUNNING`: Actively being processed. + - `SUCCEEDED`: Successfully completed. + - `FAILED`: Failed with an error. + - `CANCELLED`: Cancelled by user or system. + - `AUTO_RESOLVED`: The underlying issue was automatically resolved before + processing. + """ + + trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] + """RemediationTrigger specifies how the remediation was triggered. + + - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI + or API call). + - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires + approval. + """ + + active_health_check_run_id: Optional[str] = None + """Active health check run ID (UUID) that triggered this remediation.""" + + create_time: Optional[datetime] = None + """When the remediation was created.""" + + end_time: Optional[datetime] = None + """When the remediation completed.""" + + error_message: Optional[str] = None + """Error message if the remediation failed.""" + + passive_health_check_event_id: Optional[str] = None + """Passive health check event ID that triggered this remediation.""" + + reason: Optional[str] = None + """User-provided reason for the remediation.""" + + requested_by: Optional[str] = None + """Who requested the remediation.""" + + review_comment: Optional[str] = None + """Review comment.""" + + review_time: Optional[datetime] = None + """When the remediation was reviewed.""" + + reviewed_by: Optional[str] = None + """Who reviewed the remediation.""" + + start_time: Optional[datetime] = None + """When processing started.""" + + update_time: Optional[datetime] = None + """When the remediation was last updated.""" diff --git a/tests/api_resources/beta/clusters/test_remediations.py b/tests/api_resources/beta/clusters/test_remediations.py new file mode 100644 index 000000000..3a359fd9a --- /dev/null +++ b/tests/api_resources/beta/clusters/test_remediations.py @@ -0,0 +1,682 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +import os +from typing import Any, cast + +import pytest + +from together import Together, AsyncTogether +from tests.utils import assert_matches_type +from together.types.beta.clusters import ( + RemediationListResponse, + RemediationCancelResponse, + RemediationCreateResponse, + RemediationRejectResponse, + RemediationApproveResponse, +) + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") + + +class TestRemediations: + parametrize = pytest.mark.parametrize("client", [False, True], indirect=True, ids=["loose", "strict"]) + + @parametrize + def test_method_create(self, client: Together) -> None: + remediation = client.beta.clusters.remediations.create( + instance_id="instance_id", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + ) + assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + + @parametrize + def test_method_create_with_all_params(self, client: Together) -> None: + remediation = client.beta.clusters.remediations.create( + instance_id="instance_id", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + remediation_id="remediation_id", + reason="reason", + ) + assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + + @parametrize + def test_raw_response_create(self, client: Together) -> None: + response = client.beta.clusters.remediations.with_raw_response.create( + instance_id="instance_id", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = response.parse() + assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + + @parametrize + def test_streaming_response_create(self, client: Together) -> None: + with client.beta.clusters.remediations.with_streaming_response.create( + instance_id="instance_id", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = response.parse() + assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_create(self, client: Together) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.create( + instance_id="instance_id", + cluster_id="", + mode="REMEDIATION_MODE_VM_ONLY", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.create( + instance_id="", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + ) + + @parametrize + def test_method_list(self, client: Together) -> None: + remediation = client.beta.clusters.remediations.list( + instance_id="instance_id", + cluster_id="cluster_id", + ) + assert_matches_type(RemediationListResponse, remediation, path=["response"]) + + @parametrize + def test_method_list_with_all_params(self, client: Together) -> None: + remediation = client.beta.clusters.remediations.list( + instance_id="instance_id", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + order_by="order_by", + page_size=0, + page_token="page_token", + state=["PENDING_APPROVAL"], + trigger="REMEDIATION_TRIGGER_MANUAL", + ) + assert_matches_type(RemediationListResponse, remediation, path=["response"]) + + @parametrize + def test_raw_response_list(self, client: Together) -> None: + response = client.beta.clusters.remediations.with_raw_response.list( + instance_id="instance_id", + cluster_id="cluster_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = response.parse() + assert_matches_type(RemediationListResponse, remediation, path=["response"]) + + @parametrize + def test_streaming_response_list(self, client: Together) -> None: + with client.beta.clusters.remediations.with_streaming_response.list( + instance_id="instance_id", + cluster_id="cluster_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = response.parse() + assert_matches_type(RemediationListResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_list(self, client: Together) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.list( + instance_id="instance_id", + cluster_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.list( + instance_id="", + cluster_id="cluster_id", + ) + + @parametrize + def test_method_approve(self, client: Together) -> None: + remediation = client.beta.clusters.remediations.approve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + + @parametrize + def test_method_approve_with_all_params(self, client: Together) -> None: + remediation = client.beta.clusters.remediations.approve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + comment="comment", + ) + assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + + @parametrize + def test_raw_response_approve(self, client: Together) -> None: + response = client.beta.clusters.remediations.with_raw_response.approve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = response.parse() + assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + + @parametrize + def test_streaming_response_approve(self, client: Together) -> None: + with client.beta.clusters.remediations.with_streaming_response.approve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = response.parse() + assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_approve(self, client: Together) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.approve( + remediation_id="remediation_id", + cluster_id="", + instance_id="instance_id", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.approve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `remediation_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.approve( + remediation_id="", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + @parametrize + def test_method_cancel(self, client: Together) -> None: + remediation = client.beta.clusters.remediations.cancel( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + + @parametrize + def test_raw_response_cancel(self, client: Together) -> None: + response = client.beta.clusters.remediations.with_raw_response.cancel( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = response.parse() + assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + + @parametrize + def test_streaming_response_cancel(self, client: Together) -> None: + with client.beta.clusters.remediations.with_streaming_response.cancel( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = response.parse() + assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_cancel(self, client: Together) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.cancel( + remediation_id="remediation_id", + cluster_id="", + instance_id="instance_id", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.cancel( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `remediation_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.cancel( + remediation_id="", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + @parametrize + def test_method_reject(self, client: Together) -> None: + remediation = client.beta.clusters.remediations.reject( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + + @parametrize + def test_method_reject_with_all_params(self, client: Together) -> None: + remediation = client.beta.clusters.remediations.reject( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + comment="comment", + ) + assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + + @parametrize + def test_raw_response_reject(self, client: Together) -> None: + response = client.beta.clusters.remediations.with_raw_response.reject( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = response.parse() + assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + + @parametrize + def test_streaming_response_reject(self, client: Together) -> None: + with client.beta.clusters.remediations.with_streaming_response.reject( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = response.parse() + assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_reject(self, client: Together) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.reject( + remediation_id="remediation_id", + cluster_id="", + instance_id="instance_id", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.reject( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `remediation_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.reject( + remediation_id="", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + +class TestAsyncRemediations: + parametrize = pytest.mark.parametrize( + "async_client", [False, True, {"http_client": "aiohttp"}], indirect=True, ids=["loose", "strict", "aiohttp"] + ) + + @parametrize + async def test_method_create(self, async_client: AsyncTogether) -> None: + remediation = await async_client.beta.clusters.remediations.create( + instance_id="instance_id", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + ) + assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + + @parametrize + async def test_method_create_with_all_params(self, async_client: AsyncTogether) -> None: + remediation = await async_client.beta.clusters.remediations.create( + instance_id="instance_id", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + remediation_id="remediation_id", + reason="reason", + ) + assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + + @parametrize + async def test_raw_response_create(self, async_client: AsyncTogether) -> None: + response = await async_client.beta.clusters.remediations.with_raw_response.create( + instance_id="instance_id", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = await response.parse() + assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + + @parametrize + async def test_streaming_response_create(self, async_client: AsyncTogether) -> None: + async with async_client.beta.clusters.remediations.with_streaming_response.create( + instance_id="instance_id", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = await response.parse() + assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_create(self, async_client: AsyncTogether) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.create( + instance_id="instance_id", + cluster_id="", + mode="REMEDIATION_MODE_VM_ONLY", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.create( + instance_id="", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + ) + + @parametrize + async def test_method_list(self, async_client: AsyncTogether) -> None: + remediation = await async_client.beta.clusters.remediations.list( + instance_id="instance_id", + cluster_id="cluster_id", + ) + assert_matches_type(RemediationListResponse, remediation, path=["response"]) + + @parametrize + async def test_method_list_with_all_params(self, async_client: AsyncTogether) -> None: + remediation = await async_client.beta.clusters.remediations.list( + instance_id="instance_id", + cluster_id="cluster_id", + mode="REMEDIATION_MODE_VM_ONLY", + order_by="order_by", + page_size=0, + page_token="page_token", + state=["PENDING_APPROVAL"], + trigger="REMEDIATION_TRIGGER_MANUAL", + ) + assert_matches_type(RemediationListResponse, remediation, path=["response"]) + + @parametrize + async def test_raw_response_list(self, async_client: AsyncTogether) -> None: + response = await async_client.beta.clusters.remediations.with_raw_response.list( + instance_id="instance_id", + cluster_id="cluster_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = await response.parse() + assert_matches_type(RemediationListResponse, remediation, path=["response"]) + + @parametrize + async def test_streaming_response_list(self, async_client: AsyncTogether) -> None: + async with async_client.beta.clusters.remediations.with_streaming_response.list( + instance_id="instance_id", + cluster_id="cluster_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = await response.parse() + assert_matches_type(RemediationListResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_list(self, async_client: AsyncTogether) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.list( + instance_id="instance_id", + cluster_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.list( + instance_id="", + cluster_id="cluster_id", + ) + + @parametrize + async def test_method_approve(self, async_client: AsyncTogether) -> None: + remediation = await async_client.beta.clusters.remediations.approve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + + @parametrize + async def test_method_approve_with_all_params(self, async_client: AsyncTogether) -> None: + remediation = await async_client.beta.clusters.remediations.approve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + comment="comment", + ) + assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + + @parametrize + async def test_raw_response_approve(self, async_client: AsyncTogether) -> None: + response = await async_client.beta.clusters.remediations.with_raw_response.approve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = await response.parse() + assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + + @parametrize + async def test_streaming_response_approve(self, async_client: AsyncTogether) -> None: + async with async_client.beta.clusters.remediations.with_streaming_response.approve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = await response.parse() + assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_approve(self, async_client: AsyncTogether) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.approve( + remediation_id="remediation_id", + cluster_id="", + instance_id="instance_id", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.approve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `remediation_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.approve( + remediation_id="", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + @parametrize + async def test_method_cancel(self, async_client: AsyncTogether) -> None: + remediation = await async_client.beta.clusters.remediations.cancel( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + + @parametrize + async def test_raw_response_cancel(self, async_client: AsyncTogether) -> None: + response = await async_client.beta.clusters.remediations.with_raw_response.cancel( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = await response.parse() + assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + + @parametrize + async def test_streaming_response_cancel(self, async_client: AsyncTogether) -> None: + async with async_client.beta.clusters.remediations.with_streaming_response.cancel( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = await response.parse() + assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_cancel(self, async_client: AsyncTogether) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.cancel( + remediation_id="remediation_id", + cluster_id="", + instance_id="instance_id", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.cancel( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `remediation_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.cancel( + remediation_id="", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + @parametrize + async def test_method_reject(self, async_client: AsyncTogether) -> None: + remediation = await async_client.beta.clusters.remediations.reject( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + + @parametrize + async def test_method_reject_with_all_params(self, async_client: AsyncTogether) -> None: + remediation = await async_client.beta.clusters.remediations.reject( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + comment="comment", + ) + assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + + @parametrize + async def test_raw_response_reject(self, async_client: AsyncTogether) -> None: + response = await async_client.beta.clusters.remediations.with_raw_response.reject( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = await response.parse() + assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + + @parametrize + async def test_streaming_response_reject(self, async_client: AsyncTogether) -> None: + async with async_client.beta.clusters.remediations.with_streaming_response.reject( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = await response.parse() + assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_reject(self, async_client: AsyncTogether) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.reject( + remediation_id="remediation_id", + cluster_id="", + instance_id="instance_id", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.reject( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `remediation_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.reject( + remediation_id="", + cluster_id="cluster_id", + instance_id="instance_id", + ) From b5e42a042c367dbe14021be3c6612155ac8f6fac Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 18:09:24 +0000 Subject: [PATCH 08/30] feat(api): manual updates --- .stats.yml | 6 +- api.md | 2 + .../resources/beta/clusters/remediations.py | 115 +++++++++++++++++ src/together/types/beta/clusters/__init__.py | 1 + .../clusters/remediation_retrieve_response.py | 94 ++++++++++++++ .../beta/clusters/test_remediations.py | 121 ++++++++++++++++++ 6 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 src/together/types/beta/clusters/remediation_retrieve_response.py diff --git a/.stats.yml b/.stats.yml index 3b8397a01..fe90c6295 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 80 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-496df09bdf8bfe14dddc5ba6dc70e219336f51295d2f0671285b4056ec756f21.yml +configured_endpoints: 81 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-b17511a0596b10a96c1af8ea8ab68f43b5a1215f9c8a3ba57ee043215a382b14.yml openapi_spec_hash: 50c3ce80ecc7d25283c96e3544567995 -config_hash: 2ab62260e2c5e9527b47bef3f36e545d +config_hash: 6d7d1c4ec367638d41671e1a4f84808c diff --git a/api.md b/api.md index 1493164db..0a1fb2a72 100644 --- a/api.md +++ b/api.md @@ -98,6 +98,7 @@ Types: ```python from together.types.beta.clusters import ( RemediationCreateResponse, + RemediationRetrieveResponse, RemediationListResponse, RemediationApproveResponse, RemediationCancelResponse, @@ -108,6 +109,7 @@ from together.types.beta.clusters import ( Methods: - client.beta.clusters.remediations.create(instance_id, \*, cluster_id, \*\*params) -> RemediationCreateResponse +- client.beta.clusters.remediations.retrieve(remediation_id, \*, cluster_id, instance_id) -> RemediationRetrieveResponse - client.beta.clusters.remediations.list(instance_id, \*, cluster_id, \*\*params) -> RemediationListResponse - client.beta.clusters.remediations.approve(remediation_id, \*, cluster_id, instance_id, \*\*params) -> RemediationApproveResponse - client.beta.clusters.remediations.cancel(remediation_id, \*, cluster_id, instance_id) -> RemediationCancelResponse diff --git a/src/together/resources/beta/clusters/remediations.py b/src/together/resources/beta/clusters/remediations.py index c130fb213..582665994 100644 --- a/src/together/resources/beta/clusters/remediations.py +++ b/src/together/resources/beta/clusters/remediations.py @@ -29,6 +29,7 @@ from ....types.beta.clusters.remediation_create_response import RemediationCreateResponse from ....types.beta.clusters.remediation_reject_response import RemediationRejectResponse from ....types.beta.clusters.remediation_approve_response import RemediationApproveResponse +from ....types.beta.clusters.remediation_retrieve_response import RemediationRetrieveResponse __all__ = ["RemediationsResource", "AsyncRemediationsResource"] @@ -137,6 +138,57 @@ def create( cast_to=RemediationCreateResponse, ) + def retrieve( + self, + remediation_id: str, + *, + cluster_id: str, + instance_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationRetrieveResponse: + """ + Retrieve the status of a specific remdiation on a specific instance in a + specific cluster. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + remediation_id: The remediation ID. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + if not remediation_id: + raise ValueError(f"Expected a non-empty value for `remediation_id` but received {remediation_id!r}") + return self._get( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations/{remediation_id}", + cluster_id=cluster_id, + instance_id=instance_id, + remediation_id=remediation_id, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=RemediationRetrieveResponse, + ) + def list( self, instance_id: str, @@ -503,6 +555,57 @@ async def create( cast_to=RemediationCreateResponse, ) + async def retrieve( + self, + remediation_id: str, + *, + cluster_id: str, + instance_id: str, + # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. + # The extra values given here take precedence over values defined on the client or passed to this method. + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> RemediationRetrieveResponse: + """ + Retrieve the status of a specific remdiation on a specific instance in a + specific cluster. + + Args: + cluster_id: The cluster ID. + + instance_id: The instance ID. + + remediation_id: The remediation ID. + + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ + if not cluster_id: + raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") + if not instance_id: + raise ValueError(f"Expected a non-empty value for `instance_id` but received {instance_id!r}") + if not remediation_id: + raise ValueError(f"Expected a non-empty value for `remediation_id` but received {remediation_id!r}") + return await self._get( + path_template( + "/compute/clusters/{cluster_id}/instances/{instance_id}/remediations/{remediation_id}", + cluster_id=cluster_id, + instance_id=instance_id, + remediation_id=remediation_id, + ), + options=make_request_options( + extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + ), + cast_to=RemediationRetrieveResponse, + ) + async def list( self, instance_id: str, @@ -772,6 +875,9 @@ def __init__(self, remediations: RemediationsResource) -> None: self.create = to_raw_response_wrapper( remediations.create, ) + self.retrieve = to_raw_response_wrapper( + remediations.retrieve, + ) self.list = to_raw_response_wrapper( remediations.list, ) @@ -793,6 +899,9 @@ def __init__(self, remediations: AsyncRemediationsResource) -> None: self.create = async_to_raw_response_wrapper( remediations.create, ) + self.retrieve = async_to_raw_response_wrapper( + remediations.retrieve, + ) self.list = async_to_raw_response_wrapper( remediations.list, ) @@ -814,6 +923,9 @@ def __init__(self, remediations: RemediationsResource) -> None: self.create = to_streamed_response_wrapper( remediations.create, ) + self.retrieve = to_streamed_response_wrapper( + remediations.retrieve, + ) self.list = to_streamed_response_wrapper( remediations.list, ) @@ -835,6 +947,9 @@ def __init__(self, remediations: AsyncRemediationsResource) -> None: self.create = async_to_streamed_response_wrapper( remediations.create, ) + self.retrieve = async_to_streamed_response_wrapper( + remediations.retrieve, + ) self.list = async_to_streamed_response_wrapper( remediations.list, ) diff --git a/src/together/types/beta/clusters/__init__.py b/src/together/types/beta/clusters/__init__.py index 0ac6cbb1b..4bf653714 100644 --- a/src/together/types/beta/clusters/__init__.py +++ b/src/together/types/beta/clusters/__init__.py @@ -17,3 +17,4 @@ from .remediation_create_response import RemediationCreateResponse as RemediationCreateResponse from .remediation_reject_response import RemediationRejectResponse as RemediationRejectResponse from .remediation_approve_response import RemediationApproveResponse as RemediationApproveResponse +from .remediation_retrieve_response import RemediationRetrieveResponse as RemediationRetrieveResponse diff --git a/src/together/types/beta/clusters/remediation_retrieve_response.py b/src/together/types/beta/clusters/remediation_retrieve_response.py new file mode 100644 index 000000000..ffb6bd3b4 --- /dev/null +++ b/src/together/types/beta/clusters/remediation_retrieve_response.py @@ -0,0 +1,94 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Optional +from datetime import datetime +from typing_extensions import Literal + +from ...._models import BaseModel + +__all__ = ["RemediationRetrieveResponse"] + + +class RemediationRetrieveResponse(BaseModel): + """ + Remediation represents a node remediation request for an instance. + An instance can have multiple remediations over time (e.g., failed attempts followed by retries). + """ + + id: str + + cluster_id: str + + instance_id: str + + mode: Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + """Remediation mode specifies how the remediation should be performed. + + - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any + available host. + - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and + provisions a new one on a different host. + """ + + state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] + """RemediationState represents the lifecycle state of a remediation. + + - `PENDING_APPROVAL`: Awaiting approval before processing can begin. + - `PENDING`: Approved and queued for processing. + - `RUNNING`: Actively being processed. + - `SUCCEEDED`: Successfully completed. + - `FAILED`: Failed with an error. + - `CANCELLED`: Cancelled by user or system. + - `AUTO_RESOLVED`: The underlying issue was automatically resolved before + processing. + """ + + trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] + """RemediationTrigger specifies how the remediation was triggered. + + - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI + or API call). + - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires + approval. + """ + + active_health_check_run_id: Optional[str] = None + """Active health check run ID (UUID) that triggered this remediation.""" + + create_time: Optional[datetime] = None + """When the remediation was created.""" + + end_time: Optional[datetime] = None + """When the remediation completed.""" + + error_message: Optional[str] = None + """Error message if the remediation failed.""" + + passive_health_check_event_id: Optional[str] = None + """Passive health check event ID that triggered this remediation.""" + + reason: Optional[str] = None + """User-provided reason for the remediation.""" + + requested_by: Optional[str] = None + """Who requested the remediation.""" + + review_comment: Optional[str] = None + """Review comment.""" + + review_time: Optional[datetime] = None + """When the remediation was reviewed.""" + + reviewed_by: Optional[str] = None + """Who reviewed the remediation.""" + + start_time: Optional[datetime] = None + """When processing started.""" + + update_time: Optional[datetime] = None + """When the remediation was last updated.""" diff --git a/tests/api_resources/beta/clusters/test_remediations.py b/tests/api_resources/beta/clusters/test_remediations.py index 3a359fd9a..277266d4d 100644 --- a/tests/api_resources/beta/clusters/test_remediations.py +++ b/tests/api_resources/beta/clusters/test_remediations.py @@ -15,6 +15,7 @@ RemediationCreateResponse, RemediationRejectResponse, RemediationApproveResponse, + RemediationRetrieveResponse, ) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -87,6 +88,66 @@ def test_path_params_create(self, client: Together) -> None: mode="REMEDIATION_MODE_VM_ONLY", ) + @parametrize + def test_method_retrieve(self, client: Together) -> None: + remediation = client.beta.clusters.remediations.retrieve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + + @parametrize + def test_raw_response_retrieve(self, client: Together) -> None: + response = client.beta.clusters.remediations.with_raw_response.retrieve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = response.parse() + assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + + @parametrize + def test_streaming_response_retrieve(self, client: Together) -> None: + with client.beta.clusters.remediations.with_streaming_response.retrieve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = response.parse() + assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + def test_path_params_retrieve(self, client: Together) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.retrieve( + remediation_id="remediation_id", + cluster_id="", + instance_id="instance_id", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.retrieve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `remediation_id` but received ''"): + client.beta.clusters.remediations.with_raw_response.retrieve( + remediation_id="", + cluster_id="cluster_id", + instance_id="instance_id", + ) + @parametrize def test_method_list(self, client: Together) -> None: remediation = client.beta.clusters.remediations.list( @@ -419,6 +480,66 @@ async def test_path_params_create(self, async_client: AsyncTogether) -> None: mode="REMEDIATION_MODE_VM_ONLY", ) + @parametrize + async def test_method_retrieve(self, async_client: AsyncTogether) -> None: + remediation = await async_client.beta.clusters.remediations.retrieve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + + @parametrize + async def test_raw_response_retrieve(self, async_client: AsyncTogether) -> None: + response = await async_client.beta.clusters.remediations.with_raw_response.retrieve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) + + assert response.is_closed is True + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + remediation = await response.parse() + assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + + @parametrize + async def test_streaming_response_retrieve(self, async_client: AsyncTogether) -> None: + async with async_client.beta.clusters.remediations.with_streaming_response.retrieve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="instance_id", + ) as response: + assert not response.is_closed + assert response.http_request.headers.get("X-Stainless-Lang") == "python" + + remediation = await response.parse() + assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + + assert cast(Any, response.is_closed) is True + + @parametrize + async def test_path_params_retrieve(self, async_client: AsyncTogether) -> None: + with pytest.raises(ValueError, match=r"Expected a non-empty value for `cluster_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.retrieve( + remediation_id="remediation_id", + cluster_id="", + instance_id="instance_id", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `instance_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.retrieve( + remediation_id="remediation_id", + cluster_id="cluster_id", + instance_id="", + ) + + with pytest.raises(ValueError, match=r"Expected a non-empty value for `remediation_id` but received ''"): + await async_client.beta.clusters.remediations.with_raw_response.retrieve( + remediation_id="", + cluster_id="cluster_id", + instance_id="instance_id", + ) + @parametrize async def test_method_list(self, async_client: AsyncTogether) -> None: remediation = await async_client.beta.clusters.remediations.list( From d6310d881cc12bb67132c9446d301d1663fd9f48 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 18:18:45 +0000 Subject: [PATCH 09/30] fix(api): remove trigger parameter from remediations list method --- .stats.yml | 4 +- .../resources/beta/clusters/remediations.py | 144 ++++++------------ src/together/resources/models/models.py | 6 +- .../clusters/remediation_approve_params.py | 2 - .../clusters/remediation_create_params.py | 3 +- .../beta/clusters/remediation_list_params.py | 32 ++-- .../clusters/remediation_reject_params.py | 2 - .../beta/clusters/test_remediations.py | 2 - 8 files changed, 72 insertions(+), 123 deletions(-) diff --git a/.stats.yml b/.stats.yml index fe90c6295..d57a71e2c 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 81 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-b17511a0596b10a96c1af8ea8ab68f43b5a1215f9c8a3ba57ee043215a382b14.yml -openapi_spec_hash: 50c3ce80ecc7d25283c96e3544567995 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-c8200db997f998a51b2b469b69fcc3a24032e50b45aaaf340c2c176473393adc.yml +openapi_spec_hash: 2544f6840cf7ad23eae3fe8bffed4c4b config_hash: 6d7d1c4ec367638d41671e1a4f84808c diff --git a/src/together/resources/beta/clusters/remediations.py b/src/together/resources/beta/clusters/remediations.py index 582665994..0f4a17b59 100644 --- a/src/together/resources/beta/clusters/remediations.py +++ b/src/together/resources/beta/clusters/remediations.py @@ -77,19 +77,14 @@ def create( """ Creates a new remediation for an instance. - If mode is unspecified, it defaults to VM_ONLY. If trigger is unspecified, it - defaults to MANUAL. + Remediations created via the API goes directly to PENDING state. - For MANUAL triggers: The remediation goes directly to PENDING state. - - For AUTOMATED triggers: The remediation is created with PENDING_APPROVAL state. - The caller must then use ApproveRemediation to start the remediation process. + Our system may trigger automated remediations that require approval. These + remediations are created with PENDING_APPROVAL state. The user must call + /approve to start the actual remediation process. These operations can also be + rejected by calling /reject. Args: - cluster_id: The cluster ID. - - instance_id: The instance ID. - mode: Remediation mode specifies how the remediation should be performed. - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any @@ -97,7 +92,7 @@ def create( - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and provisions a new one on a different host. - remediation_id: Optional. Client-specified ID for idempotency. + remediation_id: Client-specified ID for idempotency. reason: User-provided reason for the remediation. @@ -156,12 +151,6 @@ def retrieve( specific cluster. Args: - cluster_id: The cluster ID. - - instance_id: The instance ID. - - remediation_id: The remediation ID. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -208,7 +197,6 @@ def list( Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] ] | Omit = omit, - trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -216,30 +204,33 @@ def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RemediationListResponse: - """Lists remediations for an instance or cluster. - - Use instances/- as wildcard to - list all remediations in a cluster. + """ + Lists remediations for an instance or cluster. Args: - cluster_id: The cluster ID. + instance_id: To list remediations on a specific node, pass the node's instance ID. To list + remediations for all nodes in a cluster, pass `-` as a wildcard for the instance + ID. - instance_id: The instance ID. - - mode: Optional. Filter by remediation mode. Returns only remediations matching the - specified mode. + mode: Filter by remediation mode. Returns only remediations matching the specified + mode. - order_by: Optional. Order by expression. + order_by: Order by expression. - page_size: Optional. Maximum results to return. + page_size: Maximum results to return. - page_token: Optional. Pagination token from previous request. + page_token: Pagination token from previous request. - state: Optional. Filter by state(s). Returns remediations matching any of the specified - states. + state: Filter by state(s). Returns remediations matching any of the specified states. - trigger: Optional. Filter by trigger type. Returns only remediations matching the - specified trigger. + - `PENDING_APPROVAL`: Awaiting approval before processing can begin. + - `PENDING`: Approved and queued for processing. + - `RUNNING`: Actively being processed. + - `SUCCEEDED`: Successfully completed. + - `FAILED`: Failed with an error. + - `CANCELLED`: Cancelled by user or system. + - `AUTO_RESOLVED`: The underlying issue was automatically resolved before + processing. extra_headers: Send extra headers @@ -271,7 +262,6 @@ def list( "page_size": page_size, "page_token": page_token, "state": state, - "trigger": trigger, }, remediation_list_params.RemediationListParams, ), @@ -303,12 +293,6 @@ def approve( remediation after approval. Args: - cluster_id: The cluster ID. - - instance_id: The instance ID. - - remediation_id: The remediation ID. - comment: Comment explaining the action. extra_headers: Send extra headers @@ -414,12 +398,6 @@ def reject( review_comment fields are populated on the remediation after rejection. Args: - cluster_id: The cluster ID. - - instance_id: The instance ID. - - remediation_id: The remediation ID. - comment: Comment explaining the action. extra_headers: Send extra headers @@ -494,19 +472,14 @@ async def create( """ Creates a new remediation for an instance. - If mode is unspecified, it defaults to VM_ONLY. If trigger is unspecified, it - defaults to MANUAL. + Remediations created via the API goes directly to PENDING state. - For MANUAL triggers: The remediation goes directly to PENDING state. - - For AUTOMATED triggers: The remediation is created with PENDING_APPROVAL state. - The caller must then use ApproveRemediation to start the remediation process. + Our system may trigger automated remediations that require approval. These + remediations are created with PENDING_APPROVAL state. The user must call + /approve to start the actual remediation process. These operations can also be + rejected by calling /reject. Args: - cluster_id: The cluster ID. - - instance_id: The instance ID. - mode: Remediation mode specifies how the remediation should be performed. - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any @@ -514,7 +487,7 @@ async def create( - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and provisions a new one on a different host. - remediation_id: Optional. Client-specified ID for idempotency. + remediation_id: Client-specified ID for idempotency. reason: User-provided reason for the remediation. @@ -573,12 +546,6 @@ async def retrieve( specific cluster. Args: - cluster_id: The cluster ID. - - instance_id: The instance ID. - - remediation_id: The remediation ID. - extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -625,7 +592,6 @@ async def list( Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] ] | Omit = omit, - trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -633,30 +599,33 @@ async def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RemediationListResponse: - """Lists remediations for an instance or cluster. - - Use instances/- as wildcard to - list all remediations in a cluster. + """ + Lists remediations for an instance or cluster. Args: - cluster_id: The cluster ID. + instance_id: To list remediations on a specific node, pass the node's instance ID. To list + remediations for all nodes in a cluster, pass `-` as a wildcard for the instance + ID. - instance_id: The instance ID. - - mode: Optional. Filter by remediation mode. Returns only remediations matching the - specified mode. + mode: Filter by remediation mode. Returns only remediations matching the specified + mode. - order_by: Optional. Order by expression. + order_by: Order by expression. - page_size: Optional. Maximum results to return. + page_size: Maximum results to return. - page_token: Optional. Pagination token from previous request. + page_token: Pagination token from previous request. - state: Optional. Filter by state(s). Returns remediations matching any of the specified - states. + state: Filter by state(s). Returns remediations matching any of the specified states. - trigger: Optional. Filter by trigger type. Returns only remediations matching the - specified trigger. + - `PENDING_APPROVAL`: Awaiting approval before processing can begin. + - `PENDING`: Approved and queued for processing. + - `RUNNING`: Actively being processed. + - `SUCCEEDED`: Successfully completed. + - `FAILED`: Failed with an error. + - `CANCELLED`: Cancelled by user or system. + - `AUTO_RESOLVED`: The underlying issue was automatically resolved before + processing. extra_headers: Send extra headers @@ -688,7 +657,6 @@ async def list( "page_size": page_size, "page_token": page_token, "state": state, - "trigger": trigger, }, remediation_list_params.RemediationListParams, ), @@ -720,12 +688,6 @@ async def approve( remediation after approval. Args: - cluster_id: The cluster ID. - - instance_id: The instance ID. - - remediation_id: The remediation ID. - comment: Comment explaining the action. extra_headers: Send extra headers @@ -831,12 +793,6 @@ async def reject( review_comment fields are populated on the remediation after rejection. Args: - cluster_id: The cluster ID. - - instance_id: The instance ID. - - remediation_id: The remediation ID. - comment: Comment explaining the action. extra_headers: Send extra headers diff --git a/src/together/resources/models/models.py b/src/together/resources/models/models.py index b14a0a630..a3486129c 100644 --- a/src/together/resources/models/models.py +++ b/src/together/resources/models/models.py @@ -68,7 +68,8 @@ def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ModelListResponse: """ - Lists all of Together's open-source models + Lists all of Together's open-source models and metadata including pricing, chat + template, and context. Args: dedicated: Filter models to only return dedicated models @@ -195,7 +196,8 @@ async def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> ModelListResponse: """ - Lists all of Together's open-source models + Lists all of Together's open-source models and metadata including pricing, chat + template, and context. Args: dedicated: Filter models to only return dedicated models diff --git a/src/together/types/beta/clusters/remediation_approve_params.py b/src/together/types/beta/clusters/remediation_approve_params.py index cb86457e7..721f96e08 100644 --- a/src/together/types/beta/clusters/remediation_approve_params.py +++ b/src/together/types/beta/clusters/remediation_approve_params.py @@ -9,10 +9,8 @@ class RemediationApproveParams(TypedDict, total=False): cluster_id: Required[str] - """The cluster ID.""" instance_id: Required[str] - """The instance ID.""" comment: str """Comment explaining the action.""" diff --git a/src/together/types/beta/clusters/remediation_create_params.py b/src/together/types/beta/clusters/remediation_create_params.py index 84374cec9..c32ade02f 100644 --- a/src/together/types/beta/clusters/remediation_create_params.py +++ b/src/together/types/beta/clusters/remediation_create_params.py @@ -9,7 +9,6 @@ class RemediationCreateParams(TypedDict, total=False): cluster_id: Required[str] - """The cluster ID.""" mode: Required[ Literal[ @@ -28,7 +27,7 @@ class RemediationCreateParams(TypedDict, total=False): """ remediation_id: str - """Optional. Client-specified ID for idempotency.""" + """Client-specified ID for idempotency.""" reason: str """User-provided reason for the remediation.""" diff --git a/src/together/types/beta/clusters/remediation_list_params.py b/src/together/types/beta/clusters/remediation_list_params.py index 18e6b884e..215800be9 100644 --- a/src/together/types/beta/clusters/remediation_list_params.py +++ b/src/together/types/beta/clusters/remediation_list_params.py @@ -10,7 +10,6 @@ class RemediationListParams(TypedDict, total=False): cluster_id: Required[str] - """The cluster ID.""" mode: Literal[ "REMEDIATION_MODE_VM_ONLY", @@ -18,30 +17,29 @@ class RemediationListParams(TypedDict, total=False): "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", "REMEDIATION_MODE_REBOOT_VM", ] - """Optional. + """Filter by remediation mode. - Filter by remediation mode. Returns only remediations matching the specified - mode. + Returns only remediations matching the specified mode. """ order_by: str - """Optional. Order by expression.""" + """Order by expression.""" page_size: int - """Optional. Maximum results to return.""" + """Maximum results to return.""" page_token: str - """Optional. Pagination token from previous request.""" + """Pagination token from previous request.""" state: List[Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"]] - """Optional. - - Filter by state(s). Returns remediations matching any of the specified states. - """ - - trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] - """Optional. - - Filter by trigger type. Returns only remediations matching the specified - trigger. + """Filter by state(s). Returns remediations matching any of the specified states. + + - `PENDING_APPROVAL`: Awaiting approval before processing can begin. + - `PENDING`: Approved and queued for processing. + - `RUNNING`: Actively being processed. + - `SUCCEEDED`: Successfully completed. + - `FAILED`: Failed with an error. + - `CANCELLED`: Cancelled by user or system. + - `AUTO_RESOLVED`: The underlying issue was automatically resolved before + processing. """ diff --git a/src/together/types/beta/clusters/remediation_reject_params.py b/src/together/types/beta/clusters/remediation_reject_params.py index de91e59f9..cfe532aa3 100644 --- a/src/together/types/beta/clusters/remediation_reject_params.py +++ b/src/together/types/beta/clusters/remediation_reject_params.py @@ -9,10 +9,8 @@ class RemediationRejectParams(TypedDict, total=False): cluster_id: Required[str] - """The cluster ID.""" instance_id: Required[str] - """The instance ID.""" comment: str """Comment explaining the action.""" diff --git a/tests/api_resources/beta/clusters/test_remediations.py b/tests/api_resources/beta/clusters/test_remediations.py index 277266d4d..7fb96a882 100644 --- a/tests/api_resources/beta/clusters/test_remediations.py +++ b/tests/api_resources/beta/clusters/test_remediations.py @@ -166,7 +166,6 @@ def test_method_list_with_all_params(self, client: Together) -> None: page_size=0, page_token="page_token", state=["PENDING_APPROVAL"], - trigger="REMEDIATION_TRIGGER_MANUAL", ) assert_matches_type(RemediationListResponse, remediation, path=["response"]) @@ -558,7 +557,6 @@ async def test_method_list_with_all_params(self, async_client: AsyncTogether) -> page_size=0, page_token="page_token", state=["PENDING_APPROVAL"], - trigger="REMEDIATION_TRIGGER_MANUAL", ) assert_matches_type(RemediationListResponse, remediation, path=["response"]) From f4de41192250b3c609e44da6bee18309db209f35 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 18:27:15 +0000 Subject: [PATCH 10/30] feat(api): manual updates --- .stats.yml | 2 +- api.md | 19 ++-- .../resources/beta/clusters/remediations.py | 46 +++++---- src/together/types/beta/cluster.py | 89 +----------------- src/together/types/beta/clusters/__init__.py | 6 +- ...tion_cancel_response.py => remediation.py} | 4 +- .../clusters/remediation_approve_response.py | 94 ------------------- .../clusters/remediation_create_response.py | 94 ------------------- .../clusters/remediation_list_response.py | 92 +----------------- .../clusters/remediation_reject_response.py | 94 ------------------- .../clusters/remediation_retrieve_response.py | 94 ------------------- .../beta/clusters/test_remediations.py | 78 ++++++++------- 12 files changed, 73 insertions(+), 639 deletions(-) rename src/together/types/beta/clusters/{remediation_cancel_response.py => remediation.py} (97%) delete mode 100644 src/together/types/beta/clusters/remediation_approve_response.py delete mode 100644 src/together/types/beta/clusters/remediation_create_response.py delete mode 100644 src/together/types/beta/clusters/remediation_reject_response.py delete mode 100644 src/together/types/beta/clusters/remediation_retrieve_response.py diff --git a/.stats.yml b/.stats.yml index d57a71e2c..37b2bba11 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 81 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-c8200db997f998a51b2b469b69fcc3a24032e50b45aaaf340c2c176473393adc.yml openapi_spec_hash: 2544f6840cf7ad23eae3fe8bffed4c4b -config_hash: 6d7d1c4ec367638d41671e1a4f84808c +config_hash: b35d5968fb07cce1c1be735f874898b1 diff --git a/api.md b/api.md index 0a1fb2a72..156264d90 100644 --- a/api.md +++ b/api.md @@ -96,24 +96,17 @@ Methods: Types: ```python -from together.types.beta.clusters import ( - RemediationCreateResponse, - RemediationRetrieveResponse, - RemediationListResponse, - RemediationApproveResponse, - RemediationCancelResponse, - RemediationRejectResponse, -) +from together.types.beta.clusters import Remediation, RemediationListResponse ``` Methods: -- client.beta.clusters.remediations.create(instance_id, \*, cluster_id, \*\*params) -> RemediationCreateResponse -- client.beta.clusters.remediations.retrieve(remediation_id, \*, cluster_id, instance_id) -> RemediationRetrieveResponse +- client.beta.clusters.remediations.create(instance_id, \*, cluster_id, \*\*params) -> Remediation +- client.beta.clusters.remediations.retrieve(remediation_id, \*, cluster_id, instance_id) -> Remediation - client.beta.clusters.remediations.list(instance_id, \*, cluster_id, \*\*params) -> RemediationListResponse -- client.beta.clusters.remediations.approve(remediation_id, \*, cluster_id, instance_id, \*\*params) -> RemediationApproveResponse -- client.beta.clusters.remediations.cancel(remediation_id, \*, cluster_id, instance_id) -> RemediationCancelResponse -- client.beta.clusters.remediations.reject(remediation_id, \*, cluster_id, instance_id, \*\*params) -> RemediationRejectResponse +- client.beta.clusters.remediations.approve(remediation_id, \*, cluster_id, instance_id, \*\*params) -> Remediation +- client.beta.clusters.remediations.cancel(remediation_id, \*, cluster_id, instance_id) -> Remediation +- client.beta.clusters.remediations.reject(remediation_id, \*, cluster_id, instance_id, \*\*params) -> Remediation ### Storage diff --git a/src/together/resources/beta/clusters/remediations.py b/src/together/resources/beta/clusters/remediations.py index 0f4a17b59..33dff106e 100644 --- a/src/together/resources/beta/clusters/remediations.py +++ b/src/together/resources/beta/clusters/remediations.py @@ -24,12 +24,8 @@ remediation_reject_params, remediation_approve_params, ) +from ....types.beta.clusters.remediation import Remediation from ....types.beta.clusters.remediation_list_response import RemediationListResponse -from ....types.beta.clusters.remediation_cancel_response import RemediationCancelResponse -from ....types.beta.clusters.remediation_create_response import RemediationCreateResponse -from ....types.beta.clusters.remediation_reject_response import RemediationRejectResponse -from ....types.beta.clusters.remediation_approve_response import RemediationApproveResponse -from ....types.beta.clusters.remediation_retrieve_response import RemediationRetrieveResponse __all__ = ["RemediationsResource", "AsyncRemediationsResource"] @@ -73,7 +69,7 @@ def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> RemediationCreateResponse: + ) -> Remediation: """ Creates a new remediation for an instance. @@ -130,7 +126,7 @@ def create( {"remediation_id": remediation_id}, remediation_create_params.RemediationCreateParams ), ), - cast_to=RemediationCreateResponse, + cast_to=Remediation, ) def retrieve( @@ -145,7 +141,7 @@ def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> RemediationRetrieveResponse: + ) -> Remediation: """ Retrieve the status of a specific remdiation on a specific instance in a specific cluster. @@ -175,7 +171,7 @@ def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=RemediationRetrieveResponse, + cast_to=Remediation, ) def list( @@ -282,7 +278,7 @@ def approve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> RemediationApproveResponse: + ) -> Remediation: """ Approves a pending remediation. @@ -320,7 +316,7 @@ def approve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=RemediationApproveResponse, + cast_to=Remediation, ) def cancel( @@ -335,7 +331,7 @@ def cancel( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> RemediationCancelResponse: + ) -> Remediation: """ Cancels a pending remediation. @@ -372,7 +368,7 @@ def cancel( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=RemediationCancelResponse, + cast_to=Remediation, ) def reject( @@ -388,7 +384,7 @@ def reject( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> RemediationRejectResponse: + ) -> Remediation: """ Rejects a pending remediation. @@ -425,7 +421,7 @@ def reject( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=RemediationRejectResponse, + cast_to=Remediation, ) @@ -468,7 +464,7 @@ async def create( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> RemediationCreateResponse: + ) -> Remediation: """ Creates a new remediation for an instance. @@ -525,7 +521,7 @@ async def create( {"remediation_id": remediation_id}, remediation_create_params.RemediationCreateParams ), ), - cast_to=RemediationCreateResponse, + cast_to=Remediation, ) async def retrieve( @@ -540,7 +536,7 @@ async def retrieve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> RemediationRetrieveResponse: + ) -> Remediation: """ Retrieve the status of a specific remdiation on a specific instance in a specific cluster. @@ -570,7 +566,7 @@ async def retrieve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=RemediationRetrieveResponse, + cast_to=Remediation, ) async def list( @@ -677,7 +673,7 @@ async def approve( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> RemediationApproveResponse: + ) -> Remediation: """ Approves a pending remediation. @@ -715,7 +711,7 @@ async def approve( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=RemediationApproveResponse, + cast_to=Remediation, ) async def cancel( @@ -730,7 +726,7 @@ async def cancel( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> RemediationCancelResponse: + ) -> Remediation: """ Cancels a pending remediation. @@ -767,7 +763,7 @@ async def cancel( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=RemediationCancelResponse, + cast_to=Remediation, ) async def reject( @@ -783,7 +779,7 @@ async def reject( extra_query: Query | None = None, extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> RemediationRejectResponse: + ) -> Remediation: """ Rejects a pending remediation. @@ -820,7 +816,7 @@ async def reject( options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), - cast_to=RemediationRejectResponse, + cast_to=Remediation, ) diff --git a/src/together/types/beta/cluster.py b/src/together/types/beta/cluster.py index e78c9a07a..2f46d55a0 100644 --- a/src/together/types/beta/cluster.py +++ b/src/together/types/beta/cluster.py @@ -5,6 +5,7 @@ from typing_extensions import Literal from ..._models import BaseModel +from .clusters.remediation import Remediation __all__ = [ "Cluster", @@ -19,7 +20,6 @@ "ControlPlaneNodePhaseTransition", "GPUWorkerNode", "GPUWorkerNodePhaseTransition", - "GPUWorkerNodeLatestRemediation", "PhaseTransition", "Volume", "ClusterConfig", @@ -123,91 +123,6 @@ class GPUWorkerNodePhaseTransition(BaseModel): """Timestamp when the phase transition occurred.""" -class GPUWorkerNodeLatestRemediation(BaseModel): - """ - Remediation represents a node remediation request for an instance. - An instance can have multiple remediations over time (e.g., failed attempts followed by retries). - """ - - id: str - - cluster_id: str - - instance_id: str - - mode: Literal[ - "REMEDIATION_MODE_VM_ONLY", - "REMEDIATION_MODE_HOST_AWARE", - "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", - "REMEDIATION_MODE_REBOOT_VM", - ] - """Remediation mode specifies how the remediation should be performed. - - - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any - available host. - - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and - provisions a new one on a different host. - """ - - state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] - """RemediationState represents the lifecycle state of a remediation. - - - `PENDING_APPROVAL`: Awaiting approval before processing can begin. - - `PENDING`: Approved and queued for processing. - - `RUNNING`: Actively being processed. - - `SUCCEEDED`: Successfully completed. - - `FAILED`: Failed with an error. - - `CANCELLED`: Cancelled by user or system. - - `AUTO_RESOLVED`: The underlying issue was automatically resolved before - processing. - """ - - trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] - """RemediationTrigger specifies how the remediation was triggered. - - - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI - or API call). - - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires - approval. - """ - - active_health_check_run_id: Optional[str] = None - """Active health check run ID (UUID) that triggered this remediation.""" - - create_time: Optional[datetime] = None - """When the remediation was created.""" - - end_time: Optional[datetime] = None - """When the remediation completed.""" - - error_message: Optional[str] = None - """Error message if the remediation failed.""" - - passive_health_check_event_id: Optional[str] = None - """Passive health check event ID that triggered this remediation.""" - - reason: Optional[str] = None - """User-provided reason for the remediation.""" - - requested_by: Optional[str] = None - """Who requested the remediation.""" - - review_comment: Optional[str] = None - """Review comment.""" - - review_time: Optional[datetime] = None - """When the remediation was reviewed.""" - - reviewed_by: Optional[str] = None - """Who reviewed the remediation.""" - - start_time: Optional[datetime] = None - """When processing started.""" - - update_time: Optional[datetime] = None - """When the remediation was last updated.""" - - class GPUWorkerNode(BaseModel): host_name: str @@ -230,7 +145,7 @@ class GPUWorkerNode(BaseModel): instance_id: Optional[str] = None - latest_remediation: Optional[GPUWorkerNodeLatestRemediation] = None + latest_remediation: Optional[Remediation] = None """ Remediation represents a node remediation request for an instance. An instance can have multiple remediations over time (e.g., failed attempts followed by diff --git a/src/together/types/beta/clusters/__init__.py b/src/together/types/beta/clusters/__init__.py index 4bf653714..89ca43ab3 100644 --- a/src/together/types/beta/clusters/__init__.py +++ b/src/together/types/beta/clusters/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from .remediation import Remediation as Remediation from .cluster_storage import ClusterStorage as ClusterStorage from .storage_list_params import StorageListParams as StorageListParams from .storage_create_params import StorageCreateParams as StorageCreateParams @@ -13,8 +14,3 @@ from .remediation_list_response import RemediationListResponse as RemediationListResponse from .remediation_reject_params import RemediationRejectParams as RemediationRejectParams from .remediation_approve_params import RemediationApproveParams as RemediationApproveParams -from .remediation_cancel_response import RemediationCancelResponse as RemediationCancelResponse -from .remediation_create_response import RemediationCreateResponse as RemediationCreateResponse -from .remediation_reject_response import RemediationRejectResponse as RemediationRejectResponse -from .remediation_approve_response import RemediationApproveResponse as RemediationApproveResponse -from .remediation_retrieve_response import RemediationRetrieveResponse as RemediationRetrieveResponse diff --git a/src/together/types/beta/clusters/remediation_cancel_response.py b/src/together/types/beta/clusters/remediation.py similarity index 97% rename from src/together/types/beta/clusters/remediation_cancel_response.py rename to src/together/types/beta/clusters/remediation.py index ec8975c9b..fc606a0f7 100644 --- a/src/together/types/beta/clusters/remediation_cancel_response.py +++ b/src/together/types/beta/clusters/remediation.py @@ -6,10 +6,10 @@ from ...._models import BaseModel -__all__ = ["RemediationCancelResponse"] +__all__ = ["Remediation"] -class RemediationCancelResponse(BaseModel): +class Remediation(BaseModel): """ Remediation represents a node remediation request for an instance. An instance can have multiple remediations over time (e.g., failed attempts followed by retries). diff --git a/src/together/types/beta/clusters/remediation_approve_response.py b/src/together/types/beta/clusters/remediation_approve_response.py deleted file mode 100644 index 040e6dab2..000000000 --- a/src/together/types/beta/clusters/remediation_approve_response.py +++ /dev/null @@ -1,94 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional -from datetime import datetime -from typing_extensions import Literal - -from ...._models import BaseModel - -__all__ = ["RemediationApproveResponse"] - - -class RemediationApproveResponse(BaseModel): - """ - Remediation represents a node remediation request for an instance. - An instance can have multiple remediations over time (e.g., failed attempts followed by retries). - """ - - id: str - - cluster_id: str - - instance_id: str - - mode: Literal[ - "REMEDIATION_MODE_VM_ONLY", - "REMEDIATION_MODE_HOST_AWARE", - "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", - "REMEDIATION_MODE_REBOOT_VM", - ] - """Remediation mode specifies how the remediation should be performed. - - - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any - available host. - - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and - provisions a new one on a different host. - """ - - state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] - """RemediationState represents the lifecycle state of a remediation. - - - `PENDING_APPROVAL`: Awaiting approval before processing can begin. - - `PENDING`: Approved and queued for processing. - - `RUNNING`: Actively being processed. - - `SUCCEEDED`: Successfully completed. - - `FAILED`: Failed with an error. - - `CANCELLED`: Cancelled by user or system. - - `AUTO_RESOLVED`: The underlying issue was automatically resolved before - processing. - """ - - trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] - """RemediationTrigger specifies how the remediation was triggered. - - - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI - or API call). - - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires - approval. - """ - - active_health_check_run_id: Optional[str] = None - """Active health check run ID (UUID) that triggered this remediation.""" - - create_time: Optional[datetime] = None - """When the remediation was created.""" - - end_time: Optional[datetime] = None - """When the remediation completed.""" - - error_message: Optional[str] = None - """Error message if the remediation failed.""" - - passive_health_check_event_id: Optional[str] = None - """Passive health check event ID that triggered this remediation.""" - - reason: Optional[str] = None - """User-provided reason for the remediation.""" - - requested_by: Optional[str] = None - """Who requested the remediation.""" - - review_comment: Optional[str] = None - """Review comment.""" - - review_time: Optional[datetime] = None - """When the remediation was reviewed.""" - - reviewed_by: Optional[str] = None - """Who reviewed the remediation.""" - - start_time: Optional[datetime] = None - """When processing started.""" - - update_time: Optional[datetime] = None - """When the remediation was last updated.""" diff --git a/src/together/types/beta/clusters/remediation_create_response.py b/src/together/types/beta/clusters/remediation_create_response.py deleted file mode 100644 index 2b148f932..000000000 --- a/src/together/types/beta/clusters/remediation_create_response.py +++ /dev/null @@ -1,94 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional -from datetime import datetime -from typing_extensions import Literal - -from ...._models import BaseModel - -__all__ = ["RemediationCreateResponse"] - - -class RemediationCreateResponse(BaseModel): - """ - Remediation represents a node remediation request for an instance. - An instance can have multiple remediations over time (e.g., failed attempts followed by retries). - """ - - id: str - - cluster_id: str - - instance_id: str - - mode: Literal[ - "REMEDIATION_MODE_VM_ONLY", - "REMEDIATION_MODE_HOST_AWARE", - "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", - "REMEDIATION_MODE_REBOOT_VM", - ] - """Remediation mode specifies how the remediation should be performed. - - - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any - available host. - - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and - provisions a new one on a different host. - """ - - state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] - """RemediationState represents the lifecycle state of a remediation. - - - `PENDING_APPROVAL`: Awaiting approval before processing can begin. - - `PENDING`: Approved and queued for processing. - - `RUNNING`: Actively being processed. - - `SUCCEEDED`: Successfully completed. - - `FAILED`: Failed with an error. - - `CANCELLED`: Cancelled by user or system. - - `AUTO_RESOLVED`: The underlying issue was automatically resolved before - processing. - """ - - trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] - """RemediationTrigger specifies how the remediation was triggered. - - - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI - or API call). - - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires - approval. - """ - - active_health_check_run_id: Optional[str] = None - """Active health check run ID (UUID) that triggered this remediation.""" - - create_time: Optional[datetime] = None - """When the remediation was created.""" - - end_time: Optional[datetime] = None - """When the remediation completed.""" - - error_message: Optional[str] = None - """Error message if the remediation failed.""" - - passive_health_check_event_id: Optional[str] = None - """Passive health check event ID that triggered this remediation.""" - - reason: Optional[str] = None - """User-provided reason for the remediation.""" - - requested_by: Optional[str] = None - """Who requested the remediation.""" - - review_comment: Optional[str] = None - """Review comment.""" - - review_time: Optional[datetime] = None - """When the remediation was reviewed.""" - - reviewed_by: Optional[str] = None - """Who reviewed the remediation.""" - - start_time: Optional[datetime] = None - """When processing started.""" - - update_time: Optional[datetime] = None - """When the remediation was last updated.""" diff --git a/src/together/types/beta/clusters/remediation_list_response.py b/src/together/types/beta/clusters/remediation_list_response.py index aaf15a9fb..174674960 100644 --- a/src/together/types/beta/clusters/remediation_list_response.py +++ b/src/together/types/beta/clusters/remediation_list_response.py @@ -1,97 +1,11 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Optional -from datetime import datetime -from typing_extensions import Literal +from typing import List from ...._models import BaseModel +from .remediation import Remediation -__all__ = ["RemediationListResponse", "Remediation"] - - -class Remediation(BaseModel): - """ - Remediation represents a node remediation request for an instance. - An instance can have multiple remediations over time (e.g., failed attempts followed by retries). - """ - - id: str - - cluster_id: str - - instance_id: str - - mode: Literal[ - "REMEDIATION_MODE_VM_ONLY", - "REMEDIATION_MODE_HOST_AWARE", - "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", - "REMEDIATION_MODE_REBOOT_VM", - ] - """Remediation mode specifies how the remediation should be performed. - - - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any - available host. - - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and - provisions a new one on a different host. - """ - - state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] - """RemediationState represents the lifecycle state of a remediation. - - - `PENDING_APPROVAL`: Awaiting approval before processing can begin. - - `PENDING`: Approved and queued for processing. - - `RUNNING`: Actively being processed. - - `SUCCEEDED`: Successfully completed. - - `FAILED`: Failed with an error. - - `CANCELLED`: Cancelled by user or system. - - `AUTO_RESOLVED`: The underlying issue was automatically resolved before - processing. - """ - - trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] - """RemediationTrigger specifies how the remediation was triggered. - - - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI - or API call). - - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires - approval. - """ - - active_health_check_run_id: Optional[str] = None - """Active health check run ID (UUID) that triggered this remediation.""" - - create_time: Optional[datetime] = None - """When the remediation was created.""" - - end_time: Optional[datetime] = None - """When the remediation completed.""" - - error_message: Optional[str] = None - """Error message if the remediation failed.""" - - passive_health_check_event_id: Optional[str] = None - """Passive health check event ID that triggered this remediation.""" - - reason: Optional[str] = None - """User-provided reason for the remediation.""" - - requested_by: Optional[str] = None - """Who requested the remediation.""" - - review_comment: Optional[str] = None - """Review comment.""" - - review_time: Optional[datetime] = None - """When the remediation was reviewed.""" - - reviewed_by: Optional[str] = None - """Who reviewed the remediation.""" - - start_time: Optional[datetime] = None - """When processing started.""" - - update_time: Optional[datetime] = None - """When the remediation was last updated.""" +__all__ = ["RemediationListResponse"] class RemediationListResponse(BaseModel): diff --git a/src/together/types/beta/clusters/remediation_reject_response.py b/src/together/types/beta/clusters/remediation_reject_response.py deleted file mode 100644 index e90306bb6..000000000 --- a/src/together/types/beta/clusters/remediation_reject_response.py +++ /dev/null @@ -1,94 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional -from datetime import datetime -from typing_extensions import Literal - -from ...._models import BaseModel - -__all__ = ["RemediationRejectResponse"] - - -class RemediationRejectResponse(BaseModel): - """ - Remediation represents a node remediation request for an instance. - An instance can have multiple remediations over time (e.g., failed attempts followed by retries). - """ - - id: str - - cluster_id: str - - instance_id: str - - mode: Literal[ - "REMEDIATION_MODE_VM_ONLY", - "REMEDIATION_MODE_HOST_AWARE", - "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", - "REMEDIATION_MODE_REBOOT_VM", - ] - """Remediation mode specifies how the remediation should be performed. - - - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any - available host. - - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and - provisions a new one on a different host. - """ - - state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] - """RemediationState represents the lifecycle state of a remediation. - - - `PENDING_APPROVAL`: Awaiting approval before processing can begin. - - `PENDING`: Approved and queued for processing. - - `RUNNING`: Actively being processed. - - `SUCCEEDED`: Successfully completed. - - `FAILED`: Failed with an error. - - `CANCELLED`: Cancelled by user or system. - - `AUTO_RESOLVED`: The underlying issue was automatically resolved before - processing. - """ - - trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] - """RemediationTrigger specifies how the remediation was triggered. - - - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI - or API call). - - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires - approval. - """ - - active_health_check_run_id: Optional[str] = None - """Active health check run ID (UUID) that triggered this remediation.""" - - create_time: Optional[datetime] = None - """When the remediation was created.""" - - end_time: Optional[datetime] = None - """When the remediation completed.""" - - error_message: Optional[str] = None - """Error message if the remediation failed.""" - - passive_health_check_event_id: Optional[str] = None - """Passive health check event ID that triggered this remediation.""" - - reason: Optional[str] = None - """User-provided reason for the remediation.""" - - requested_by: Optional[str] = None - """Who requested the remediation.""" - - review_comment: Optional[str] = None - """Review comment.""" - - review_time: Optional[datetime] = None - """When the remediation was reviewed.""" - - reviewed_by: Optional[str] = None - """Who reviewed the remediation.""" - - start_time: Optional[datetime] = None - """When processing started.""" - - update_time: Optional[datetime] = None - """When the remediation was last updated.""" diff --git a/src/together/types/beta/clusters/remediation_retrieve_response.py b/src/together/types/beta/clusters/remediation_retrieve_response.py deleted file mode 100644 index ffb6bd3b4..000000000 --- a/src/together/types/beta/clusters/remediation_retrieve_response.py +++ /dev/null @@ -1,94 +0,0 @@ -# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -from typing import Optional -from datetime import datetime -from typing_extensions import Literal - -from ...._models import BaseModel - -__all__ = ["RemediationRetrieveResponse"] - - -class RemediationRetrieveResponse(BaseModel): - """ - Remediation represents a node remediation request for an instance. - An instance can have multiple remediations over time (e.g., failed attempts followed by retries). - """ - - id: str - - cluster_id: str - - instance_id: str - - mode: Literal[ - "REMEDIATION_MODE_VM_ONLY", - "REMEDIATION_MODE_HOST_AWARE", - "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", - "REMEDIATION_MODE_REBOOT_VM", - ] - """Remediation mode specifies how the remediation should be performed. - - - `REMEDIATION_MODE_VM_ONLY`: Deletes the VM and provisions a new one on any - available host. - - `REMEDIATION_MODE_HOST_AWARE`: Cordons the host, deletes the VM, and - provisions a new one on a different host. - """ - - state: Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] - """RemediationState represents the lifecycle state of a remediation. - - - `PENDING_APPROVAL`: Awaiting approval before processing can begin. - - `PENDING`: Approved and queued for processing. - - `RUNNING`: Actively being processed. - - `SUCCEEDED`: Successfully completed. - - `FAILED`: Failed with an error. - - `CANCELLED`: Cancelled by user or system. - - `AUTO_RESOLVED`: The underlying issue was automatically resolved before - processing. - """ - - trigger: Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"] - """RemediationTrigger specifies how the remediation was triggered. - - - `REMEDIATION_TRIGGER_MANUAL`: A user-initiated remediation (either via web UI - or API call). - - `REMEDIATION_TRIGGER_AUTOMATED`: A system-initiated remediation that requires - approval. - """ - - active_health_check_run_id: Optional[str] = None - """Active health check run ID (UUID) that triggered this remediation.""" - - create_time: Optional[datetime] = None - """When the remediation was created.""" - - end_time: Optional[datetime] = None - """When the remediation completed.""" - - error_message: Optional[str] = None - """Error message if the remediation failed.""" - - passive_health_check_event_id: Optional[str] = None - """Passive health check event ID that triggered this remediation.""" - - reason: Optional[str] = None - """User-provided reason for the remediation.""" - - requested_by: Optional[str] = None - """Who requested the remediation.""" - - review_comment: Optional[str] = None - """Review comment.""" - - review_time: Optional[datetime] = None - """When the remediation was reviewed.""" - - reviewed_by: Optional[str] = None - """Who reviewed the remediation.""" - - start_time: Optional[datetime] = None - """When processing started.""" - - update_time: Optional[datetime] = None - """When the remediation was last updated.""" diff --git a/tests/api_resources/beta/clusters/test_remediations.py b/tests/api_resources/beta/clusters/test_remediations.py index 7fb96a882..7c50553d5 100644 --- a/tests/api_resources/beta/clusters/test_remediations.py +++ b/tests/api_resources/beta/clusters/test_remediations.py @@ -10,12 +10,8 @@ from together import Together, AsyncTogether from tests.utils import assert_matches_type from together.types.beta.clusters import ( + Remediation, RemediationListResponse, - RemediationCancelResponse, - RemediationCreateResponse, - RemediationRejectResponse, - RemediationApproveResponse, - RemediationRetrieveResponse, ) base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -31,7 +27,7 @@ def test_method_create(self, client: Together) -> None: cluster_id="cluster_id", mode="REMEDIATION_MODE_VM_ONLY", ) - assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_method_create_with_all_params(self, client: Together) -> None: @@ -42,7 +38,7 @@ def test_method_create_with_all_params(self, client: Together) -> None: remediation_id="remediation_id", reason="reason", ) - assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_raw_response_create(self, client: Together) -> None: @@ -55,7 +51,7 @@ def test_raw_response_create(self, client: Together) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = response.parse() - assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_streaming_response_create(self, client: Together) -> None: @@ -68,7 +64,7 @@ def test_streaming_response_create(self, client: Together) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = response.parse() - assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) assert cast(Any, response.is_closed) is True @@ -95,7 +91,7 @@ def test_method_retrieve(self, client: Together) -> None: cluster_id="cluster_id", instance_id="instance_id", ) - assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_raw_response_retrieve(self, client: Together) -> None: @@ -108,7 +104,7 @@ def test_raw_response_retrieve(self, client: Together) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = response.parse() - assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_streaming_response_retrieve(self, client: Together) -> None: @@ -121,7 +117,7 @@ def test_streaming_response_retrieve(self, client: Together) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = response.parse() - assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) assert cast(Any, response.is_closed) is True @@ -216,7 +212,7 @@ def test_method_approve(self, client: Together) -> None: cluster_id="cluster_id", instance_id="instance_id", ) - assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_method_approve_with_all_params(self, client: Together) -> None: @@ -226,7 +222,7 @@ def test_method_approve_with_all_params(self, client: Together) -> None: instance_id="instance_id", comment="comment", ) - assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_raw_response_approve(self, client: Together) -> None: @@ -239,7 +235,7 @@ def test_raw_response_approve(self, client: Together) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = response.parse() - assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_streaming_response_approve(self, client: Together) -> None: @@ -252,7 +248,7 @@ def test_streaming_response_approve(self, client: Together) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = response.parse() - assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) assert cast(Any, response.is_closed) is True @@ -286,7 +282,7 @@ def test_method_cancel(self, client: Together) -> None: cluster_id="cluster_id", instance_id="instance_id", ) - assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_raw_response_cancel(self, client: Together) -> None: @@ -299,7 +295,7 @@ def test_raw_response_cancel(self, client: Together) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = response.parse() - assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_streaming_response_cancel(self, client: Together) -> None: @@ -312,7 +308,7 @@ def test_streaming_response_cancel(self, client: Together) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = response.parse() - assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) assert cast(Any, response.is_closed) is True @@ -346,7 +342,7 @@ def test_method_reject(self, client: Together) -> None: cluster_id="cluster_id", instance_id="instance_id", ) - assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_method_reject_with_all_params(self, client: Together) -> None: @@ -356,7 +352,7 @@ def test_method_reject_with_all_params(self, client: Together) -> None: instance_id="instance_id", comment="comment", ) - assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_raw_response_reject(self, client: Together) -> None: @@ -369,7 +365,7 @@ def test_raw_response_reject(self, client: Together) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = response.parse() - assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize def test_streaming_response_reject(self, client: Together) -> None: @@ -382,7 +378,7 @@ def test_streaming_response_reject(self, client: Together) -> None: assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = response.parse() - assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) assert cast(Any, response.is_closed) is True @@ -422,7 +418,7 @@ async def test_method_create(self, async_client: AsyncTogether) -> None: cluster_id="cluster_id", mode="REMEDIATION_MODE_VM_ONLY", ) - assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_method_create_with_all_params(self, async_client: AsyncTogether) -> None: @@ -433,7 +429,7 @@ async def test_method_create_with_all_params(self, async_client: AsyncTogether) remediation_id="remediation_id", reason="reason", ) - assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_raw_response_create(self, async_client: AsyncTogether) -> None: @@ -446,7 +442,7 @@ async def test_raw_response_create(self, async_client: AsyncTogether) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = await response.parse() - assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_streaming_response_create(self, async_client: AsyncTogether) -> None: @@ -459,7 +455,7 @@ async def test_streaming_response_create(self, async_client: AsyncTogether) -> N assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = await response.parse() - assert_matches_type(RemediationCreateResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) assert cast(Any, response.is_closed) is True @@ -486,7 +482,7 @@ async def test_method_retrieve(self, async_client: AsyncTogether) -> None: cluster_id="cluster_id", instance_id="instance_id", ) - assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_raw_response_retrieve(self, async_client: AsyncTogether) -> None: @@ -499,7 +495,7 @@ async def test_raw_response_retrieve(self, async_client: AsyncTogether) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = await response.parse() - assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_streaming_response_retrieve(self, async_client: AsyncTogether) -> None: @@ -512,7 +508,7 @@ async def test_streaming_response_retrieve(self, async_client: AsyncTogether) -> assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = await response.parse() - assert_matches_type(RemediationRetrieveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) assert cast(Any, response.is_closed) is True @@ -607,7 +603,7 @@ async def test_method_approve(self, async_client: AsyncTogether) -> None: cluster_id="cluster_id", instance_id="instance_id", ) - assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_method_approve_with_all_params(self, async_client: AsyncTogether) -> None: @@ -617,7 +613,7 @@ async def test_method_approve_with_all_params(self, async_client: AsyncTogether) instance_id="instance_id", comment="comment", ) - assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_raw_response_approve(self, async_client: AsyncTogether) -> None: @@ -630,7 +626,7 @@ async def test_raw_response_approve(self, async_client: AsyncTogether) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = await response.parse() - assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_streaming_response_approve(self, async_client: AsyncTogether) -> None: @@ -643,7 +639,7 @@ async def test_streaming_response_approve(self, async_client: AsyncTogether) -> assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = await response.parse() - assert_matches_type(RemediationApproveResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) assert cast(Any, response.is_closed) is True @@ -677,7 +673,7 @@ async def test_method_cancel(self, async_client: AsyncTogether) -> None: cluster_id="cluster_id", instance_id="instance_id", ) - assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_raw_response_cancel(self, async_client: AsyncTogether) -> None: @@ -690,7 +686,7 @@ async def test_raw_response_cancel(self, async_client: AsyncTogether) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = await response.parse() - assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_streaming_response_cancel(self, async_client: AsyncTogether) -> None: @@ -703,7 +699,7 @@ async def test_streaming_response_cancel(self, async_client: AsyncTogether) -> N assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = await response.parse() - assert_matches_type(RemediationCancelResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) assert cast(Any, response.is_closed) is True @@ -737,7 +733,7 @@ async def test_method_reject(self, async_client: AsyncTogether) -> None: cluster_id="cluster_id", instance_id="instance_id", ) - assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_method_reject_with_all_params(self, async_client: AsyncTogether) -> None: @@ -747,7 +743,7 @@ async def test_method_reject_with_all_params(self, async_client: AsyncTogether) instance_id="instance_id", comment="comment", ) - assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_raw_response_reject(self, async_client: AsyncTogether) -> None: @@ -760,7 +756,7 @@ async def test_raw_response_reject(self, async_client: AsyncTogether) -> None: assert response.is_closed is True assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = await response.parse() - assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) @parametrize async def test_streaming_response_reject(self, async_client: AsyncTogether) -> None: @@ -773,7 +769,7 @@ async def test_streaming_response_reject(self, async_client: AsyncTogether) -> N assert response.http_request.headers.get("X-Stainless-Lang") == "python" remediation = await response.parse() - assert_matches_type(RemediationRejectResponse, remediation, path=["response"]) + assert_matches_type(Remediation, remediation, path=["response"]) assert cast(Any, response.is_closed) is True From 877c44f672b5f158c454c1711bce5fd4775598b3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 18:29:34 +0000 Subject: [PATCH 11/30] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 37b2bba11..cfa550f54 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 81 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-c8200db997f998a51b2b469b69fcc3a24032e50b45aaaf340c2c176473393adc.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-3ae47a93fc03893f34b2e442ab8194c1f4224d9d7fe2d02b6342dbe03862f1e7.yml openapi_spec_hash: 2544f6840cf7ad23eae3fe8bffed4c4b config_hash: b35d5968fb07cce1c1be735f874898b1 From 5ea2c1f97463e872a5a9335ccde187878b1ca62a Mon Sep 17 00:00:00 2001 From: Dulaj Disanayaka <8828757+dulaj-me@users.noreply.github.com> Date: Mon, 18 May 2026 12:36:43 +0200 Subject: [PATCH 12/30] ENG-84365 tool jig deploy image should block jig build (#330) * fix: jig build should error when deploy.image is set * chore: fix error msg --- src/together/lib/cli/api/beta/jig/jig.py | 5 +++++ tests/cli/test_beta_jig.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/together/lib/cli/api/beta/jig/jig.py b/src/together/lib/cli/api/beta/jig/jig.py index 08bfc40b7..d4dffabcd 100644 --- a/src/together/lib/cli/api/beta/jig/jig.py +++ b/src/together/lib/cli/api/beta/jig/jig.py @@ -615,6 +615,11 @@ def delete_secret(self, name: str) -> None: # == Build / Push / Deploy / Track == def build(self, tag: str = "latest", warmup: bool = False, docker_args: str | None = None) -> None: + if self.config.deploy.image: + raise JigError( + f"Invalid command: deploy.image is set to '{self.config.deploy.image}'. " + "Use 'jig deploy' to deploy the configured image, or remove deploy.image to build from source." + ) image = self.image(tag) if not _dockerfile(self.config): diff --git a/tests/cli/test_beta_jig.py b/tests/cli/test_beta_jig.py index 446de3d1b..5baddad56 100644 --- a/tests/cli/test_beta_jig.py +++ b/tests/cli/test_beta_jig.py @@ -219,6 +219,28 @@ def test_unset_missing_secret_message(self, tmp_path: Path, cli_runner: CliRunne assert result.exit_code == 0 +class TestBetaJigBuild: + def test_build_blocked_when_deploy_image_set(self, tmp_path: Path) -> None: + with patch.object(_jig_mod.Config, "__post_init__", _noop_config_post_init): + cfg = _jig_mod.Config( + model_name=_DEPLOY_NAME, + image=_jig_mod.ImageConfig(), + deploy=_jig_mod.DeployConfig(image="ghcr.io/org/prebuilt:latest"), + _path=tmp_path / "pyproject.toml", + _unique_name_hint="h", + ) + + def _find(*_args: Any): + return cfg + + with patch.object(_jig_mod.Config, "find", classmethod(_find)): + runner = CliRunner(env=_ENV) + with _chdir(tmp_path): + result = runner.invoke(main, ["beta", "jig", "build"]) + assert result.exit_code == 1 + assert "deploy.image is set" in result.output + + class TestBetaJigVolumes: @pytest.mark.respx(base_url=base_url) def test_delete(self, respx_mock: MockRouter, tmp_path: Path, cli_runner: CliRunner) -> None: From 85cf77b6dc8df8a4f5859deb543123195c484b5b Mon Sep 17 00:00:00 2001 From: Dulaj Disanayaka <8828757+dulaj-me@users.noreply.github.com> Date: Mon, 18 May 2026 12:37:21 +0200 Subject: [PATCH 13/30] fix(jig): honor uv default groups in autogenerated dockerfile (#301) * fix(jig): honor uv default groups in autogenerated dockerfile * fix(jig): use uv sync to install req --- src/together/lib/cli/api/beta/jig/jig.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/together/lib/cli/api/beta/jig/jig.py b/src/together/lib/cli/api/beta/jig/jig.py index d4dffabcd..928b22999 100644 --- a/src/together/lib/cli/api/beta/jig/jig.py +++ b/src/together/lib/cli/api/beta/jig/jig.py @@ -364,8 +364,9 @@ def _generate_dockerfile(config: JigConfig) -> str: pip = "" if Path("pyproject.toml").exists(): pip = """COPY pyproject.toml . +ENV UV_PROJECT_ENVIRONMENT=/usr/local RUN --mount=type=cache,target=/root/.cache/uv \\ - uv pip install --system --compile-bytecode . && \\ + uv sync --inexact --no-dev --no-install-project --compile-bytecode && \\ (python -c "import sprocket" 2>/dev/null || (echo "sprocket not found in pyproject.toml, installing from pypi.together.ai..." && uv pip install --system --extra-index-url https://pypi.together.ai/ sprocket)) """ From 5ae0fbca3e592dafedc4638642582585f29098df Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 21:10:42 +0000 Subject: [PATCH 14/30] fix(api): remove error field, make request_id required in jig queue submit response --- .stats.yml | 4 ++-- src/together/resources/beta/jig/queue.py | 8 +++---- .../types/beta/jig/queue_cancel_response.py | 2 ++ .../types/beta/jig/queue_metrics_response.py | 2 ++ .../types/beta/jig/queue_retrieve_response.py | 2 ++ .../types/beta/jig/queue_submit_params.py | 5 +++-- .../types/beta/jig/queue_submit_response.py | 22 +++---------------- tests/api_resources/beta/jig/test_queue.py | 16 +++++++------- 8 files changed, 26 insertions(+), 35 deletions(-) diff --git a/.stats.yml b/.stats.yml index cfa550f54..e4dfd7906 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 81 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-3ae47a93fc03893f34b2e442ab8194c1f4224d9d7fe2d02b6342dbe03862f1e7.yml -openapi_spec_hash: 2544f6840cf7ad23eae3fe8bffed4c4b +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-ecf580edec990fe441cc295edaa94c3ee9f29229cf86770ae3a98cd2c62c1c91.yml +openapi_spec_hash: 28960794b18db3cde13c972a7240eef2 config_hash: b35d5968fb07cce1c1be735f874898b1 diff --git a/src/together/resources/beta/jig/queue.py b/src/together/resources/beta/jig/queue.py index a48837dbf..3c5eaaf10 100644 --- a/src/together/resources/beta/jig/queue.py +++ b/src/together/resources/beta/jig/queue.py @@ -204,8 +204,8 @@ def submit( payload: Freeform model input. Passed unchanged to the model. Contents are model-specific. - info: Arbitrary JSON metadata stored with the job and returned in status responses. - The model and system may add or update keys during processing. + info: Arbitrary JSON metadata stored with the job. Returned in status responses, where + the model and system may have added or modified keys (e.g. progress). priority: Job priority. Higher values are processed first (strict priority ordering). Jobs with equal priority are processed in submission order (FIFO). @@ -414,8 +414,8 @@ async def submit( payload: Freeform model input. Passed unchanged to the model. Contents are model-specific. - info: Arbitrary JSON metadata stored with the job and returned in status responses. - The model and system may add or update keys during processing. + info: Arbitrary JSON metadata stored with the job. Returned in status responses, where + the model and system may have added or modified keys (e.g. progress). priority: Job priority. Higher values are processed first (strict priority ordering). Jobs with equal priority are processed in submission order (FIFO). diff --git a/src/together/types/beta/jig/queue_cancel_response.py b/src/together/types/beta/jig/queue_cancel_response.py index eeb6e820d..13a0fec93 100644 --- a/src/together/types/beta/jig/queue_cancel_response.py +++ b/src/together/types/beta/jig/queue_cancel_response.py @@ -8,6 +8,8 @@ class QueueCancelResponse(BaseModel): + """Status returned after a cancel attempt.""" + status: Literal["canceled", "running", "done", "failed"] """Job status after the cancel attempt. diff --git a/src/together/types/beta/jig/queue_metrics_response.py b/src/together/types/beta/jig/queue_metrics_response.py index 3387b0553..0b0c803e5 100644 --- a/src/together/types/beta/jig/queue_metrics_response.py +++ b/src/together/types/beta/jig/queue_metrics_response.py @@ -6,6 +6,8 @@ class QueueMetricsResponse(BaseModel): + """Queue job counts for a model.""" + messages_running: int """Number of jobs currently being processed""" diff --git a/src/together/types/beta/jig/queue_retrieve_response.py b/src/together/types/beta/jig/queue_retrieve_response.py index d93654a12..df9a1f2e3 100644 --- a/src/together/types/beta/jig/queue_retrieve_response.py +++ b/src/together/types/beta/jig/queue_retrieve_response.py @@ -10,6 +10,8 @@ class QueueRetrieveResponse(BaseModel): + """Current status and metadata for a queued job.""" + model: str """Model identifier the job was submitted to""" diff --git a/src/together/types/beta/jig/queue_submit_params.py b/src/together/types/beta/jig/queue_submit_params.py index 3c80dbd8c..113a16945 100644 --- a/src/together/types/beta/jig/queue_submit_params.py +++ b/src/together/types/beta/jig/queue_submit_params.py @@ -19,9 +19,10 @@ class QueueSubmitParams(TypedDict, total=False): """ info: Dict[str, object] - """Arbitrary JSON metadata stored with the job and returned in status responses. + """Arbitrary JSON metadata stored with the job. - The model and system may add or update keys during processing. + Returned in status responses, where the model and system may have added or + modified keys (e.g. progress). """ priority: int diff --git a/src/together/types/beta/jig/queue_submit_response.py b/src/together/types/beta/jig/queue_submit_response.py index a6bb15890..67a94db67 100644 --- a/src/together/types/beta/jig/queue_submit_response.py +++ b/src/together/types/beta/jig/queue_submit_response.py @@ -1,30 +1,14 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import Optional - from pydantic import Field as FieldInfo from ...._models import BaseModel -__all__ = ["QueueSubmitResponse", "Error"] - - -class Error(BaseModel): - code: Optional[str] = None - """Machine-readable error code""" - - message: Optional[str] = None - """Human-readable error message""" - - param: Optional[str] = None - """The parameter that caused the error, if applicable""" - - type: Optional[str] = None - """Error category (e.g. "invalid_request_error", "not_found_error")""" +__all__ = ["QueueSubmitResponse"] class QueueSubmitResponse(BaseModel): - error: Optional[Error] = None + """Response returned after queueing a job.""" - request_id: Optional[str] = FieldInfo(alias="requestId", default=None) + request_id: str = FieldInfo(alias="requestId") """Unique identifier for the submitted job. Use this to poll status or cancel.""" diff --git a/tests/api_resources/beta/jig/test_queue.py b/tests/api_resources/beta/jig/test_queue.py index 3058edbc8..e3d779f96 100644 --- a/tests/api_resources/beta/jig/test_queue.py +++ b/tests/api_resources/beta/jig/test_queue.py @@ -124,7 +124,7 @@ def test_streaming_response_metrics(self, client: Together) -> None: @parametrize def test_method_submit(self, client: Together) -> None: queue = client.beta.jig.queue.submit( - model="model", + model="my-queue-model", payload={"foo": "bar"}, ) assert_matches_type(QueueSubmitResponse, queue, path=["response"]) @@ -132,7 +132,7 @@ def test_method_submit(self, client: Together) -> None: @parametrize def test_method_submit_with_all_params(self, client: Together) -> None: queue = client.beta.jig.queue.submit( - model="model", + model="my-queue-model", payload={"foo": "bar"}, info={"foo": "bar"}, priority=0, @@ -142,7 +142,7 @@ def test_method_submit_with_all_params(self, client: Together) -> None: @parametrize def test_raw_response_submit(self, client: Together) -> None: response = client.beta.jig.queue.with_raw_response.submit( - model="model", + model="my-queue-model", payload={"foo": "bar"}, ) @@ -154,7 +154,7 @@ def test_raw_response_submit(self, client: Together) -> None: @parametrize def test_streaming_response_submit(self, client: Together) -> None: with client.beta.jig.queue.with_streaming_response.submit( - model="model", + model="my-queue-model", payload={"foo": "bar"}, ) as response: assert not response.is_closed @@ -273,7 +273,7 @@ async def test_streaming_response_metrics(self, async_client: AsyncTogether) -> @parametrize async def test_method_submit(self, async_client: AsyncTogether) -> None: queue = await async_client.beta.jig.queue.submit( - model="model", + model="my-queue-model", payload={"foo": "bar"}, ) assert_matches_type(QueueSubmitResponse, queue, path=["response"]) @@ -281,7 +281,7 @@ async def test_method_submit(self, async_client: AsyncTogether) -> None: @parametrize async def test_method_submit_with_all_params(self, async_client: AsyncTogether) -> None: queue = await async_client.beta.jig.queue.submit( - model="model", + model="my-queue-model", payload={"foo": "bar"}, info={"foo": "bar"}, priority=0, @@ -291,7 +291,7 @@ async def test_method_submit_with_all_params(self, async_client: AsyncTogether) @parametrize async def test_raw_response_submit(self, async_client: AsyncTogether) -> None: response = await async_client.beta.jig.queue.with_raw_response.submit( - model="model", + model="my-queue-model", payload={"foo": "bar"}, ) @@ -303,7 +303,7 @@ async def test_raw_response_submit(self, async_client: AsyncTogether) -> None: @parametrize async def test_streaming_response_submit(self, async_client: AsyncTogether) -> None: async with async_client.beta.jig.queue.with_streaming_response.submit( - model="model", + model="my-queue-model", payload={"foo": "bar"}, ) as response: assert not response.is_closed From 4e3a3cfd730c15eada08ca5bb90c1818a858710f Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 19:05:23 +0000 Subject: [PATCH 15/30] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index e4dfd7906..1dcbbbb44 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 81 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-ecf580edec990fe441cc295edaa94c3ee9f29229cf86770ae3a98cd2c62c1c91.yml -openapi_spec_hash: 28960794b18db3cde13c972a7240eef2 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-4ac2f3d9b5d799b305a422c2914a36228953a7975c5279e5e3591ae953edb5b1.yml +openapi_spec_hash: 23512750c02dc91c9598c94a5e1e2062 config_hash: b35d5968fb07cce1c1be735f874898b1 From 59ba233892e6aa12332519b648e6c83ef3bf3a40 Mon Sep 17 00:00:00 2001 From: Blaine Kasten Date: Tue, 19 May 2026 14:50:22 -0500 Subject: [PATCH 16/30] Add beta cluster remediation CLI commands (#369) * feat(api): add cluster config/OIDC/add-ons params, project filtering, update storage types * fix(types): correct status field to enum in cluster_storage model * feat(api): add h200-140gb gpu_type to jig deploy/update methods * fix(api): make duration_days optional in clusters create, size_tib optional in storage update * feat: Sync deployments OpenAPI spec * docs(api): add parameter descriptions to storage methods and types * feat(api): Add node remediation APIs to clusters sdks * feat(api): manual updates * fix(api): remove trigger parameter from remediations list method * feat(api): manual updates * codegen metadata * Add beta cluster remediation CLI commands Co-authored-by: Blaine Kasten * add remediation retrieve command and some fixes * address feedback --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent --- src/together/lib/cli/__init__.py | 40 +++++ .../remediations/_resolve_remediation.py | 33 ++++ .../api/beta/clusters/remediations/approve.py | 37 ++++ .../api/beta/clusters/remediations/cancel.py | 30 ++++ .../api/beta/clusters/remediations/create.py | 62 +++++++ .../api/beta/clusters/remediations/list.py | 73 ++++++++ .../api/beta/clusters/remediations/reject.py | 37 ++++ .../beta/clusters/remediations/retrieve.py | 31 ++++ src/together/lib/cli/components/model_dump.py | 39 +++- src/together/lib/cli/utils/_help_examples.py | 37 ++++ .../lib/cli/utils/_preparse_tokens.py | 1 + tests/cli/test_beta_clusters.py | 167 ++++++++++++++++++ uv.lock | 2 +- 13 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 src/together/lib/cli/api/beta/clusters/remediations/_resolve_remediation.py create mode 100644 src/together/lib/cli/api/beta/clusters/remediations/approve.py create mode 100644 src/together/lib/cli/api/beta/clusters/remediations/cancel.py create mode 100644 src/together/lib/cli/api/beta/clusters/remediations/create.py create mode 100644 src/together/lib/cli/api/beta/clusters/remediations/list.py create mode 100644 src/together/lib/cli/api/beta/clusters/remediations/reject.py create mode 100644 src/together/lib/cli/api/beta/clusters/remediations/retrieve.py diff --git a/src/together/lib/cli/__init__.py b/src/together/lib/cli/__init__.py index 445871f45..ffac00a2b 100644 --- a/src/together/lib/cli/__init__.py +++ b/src/together/lib/cli/__init__.py @@ -61,9 +61,11 @@ FINE_TUNING_DOWNLOAD_HELP_EXAMPLES, BETA_CLUSTERS_STORAGE_HELP_EXAMPLES, FILES_RETRIEVE_CONTENT_HELP_EXAMPLES, + BETA_CLUSTERS_REMEDIATIONS_HELP_EXAMPLES, BETA_CLUSTERS_STORAGE_CREATE_HELP_EXAMPLES, BETA_CLUSTERS_STORAGE_UPDATE_HELP_EXAMPLES, BETA_CLUSTERS_GET_CREDENTIALS_HELP_EXAMPLES, + BETA_CLUSTERS_REMEDIATIONS_CREATE_HELP_EXAMPLES, ) from together.lib.cli.utils._help_formatter import help_formatter from together.lib.cli.utils._preparse_tokens import preparse_tokens @@ -486,6 +488,44 @@ async def run_command() -> None: ) storage_app.command((f"{_CLI}.beta.clusters.storage.delete:delete"), help="Delete a storage volume", alias="-d") +### Clusters > Remediations API commands +remediations_app = clusters_app.command( + App( + name="remediations", + help="Manage node remediations", + group="Subcommands", + help_epilogue=BETA_CLUSTERS_REMEDIATIONS_HELP_EXAMPLES, + ) +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.create:create"), + alias="-c", + help="Create a node remediation", + help_epilogue=BETA_CLUSTERS_REMEDIATIONS_CREATE_HELP_EXAMPLES, +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.list:list"), + alias="ls", + help="List node remediations", +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.retrieve:retrieve"), + alias="get", + help="Get remediation details", +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.approve:approve"), + help="Approve a pending remediation", +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.cancel:cancel"), + help="Cancel a pending remediation", +) +remediations_app.command( + (f"{_CLI}.beta.clusters.remediations.reject:reject"), + help="Reject a pending remediation", +) + ### Jig commands jig_app = beta_app.command( App(name="jig", help="Build, deploy, and manage custom containers", help_epilogue=JIG_HELP_EXAMPLES) diff --git a/src/together/lib/cli/api/beta/clusters/remediations/_resolve_remediation.py b/src/together/lib/cli/api/beta/clusters/remediations/_resolve_remediation.py new file mode 100644 index 000000000..55a49e7ba --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/_resolve_remediation.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import sys + +from together import omit +from together._types import Omit +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.types.beta.clusters.remediation import Remediation + + +async def resolve_remediation(config: CLIConfigParameter, remediation_id: str) -> Remediation: + clusters = await config.client.beta.clusters.list() + + for cluster in clusters.clusters: + page_token: str | Omit = omit + while True: + response = await config.client.beta.clusters.remediations.list( + "-", + cluster_id=cluster.cluster_id, + page_size=100, + page_token=page_token, + ) + for remediation in response.remediations: + if remediation.id == remediation_id: + return remediation + + if not response.has_next or not response.next_page_token: + break + page_token = response.next_page_token + + console.print(f"[red]Error:[/red] Remediation not found: {remediation_id}") + sys.exit(1) diff --git a/src/together/lib/cli/api/beta/clusters/remediations/approve.py b/src/together/lib/cli/api/beta/clusters/remediations/approve.py new file mode 100644 index 000000000..eb81a5346 --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/approve.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Optional, Annotated + +from cyclopts import Parameter + +from together import omit +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status +from together.lib.cli.api.beta.clusters.remediations._resolve_remediation import resolve_remediation + + +async def approve( + remediation_id: str, + comment: Annotated[Optional[str], Parameter(help="Comment explaining the approval")] = None, + *, + config: CLIConfigParameter, +) -> None: + """Approve a pending remediation.""" + remediation = await show_loading_status("Finding remediation...", resolve_remediation(config, remediation_id)) + response = await show_loading_status( + "Approving remediation...", + config.client.beta.clusters.remediations.approve( + remediation_id, + cluster_id=remediation.cluster_id, + instance_id=remediation.instance_id, + comment=comment or omit, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + console.print(f"[blue]Remediation approved.[/blue] ({response.id})") diff --git a/src/together/lib/cli/api/beta/clusters/remediations/cancel.py b/src/together/lib/cli/api/beta/clusters/remediations/cancel.py new file mode 100644 index 000000000..99adc6bff --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/cancel.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status +from together.lib.cli.api.beta.clusters.remediations._resolve_remediation import resolve_remediation + + +async def cancel( + remediation_id: str, + *, + config: CLIConfigParameter, +) -> None: + """Cancel a pending remediation.""" + remediation = await show_loading_status("Finding remediation...", resolve_remediation(config, remediation_id)) + response = await show_loading_status( + "Cancelling remediation...", + config.client.beta.clusters.remediations.cancel( + remediation_id, + cluster_id=remediation.cluster_id, + instance_id=remediation.instance_id, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + console.print(f"[blue]Remediation cancelled.[/blue] ({response.id})") diff --git a/src/together/lib/cli/api/beta/clusters/remediations/create.py b/src/together/lib/cli/api/beta/clusters/remediations/create.py new file mode 100644 index 000000000..8a6f2db49 --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/create.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Literal, Optional, Annotated, cast + +from cyclopts import Parameter + +from together import omit +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status + +RemediationModeParameter = Annotated[ + Literal[ + "VM_ONLY", + "HOST_AWARE", + "EVICT_WITHOUT_REPLACEMENT", + "REBOOT_VM", + ], + Parameter(help="The type of remediation to perform"), +] + + +async def create( + cluster_id: Annotated[str, Parameter(help="The ID of the cluster")], + instance_id: Annotated[str, Parameter(help="The ID of the node within the cluster to remediate")], + *, + mode: RemediationModeParameter, + remediation_id: Annotated[Optional[str], Parameter(help="Client-specified ID for idempotency")] = None, + reason: Annotated[Optional[str], Parameter(help="Reason for the remediation")] = None, + config: CLIConfigParameter, +) -> None: + """Create a node remediation for an instance.""" + safe_mode = cast( + Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ], + f"REMEDIATION_MODE_{mode}", + ) + + response = await show_loading_status( + "Creating remediation...", + config.client.beta.clusters.remediations.create( + instance_id, + cluster_id=cluster_id, + mode=safe_mode, + remediation_id=remediation_id or omit, + reason=reason or omit, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + console.print(f"[green]√ Remediation created[/green] [dim]({response.id})[/dim]") + console.print(f" Remediations may take some time to complete.\n") + console.print(f" To retrieve the status:") + console.print(f" [dim]-[/dim] [primary]tg beta clusters remediations {response.id}[/primary]") diff --git a/src/together/lib/cli/api/beta/clusters/remediations/list.py b/src/together/lib/cli/api/beta/clusters/remediations/list.py new file mode 100644 index 000000000..22c70231d --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/list.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Optional, Annotated + +from cyclopts import Parameter + +from together import omit +from together._utils._json import openapi_dumps +from together.lib.utils.tools import format_datetime +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.list import ListTable +from together.lib.cli.components.loader import show_loading_status + + +async def list( + cluster_id: str, + instance_id: Annotated[Optional[str], Parameter(help="Instance ID to list remediations for")] = None, + after: Annotated[Optional[str], Parameter(help="Pagination token from a previous request")] = None, + *, + config: CLIConfigParameter, +) -> None: + """List node remediations for a cluster or instance.""" + response = await show_loading_status( + "Loading remediations...", + config.client.beta.clusters.remediations.list( + instance_id or "-", + cluster_id=cluster_id, + page_token=after or omit, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + table = ListTable(title="Cluster Remediations", empty_message="No remediations found for this cluster.") + table.add_column("Created") + table.add_primary_column("Instance", ratio=3) + table.add_column("Mode") + table.add_column("State") + table.add_column("Remediation ID", ratio=3) + + for remediation in response.remediations: + table.add_row( + format_datetime(remediation.create_time) if remediation.create_time else "-", + remediation.instance_id, + remediation.mode.replace("REMEDIATION_MODE_", ""), + _colorize(remediation.state), + remediation.id, + ) + + console.print(table) + if response.has_next and response.next_page_token: + command = f"tg beta clusters remediations ls {cluster_id}" + if instance_id: + command += f" {instance_id}" + console.print("\n[blue dim]To display the next page, run:[/blue dim]") + console.print(f" [dim]-[/dim] [white]{command} --after {response.next_page_token}[/white]") + + +def _colorize(state: str) -> str: + state_colors = { + "PENDING_APPROVAL": "yellow", + "PENDING": "yellow", + "RUNNING": "yellow", + "SUCCEEDED": "green", + "FAILED": "red", + "CANCELLED": "dim", + "AUTO_RESOLVED": "green", + } + color = state_colors[state] if state in state_colors else "white" + return f"[{color}]{state}[/{color}]" diff --git a/src/together/lib/cli/api/beta/clusters/remediations/reject.py b/src/together/lib/cli/api/beta/clusters/remediations/reject.py new file mode 100644 index 000000000..6fab890c4 --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/reject.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Optional, Annotated + +from cyclopts import Parameter + +from together import omit +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status +from together.lib.cli.api.beta.clusters.remediations._resolve_remediation import resolve_remediation + + +async def reject( + remediation_id: str, + comment: Annotated[Optional[str], Parameter(help="Comment explaining the rejection")] = None, + *, + config: CLIConfigParameter, +) -> None: + """Reject a pending remediation.""" + remediation = await show_loading_status("Finding remediation...", resolve_remediation(config, remediation_id)) + response = await show_loading_status( + "Rejecting remediation...", + config.client.beta.clusters.remediations.reject( + remediation_id, + cluster_id=remediation.cluster_id, + instance_id=remediation.instance_id, + comment=comment or omit, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + console.print(f"[blue]Remediation rejected.[/blue] ({response.id})") diff --git a/src/together/lib/cli/api/beta/clusters/remediations/retrieve.py b/src/together/lib/cli/api/beta/clusters/remediations/retrieve.py new file mode 100644 index 000000000..8143dc450 --- /dev/null +++ b/src/together/lib/cli/api/beta/clusters/remediations/retrieve.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status +from together.lib.cli.components.model_dump import print_model_dump +from together.lib.cli.api.beta.clusters.remediations._resolve_remediation import resolve_remediation + + +async def retrieve( + remediation_id: str, + *, + config: CLIConfigParameter, +) -> None: + """Retrieve remediation details.""" + remediation = await show_loading_status("Finding remediation...", resolve_remediation(config, remediation_id)) + response = await show_loading_status( + "Retrieving remediation...", + config.client.beta.clusters.remediations.retrieve( + remediation_id, + cluster_id=remediation.cluster_id, + instance_id=remediation.instance_id, + ), + ) + + if config.json: + console.print_json(openapi_dumps(response).decode("utf-8")) + return + + print_model_dump(response, show_nulls=False, only_set_fields=True) diff --git a/src/together/lib/cli/components/model_dump.py b/src/together/lib/cli/components/model_dump.py index fe2e2d196..4bef66807 100644 --- a/src/together/lib/cli/components/model_dump.py +++ b/src/together/lib/cli/components/model_dump.py @@ -12,9 +12,25 @@ def print_model_dump( - model: BaseModel, show_nulls: bool = True, expand: bool = True, padding: PaddingDimensions = (0, 1, 0, 0) + model: BaseModel, + show_nulls: bool = True, + expand: bool = True, + padding: PaddingDimensions = (0, 1, 0, 0), + *, + only_set_fields: bool = False, ) -> None: - """Print an entire model with __decent__ formatting.""" + """Print an entire model with __decent__ formatting. + + Args: + model: The response model to render. + show_nulls: When True, include fields whose value is None or empty, displayed as + "n/a". When False, omit those fields entirely. + expand: Passed to the Rich table; when True, the table stretches to the terminal width. + padding: Rich table cell padding as (top, right, bottom, left). + only_set_fields: When True, only include fields present in the API response + (model.model_fields_set). Use this to avoid showing optional fields that were never + sent and still carry a default value. When False, all model fields are shown. + """ def _pretty_print_results( results: Any, show_nulls: bool = True, expand: bool = False, padding: PaddingDimensions = (0, 1, 0, 0) @@ -37,6 +53,8 @@ def _pretty_print_results( table.add_row("-", _pretty_print_results(item)) elif isinstance(results, BaseModel): table.add_row("", _pretty_print_results(results.model_dump(), show_nulls=show_nulls)) + elif isinstance(results, datetime): + table.add_row("", _colorize_value(format_datetime(results))) else: table.add_row("", _colorize_value(results)) return table @@ -70,18 +88,25 @@ def _dump_sorted_model(model: BaseModel) -> dict[str, Any]: - Lists last """ + model_dump = model.model_dump() + + if only_set_fields: + model_dump = {k: v for k, v in model_dump.items() if k in model.model_fields_set} + def _sort_items(key: str, value: Any) -> int: - # Returns a sort key: 0 for ID fields, 1 for primitives, 2 for dicts/objects, 3 for lists + # Returns a sort key: 0 for ID fields, 1 for dates, 2 for primitives, 3 for dicts/objects, 4 for lists if key.endswith("_id"): return 0 + elif isinstance(value, datetime): + return 1 elif isinstance(value, dict) or isinstance(value, BaseModel): - return 2 - elif isinstance(value, list): return 3 + elif isinstance(value, list): + return 4 else: - return 1 + return 2 - return dict(sorted(model.model_dump().items(), key=lambda kv: _sort_items(kv[0], kv[1]))) + return dict(sorted(model_dump.items(), key=lambda kv: _sort_items(kv[0], kv[1]))) console.print( _pretty_print_results(_dump_sorted_model(model), show_nulls=show_nulls, expand=expand, padding=padding) diff --git a/src/together/lib/cli/utils/_help_examples.py b/src/together/lib/cli/utils/_help_examples.py index 224eee8a4..75ff7cb2c 100644 --- a/src/together/lib/cli/utils/_help_examples.py +++ b/src/together/lib/cli/utils/_help_examples.py @@ -251,6 +251,10 @@ [dim]-[/dim] Update or delete a cluster: [primary]tg beta clusters update --num-gpus 16 --cluster-type KUBERNETES[/primary] [primary]tg beta clusters delete [/primary] + +[dim]-[/dim] Manage node remediations: + [primary]tg beta clusters remediations ls [/primary] + [primary]tg beta clusters remediations create --mode VM_ONLY[/primary] """ BETA_CLUSTERS_CREATE_HELP_EXAMPLES = """[dim]Examples:[/dim] @@ -329,6 +333,39 @@ [primary]tg beta clusters storage update --size-tib 4[/primary] """ +BETA_CLUSTERS_REMEDIATIONS_HELP_EXAMPLES = """[dim]Examples:[/dim] +[dim]-[/dim] List all remediations for a cluster: + [primary]tg beta clusters remediations ls [/primary] + +[dim]-[/dim] List remediations for one instance: + [primary]tg beta clusters remediations ls [/primary] + +[dim]-[/dim] Create a remediation: + [primary]tg beta clusters remediations create --mode VM_ONLY --reason "node unhealthy"[/primary] + +[dim]-[/dim] Get remediation details: + [primary]tg beta clusters remediations [/primary] + +[dim]-[/dim] Review automated remediations: + [primary]tg beta clusters remediations approve [/primary] + [primary]tg beta clusters remediations reject --comment "already handled"[/primary] + [primary]tg beta clusters remediations cancel [/primary] +""" + +BETA_CLUSTERS_REMEDIATIONS_CREATE_HELP_EXAMPLES = """[dim]Examples:[/dim] +[dim]-[/dim] Create a VM-only remediation: + [primary]tg beta clusters remediations create --mode VM_ONLY[/primary] + +[dim]-[/dim] Create a host-aware remediation: + [primary]tg beta clusters remediations create --mode HOST_AWARE[/primary] + +[dim]-[/dim] Create a eviction-without-replacement remediation: + [primary]tg beta clusters remediations create --mode EVICT_WITHOUT_REPLACEMENT[/primary] + +[dim]-[/dim] Create a reboot-vm remediation: + [primary]tg beta clusters remediations create --mode REBOOT_VM[/primary] +""" + ## Beta > Jig commands JIG_HELP_EXAMPLES = """[dim]Examples:[/dim] diff --git a/src/together/lib/cli/utils/_preparse_tokens.py b/src/together/lib/cli/utils/_preparse_tokens.py index cfc7ba9a7..2b302303a 100644 --- a/src/together/lib/cli/utils/_preparse_tokens.py +++ b/src/together/lib/cli/utils/_preparse_tokens.py @@ -15,6 +15,7 @@ "endpoints": re.compile(r"^endpoint-"), "beta clusters": _UUID_RE, "beta clusters storage": _UUID_RE, + "beta clusters remediations": _UUID_RE, "beta jig volumes": _UUID_RE, } diff --git a/tests/cli/test_beta_clusters.py b/tests/cli/test_beta_clusters.py index 5f2bf86ac..1e204c49f 100644 --- a/tests/cli/test_beta_clusters.py +++ b/tests/cli/test_beta_clusters.py @@ -53,6 +53,28 @@ def _cluster_body(cluster_id: str = "cluster-1", name: str = "my-cluster", **ove } +def _remediation_body(remediation_id: str = "rem-1", **overrides: Any) -> dict[str, Any]: + body: dict[str, Any] = { + "id": remediation_id, + "cluster_id": "c1", + "instance_id": "i1", + "mode": "REMEDIATION_MODE_VM_ONLY", + "state": "PENDING_APPROVAL", + "trigger": "REMEDIATION_TRIGGER_AUTOMATED", + "reason": "health check failed", + } + body.update(overrides) + return body + + +def _remediation_list_body(*remediations: dict[str, Any]) -> dict[str, Any]: + return { + "has_next": False, + "next_page_token": "", + "remediations": list(remediations), + } + + class TestBetaClustersList: @pytest.mark.respx(base_url=base_url) def test_list_table(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: @@ -238,3 +260,148 @@ def test_storage_delete_json(self, respx_mock: MockRouter, cli_runner: CliRunner result = cli_runner.invoke(["beta", "clusters", "storage", "delete", "vol-1", "--json"]) assert json.loads(result.output) == {"success": True} assert result.exit_code == 0 + + +class TestBetaClustersRemediations: + @pytest.mark.respx(base_url=base_url) + def test_remediations_create_json(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: + route = respx_mock.post("/compute/clusters/c1/instances/i1/remediations").mock( + return_value=httpx.Response(200, json=_remediation_body("rem-created", state="PENDING")) + ) + result = cli_runner.invoke( + [ + "beta", + "clusters", + "remediations", + "create", + "c1", + "i1", + "--mode", + "VM_ONLY", + "--reason", + "node unhealthy", + "--remediation-id", + "rem-created", + "--json", + ], + ) + + assert json.loads(result.output)["id"] == "rem-created" + request = cast(Call, route.calls[0]).request + assert request.url.params["remediation_id"] == "rem-created" + assert json.loads(request.content.decode()) == { + "mode": "REMEDIATION_MODE_VM_ONLY", + "reason": "node unhealthy", + } + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_list_uses_wildcard_when_instance_id_omitted( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + payload = _remediation_list_body(_remediation_body()) + route = respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=payload) + ) + result = cli_runner.invoke(["beta", "clusters", "remediations", "list", "c1", "--json"]) + + assert json.loads(result.output) == payload + assert cast(Call, route.calls[0]).request.url.path == "/compute/clusters/c1/instances/-/remediations" + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_list_accepts_instance_id(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: + payload = _remediation_list_body(_remediation_body()) + route = respx_mock.get("/compute/clusters/c1/instances/i1/remediations").mock( + return_value=httpx.Response(200, json=payload) + ) + result = cli_runner.invoke(["beta", "clusters", "remediations", "list", "c1", "i1", "--json"]) + + assert json.loads(result.output) == payload + assert cast(Call, route.calls[0]).request.url.path == "/compute/clusters/c1/instances/i1/remediations" + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_retrieve_resolves_cluster_and_instance( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + body = _remediation_body("rem-get", state="RUNNING") + respx_mock.get("/compute/clusters").mock( + return_value=httpx.Response(200, json={"clusters": [_cluster_body("c1")]}) + ) + respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=_remediation_list_body(_remediation_body("rem-get"))) + ) + route = respx_mock.get("/compute/clusters/c1/instances/i1/remediations/rem-get").mock( + return_value=httpx.Response(200, json=body) + ) + + result = cli_runner.invoke(["beta", "clusters", "remediations", "get", "rem-get", "--json"]) + + assert json.loads(result.output) == body + assert cast(Call, route.calls[0]).request.url.path == "/compute/clusters/c1/instances/i1/remediations/rem-get" + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_approve_resolves_cluster_and_instance( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + respx_mock.get("/compute/clusters").mock( + return_value=httpx.Response(200, json={"clusters": [_cluster_body("c1")]}) + ) + respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=_remediation_list_body(_remediation_body("rem-approve"))) + ) + route = respx_mock.post("/compute/clusters/c1/instances/i1/remediations/rem-approve/approve").mock( + return_value=httpx.Response(200, json=_remediation_body("rem-approve", state="PENDING")) + ) + + result = cli_runner.invoke( + ["beta", "clusters", "remediations", "approve", "rem-approve", "--comment", "go", "--json"] + ) + + assert json.loads(result.output)["state"] == "PENDING" + assert json.loads(cast(Call, route.calls[0]).request.content.decode()) == {"comment": "go"} + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_cancel_resolves_cluster_and_instance( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + respx_mock.get("/compute/clusters").mock( + return_value=httpx.Response(200, json={"clusters": [_cluster_body("c1")]}) + ) + respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=_remediation_list_body(_remediation_body("rem-cancel"))) + ) + route = respx_mock.post("/compute/clusters/c1/instances/i1/remediations/rem-cancel/cancel").mock( + return_value=httpx.Response(200, json=_remediation_body("rem-cancel", state="CANCELLED")) + ) + + result = cli_runner.invoke(["beta", "clusters", "remediations", "cancel", "rem-cancel", "--json"]) + + assert json.loads(result.output)["state"] == "CANCELLED" + assert route.calls + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_reject_resolves_cluster_and_instance( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + respx_mock.get("/compute/clusters").mock( + return_value=httpx.Response(200, json={"clusters": [_cluster_body("c1")]}) + ) + respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=_remediation_list_body(_remediation_body("rem-reject"))) + ) + route = respx_mock.post("/compute/clusters/c1/instances/i1/remediations/rem-reject/reject").mock( + return_value=httpx.Response(200, json=_remediation_body("rem-reject", state="CANCELLED")) + ) + + result = cli_runner.invoke( + ["beta", "clusters", "remediations", "reject", "rem-reject", "--comment", "skip", "--json"] + ) + + assert json.loads(result.output)["state"] == "CANCELLED" + assert json.loads(cast(Call, route.calls[0]).request.content.decode()) == {"comment": "skip"} + assert result.exit_code == 0 diff --git a/uv.lock b/uv.lock index a10f2d912..abe93dbc1 100644 --- a/uv.lock +++ b/uv.lock @@ -1559,7 +1559,7 @@ wheels = [ [[package]] name = "together" -version = "2.12.0" +version = "2.14.0" source = { editable = "." } dependencies = [ { name = "anyio" }, From 47e5c891ac2272b0d20c1c266d0e3a9527448019 Mon Sep 17 00:00:00 2001 From: Dulaj Disanayaka <8828757+dulaj-me@users.noreply.github.com> Date: Wed, 20 May 2026 15:42:39 +0200 Subject: [PATCH 17/30] feat(jig): copy and use uv.lock if exists on autogenerated dockerfile (#370) --- src/together/lib/cli/api/beta/jig/jig.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/together/lib/cli/api/beta/jig/jig.py b/src/together/lib/cli/api/beta/jig/jig.py index 928b22999..9deabc0c6 100644 --- a/src/together/lib/cli/api/beta/jig/jig.py +++ b/src/together/lib/cli/api/beta/jig/jig.py @@ -363,10 +363,14 @@ def _generate_dockerfile(config: JigConfig) -> str: pip = "" if Path("pyproject.toml").exists(): - pip = """COPY pyproject.toml . -ENV UV_PROJECT_ENVIRONMENT=/usr/local + pip = "COPY pyproject.toml .\n" + sync_flags = "--inexact --no-dev --no-install-project --compile-bytecode" + if Path("uv.lock").exists(): + pip += "COPY uv.lock .\n" + sync_flags = f"--frozen {sync_flags}" + pip += f"""ENV UV_PROJECT_ENVIRONMENT=/usr/local RUN --mount=type=cache,target=/root/.cache/uv \\ - uv sync --inexact --no-dev --no-install-project --compile-bytecode && \\ + uv sync {sync_flags} && \\ (python -c "import sprocket" 2>/dev/null || (echo "sprocket not found in pyproject.toml, installing from pypi.together.ai..." && uv pip install --system --extra-index-url https://pypi.together.ai/ sprocket)) """ From 997deeae7c514b0ce2dc65394d262abe9bd35766 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 16:16:04 +0000 Subject: [PATCH 18/30] feat(api): add trigger param, support multiple modes in remediations list --- .stats.yml | 4 +- .../resources/beta/clusters/remediations.py | 42 ++++++++++++------- .../beta/clusters/remediation_list_params.py | 22 ++++++---- .../beta/clusters/test_remediations.py | 6 ++- 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/.stats.yml b/.stats.yml index 1dcbbbb44..e0d0b45cc 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 81 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-4ac2f3d9b5d799b305a422c2914a36228953a7975c5279e5e3591ae953edb5b1.yml -openapi_spec_hash: 23512750c02dc91c9598c94a5e1e2062 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-8671c2d4a49490bb7814a9cf067d8c3494c0aadd7b048986d920e0f9936766cc.yml +openapi_spec_hash: e1bd8079218c2a05ad2075d8e146ef60 config_hash: b35d5968fb07cce1c1be735f874898b1 diff --git a/src/together/resources/beta/clusters/remediations.py b/src/together/resources/beta/clusters/remediations.py index 33dff106e..087b3a2e1 100644 --- a/src/together/resources/beta/clusters/remediations.py +++ b/src/together/resources/beta/clusters/remediations.py @@ -179,11 +179,13 @@ def list( instance_id: str, *, cluster_id: str, - mode: Literal[ - "REMEDIATION_MODE_VM_ONLY", - "REMEDIATION_MODE_HOST_AWARE", - "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", - "REMEDIATION_MODE_REBOOT_VM", + mode: List[ + Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] ] | Omit = omit, order_by: str | Omit = omit, @@ -193,6 +195,7 @@ def list( Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] ] | Omit = omit, + trigger: List[Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"]] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -208,8 +211,8 @@ def list( remediations for all nodes in a cluster, pass `-` as a wildcard for the instance ID. - mode: Filter by remediation mode. Returns only remediations matching the specified - mode. + mode: Filter by remediation mode(s). Returns remediations matching any of the + specified modes. order_by: Order by expression. @@ -228,6 +231,9 @@ def list( - `AUTO_RESOLVED`: The underlying issue was automatically resolved before processing. + trigger: Filter by trigger type(s). Returns remediations matching any of the specified + triggers. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -258,6 +264,7 @@ def list( "page_size": page_size, "page_token": page_token, "state": state, + "trigger": trigger, }, remediation_list_params.RemediationListParams, ), @@ -574,11 +581,13 @@ async def list( instance_id: str, *, cluster_id: str, - mode: Literal[ - "REMEDIATION_MODE_VM_ONLY", - "REMEDIATION_MODE_HOST_AWARE", - "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", - "REMEDIATION_MODE_REBOOT_VM", + mode: List[ + Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] ] | Omit = omit, order_by: str | Omit = omit, @@ -588,6 +597,7 @@ async def list( Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"] ] | Omit = omit, + trigger: List[Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"]] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -603,8 +613,8 @@ async def list( remediations for all nodes in a cluster, pass `-` as a wildcard for the instance ID. - mode: Filter by remediation mode. Returns only remediations matching the specified - mode. + mode: Filter by remediation mode(s). Returns remediations matching any of the + specified modes. order_by: Order by expression. @@ -623,6 +633,9 @@ async def list( - `AUTO_RESOLVED`: The underlying issue was automatically resolved before processing. + trigger: Filter by trigger type(s). Returns remediations matching any of the specified + triggers. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -653,6 +666,7 @@ async def list( "page_size": page_size, "page_token": page_token, "state": state, + "trigger": trigger, }, remediation_list_params.RemediationListParams, ), diff --git a/src/together/types/beta/clusters/remediation_list_params.py b/src/together/types/beta/clusters/remediation_list_params.py index 215800be9..39054359d 100644 --- a/src/together/types/beta/clusters/remediation_list_params.py +++ b/src/together/types/beta/clusters/remediation_list_params.py @@ -11,15 +11,17 @@ class RemediationListParams(TypedDict, total=False): cluster_id: Required[str] - mode: Literal[ - "REMEDIATION_MODE_VM_ONLY", - "REMEDIATION_MODE_HOST_AWARE", - "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", - "REMEDIATION_MODE_REBOOT_VM", + mode: List[ + Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] ] - """Filter by remediation mode. + """Filter by remediation mode(s). - Returns only remediations matching the specified mode. + Returns remediations matching any of the specified modes. """ order_by: str @@ -43,3 +45,9 @@ class RemediationListParams(TypedDict, total=False): - `AUTO_RESOLVED`: The underlying issue was automatically resolved before processing. """ + + trigger: List[Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"]] + """Filter by trigger type(s). + + Returns remediations matching any of the specified triggers. + """ diff --git a/tests/api_resources/beta/clusters/test_remediations.py b/tests/api_resources/beta/clusters/test_remediations.py index 7c50553d5..bb10b17f9 100644 --- a/tests/api_resources/beta/clusters/test_remediations.py +++ b/tests/api_resources/beta/clusters/test_remediations.py @@ -157,11 +157,12 @@ def test_method_list_with_all_params(self, client: Together) -> None: remediation = client.beta.clusters.remediations.list( instance_id="instance_id", cluster_id="cluster_id", - mode="REMEDIATION_MODE_VM_ONLY", + mode=["REMEDIATION_MODE_VM_ONLY"], order_by="order_by", page_size=0, page_token="page_token", state=["PENDING_APPROVAL"], + trigger=["REMEDIATION_TRIGGER_MANUAL"], ) assert_matches_type(RemediationListResponse, remediation, path=["response"]) @@ -548,11 +549,12 @@ async def test_method_list_with_all_params(self, async_client: AsyncTogether) -> remediation = await async_client.beta.clusters.remediations.list( instance_id="instance_id", cluster_id="cluster_id", - mode="REMEDIATION_MODE_VM_ONLY", + mode=["REMEDIATION_MODE_VM_ONLY"], order_by="order_by", page_size=0, page_token="page_token", state=["PENDING_APPROVAL"], + trigger=["REMEDIATION_TRIGGER_MANUAL"], ) assert_matches_type(RemediationListResponse, remediation, path=["response"]) From a2d9e5980a5221db3b7df48a3a5ae96f8dc332e7 Mon Sep 17 00:00:00 2001 From: Artem Chumachenko Date: Wed, 20 May 2026 18:42:43 +0200 Subject: [PATCH 19/30] Add fancy Metrics plot to the together-py (#344) * feat(plots): add fine-tuning metrics plots with ASCII charts and sparklines * fix comment * revert * first pack of fixes * fixes * revert * final fixes * remove save * feedback fixes * add tty detector * make resolution affect plots * use --output flag * return --json flag * simplify * fix * Apply suggestion from @blainekasten Co-authored-by: Blaine Kasten --------- Co-authored-by: Blaine Kasten --- src/together/lib/cli/__init__.py | 6 + .../lib/cli/api/fine_tuning/list_metrics.py | 60 ++ .../lib/cli/api/fine_tuning/retrieve.py | 17 + .../cli/components/plot_finetune_metrics.py | 144 +++++ .../lib/cli/components/plots/__init__.py | 9 + .../lib/cli/components/plots/_engine.py | 591 ++++++++++++++++++ src/together/lib/cli/utils/_help_examples.py | 20 + tests/test_plots_engine.py | 319 ++++++++++ 8 files changed, 1166 insertions(+) create mode 100644 src/together/lib/cli/api/fine_tuning/list_metrics.py create mode 100644 src/together/lib/cli/components/plot_finetune_metrics.py create mode 100644 src/together/lib/cli/components/plots/__init__.py create mode 100644 src/together/lib/cli/components/plots/_engine.py create mode 100644 tests/test_plots_engine.py diff --git a/src/together/lib/cli/__init__.py b/src/together/lib/cli/__init__.py index ffac00a2b..dbde778e7 100644 --- a/src/together/lib/cli/__init__.py +++ b/src/together/lib/cli/__init__.py @@ -61,6 +61,7 @@ FINE_TUNING_DOWNLOAD_HELP_EXAMPLES, BETA_CLUSTERS_STORAGE_HELP_EXAMPLES, FILES_RETRIEVE_CONTENT_HELP_EXAMPLES, + FINE_TUNING_LIST_METRICS_HELP_EXAMPLES, BETA_CLUSTERS_REMEDIATIONS_HELP_EXAMPLES, BETA_CLUSTERS_STORAGE_CREATE_HELP_EXAMPLES, BETA_CLUSTERS_STORAGE_UPDATE_HELP_EXAMPLES, @@ -382,6 +383,11 @@ async def run_command() -> None: help_epilogue=FINE_TUNING_DOWNLOAD_HELP_EXAMPLES, ) fine_tuning_app.command((f"{_CLI}.fine_tuning.delete:delete"), alias="-d", help="Delete a fine-tuning job") +fine_tuning_app.command( + (f"{_CLI}.fine_tuning.list_metrics:list_metrics"), + help="Retrieve training metrics for a fine-tuning job", + help_epilogue=FINE_TUNING_LIST_METRICS_HELP_EXAMPLES, +) ## Models API commands models_app = app.command(App(name="models", help="List and upload models", help_epilogue=MODELS_HELP_EXAMPLES)) diff --git a/src/together/lib/cli/api/fine_tuning/list_metrics.py b/src/together/lib/cli/api/fine_tuning/list_metrics.py new file mode 100644 index 000000000..5e15a753b --- /dev/null +++ b/src/together/lib/cli/api/fine_tuning/list_metrics.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import sys +from typing import Literal, Optional, Annotated +from datetime import datetime + +from cyclopts import Parameter + +from together import omit +from together._utils._json import openapi_dumps +from together.lib.cli.utils.config import CLIConfigParameter +from together.lib.cli.utils._console import console +from together.lib.cli.components.loader import show_loading_status +from together.lib.cli.components.plot_finetune_metrics import METRICS_WIDTH_PADDING, metrics_ascii_charts + + +async def list_metrics( + fine_tune_id: Annotated[str, Parameter(help="The ID of the fine-tuning job")], + *, + config: CLIConfigParameter, + global_step_from: Annotated[ + Optional[int], Parameter(help="Filter metrics from this global step (inclusive).") + ] = None, + global_step_to: Annotated[Optional[int], Parameter(help="Filter metrics to this global step (inclusive).")] = None, + logged_at_from: Annotated[ + Optional[datetime], Parameter(help="Filter metrics logged at or after this time.") + ] = None, + logged_at_to: Annotated[Optional[datetime], Parameter(help="Filter metrics logged at or before this time.")] = None, + resolution: Annotated[ + Optional[int], + Parameter( + help="Number of uniformly sampled training metric points to return. Does not limit the number of eval metric points." + ), + ] = None, +) -> None: + """Retrieve training metrics for a fine-tuning job.""" + response = await show_loading_status( + "Fetching metrics...", + config.client.fine_tuning.list_metrics( + fine_tune_id, + global_step_from=global_step_from or omit, + global_step_to=global_step_to or omit, + logged_at_from=logged_at_from or omit, + logged_at_to=logged_at_to or omit, + resolution=resolution or omit, + ), + ) + + metrics = response.metrics or [] + + if config.json: + json_bytes = openapi_dumps(metrics) + console.print_json(json_bytes.decode("utf-8")) + return + + if len(metrics) == 0: + console.print(f"[muted]No metrics found for job {fine_tune_id}[/muted]") + return + + console.print(metrics_ascii_charts(metrics, width=console.width - METRICS_WIDTH_PADDING)) diff --git a/src/together/lib/cli/api/fine_tuning/retrieve.py b/src/together/lib/cli/api/fine_tuning/retrieve.py index 64bc68378..0b77997e0 100644 --- a/src/together/lib/cli/api/fine_tuning/retrieve.py +++ b/src/together/lib/cli/api/fine_tuning/retrieve.py @@ -1,7 +1,10 @@ from __future__ import annotations +from typing import Annotated from datetime import datetime +from cyclopts import Parameter + from together._utils._json import openapi_dumps from together.lib.cli.api._utils import generate_progress_bar from together.lib.cli.utils.config import CLIConfigParameter @@ -9,6 +12,7 @@ from together.lib.cli.utils._console import console from together.lib.cli.components.loader import show_loading_status from together.lib.cli.components.model_dump import print_model_dump +from together.lib.cli.components.plot_finetune_metrics import METRICS_WIDTH_PADDING, metrics_block_sparklines _NEST_INDENT = 4 @@ -17,6 +21,7 @@ async def retrieve( fine_tune_id: str, *, config: CLIConfigParameter, + no_plots: Annotated[bool, Parameter(help="Print training metric sparklines.", negative=())] = False, ) -> None: """Retrieve fine-tuning job details.""" response = await show_loading_status( @@ -35,6 +40,18 @@ async def retrieve( console.print(progress_text) print_model_dump(response, show_nulls=False) + + if not no_plots: + metrics_response = await show_loading_status( + "Fetching metrics...", + config.client.fine_tuning.list_metrics(fine_tune_id, resolution=console.width - METRICS_WIDTH_PADDING), + ) + metrics = metrics_response.metrics or [] + + if metrics: + console.print("\n[muted]Training metrics:[/muted]") + console.print(metrics_block_sparklines(metrics, width=console.width - METRICS_WIDTH_PADDING)) + if event_count > 0: console.print("\n[dim]FT Events:[/dim]") console.print(f" [dim]Total events:[/dim] {event_count}") diff --git a/src/together/lib/cli/components/plot_finetune_metrics.py b/src/together/lib/cli/components/plot_finetune_metrics.py new file mode 100644 index 000000000..8a926bc84 --- /dev/null +++ b/src/together/lib/cli/components/plot_finetune_metrics.py @@ -0,0 +1,144 @@ +"""Fine-tuning metrics plotting utilities. + +Public API +---------- +``metrics_block_sparklines(metrics)`` + One ▁▂▃▄▅▆▇█ sparkline line per metric — used in ``retrieve``. + +``metrics_ascii_charts(metrics, height=6)`` + One full ASCII line chart per metric — used in ``list-metrics``. +""" + +from __future__ import annotations + +import math +from typing import Any +from collections import defaultdict + +from rich.text import Text + +from together.lib.cli.components.plots import should_log, render_line_chart, render_sparklines + +# Columns reserved for the y-axis label area, ┼ connector, leading indent, and +# surrounding margin in the ASCII chart layout. This must be >= label_width + 1 +# (the default label_width used in metrics_ascii_charts is 8, so the minimum is +# 9). Callers subtract this from the terminal width to get the usable plot width. +METRICS_WIDTH_PADDING = 48 + +_SKIP_KEYS: frozenset[str] = frozenset({"timestamp", "step", "global_step", "epoch"}) + + +def _is_skip(k: str) -> bool: + base = k.rsplit("/", 1)[-1] + return base in _SKIP_KEYS or base.endswith("_step") or base.endswith("_epoch") + + +def _step_label(x: float) -> str: + return str(int(x)) + + +def _collect_series( + metrics: list[dict[str, Any]], +) -> dict[str, tuple[list[float], list[float]]]: + """Collect plottable numeric series from a list of metric dicts. + + Returns a mapping of name → (xs, ys). Keys are discovered in insertion + order; step/epoch/timestamp fields are skipped. NaN values are converted + to ``-inf`` so the rendering engine plots them at the very bottom of the + chart rather than silently dropping them. + """ + series: dict[str, tuple[list[float], list[float]]] = defaultdict(lambda: ([], [])) + for row in metrics: + step = float(row["train/global_step"]) + for k, v in row.items(): + if _is_skip(k) or isinstance(v, bool) or not isinstance(v, (int, float)): + continue + val = float(v) + # NaN is rendered as a dip to the bottom (-inf sentinel). + if math.isnan(val): + val = float("-inf") + series[k][0].append(step) + series[k][1].append(val) + return series + + +def _no_data() -> Text: + t = Text() + t.append("No plottable metrics found.", style="muted") + return t + + +def metrics_block_sparklines( + metrics: list[dict[str, Any]], + *, + width: int = 60, +) -> Text: + """One block-sparkline line per metric, coloured with the CLI theme. + + Args: + metrics: List of flat metric dicts (one per training step). + width: Sparkline character width (default 60). + + Returns: + A ``rich.text.Text`` ready for ``console.print()``. + """ + series = _collect_series(metrics) + if not series: + return _no_data() + label_w = max(len(k) for k in series) + text = Text() + for key, (xs, ys) in series.items(): + text.append_text( + render_sparklines( + key, + xs, + ys, + width=width, + y_log=should_log(ys), + label_width=label_w, + ) + ) + return text + + +def metrics_ascii_charts( + metrics: list[dict[str, Any]], + *, + height: int = 6, + width: int = 60, + label_width: int = 8, +) -> Text: + """One ASCII line chart per metric, with a global-step x-axis. + + Args: + metrics: List of flat metric dicts (one per training step). + height: Chart body height in rows (default 6). + width: Plot character width (default 60). + + Returns: + A ``rich.text.Text`` ready for ``console.print()``. + """ + series = _collect_series(metrics) + text = Text() + for key, (xs, ys) in series.items(): + if text: + text.append("\n") + text.append_text( + render_line_chart( + xs, + {key: ys}, + x_label=_step_label, + y_log=should_log(ys), + height=height, + width=width, + label_width=label_width, + ) + ) + return text if text else _no_data() + + +__all__ = [ + "metrics_block_sparklines", + "metrics_ascii_charts", + "METRICS_WIDTH_PADDING", +] diff --git a/src/together/lib/cli/components/plots/__init__.py b/src/together/lib/cli/components/plots/__init__.py new file mode 100644 index 000000000..c2d08bf68 --- /dev/null +++ b/src/together/lib/cli/components/plots/__init__.py @@ -0,0 +1,9 @@ +"""Generic CLI plot utilities.""" + +from together.lib.cli.components.plots._engine import should_log, render_line_chart, render_sparklines + +__all__ = [ + "render_line_chart", + "render_sparklines", + "should_log", +] diff --git a/src/together/lib/cli/components/plots/_engine.py b/src/together/lib/cli/components/plots/_engine.py new file mode 100644 index 000000000..62c2f6738 --- /dev/null +++ b/src/together/lib/cli/components/plots/_engine.py @@ -0,0 +1,591 @@ +"""ASCII sparkline and chart engine for time-series data. + +Designed for scalar time-series (loss, accuracy, …); not a general-purpose +plotting library. + +Internal pipeline (``_plot``, ``_interpolate``, …) uses a shared x-grid with +named y series: ``xs: list[float]`` + ``ys: dict[str, list[float]]``. + +Public API +---------- +``render_line_chart(xs, ys, ...)`` + One or more named series plotted on a shared ASCII line chart. All series + share the same x-axis and y-scale. + +``render_sparklines(name, xs, ys, ...)`` + A single block-sparkline row (▁▂▃▄▅▆▇█). Call once per series and pass a + shared ``label_width`` across calls for consistent label alignment. Names + are right-justified; those that exceed ``label_width`` are truncated with + ``...``. +""" + +from __future__ import annotations + +import math +import bisect +from typing import Callable + +from rich.text import Text + +_SPARK_BLOCKS = " ▁▂▃▄▅▆▇█" + +# Styles cycled across series in insertion order. +_SERIES_STYLES = ["white", "green", "yellow", "cyan", "magenta"] + +# UI style tokens used throughout the rendering pipeline. +_STYLE_PRIMARY = "primary" # default plot body text +_STYLE_SECONDARY = "secondary" # axis labels and tick text +_STYLE_ACCENT = "accent" # axis border characters (┼ └ ┬ …) +_STYLE_MUTED = "muted" # series name labels and empty-state messages +_STYLE_SPARK = "white" # sparkline bar characters + +# Sentinels used in quantized_ys to signal out-of-range non-finite values. +# Both are outside the valid slot range [0, height-1]. +_NEG_INF_SENTINEL = -1 # -inf: line descends to the x-axis border +_POS_INF_SENTINEL = -2 # +inf: line ascends to the top data row +_NAN_SENTINEL = -3 # NaN: no line at the place + + +def should_log(vals: list[float]) -> bool: + """Return True when values span more than 100×, suggesting log scale.""" + positive_val = [v for v in vals if v > 0] + return len(positive_val) > 1 and (max(positive_val) / min(positive_val)) > 100 + + +def _uniform_grid(vals: list[float], n: int) -> list[float]: + """Return n evenly-spaced points spanning [min(vals), max(vals)]. + + Non-finite values (e.g. the -inf sentinel used for NaN data points) are + excluded from the range computation so they don't corrupt the grid. + """ + finite_val = [v for v in vals if math.isfinite(v)] + min_val, max_val = min(finite_val), max(finite_val) + if n <= 1: + return [min_val] + return [min_val + (max_val - min_val) * idx / (n - 1) for idx in range(n)] + + +def _interpolate( + xs: list[float], + ys: dict[str, list[float]], + x_grid: list[float], +) -> dict[str, list[float]]: + """Linearly interpolate each named y series onto x_grid; clamp at the edges. + + For each grid point: + - If it falls before the first data point, use the first y value. + - If it falls after the last data point, use the last y value. + - Otherwise, linearly interpolate between the two bracketing data points. + """ + results: dict[str, list[float]] = {} + for name, yvals in ys.items(): + # Sort by x, using insertion order as a tiebreaker so that duplicate + # steps are resolved deterministically (first occurrence wins). + pairs = sorted(enumerate(zip(xs, yvals)), key=lambda t: (t[1][0], t[0])) + xs_s = [x for _, (x, _y) in pairs] + ys_s = [y for _, (_x, y) in pairs] + + interpolated: list[float] = [] + for x_point in x_grid: + pos = bisect.bisect_left(xs_s, x_point) + if pos == 0: + interpolated.append(ys_s[0]) + elif pos == len(xs_s): + interpolated.append(ys_s[-1]) + elif xs_s[pos] == x_point: + interpolated.append(ys_s[pos]) + else: + left_x, left_y = xs_s[pos - 1], ys_s[pos - 1] + right_x, right_y = xs_s[pos], ys_s[pos] + # When either bracket endpoint is a non-finite sentinel + # (-inf/NaN or +inf) we cannot compute a meaningful slope. + # Instead, assign this grid point to whichever bracket is + # closer: if that bracket is non-finite the spike/dip extends + # to this column; if it is finite we use its value so the + # spike/dip stays as narrow as the grid resolution allows. + if not math.isfinite(left_y) or not math.isfinite(right_y): + closer_y = left_y if (x_point - left_x) <= (right_x - x_point) else right_y + interpolated.append(closer_y) + else: + slope = (right_y - left_y) / (right_x - left_x) + interpolated.append(left_y + slope * (x_point - left_x)) + + results[name] = interpolated + return results + + +def _log_transform( + named_values: dict[str, list[float]], +) -> dict[str, list[float]]: + """Return new traces with ys replaced by their log10 values.""" + result: dict[str, list[float]] = {} + for name, values in named_values.items(): + nz = [value for value in values if value > 0] + eps = min(nz) * 0.01 if nz else 1e-10 + result[name] = [math.log10(max(value, eps)) for value in values] + return result + + +def _quantize_ys( + interpolated_ys: dict[str, list[float]], + y_grid: list[float], +) -> list[list[int]]: + """Snap each interpolated y value to the index of the nearest y_grid slot. + + Non-finite values are mapped to out-of-band sentinels: + + * ``_NEG_INF_SENTINEL`` (``-1``) for ``-inf`` — the line descends to the + x-axis border row. + * ``_POS_INF_SENTINEL`` (``-2``) for ``+inf`` — the line spikes to the top + data row. + * ``_NAN_SENTINEL`` (``-3``) for ``NaN`` — no line is drawn at that point. + """ + quantized_ys: list[list[int]] = [] + for ys in interpolated_ys.values(): + row: list[int] = [] + for y in ys: + if math.isfinite(y): + row.append(min(range(len(y_grid)), key=lambda i: abs(y_grid[i] - y))) + elif y > 0: # +inf + row.append(_POS_INF_SENTINEL) + elif math.isinf(y): + row.append(_NEG_INF_SENTINEL) + else: # -inf or NaN (NaN > 0 is False) + row.append(_NAN_SENTINEL) + quantized_ys.append(row) + return quantized_ys + + +def _fit_spark_label(name: str, label_width: int) -> str: + """Right-justify *name* in *label_width* chars, truncating with '...' if needed.""" + if len(name) <= label_width: + return name.rjust(label_width) + return name[: max(0, label_width - 3)] + "..." + + +def _y_labels( + y_grid: list[float], + y_log: bool, + y_label: Callable[[float], str], +) -> list[str]: + """Build y-axis tick label strings from the y grid.""" + labels = [y_label(10**y) if y_log else y_label(y) for y in y_grid[::-1]] + return labels + + +def _x_labels( + x_grid: list[float], + n_xticks: int, + x_label: Callable[[float], str], +) -> list[tuple[int, str]]: + """Return (column_index, label_string) pairs for each x-axis tick.""" + width = len(x_grid) + x_min = x_grid[0] + # Extend by one grid step beyond the last point so the rightmost tick + # label shows the true data maximum. round() suppresses floating-point + # noise that would otherwise accumulate in the tick value calculations. + x_max = round(x_grid[-1] + ((x_grid[-1] - x_grid[0]) / (width - 1) if width > 1 else 0.0), 10) + if n_xticks < 2 or width <= 1: + return [(0, x_label(x_min))] + tick_cols = [round(i * (width - 1) / (n_xticks - 1)) for i in range(n_xticks)] + tick_vals = [x_min + (x_max - x_min) * i / (n_xticks - 1) for i in range(n_xticks)] + return [(col, x_label(val)) for col, val in zip(tick_cols, tick_vals)] + + +def _draw_y_axis( + grid: list[list[str]], + style_grid: list[list[str]], + labels: list[str], + label_w: int, +) -> None: + """Fill y-axis labels and ┼ connectors into the grid.""" + for label, grid_row, style_row in zip(labels, grid, style_grid): + if len(label) > label_w: + label = label[: max(0, label_w - 3)] + "..." + label = label.rjust(label_w) + for ci, ch in enumerate(label): + grid_row[ci] = ch + style_row[ci] = _STYLE_SECONDARY + grid_row[label_w] = "┼" + style_row[label_w] = _STYLE_ACCENT + + +def _draw_lines( + grid: list[list[str]], + style_grid: list[list[str]], + quantized_ys: list[list[int]], + styles: list[str], + label_w: int, +) -> frozenset[int]: + """Draw all series into the shared grid (last writer wins on collision). + + Coordinate system: y_grid index 0 is the *bottom* of the data range, but + grid row 0 is the *top* of the terminal output. The conversion is: + screen_row = len(grid) - y_grid_index - 1 + So a higher y_grid index means a higher data value and a *lower* screen row. + + Out-of-band sentinels (``_NEG_INF_SENTINEL``, ``_POS_INF_SENTINEL``) signal + non-finite source values: + + * ``_NEG_INF_SENTINEL`` (-inf / NaN): line descends to the x-axis border. + The set of affected plot-body column indices is returned so + ``_draw_x_axis`` can mark them with ``┴``. + * ``_POS_INF_SENTINEL`` (+inf): line spikes to the top data row (row 0). + """ + height = len(grid) + border_cols: set[int] = set() + offset = label_w + 1 + width = len(grid[0]) + for style, pv in zip(styles, quantized_ys): + # We look one column ahead (pv[col+1]), so stop one short of the end. + for col_idx in range(width - label_w - 2): + cur = pv[col_idx] + nxt = pv[col_idx + 1] + col = col_idx + offset + + cur_is_neg_inf = cur == _NEG_INF_SENTINEL + nxt_is_neg_inf = nxt == _NEG_INF_SENTINEL + cur_is_pos_inf = cur == _POS_INF_SENTINEL + nxt_is_pos_inf = nxt == _POS_INF_SENTINEL + cur_is_nan = cur == _NAN_SENTINEL + nxt_is_nan = nxt == _NAN_SENTINEL + + # Two consecutive non-finite points of the same kind: nothing to draw. + if ( + (cur_is_neg_inf and nxt_is_neg_inf) + or (cur_is_pos_inf and nxt_is_pos_inf) + or (cur_is_nan and nxt_is_nan) + ): + continue + + screen_row = height - cur - 1 + next_screen_row = height - nxt - 1 + + # Recovering from border: │ up from bottom data row to nxt. + if cur_is_neg_inf: + border_cols.add(col_idx) + grid[next_screen_row][col] = "╭" + style_grid[next_screen_row][col] = style + for mid_row in range(next_screen_row + 1, height): + grid[mid_row][col] = "│" + style_grid[mid_row][col] = style + continue + + # Descending to border: │ down from cur to bottom data row. + if nxt_is_neg_inf: + border_cols.add(col_idx) + grid[screen_row][col] = "╮" + style_grid[screen_row][col] = style + for mid_row in range(screen_row + 1, height): + grid[mid_row][col] = "│" + style_grid[mid_row][col] = style + continue + + # Descending from top: │ down from row 0 to nxt. + if cur_is_pos_inf: + grid[0][col] = "│" + style_grid[0][col] = style + for mid_row in range(1, next_screen_row): + grid[mid_row][col] = "│" + style_grid[mid_row][col] = style + grid[next_screen_row][col] = "╰" + style_grid[next_screen_row][col] = style + continue + + # Ascending to top: │ up from cur to row 0. + if nxt_is_pos_inf: + grid[screen_row][col] = "╯" + style_grid[screen_row][col] = style + for mid_row in range(1, screen_row): + grid[mid_row][col] = "│" + style_grid[mid_row][col] = style + grid[0][col] = "│" + style_grid[0][col] = style + continue + + # Continue previous line if the next one is NaN + if not cur_is_nan and nxt_is_nan: + grid[screen_row][col] = "─" + continue + + # Start a new line if the current one is nan, but the previous one is not + if cur_is_nan and not nxt_is_nan: + grid[next_screen_row][col] = "─" + continue + + # If everything is finite and good, compare the values and add horizontal line or increasing/decreasing line + if screen_row == next_screen_row: + grid[screen_row][col] = "─" + style_grid[screen_row][col] = style + continue + + going_down = cur > nxt # value decreases → line goes down on screen + grid[screen_row][col] = "╮" if going_down else "╯" + style_grid[screen_row][col] = style + grid[next_screen_row][col] = "╰" if going_down else "╭" + style_grid[next_screen_row][col] = style + for mid_row in range(min(screen_row, next_screen_row) + 1, max(screen_row, next_screen_row)): + grid[mid_row][col] = "│" + style_grid[mid_row][col] = style + + return frozenset(border_cols) + + +def _draw_x_axis( + grid: list[list[str]], + style_grid: list[list[str]], + label_w: int, + x_labels: list[tuple[int, str]], + nan_cols: frozenset[int] = frozenset(), +) -> None: + """Append the └───┬─── border row and tick label row to the grid. + + ``nan_cols`` is a set of plot-body column indices (0-based within the plot + body, i.e. not including the y-axis label area) where a NaN line descends + to the border. Those positions get ``┴`` instead of ``─``, or ``┼`` when + they coincide with an x-tick ``┬``. + """ + row_len = len(grid[0]) + width = row_len - label_w - 1 + + # Border row: spaces | └ | ─ … ┬ … ─ + tick_cols = {col for col, _ in x_labels} + border_chars = list("─" * width) + for col in tick_cols: + border_chars[col] = "┬" + + # Adding hitting lines to -inf to the border + for col in nan_cols: + if 0 <= col < width: + border_chars[col] = "┼" if col in tick_cols else "┴" + border_row = [" "] * label_w + ["└"] + border_chars + border_styles = [_STYLE_SECONDARY] * label_w + [_STYLE_ACCENT] + [_STYLE_ACCENT] * width + grid.append(border_row) + style_grid.append(border_styles) + + # Label row: tick strings centred under their tick column + label_row = [" "] * row_len + for col, lbl in x_labels: + start = label_w + 1 + col - len(lbl) // 2 + start = max(0, min(start, row_len - len(lbl))) + for i, ch in enumerate(lbl): + label_row[start + i] = ch + grid.append(label_row) + style_grid.append([_STYLE_SECONDARY] * row_len) + + +def _render_data_row( + row: list[str], + style_row: list[str], +) -> Text: + """Colorize one grid row, appending each character with its style.""" + text = Text() + for ch, style in zip(row, style_row): + text.append(ch, style=style) + text.append("\n") + return text + + +def _render_body( + grid: list[list[str]], + style_grid: list[list[str]], +) -> Text: + """Convert the finished grid into a Rich Text object.""" + text = Text() + for row, style_row in zip(grid, style_grid): + text.append_text(_render_data_row(row, style_row)) + return text + + +def _plot( + xs: list[float], + ys: dict[str, list[float]], + *, + width: int = 60, + height: int = 6, + x_label: Callable[[float], str] = str, + y_label: Callable[[float], str] = str, + y_log: bool = False, + n_xticks: int = 3, + label_width: int = 8, +) -> Text: + """Render one or more named y series against a shared x-axis as an ASCII chart. + + Args: + xs: Shared x values for all series. + ys: Mapping of name → y values (must be same length as xs). + width: Number of character columns in the plot body. + height: Number of character rows in the chart body. + x_label: Callable that formats an x value into a tick-label string. + y_label: Callable that formats a y value into a tick-label string. + y_log: When True, values are plotted on a log10 axis. + n_xticks: Number of tick marks and labels on the x-axis (default 3). + label_width: Cap on the y-axis label column width (default 8). + Labels longer than this are truncated with ``...``. + + Returns: + A ``rich.text.Text`` ready for ``console.print()``. + """ + if not ys: + t = Text() + t.append("No data.", style=_STYLE_MUTED) + return t + + ordered_styles = [_SERIES_STYLES[i % len(_SERIES_STYLES)] for i in range(len(ys))] + + x_grid = _uniform_grid(xs, width) + interpolated_ys = _interpolate(xs, ys, x_grid) + if y_log: + interpolated_ys = _log_transform(interpolated_ys) + flat_ys = [v for ys_list in interpolated_ys.values() for v in ys_list] + y_grid = _uniform_grid(flat_ys, height) + + quantized_ys = _quantize_ys(interpolated_ys, y_grid) + y_labels = _y_labels(y_grid, y_log, y_label) + x_labels = _x_labels(x_grid, n_xticks, x_label) + + grid: list[list[str]] = [[" "] * (width + label_width + 1) for _ in range(height)] + style_grid: list[list[str]] = [[_STYLE_PRIMARY] * (width + label_width + 1) for _ in range(height)] + + _draw_y_axis(grid, style_grid, y_labels, label_width) + nan_cols = _draw_lines(grid, style_grid, quantized_ys, ordered_styles, label_width) + _draw_x_axis(grid, style_grid, label_width, x_labels, nan_cols) + + text = _render_body(grid, style_grid) + return text + + +def render_sparklines( + name: str, + xs: list[float], + ys: list[float], + *, + width: int = 60, + y_log: bool = False, + label_width: int = 8, +) -> Text: + """Render a single block-sparkline row for one series. + + Call once per series, passing a shared ``label_width`` across all calls to + keep label columns aligned. The name is right-justified within the column; + names longer than ``label_width`` are truncated with ``...``. + + Args: + name: Series name, used as the row label. + xs: X values (e.g. training steps). + ys: Y values. + width: Sparkline character width (default 60). + y_log: When True, plot on a log10 scale (default False). + label_width: Exact label column width (default 8). Pass the same + value to every call in a group to get consistent + alignment. + + Returns: + A ``rich.text.Text`` ready for ``console.print()``. + """ + if not xs: + t = Text() + t.append("No plottable data.", style=_STYLE_MUTED) + return t + + x_grid = _uniform_grid(xs, width) + interpolated = _interpolate(xs, {name: ys}, x_grid) + if y_log: + interpolated = _log_transform(interpolated) + + series_vals = interpolated[name] + y_grid = _uniform_grid(series_vals, len(_SPARK_BLOCKS)) + quantized = _quantize_ys({name: series_vals}, y_grid)[0] + + label = _fit_spark_label(name, label_width) + + # The sentinel value (len(y_grid)) indicates a NaN data point; render it + # as a space (the lowest sparkline block) since sparklines have no border row. + # Map out-of-band sentinels to the extreme sparkline blocks: + # _NEG_INF_SENTINEL (-inf) or _NAN_SENTINEL (NaN) → space (lowest block, index 0) + # _POS_INF_SENTINEL (+inf) → █ (highest block, last index) + def _spark_block(idx: int) -> str: + if idx == _NEG_INF_SENTINEL or idx == _NAN_SENTINEL: + return _SPARK_BLOCKS[0] + if idx == _POS_INF_SENTINEL: + return _SPARK_BLOCKS[-1] + return _SPARK_BLOCKS[idx] + + spark = "".join(_spark_block(idx) for idx in quantized).ljust(width) + + text = Text() + text.append(f" {label} ", style=_STYLE_MUTED) + text.append(spark, style=_STYLE_SPARK) + text.append(f" {ys[0]:.4g} → {ys[-1]:.4g}", style=_STYLE_SECONDARY) + text.append("\n") + return text + + +def render_line_chart( + xs: list[float], + ys: dict[str, list[float]], + *, + x_label: Callable[[float], str] = str, + y_log: bool = False, + y_label: Callable[[float], str] | None = None, + width: int = 60, + height: int = 6, + n_xticks: int = 3, + label_width: int = 8, +) -> Text: + """Render one or more named series as a shared ASCII line chart with a legend header. + + All series share the same x-axis (``xs``); each has its own named y values:: + + console.print( + render_line_chart( + steps, + {"train_loss": train_losses, "val_loss": val_losses}, + x_label=lambda s: f"step {s:.0f}", + ) + ) + + Args: + xs: Shared x values for all series. + ys: Mapping of name → y values. + x_label: Callable that formats an x value into a tick-label string. + y_log: When True, plot on a log10 y-axis (default False). + y_label: Callable that formats a y value into a tick-label string. + width: Plot width in terminal characters (default 60). + height: Plot height in terminal rows (default 6). + n_xticks: Number of x-axis tick marks and labels (default 3). + label_width: Cap on the y-axis label column width. + + Returns: + A ``rich.text.Text`` ready for ``console.print()``. + """ + if not ys: + t = Text() + t.append("No plottable data.", style=_STYLE_MUTED) + return t + + styles = {key: _SERIES_STYLES[i % len(_SERIES_STYLES)] for i, key in enumerate(ys)} + + text = Text() + x_from = x_label(xs[0]) + x_to = x_label(xs[-1]) + for key in ys: + text.append( + f" {key} ({x_from} – {x_to}) {ys[key][0]:.4g} → {ys[key][-1]:.4g}\n", + style=styles[key], + ) + + text.append_text( + _plot( + xs, + ys, + width=width, + height=height, + x_label=x_label, + y_label=y_label or (lambda v: f"{v:.3g}"), + y_log=y_log, + n_xticks=n_xticks, + label_width=label_width, + ) + ) + return text diff --git a/src/together/lib/cli/utils/_help_examples.py b/src/together/lib/cli/utils/_help_examples.py index 75ff7cb2c..70285209e 100644 --- a/src/together/lib/cli/utils/_help_examples.py +++ b/src/together/lib/cli/utils/_help_examples.py @@ -100,6 +100,26 @@ [primary]tg ft create --n-checkpoints 3 -M Qwen/Qwen2-1.5B --training-file ./my-dataset.jsonl[/primary] """ +FINE_TUNING_LIST_METRICS_HELP_EXAMPLES = """[dim]Examples:[/dim] +[dim]-[/dim] Retrieve metrics for a fine-tuning job: + [primary]tg ft list-metrics [/primary] + +[dim]-[/dim] Retrieve metrics from a specific global step range: + [primary]tg ft list-metrics --global-step-from 100 --global-step-to 500[/primary] + +[dim]-[/dim] Retrieve metrics logged within a time range: + [primary]tg ft list-metrics --logged-at-from 2024-01-01T00:00:00 --logged-at-to 2024-01-02T00:00:00[/primary] + +[dim]-[/dim] Retrieve a fixed number of data points as JSON: + [primary]tg ft list-metrics --resolution 50 --json[/primary] + +[dim]-[/dim] Save raw metrics to a file: + [primary]tg ft list-metrics --json > metrics.json[/primary] + +[dim]-[/dim] Save ASCII plots to a file: + [primary]tg ft list-metrics > plots.txt[/primary] +""" + FINE_TUNING_DOWNLOAD_HELP_EXAMPLES = """[dim]Examples:[/dim] [dim]-[/dim] Download a fine-tuned model's weights: [primary]tg ft download --output-dir ./my-model[/primary] diff --git a/tests/test_plots_engine.py b/tests/test_plots_engine.py new file mode 100644 index 000000000..f612cffc2 --- /dev/null +++ b/tests/test_plots_engine.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import pytest + +from together.lib.cli.components.plots._engine import ( + _interpolate, + _uniform_grid, + render_line_chart, + render_sparklines, +) +from together.lib.cli.components.plot_finetune_metrics import _step_label + + +def constant_series(n: int = 5, value: float = 1.0) -> list[tuple[float, float]]: + return [(float(i), value) for i in range(n)] + + +# Shared deterministic series used by golden-output tests +_LOSS = [(float(i), 1.0 - i * 0.1) for i in range(10)] # 1.0 → 0.1 +_ACCURACY = [(float(i), 0.5 + i * 0.05) for i in range(10)] # 0.5 → 0.95 +_WIDE = [(float(i), 10.0**i) for i in range(5)] # 1, 10, 100, 1000, 10000 + +_LOSS_XS = [p[0] for p in _LOSS] +_LOSS_YS = [p[1] for p in _LOSS] +_ACCURACY_XS = [p[0] for p in _ACCURACY] +_ACCURACY_YS = [p[1] for p in _ACCURACY] +_WIDE_XS = [p[0] for p in _WIDE] +_WIDE_YS = [p[1] for p in _WIDE] + + +def _interp(xs: list[float], ys: list[float], x_grid: list[float]) -> list[float]: + """Helper: interpolate a single series onto x_grid.""" + return _interpolate(xs, {"s": ys}, x_grid)["s"] + + +class TestInterpolate: + def test_output_length_equals_grid(self) -> None: + xs = [float(i) for i in range(10)] + ys = [float(i) for i in range(10)] + x_grid = _uniform_grid(xs, 5) + result = _interp(xs, ys, x_grid) + assert len(result) == 5 + + def test_linear_data_interpolates_exactly(self) -> None: + xs = [0.0, 9.0] + ys = [0.0, 9.0] + x_grid = _uniform_grid(xs, 10) + result = _interp(xs, ys, x_grid) + # grid points are 0.0, 0.9, 1.8, ..., 8.1 — y=x so values match + assert result == pytest.approx(x_grid, abs=1e-9) # type: ignore[misc] + + def test_constant_series_stays_constant(self) -> None: + xs = [float(i) for i in range(20)] + ys = [7.0] * 20 + x_grid = _uniform_grid(xs, 10) + result = _interp(xs, ys, x_grid) + assert result == pytest.approx([7.0] * 10, abs=1e-9) # type: ignore[misc] + + def test_left_clamp(self) -> None: + xs = [5.0, 9.0] + ys = [99.0, 99.0] + x_grid = _uniform_grid([0.0, 9.0], 10) + result = _interp(xs, ys, x_grid) + assert result == [99.0] * 10 + + def test_right_clamp(self) -> None: + xs = [0.0, 2.0] + ys = [42.0, 42.0] + x_grid = _uniform_grid([0.0, 9.0], 10) + result = _interp(xs, ys, x_grid) + assert result == [42.0] * 10 + + def test_single_point_fills_all(self) -> None: + xs = [5.0] + ys = [3.14] + x_grid = _uniform_grid([0.0, 9.0], 8) + result = _interp(xs, ys, x_grid) + assert result == [3.14] * 8 + + def test_uniform_grid_length(self) -> None: + assert len(_uniform_grid([0.0, 10.0], 5)) == 5 + + def test_uniform_grid_endpoints(self) -> None: + grid = _uniform_grid([0.0, 9.0], 10) + assert grid[0] == pytest.approx(0.0) # type: ignore[misc] + assert grid[-1] == pytest.approx(9.0) # type: ignore[misc] + + +class TestRenderSparklines: + def test_empty_series_returns_no_data_message(self) -> None: + result = render_sparklines("loss", [], [], width=20) + assert result.plain == "No plottable data." + + def test_single_series_golden(self) -> None: + result = render_sparklines("loss", _LOSS_XS, _LOSS_YS, width=20) + assert result.plain == " loss ██▇▇▆▆▅▅▅▄▄▃▃▃▂▂▁▁ 1 → 0.1\n" + + def test_constant_series_golden(self) -> None: + _flat = constant_series(10, 5.0) + result = render_sparklines("flat", [p[0] for p in _flat], [p[1] for p in _flat], width=20) + assert result.plain == " flat 5 → 5\n" + + def test_single_point_golden(self) -> None: + result = render_sparklines("single", [0.0], [1.0], width=20) + assert result.plain == " single 1 → 1\n" + + def test_log_scale_golden(self) -> None: + result = render_sparklines("wide", _WIDE_XS, _WIDE_YS, width=20, y_log=True) + assert result.plain == " wide ▁▁▂▂▂▃▃▄▄▅▅▆▆▆▇▇███ 1 → 1e+04\n" # leading space = first sparkline block + + def test_label_width_truncates_with_ellipsis(self) -> None: + result = render_sparklines("verylongname", _LOSS_XS, _LOSS_YS, width=20, label_width=6) + # "verylongname" (12 chars) truncated to label_width=6: "ver..." + assert result.plain.startswith(" ver... ") + + def test_label_width_truncates_long_name_aligned(self) -> None: + # A name longer than label_width is truncated with ..., staying aligned + r1 = render_sparklines("loss", _LOSS_XS, _LOSS_YS, width=20, label_width=8) + r2 = render_sparklines("averylongmetricname", _LOSS_XS, _LOSS_YS, width=20, label_width=8) + assert r1.plain == " loss ██▇▇▆▆▅▅▅▄▄▃▃▃▂▂▁▁ 1 → 0.1\n" # right-justified + assert r2.plain == " avery... ██▇▇▆▆▅▅▅▄▄▃▃▃▂▂▁▁ 1 → 0.1\n" # truncated to 8 + + def test_aligned_across_calls(self) -> None: + # Pass the same label_width to both calls → sparklines start at the same column + shared_w = 8 + r1 = render_sparklines("loss", _LOSS_XS, _LOSS_YS, width=20, label_width=shared_w) + r2 = render_sparklines("accuracy", _ACCURACY_XS, _ACCURACY_YS, width=20, label_width=shared_w) + assert r1.plain == " loss ██▇▇▆▆▅▅▅▄▄▃▃▃▂▂▁▁ 1 → 0.1\n" # "loss" right-justified in 8 + assert r2.plain == " accuracy ▁▁▂▂▃▃▃▄▄▅▅▅▆▆▇▇██ 0.5 → 0.95\n" # "accuracy" fills 8 exactly + + @pytest.mark.parametrize( + "bad_value, expected", + [ + (float("-inf"), " loss ██▇▇▆▆▅▅▅▄ ▃▃▂▂▁▁ 1 → 0.1\n"), + (float("nan"), " loss ██▇▇▆▆▅▅▅▄ ▃▃▂▂▁▁ 1 → 0.1\n"), + (float("inf"), " loss ██▇▇▆▆▅▅▅▄██▃▃▂▂▁▁ 1 → 0.1\n"), + ], + ids=["neg_inf", "nan", "pos_inf"], + ) + def test_non_finite_rendered_as_extreme_block_golden(self, bad_value: float, expected: str) -> None: + # -inf/NaN → blank (bottom) block; +inf → █ (top) block. + xs = [float(i) for i in range(10)] + ys = [(1.0 - i * 0.1) if i != 5 else bad_value for i in range(10)] + result = render_sparklines("loss", xs, ys, width=20) + assert result.plain == expected + + +class TestRenderLineChart: + def test_empty_series_returns_no_data_message(self) -> None: + result = render_line_chart([], {}) + assert result.plain == "No plottable data." + + def test_single_series_golden(self) -> None: + result = render_line_chart( + _LOSS_XS, + {"loss": _LOSS_YS}, + width=20, + height=4, + n_xticks=3, + x_label=_step_label, + ) + assert result.plain == ( + " loss (0 – 9) 1 → 0.1\n" + " 1┼───╮ \n" + " 0.7┼ ╰─────╮ \n" + " 0.4┼ ╰─────╮ \n" + " 0.1┼ ╰─── \n" + " └┬─────────┬────────┬\n" + " 0 4 9\n" + ) + + def test_multi_series_golden(self) -> None: + # loss and accuracy share the same x-axis (steps 0–9) + result = render_line_chart( + _LOSS_XS, + {"loss": _LOSS_YS, "accuracy": _ACCURACY_YS}, + width=20, + height=4, + n_xticks=3, + x_label=_step_label, + ) + assert result.plain == ( + " loss (0 – 9) 1 → 0.1\n" + " accuracy (0 – 9) 0.5 → 0.95\n" + " 1┼───╮ ╭──── \n" + " 0.7┼ ╭───────────╯ \n" + " 0.4┼──╯ ╰─────╮ \n" + " 0.1┼ ╰─── \n" + " └┬─────────┬────────┬\n" + " 0 4 9\n" + ) + + def test_log_scale_golden(self) -> None: + result = render_line_chart( + _WIDE_XS, + {"metric": _WIDE_YS}, + width=20, + height=4, + n_xticks=3, + x_label=_step_label, + y_log=True, + ) + assert result.plain == ( + " metric (0 – 4) 1 → 1e+04\n" + " 1e+04┼ ╭──── \n" + " 464┼ ╭────╯ \n" + " 21.5┼ ╭───────╯ \n" + " 1┼─╯ \n" + " └┬─────────┬────────┬\n" + " 0 2 4\n" + ) + + def test_constant_series_golden(self) -> None: + _flat = constant_series(10, 42.0) + result = render_line_chart( + [p[0] for p in _flat], + {"flat": [p[1] for p in _flat]}, + width=20, + height=4, + x_label=_step_label, + ) + assert result.plain == ( + " flat (0 – 9) 42 → 42\n" + " 42┼ \n" + " 42┼ \n" + " 42┼ \n" + " 42┼─────────────────── \n" + " └┬─────────┬────────┬\n" + " 0 4 9\n" + ) + + def test_custom_x_label_golden(self) -> None: + result = render_line_chart( + _LOSS_XS, + {"m": _LOSS_YS}, + width=20, + height=4, + n_xticks=3, + x_label=lambda x: f"step{int(x)}", + ) + assert result.plain == ( + " m (step0 – step9) 1 → 0.1\n" + " 1┼───╮ \n" + " 0.7┼ ╰─────╮ \n" + " 0.4┼ ╰─────╮ \n" + " 0.1┼ ╰─── \n" + " └┬─────────┬────────┬\n" + " step0 step4 step9\n" + ) + + @pytest.mark.parametrize( + "bad_value, expected", + [ + ( + float("-inf"), + ( + " loss (0 – 9) 1 → 0.1\n" + " 1┼───╮ \n" + " 0.7┼ ╰─────╮ \n" + " 0.4┼ │ ╭───╮ \n" + " 0.1┼ │ │ ╰─── \n" + " └┬────────┴┬┴───────┬\n" + " 0 4 9\n" + ), + ), + ( + float("nan"), + ( + " loss (0 – 9) 1 → 0.1\n" + " 1┼───╮ \n" + " 0.7┼ ╰────── \n" + " 0.4┼ ────╮ \n" + " 0.1┼ ╰─── \n" + " └┬─────────┬────────┬\n" + " 0 4 9\n" + ), + ), + ( + float("inf"), + ( + " loss (0 – 9) 1 → 0.1\n" + " 1┼───╮ │ │ \n" + " 0.7┼ ╰─────╯ │ \n" + " 0.4┼ ╰───╮ \n" + " 0.1┼ ╰─── \n" + " └┬─────────┬────────┬\n" + " 0 4 9\n" + ), + ), + ], + ids=["neg_inf", "nan", "pos_inf"], + ) + def test_non_finite_rendered_as_extreme_golden(self, bad_value: float, expected: str) -> None: + # -inf/NaN → dip to x-axis border; +inf → spike to top data row. + xs = [float(i) for i in range(10)] + ys = [(1.0 - i * 0.1) if i != 5 else bad_value for i in range(10)] + result = render_line_chart(xs, {"loss": ys}, width=20, height=4, n_xticks=3, x_label=_step_label) + assert result.plain == expected + + def test_label_width_caps_y_axis(self) -> None: + # "1e+04" is exactly 5 chars; label_width=5 fits it without truncation + result = render_line_chart( + _WIDE_XS, + {"metric": _WIDE_YS}, + width=20, + height=4, + x_label=_step_label, + y_log=True, + label_width=5, + ) + assert result.plain == ( + " metric (0 – 4) 1 → 1e+04\n" + "1e+04┼ ╭──── \n" + " 464┼ ╭────╯ \n" + " 21.5┼ ╭───────╯ \n" + " 1┼─╯ \n" + " └┬─────────┬────────┬\n" + " 0 2 4\n" + ) From 4c7fc662363054f47d5e7a01353e3f0da98d8b6a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 16:43:38 +0000 Subject: [PATCH 20/30] feat(api): add instance_name field to remediation model --- .stats.yml | 4 ++-- src/together/types/beta/clusters/remediation.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index e0d0b45cc..0d4605871 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 81 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-8671c2d4a49490bb7814a9cf067d8c3494c0aadd7b048986d920e0f9936766cc.yml -openapi_spec_hash: e1bd8079218c2a05ad2075d8e146ef60 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-b7b48bf22d5bb751c9ccc7f9960dfa823f92c83889469918c13074f6d6eacd41.yml +openapi_spec_hash: e26bbd1583736995e71ee020e6f76e58 config_hash: b35d5968fb07cce1c1be735f874898b1 diff --git a/src/together/types/beta/clusters/remediation.py b/src/together/types/beta/clusters/remediation.py index fc606a0f7..d5606b57f 100644 --- a/src/together/types/beta/clusters/remediation.py +++ b/src/together/types/beta/clusters/remediation.py @@ -69,6 +69,9 @@ class Remediation(BaseModel): error_message: Optional[str] = None """Error message if the remediation failed.""" + instance_name: Optional[str] = None + """Display name of the targeted instance.""" + passive_health_check_event_id: Optional[str] = None """Passive health check event ID that triggered this remediation.""" From 27e6c2db1e2549d8b2352f73265067f0eac9b44c Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 16:44:38 +0000 Subject: [PATCH 21/30] feat(api): add disable_position_bias_correction, remove num_samples from eval compare results --- .stats.yml | 4 +- src/together/types/eval_create_params.py | 300 +++++++++++++++------ src/together/types/eval_status_response.py | 24 +- src/together/types/evaluation_job.py | 24 +- 4 files changed, 257 insertions(+), 95 deletions(-) diff --git a/.stats.yml b/.stats.yml index 0d4605871..d261a070e 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 81 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-b7b48bf22d5bb751c9ccc7f9960dfa823f92c83889469918c13074f6d6eacd41.yml -openapi_spec_hash: e26bbd1583736995e71ee020e6f76e58 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-835a3888d379fab044fd90910453b084ca9befdca7ce367f358dfd9cabcfcb14.yml +openapi_spec_hash: 36dae2e8eaa8dbf627d639e08e3fe24a config_hash: b35d5968fb07cce1c1be735f874898b1 diff --git a/src/together/types/eval_create_params.py b/src/together/types/eval_create_params.py index 081698c6c..a574a6bba 100644 --- a/src/together/types/eval_create_params.py +++ b/src/together/types/eval_create_params.py @@ -40,65 +40,101 @@ class ParametersEvaluationClassifyParametersJudge(TypedDict, total=False): """Name of the judge model""" model_source: Required[Literal["serverless", "dedicated", "external"]] - """Source of the judge model.""" + """ + Source of the judge model inference: - `serverless`: Together's shared + serverless inference API. Default concurrency: 25 workers. - `dedicated`: A + Together dedicated deployment endpoint. Default concurrency: 5 workers (minimum + enforced even if num_workers is set lower). + + - `external`: An external inference API (e.g. OpenAI, Anthropic, Google, + OpenRouter). Requires `external_api_token` and `external_base_url`. Default + concurrency: 2 workers for first-party APIs, 20 for proxy/aggregator + endpoints. + """ system_template: Required[str] """System prompt template for the judge""" external_api_token: str - """Bearer/API token for external judge models.""" + """Bearer/API token for the external judge model provider. + + Required when model_source is 'external'. + """ external_base_url: str - """Base URL for external judge models. Must be OpenAI-compatible base URL.""" + """Base URL of the external inference API for the judge. + + Must be OpenAI-compatible. Required when model_source is 'external'. + """ max_tokens: int - """Maximum number of tokens the judge model can generate. + """Maximum number of tokens the judge model may generate. - Defaults to 32768. Increase for reasoning models (e.g. Gemini, o-series) that - consume output token budget for chain-of-thought. + Defaults to 32768 if omitted. Set higher for reasoning judges (e.g. o-series, + Gemini) that spend tokens on internal chain-of-thought before emitting the + verdict JSON. """ num_workers: int - """Number of concurrent workers for inference requests. + """Number of concurrent inference workers for the judge. - Overrides the default concurrency for this model. Useful for tuning throughput - when using proxy endpoints (e.g. OpenRouter) or rate-limited external APIs. + Overrides the source-specific default (serverless: 25, dedicated: 5, external: + 2–20). For dedicated endpoints the value is clamped to a minimum of 5 regardless + of what is set here. """ temperature: float - """Sampling temperature for the judge model. Defaults to 0.05.""" + """Sampling temperature for the judge model. Defaults to 0.05 if omitted.""" class ParametersEvaluationClassifyParametersModelToEvaluateEvaluationModelRequest(TypedDict, total=False): input_template: Required[str] - """Input prompt template""" + """User message template. Supports Jinja2 variables referencing dataset columns.""" max_tokens: Required[int] - """Maximum number of tokens to generate""" + """Maximum number of tokens to generate.""" model: Required[str] """Name of the model to evaluate""" model_source: Required[Literal["serverless", "dedicated", "external"]] - """Source of the model.""" + """ + Source of the model inference: - `serverless`: Together's shared serverless + inference API. Default concurrency: 25 workers. - `dedicated`: A Together + dedicated deployment endpoint. Default concurrency: 5 workers (minimum enforced + even if num_workers is set lower). Authentication uses the requesting user's + Together API token automatically. + + - `external`: An external inference API (e.g. OpenAI, Anthropic, Google, + OpenRouter). Requires `external_api_token` and `external_base_url`. Default + concurrency: 2 workers for first-party APIs (OpenAI, Anthropic, Google), 20 + for proxy/aggregator endpoints. + """ system_template: Required[str] - """System prompt template""" + """System prompt template. Supports Jinja2 variables referencing dataset columns.""" temperature: Required[float] - """Sampling temperature""" + """Sampling temperature for generation.""" external_api_token: str - """Bearer/API token for external models.""" + """Bearer/API token for the external model provider. + + Required when model_source is 'external'. + """ external_base_url: str - """Base URL for external models. Must be OpenAI-compatible base URL""" + """Base URL of the external inference API. + + Must be OpenAI-compatible. Required when model_source is 'external'. + """ num_workers: int - """Number of concurrent workers for inference requests. + """Number of concurrent inference workers. - Overrides the default concurrency for this model. Useful for tuning throughput - when using proxy endpoints (e.g. OpenRouter) or rate-limited external APIs. + Overrides the source-specific default (serverless: 25, dedicated: 5, external: + 2–20). For dedicated endpoints the value is clamped to a minimum of 5 regardless + of what is set here. """ @@ -120,7 +156,7 @@ class ParametersEvaluationClassifyParameters(TypedDict, total=False): """List of labels that are considered passing""" model_to_evaluate: ParametersEvaluationClassifyParametersModelToEvaluate - """Field name in the input data""" + """Column name in the input dataset containing pre-generated responses""" class ParametersEvaluationScoreParametersJudge(TypedDict, total=False): @@ -128,65 +164,101 @@ class ParametersEvaluationScoreParametersJudge(TypedDict, total=False): """Name of the judge model""" model_source: Required[Literal["serverless", "dedicated", "external"]] - """Source of the judge model.""" + """ + Source of the judge model inference: - `serverless`: Together's shared + serverless inference API. Default concurrency: 25 workers. - `dedicated`: A + Together dedicated deployment endpoint. Default concurrency: 5 workers (minimum + enforced even if num_workers is set lower). + + - `external`: An external inference API (e.g. OpenAI, Anthropic, Google, + OpenRouter). Requires `external_api_token` and `external_base_url`. Default + concurrency: 2 workers for first-party APIs, 20 for proxy/aggregator + endpoints. + """ system_template: Required[str] """System prompt template for the judge""" external_api_token: str - """Bearer/API token for external judge models.""" + """Bearer/API token for the external judge model provider. + + Required when model_source is 'external'. + """ external_base_url: str - """Base URL for external judge models. Must be OpenAI-compatible base URL.""" + """Base URL of the external inference API for the judge. + + Must be OpenAI-compatible. Required when model_source is 'external'. + """ max_tokens: int - """Maximum number of tokens the judge model can generate. + """Maximum number of tokens the judge model may generate. - Defaults to 32768. Increase for reasoning models (e.g. Gemini, o-series) that - consume output token budget for chain-of-thought. + Defaults to 32768 if omitted. Set higher for reasoning judges (e.g. o-series, + Gemini) that spend tokens on internal chain-of-thought before emitting the + verdict JSON. """ num_workers: int - """Number of concurrent workers for inference requests. + """Number of concurrent inference workers for the judge. - Overrides the default concurrency for this model. Useful for tuning throughput - when using proxy endpoints (e.g. OpenRouter) or rate-limited external APIs. + Overrides the source-specific default (serverless: 25, dedicated: 5, external: + 2–20). For dedicated endpoints the value is clamped to a minimum of 5 regardless + of what is set here. """ temperature: float - """Sampling temperature for the judge model. Defaults to 0.05.""" + """Sampling temperature for the judge model. Defaults to 0.05 if omitted.""" class ParametersEvaluationScoreParametersModelToEvaluateEvaluationModelRequest(TypedDict, total=False): input_template: Required[str] - """Input prompt template""" + """User message template. Supports Jinja2 variables referencing dataset columns.""" max_tokens: Required[int] - """Maximum number of tokens to generate""" + """Maximum number of tokens to generate.""" model: Required[str] """Name of the model to evaluate""" model_source: Required[Literal["serverless", "dedicated", "external"]] - """Source of the model.""" + """ + Source of the model inference: - `serverless`: Together's shared serverless + inference API. Default concurrency: 25 workers. - `dedicated`: A Together + dedicated deployment endpoint. Default concurrency: 5 workers (minimum enforced + even if num_workers is set lower). Authentication uses the requesting user's + Together API token automatically. + + - `external`: An external inference API (e.g. OpenAI, Anthropic, Google, + OpenRouter). Requires `external_api_token` and `external_base_url`. Default + concurrency: 2 workers for first-party APIs (OpenAI, Anthropic, Google), 20 + for proxy/aggregator endpoints. + """ system_template: Required[str] - """System prompt template""" + """System prompt template. Supports Jinja2 variables referencing dataset columns.""" temperature: Required[float] - """Sampling temperature""" + """Sampling temperature for generation.""" external_api_token: str - """Bearer/API token for external models.""" + """Bearer/API token for the external model provider. + + Required when model_source is 'external'. + """ external_base_url: str - """Base URL for external models. Must be OpenAI-compatible base URL""" + """Base URL of the external inference API. + + Must be OpenAI-compatible. Required when model_source is 'external'. + """ num_workers: int - """Number of concurrent workers for inference requests. + """Number of concurrent inference workers. - Overrides the default concurrency for this model. Useful for tuning throughput - when using proxy endpoints (e.g. OpenRouter) or rate-limited external APIs. + Overrides the source-specific default (serverless: 25, dedicated: 5, external: + 2–20). For dedicated endpoints the value is clamped to a minimum of 5 regardless + of what is set here. """ @@ -211,7 +283,7 @@ class ParametersEvaluationScoreParameters(TypedDict, total=False): """Score threshold for passing""" model_to_evaluate: ParametersEvaluationScoreParametersModelToEvaluate - """Field name in the input data""" + """Column name in the input dataset containing pre-generated responses""" class ParametersEvaluationCompareParametersJudge(TypedDict, total=False): @@ -219,122 +291,196 @@ class ParametersEvaluationCompareParametersJudge(TypedDict, total=False): """Name of the judge model""" model_source: Required[Literal["serverless", "dedicated", "external"]] - """Source of the judge model.""" + """ + Source of the judge model inference: - `serverless`: Together's shared + serverless inference API. Default concurrency: 25 workers. - `dedicated`: A + Together dedicated deployment endpoint. Default concurrency: 5 workers (minimum + enforced even if num_workers is set lower). + + - `external`: An external inference API (e.g. OpenAI, Anthropic, Google, + OpenRouter). Requires `external_api_token` and `external_base_url`. Default + concurrency: 2 workers for first-party APIs, 20 for proxy/aggregator + endpoints. + """ system_template: Required[str] """System prompt template for the judge""" external_api_token: str - """Bearer/API token for external judge models.""" + """Bearer/API token for the external judge model provider. + + Required when model_source is 'external'. + """ external_base_url: str - """Base URL for external judge models. Must be OpenAI-compatible base URL.""" + """Base URL of the external inference API for the judge. + + Must be OpenAI-compatible. Required when model_source is 'external'. + """ max_tokens: int - """Maximum number of tokens the judge model can generate. + """Maximum number of tokens the judge model may generate. - Defaults to 32768. Increase for reasoning models (e.g. Gemini, o-series) that - consume output token budget for chain-of-thought. + Defaults to 32768 if omitted. Set higher for reasoning judges (e.g. o-series, + Gemini) that spend tokens on internal chain-of-thought before emitting the + verdict JSON. """ num_workers: int - """Number of concurrent workers for inference requests. + """Number of concurrent inference workers for the judge. - Overrides the default concurrency for this model. Useful for tuning throughput - when using proxy endpoints (e.g. OpenRouter) or rate-limited external APIs. + Overrides the source-specific default (serverless: 25, dedicated: 5, external: + 2–20). For dedicated endpoints the value is clamped to a minimum of 5 regardless + of what is set here. """ temperature: float - """Sampling temperature for the judge model. Defaults to 0.05.""" + """Sampling temperature for the judge model. Defaults to 0.05 if omitted.""" class ParametersEvaluationCompareParametersModelAEvaluationModelRequest(TypedDict, total=False): input_template: Required[str] - """Input prompt template""" + """User message template. Supports Jinja2 variables referencing dataset columns.""" max_tokens: Required[int] - """Maximum number of tokens to generate""" + """Maximum number of tokens to generate.""" model: Required[str] """Name of the model to evaluate""" model_source: Required[Literal["serverless", "dedicated", "external"]] - """Source of the model.""" + """ + Source of the model inference: - `serverless`: Together's shared serverless + inference API. Default concurrency: 25 workers. - `dedicated`: A Together + dedicated deployment endpoint. Default concurrency: 5 workers (minimum enforced + even if num_workers is set lower). Authentication uses the requesting user's + Together API token automatically. + + - `external`: An external inference API (e.g. OpenAI, Anthropic, Google, + OpenRouter). Requires `external_api_token` and `external_base_url`. Default + concurrency: 2 workers for first-party APIs (OpenAI, Anthropic, Google), 20 + for proxy/aggregator endpoints. + """ system_template: Required[str] - """System prompt template""" + """System prompt template. Supports Jinja2 variables referencing dataset columns.""" temperature: Required[float] - """Sampling temperature""" + """Sampling temperature for generation.""" external_api_token: str - """Bearer/API token for external models.""" + """Bearer/API token for the external model provider. + + Required when model_source is 'external'. + """ external_base_url: str - """Base URL for external models. Must be OpenAI-compatible base URL""" + """Base URL of the external inference API. + + Must be OpenAI-compatible. Required when model_source is 'external'. + """ num_workers: int - """Number of concurrent workers for inference requests. + """Number of concurrent inference workers. - Overrides the default concurrency for this model. Useful for tuning throughput - when using proxy endpoints (e.g. OpenRouter) or rate-limited external APIs. + Overrides the source-specific default (serverless: 25, dedicated: 5, external: + 2–20). For dedicated endpoints the value is clamped to a minimum of 5 regardless + of what is set here. """ ParametersEvaluationCompareParametersModelA: TypeAlias = Union[ - str, ParametersEvaluationCompareParametersModelAEvaluationModelRequest + ParametersEvaluationCompareParametersModelAEvaluationModelRequest, str ] class ParametersEvaluationCompareParametersModelBEvaluationModelRequest(TypedDict, total=False): input_template: Required[str] - """Input prompt template""" + """User message template. Supports Jinja2 variables referencing dataset columns.""" max_tokens: Required[int] - """Maximum number of tokens to generate""" + """Maximum number of tokens to generate.""" model: Required[str] """Name of the model to evaluate""" model_source: Required[Literal["serverless", "dedicated", "external"]] - """Source of the model.""" + """ + Source of the model inference: - `serverless`: Together's shared serverless + inference API. Default concurrency: 25 workers. - `dedicated`: A Together + dedicated deployment endpoint. Default concurrency: 5 workers (minimum enforced + even if num_workers is set lower). Authentication uses the requesting user's + Together API token automatically. + + - `external`: An external inference API (e.g. OpenAI, Anthropic, Google, + OpenRouter). Requires `external_api_token` and `external_base_url`. Default + concurrency: 2 workers for first-party APIs (OpenAI, Anthropic, Google), 20 + for proxy/aggregator endpoints. + """ system_template: Required[str] - """System prompt template""" + """System prompt template. Supports Jinja2 variables referencing dataset columns.""" temperature: Required[float] - """Sampling temperature""" + """Sampling temperature for generation.""" external_api_token: str - """Bearer/API token for external models.""" + """Bearer/API token for the external model provider. + + Required when model_source is 'external'. + """ external_base_url: str - """Base URL for external models. Must be OpenAI-compatible base URL""" + """Base URL of the external inference API. + + Must be OpenAI-compatible. Required when model_source is 'external'. + """ num_workers: int - """Number of concurrent workers for inference requests. + """Number of concurrent inference workers. - Overrides the default concurrency for this model. Useful for tuning throughput - when using proxy endpoints (e.g. OpenRouter) or rate-limited external APIs. + Overrides the source-specific default (serverless: 25, dedicated: 5, external: + 2–20). For dedicated endpoints the value is clamped to a minimum of 5 regardless + of what is set here. """ ParametersEvaluationCompareParametersModelB: TypeAlias = Union[ - str, ParametersEvaluationCompareParametersModelBEvaluationModelRequest + ParametersEvaluationCompareParametersModelBEvaluationModelRequest, str ] class ParametersEvaluationCompareParameters(TypedDict, total=False): input_data_file_path: Required[str] - """Data file name""" + """Data file ID""" judge: Required[ParametersEvaluationCompareParametersJudge] + disable_position_bias_correction: bool + """ + When false (default), the judge runs twice per sample: once with model A's + response first (original order) and once with model B's response first (flipped + order). The two verdicts are reconciled to cancel out position bias. When true, + only the original-order pass is run, halving judge cost and latency at the + expense of position-bias correction. The result file will not contain + flipped-order judge fields when this is true. + """ + model_a: ParametersEvaluationCompareParametersModelA - """Field name in the input data""" + """ + Either an EvaluationModelRequest for generation or a string column name from the + dataset (when responses are pre-generated). When both model_a and model_b are + EvaluationModelRequest objects, their inference runs execute in parallel to + reduce total wall-clock time. + """ model_b: ParametersEvaluationCompareParametersModelB - """Field name in the input data""" + """ + Either an EvaluationModelRequest for generation or a string column name from the + dataset (when responses are pre-generated). When both model_a and model_b are + EvaluationModelRequest objects, their inference runs execute in parallel to + reduce total wall-clock time. + """ Parameters: TypeAlias = Union[ diff --git a/src/together/types/eval_status_response.py b/src/together/types/eval_status_response.py index ceaa864ea..0caf5327a 100644 --- a/src/together/types/eval_status_response.py +++ b/src/together/types/eval_status_response.py @@ -66,25 +66,33 @@ class ResultsEvaluationScoreResults(BaseModel): class ResultsEvaluationCompareResults(BaseModel): a_wins: Optional[int] = FieldInfo(alias="A_wins", default=None) - """Number of times model A won""" + """Number of samples where model A was judged the winner""" b_wins: Optional[int] = FieldInfo(alias="B_wins", default=None) - """Number of times model B won""" + """Number of samples where model B was judged the winner""" generation_fail_count: Optional[float] = None - """Number of failed generations.""" + """Number of generation failures across model A and model B.""" judge_fail_count: Optional[float] = None - """Number of failed judge generations""" + """Number of judge inference failures. - num_samples: Optional[int] = None - """Total number of samples compared""" + In the default two-pass mode (disable_position_bias_correction=false) this is + the combined failure count from both the original-order and flipped-order judge + passes. + """ result_file_id: Optional[str] = None - """Data File ID""" + """File ID of the detailed output file. + + Each row contains the original input fields plus judge outputs. In two-pass mode + the file includes both original-order and flipped-order judge fields; in + single-pass mode (disable_position_bias_correction=true) only original-order + fields are present. + """ ties: Optional[int] = FieldInfo(alias="Ties", default=None) - """Number of ties""" + """Number of samples that resulted in a tie""" Results: TypeAlias = Union[ diff --git a/src/together/types/evaluation_job.py b/src/together/types/evaluation_job.py index 5f9aba7ff..e822f854d 100644 --- a/src/together/types/evaluation_job.py +++ b/src/together/types/evaluation_job.py @@ -69,25 +69,33 @@ class ResultsEvaluationScoreResults(BaseModel): class ResultsEvaluationCompareResults(BaseModel): a_wins: Optional[int] = FieldInfo(alias="A_wins", default=None) - """Number of times model A won""" + """Number of samples where model A was judged the winner""" b_wins: Optional[int] = FieldInfo(alias="B_wins", default=None) - """Number of times model B won""" + """Number of samples where model B was judged the winner""" generation_fail_count: Optional[float] = None - """Number of failed generations.""" + """Number of generation failures across model A and model B.""" judge_fail_count: Optional[float] = None - """Number of failed judge generations""" + """Number of judge inference failures. - num_samples: Optional[int] = None - """Total number of samples compared""" + In the default two-pass mode (disable_position_bias_correction=false) this is + the combined failure count from both the original-order and flipped-order judge + passes. + """ result_file_id: Optional[str] = None - """Data File ID""" + """File ID of the detailed output file. + + Each row contains the original input fields plus judge outputs. In two-pass mode + the file includes both original-order and flipped-order judge fields; in + single-pass mode (disable_position_bias_correction=true) only original-order + fields are present. + """ ties: Optional[int] = FieldInfo(alias="Ties", default=None) - """Number of ties""" + """Number of samples that resulted in a tie""" class ResultsError(BaseModel): From bc211eb1451725cc28b66c1f1ed894af883d1681 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 11:48:05 -0500 Subject: [PATCH 22/30] Expose new Jig SDK parameters in CLI (#371) * feat(cli): expose new jig SDK parameters Co-authored-by: Blaine Kasten * fix(cli): avoid jig version flag conflicts Co-authored-by: Blaine Kasten * style: format jig CLI tests Co-authored-by: Blaine Kasten * style: sort jig CLI imports Co-authored-by: Blaine Kasten * Update src/together/lib/cli/api/beta/jig/jig.py Co-authored-by: Rasul Nabiyev * Update src/together/lib/cli/api/beta/jig/jig.py Co-authored-by: Rasul Nabiyev * Update src/together/lib/cli/utils/_help_examples.py Co-authored-by: Rasul Nabiyev * Update tests/cli/test_beta_jig.py Co-authored-by: Rasul Nabiyev --------- Co-authored-by: Cursor Agent Co-authored-by: Blaine Kasten Co-authored-by: Rasul Nabiyev --- src/together/lib/cli/api/beta/jig/jig.py | 71 ++++++++++++++++---- src/together/lib/cli/utils/_help_examples.py | 3 + tests/cli/test_beta_jig.py | 52 +++++++++++++- 3 files changed, 110 insertions(+), 16 deletions(-) diff --git a/src/together/lib/cli/api/beta/jig/jig.py b/src/together/lib/cli/api/beta/jig/jig.py index 9deabc0c6..df1e9ec4b 100644 --- a/src/together/lib/cli/api/beta/jig/jig.py +++ b/src/together/lib/cli/api/beta/jig/jig.py @@ -29,6 +29,7 @@ from cyclopts import Parameter from together import Together +from together._types import Omit, omit from together._exceptions import APIError, NotFoundError, AuthenticationError from together._utils._json import openapi_dumps from together.lib.cli.utils.config import CLIConfig, CLIConfigParameter @@ -919,15 +920,36 @@ def once(msg: str, detail: str | None = None) -> None: # == Query == - def logs(self, rid: str | None = None) -> str: - if not rid: - return "\n".join(self.api.retrieve_logs(self.name).lines or []) or "No logs available" - body = "\n".join(self.api.retrieve_logs(self.name, replica_id=rid).lines or []) - return f"\n--- Logs for {rid} ---\n{body or 'No logs available'}\n--- End of logs ---\n" + def logs( + self, + replica_id: str | None = None, + revision: str | None = None, + version: str | None = None, + ) -> str: + response = self.api.retrieve_logs( + self.name, + replica_id=replica_id or omit, + revision=revision or omit, + version=version or omit, + ) + body = "\n".join(response.lines or []) or "No logs available" + if replica_id: + return f"\n--- Logs for {replica_id} ---\n{body}\n--- End of logs ---\n" + return body - def follow_logs(self) -> None: + def follow_logs( + self, + replica_id: str | None = None, + revision: str | None = None, + version: str | None = None, + ) -> None: try: - with self.api.with_streaming_response.retrieve_logs(self.name) as stream: + with self.api.with_streaming_response.retrieve_logs( + self.name, + replica_id=replica_id or omit, + revision=revision or omit, + version=version or omit, + ) as stream: for line in stream.iter_lines(): if line: log_lines = json.loads(line).get("lines", []) @@ -1191,9 +1213,15 @@ def endpoint(jig: Jig) -> str: return f"https://api.together.ai/v1/deployment-request/{jig.name}" -def logs(jig: Jig, follow: bool) -> str | None: +def logs( + jig: Jig, + follow: bool, + replica_id: str | None, + revision: str | None, + version: str | None, +) -> str | None: """Get deployment logs""" - return jig.follow_logs() if follow else jig.logs() + return jig.follow_logs(replica_id, revision, version) if follow else jig.logs(replica_id, revision, version) def destroy(jig: Jig) -> str: @@ -1330,10 +1358,14 @@ def volumes_delete(jig: Jig, name: str) -> None: console.print(f"\N{CHECK MARK} Deleted volume {name}") -def volumes_describe(jig: Jig, name: str) -> Any: +def _optional_int(value: int | None) -> int | Omit: + return value if value is not None else omit + + +def volumes_describe(jig: Jig, name: str, version: int | None = None) -> Any: """Describe a volume""" try: - return jig.api.volumes.with_raw_response.retrieve(name) + return jig.api.volumes.with_raw_response.retrieve(name, version=_optional_int(version)) except NotFoundError: raise JigError(f"Volume {name} not found") from None @@ -1371,12 +1403,16 @@ async def jig_volumes_list( async def jig_volumes_describe( name: Annotated[str, Parameter(name="--name", help="Volume name")], + volume_version: Annotated[ + Optional[int], + Parameter(name="--volume-version", help="Volume version to describe"), + ] = None, *, config: CLIConfigParameter, ) -> None: """Describe a volume.""" try: - vol = await config.client.beta.jig.volumes.retrieve(name) + vol = await config.client.beta.jig.volumes.retrieve(name, version=_optional_int(volume_version)) except NotFoundError: _jig_fail(f"Volume {name} not found") else: @@ -1466,12 +1502,21 @@ def endpoint_cli( def logs_cli( follow: Annotated[bool, Parameter(help="Follow log output", negative=())] = False, + replica_id: Annotated[Optional[str], Parameter(name="--replica-id", help="Replica ID to filter logs")] = None, + revision: Annotated[ + Optional[str], + Parameter(name="--revision", help="Deployment revision UUID to filter logs"), + ] = None, + image_version: Annotated[ + Optional[str], + Parameter(name="--image-version", help="Deployment image version (tag or last 4 characters of image digest) to filter logs"), + ] = None, *, config: CLIConfigParameter, toml_config: TomlConfigParameter = None, ) -> None: """Get deployment logs.""" - _run_jig_cmd(config, toml_config, lambda jig: logs(jig, follow)) + _run_jig_cmd(config, toml_config, lambda jig: logs(jig, follow, replica_id, revision, image_version)) def destroy_cli( diff --git a/src/together/lib/cli/utils/_help_examples.py b/src/together/lib/cli/utils/_help_examples.py index 70285209e..925847481 100644 --- a/src/together/lib/cli/utils/_help_examples.py +++ b/src/together/lib/cli/utils/_help_examples.py @@ -468,6 +468,9 @@ [dim]-[/dim] Stream logs ([primary]Ctrl+C[/primary] to stop): [primary]tg beta jig logs --follow[/primary] + +[dim]-[/dim] Filter logs by replica and deployment revision: + [primary]tg beta jig logs --replica-id --revision --image-version [/primary] """ JIG_SUBMIT_HELP_EXAMPLES = """[dim]Examples:[/dim] diff --git a/tests/cli/test_beta_jig.py b/tests/cli/test_beta_jig.py index 5baddad56..35a89402a 100644 --- a/tests/cli/test_beta_jig.py +++ b/tests/cli/test_beta_jig.py @@ -220,7 +220,7 @@ def test_unset_missing_secret_message(self, tmp_path: Path, cli_runner: CliRunne class TestBetaJigBuild: - def test_build_blocked_when_deploy_image_set(self, tmp_path: Path) -> None: + def test_build_blocked_when_deploy_image_set(self, tmp_path: Path, cli_runner: CliRunner) -> None: with patch.object(_jig_mod.Config, "__post_init__", _noop_config_post_init): cfg = _jig_mod.Config( model_name=_DEPLOY_NAME, @@ -234,13 +234,44 @@ def _find(*_args: Any): return cfg with patch.object(_jig_mod.Config, "find", classmethod(_find)): - runner = CliRunner(env=_ENV) with _chdir(tmp_path): - result = runner.invoke(main, ["beta", "jig", "build"]) + result = cli_runner.invoke(["beta", "jig", "build"]) assert result.exit_code == 1 assert "deploy.image is set" in result.output +class TestBetaJigLogs: + @pytest.mark.respx(base_url=base_url) + def test_logs_forwards_sdk_filters(self, respx_mock: MockRouter, tmp_path: Path, cli_runner: CliRunner) -> None: + _write_jig_project(tmp_path) + route = respx_mock.get(f"/deployments/{_DEPLOY_NAME}/logs").mock( + return_value=httpx.Response(200, json={"lines": ["line 1", "line 2"]}) + ) + + with _chdir(tmp_path): + result = cli_runner.invoke( + [ + "beta", + "jig", + "logs", + "--replica-id", + "replica-1", + "--revision", + "revision-1", + "--image-version", + "v2", + ] + ) + + assert "line 1" in result.output + assert "line 2" in result.output + request = cast(Call, route.calls[0]).request + assert request.url.params["replica_id"] == "replica-1" + assert request.url.params["revision"] == "revision-1" + assert request.url.params["version"] == "v2" + assert result.exit_code == 0 + + class TestBetaJigVolumes: @pytest.mark.respx(base_url=base_url) def test_delete(self, respx_mock: MockRouter, tmp_path: Path, cli_runner: CliRunner) -> None: @@ -275,6 +306,21 @@ def test_describe_json(self, respx_mock: MockRouter, tmp_path: Path, cli_runner: assert json.loads(result.output) == payload assert result.exit_code == 0 + @pytest.mark.respx(base_url=base_url) + def test_describe_forwards_version(self, respx_mock: MockRouter, tmp_path: Path, cli_runner: CliRunner) -> None: + _write_jig_project(tmp_path) + route = respx_mock.get("/deployments/storage/volumes/v1").mock( + return_value=httpx.Response(200, json=_volume_api_body("v1", current_version=1)) + ) + + with _chdir(tmp_path): + result = cli_runner.invoke(["beta", "jig", "volumes", "describe", "--name", "v1", "--volume-version", "1"]) + + assert "Version" in result.output + request = cast(Call, route.calls[0]).request + assert request.url.params["version"] == "1" + assert result.exit_code == 0 + @pytest.mark.respx(base_url=base_url) def test_list_json(self, respx_mock: MockRouter, tmp_path: Path, cli_runner: CliRunner) -> None: _write_jig_project(tmp_path) From 939e1c7661a0eed53598bfb9684eeddc417e87b3 Mon Sep 17 00:00:00 2001 From: Artem Chumachenko Date: Wed, 20 May 2026 18:54:15 +0200 Subject: [PATCH 23/30] lint and fmt fixes (#376) --- src/together/lib/cli/api/beta/jig/jig.py | 5 ++++- src/together/lib/cli/api/fine_tuning/list_metrics.py | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/together/lib/cli/api/beta/jig/jig.py b/src/together/lib/cli/api/beta/jig/jig.py index df1e9ec4b..002ccefdb 100644 --- a/src/together/lib/cli/api/beta/jig/jig.py +++ b/src/together/lib/cli/api/beta/jig/jig.py @@ -1509,7 +1509,10 @@ def logs_cli( ] = None, image_version: Annotated[ Optional[str], - Parameter(name="--image-version", help="Deployment image version (tag or last 4 characters of image digest) to filter logs"), + Parameter( + name="--image-version", + help="Deployment image version (tag or last 4 characters of image digest) to filter logs", + ), ] = None, *, config: CLIConfigParameter, diff --git a/src/together/lib/cli/api/fine_tuning/list_metrics.py b/src/together/lib/cli/api/fine_tuning/list_metrics.py index 5e15a753b..fa804fc01 100644 --- a/src/together/lib/cli/api/fine_tuning/list_metrics.py +++ b/src/together/lib/cli/api/fine_tuning/list_metrics.py @@ -1,7 +1,6 @@ from __future__ import annotations -import sys -from typing import Literal, Optional, Annotated +from typing import Optional, Annotated from datetime import datetime from cyclopts import Parameter From 16567597382f3345aff0450bcf1a257976a97139 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 11:55:46 -0500 Subject: [PATCH 24/30] feat(cli): add remediation list filters (#372) * feat(cli): add remediation list filters Co-authored-by: Blaine Kasten * test(cli): assert remediation filter encoding Co-authored-by: Blaine Kasten --------- Co-authored-by: Cursor Agent Co-authored-by: Blaine Kasten --- .../api/beta/clusters/remediations/list.py | 47 ++++++++++++++++++- src/together/lib/cli/utils/_help_examples.py | 3 ++ tests/cli/test_beta_clusters.py | 34 ++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/together/lib/cli/api/beta/clusters/remediations/list.py b/src/together/lib/cli/api/beta/clusters/remediations/list.py index 22c70231d..1ee325e88 100644 --- a/src/together/lib/cli/api/beta/clusters/remediations/list.py +++ b/src/together/lib/cli/api/beta/clusters/remediations/list.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Optional, Annotated +from typing import List, Literal, Optional, Annotated, cast from cyclopts import Parameter @@ -12,21 +12,64 @@ from together.lib.cli.components.list import ListTable from together.lib.cli.components.loader import show_loading_status +RemediationModeParameter = Annotated[ + Optional[ + list[ + Literal[ + "VM_ONLY", + "HOST_AWARE", + "EVICT_WITHOUT_REPLACEMENT", + "REBOOT_VM", + ] + ] + ], + Parameter(help="Filter by remediation mode. Can be used multiple times."), +] +RemediationStateParameter = Annotated[ + Optional[list[Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"]]], + Parameter(help="Filter by remediation state. Can be used multiple times."), +] +RemediationTriggerParameter = Annotated[ + Optional[list[Literal["MANUAL", "AUTOMATED"]]], + Parameter(help="Filter by remediation trigger. Can be used multiple times."), +] + async def list( cluster_id: str, instance_id: Annotated[Optional[str], Parameter(help="Instance ID to list remediations for")] = None, after: Annotated[Optional[str], Parameter(help="Pagination token from a previous request")] = None, + mode: RemediationModeParameter = None, + state: RemediationStateParameter = None, + trigger: RemediationTriggerParameter = None, *, config: CLIConfigParameter, ) -> None: """List node remediations for a cluster or instance.""" + safe_modes = cast( + List[ + Literal[ + "REMEDIATION_MODE_VM_ONLY", + "REMEDIATION_MODE_HOST_AWARE", + "REMEDIATION_MODE_EVICT_WITHOUT_REPLACEMENT", + "REMEDIATION_MODE_REBOOT_VM", + ] + ], + [f"REMEDIATION_MODE_{value}" for value in mode] if mode else [], + ) + safe_triggers = cast( + List[Literal["REMEDIATION_TRIGGER_MANUAL", "REMEDIATION_TRIGGER_AUTOMATED"]], + [f"REMEDIATION_TRIGGER_{value}" for value in trigger] if trigger else [], + ) response = await show_loading_status( "Loading remediations...", config.client.beta.clusters.remediations.list( instance_id or "-", cluster_id=cluster_id, + mode=safe_modes or omit, page_token=after or omit, + state=state or omit, + trigger=safe_triggers or omit, ), ) @@ -39,6 +82,7 @@ async def list( table.add_primary_column("Instance", ratio=3) table.add_column("Mode") table.add_column("State") + table.add_column("Trigger") table.add_column("Remediation ID", ratio=3) for remediation in response.remediations: @@ -47,6 +91,7 @@ async def list( remediation.instance_id, remediation.mode.replace("REMEDIATION_MODE_", ""), _colorize(remediation.state), + remediation.trigger.replace("REMEDIATION_TRIGGER_", ""), remediation.id, ) diff --git a/src/together/lib/cli/utils/_help_examples.py b/src/together/lib/cli/utils/_help_examples.py index 925847481..817858769 100644 --- a/src/together/lib/cli/utils/_help_examples.py +++ b/src/together/lib/cli/utils/_help_examples.py @@ -360,6 +360,9 @@ [dim]-[/dim] List remediations for one instance: [primary]tg beta clusters remediations ls [/primary] +[dim]-[/dim] List automated remediations by mode: + [primary]tg beta clusters remediations ls --mode VM_ONLY --mode REBOOT_VM --trigger AUTOMATED[/primary] + [dim]-[/dim] Create a remediation: [primary]tg beta clusters remediations create --mode VM_ONLY --reason "node unhealthy"[/primary] diff --git a/tests/cli/test_beta_clusters.py b/tests/cli/test_beta_clusters.py index 1e204c49f..892940c8b 100644 --- a/tests/cli/test_beta_clusters.py +++ b/tests/cli/test_beta_clusters.py @@ -321,6 +321,40 @@ def test_remediations_list_accepts_instance_id(self, respx_mock: MockRouter, cli assert cast(Call, route.calls[0]).request.url.path == "/compute/clusters/c1/instances/i1/remediations" assert result.exit_code == 0 + @pytest.mark.respx(base_url=base_url) + def test_remediations_list_accepts_filters(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: + payload = _remediation_list_body(_remediation_body()) + route = respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=payload) + ) + result = cli_runner.invoke( + [ + "beta", + "clusters", + "remediations", + "list", + "c1", + "--mode", + "VM_ONLY", + "--mode", + "REBOOT_VM", + "--state", + "PENDING_APPROVAL", + "--trigger", + "AUTOMATED", + "--after", + "next-token", + "--json", + ] + ) + + params = cast(Call, route.calls[0]).request.url.params + assert params["mode"] == "REMEDIATION_MODE_VM_ONLY,REMEDIATION_MODE_REBOOT_VM" + assert params["state"] == "PENDING_APPROVAL" + assert params["trigger"] == "REMEDIATION_TRIGGER_AUTOMATED" + assert params["page_token"] == "next-token" + assert result.exit_code == 0 + @pytest.mark.respx(base_url=base_url) def test_remediations_retrieve_resolves_cluster_and_instance( self, respx_mock: MockRouter, cli_runner: CliRunner From 7a1a7c21f1cb0e898cf9c9cf12746964c5d1b978 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 17:00:08 +0000 Subject: [PATCH 25/30] fix(types): remove node_name from ControlPlaneNode and GPUWorkerNode --- .stats.yml | 4 ++-- src/together/types/beta/cluster.py | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.stats.yml b/.stats.yml index d261a070e..94ed598fd 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 81 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-835a3888d379fab044fd90910453b084ca9befdca7ce367f358dfd9cabcfcb14.yml -openapi_spec_hash: 36dae2e8eaa8dbf627d639e08e3fe24a +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-bab5a37892a3ada78d4cf218b2ae742fffdca6b39a1671624b47d5bf61bcb7f0.yml +openapi_spec_hash: 36032066f6699857a4e6ed02d682a21c config_hash: b35d5968fb07cce1c1be735f874898b1 diff --git a/src/together/types/beta/cluster.py b/src/together/types/beta/cluster.py index 2f46d55a0..cf130458b 100644 --- a/src/together/types/beta/cluster.py +++ b/src/together/types/beta/cluster.py @@ -96,8 +96,6 @@ class ControlPlaneNode(BaseModel): node_id: str - node_name: str - num_cpu_cores: int phase_transitions: List[ControlPlaneNodePhaseTransition] @@ -132,8 +130,6 @@ class GPUWorkerNode(BaseModel): node_id: str - node_name: str - num_cpu_cores: int num_gpus: int From 1edd32e87912fed96124dac5596eb007dce0abc8 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 12:10:58 -0500 Subject: [PATCH 26/30] Show remediation instance names in CLI (#373) * feat(cli): show remediation instance names Co-authored-by: Blaine Kasten * Run formatter on remediation CLI changes Co-authored-by: Blaine Kasten --------- Co-authored-by: Cursor Agent Co-authored-by: Blaine Kasten --- .../api/beta/clusters/remediations/list.py | 12 +++++++-- tests/cli/test_beta_clusters.py | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/together/lib/cli/api/beta/clusters/remediations/list.py b/src/together/lib/cli/api/beta/clusters/remediations/list.py index 1ee325e88..a25618b76 100644 --- a/src/together/lib/cli/api/beta/clusters/remediations/list.py +++ b/src/together/lib/cli/api/beta/clusters/remediations/list.py @@ -26,7 +26,9 @@ Parameter(help="Filter by remediation mode. Can be used multiple times."), ] RemediationStateParameter = Annotated[ - Optional[list[Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"]]], + Optional[ + list[Literal["PENDING_APPROVAL", "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "AUTO_RESOLVED"]] + ], Parameter(help="Filter by remediation state. Can be used multiple times."), ] RemediationTriggerParameter = Annotated[ @@ -88,7 +90,7 @@ async def list( for remediation in response.remediations: table.add_row( format_datetime(remediation.create_time) if remediation.create_time else "-", - remediation.instance_id, + _format_instance(remediation.instance_id, remediation.instance_name), remediation.mode.replace("REMEDIATION_MODE_", ""), _colorize(remediation.state), remediation.trigger.replace("REMEDIATION_TRIGGER_", ""), @@ -116,3 +118,9 @@ def _colorize(state: str) -> str: } color = state_colors[state] if state in state_colors else "white" return f"[{color}]{state}[/{color}]" + + +def _format_instance(instance_id: str, instance_name: str | None) -> str: + if not instance_name: + return instance_id + return f"{instance_name} ({instance_id})" diff --git a/tests/cli/test_beta_clusters.py b/tests/cli/test_beta_clusters.py index 892940c8b..ead923348 100644 --- a/tests/cli/test_beta_clusters.py +++ b/tests/cli/test_beta_clusters.py @@ -321,6 +321,32 @@ def test_remediations_list_accepts_instance_id(self, respx_mock: MockRouter, cli assert cast(Call, route.calls[0]).request.url.path == "/compute/clusters/c1/instances/i1/remediations" assert result.exit_code == 0 + @pytest.mark.respx(base_url=base_url) + def test_remediations_list_table_uses_instance_name(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: + payload = _remediation_list_body(_remediation_body(instance_name="gpu-node-a")) + respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=payload) + ) + + result = cli_runner.invoke(["beta", "clusters", "remediations", "list", "c1"]) + + assert "gpu-node-a (i1)" in result.output + assert result.exit_code == 0 + + @pytest.mark.respx(base_url=base_url) + def test_remediations_list_table_falls_back_to_instance_id( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + payload = _remediation_list_body(_remediation_body()) + respx_mock.get("/compute/clusters/c1/instances/-/remediations").mock( + return_value=httpx.Response(200, json=payload) + ) + + result = cli_runner.invoke(["beta", "clusters", "remediations", "list", "c1"]) + + assert "i1" in result.output + assert result.exit_code == 0 + @pytest.mark.respx(base_url=base_url) def test_remediations_list_accepts_filters(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: payload = _remediation_list_body(_remediation_body()) From acf7bc9659a8cf14b466f98f51960f19825b55b2 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 12:36:16 -0500 Subject: [PATCH 27/30] Fix fine-tuning metrics CLI zero filters (#374) * Fix fine-tuning metrics CLI zero filters Co-authored-by: Blaine Kasten * Fix metrics CLI test datetime input Co-authored-by: Blaine Kasten * Format fine tuning CLI test Co-authored-by: Blaine Kasten --------- Co-authored-by: Cursor Agent Co-authored-by: Blaine Kasten --- .../lib/cli/api/fine_tuning/list_metrics.py | 10 ++-- tests/cli/test_fine_tuning.py | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/together/lib/cli/api/fine_tuning/list_metrics.py b/src/together/lib/cli/api/fine_tuning/list_metrics.py index fa804fc01..c30728564 100644 --- a/src/together/lib/cli/api/fine_tuning/list_metrics.py +++ b/src/together/lib/cli/api/fine_tuning/list_metrics.py @@ -37,11 +37,11 @@ async def list_metrics( "Fetching metrics...", config.client.fine_tuning.list_metrics( fine_tune_id, - global_step_from=global_step_from or omit, - global_step_to=global_step_to or omit, - logged_at_from=logged_at_from or omit, - logged_at_to=logged_at_to or omit, - resolution=resolution or omit, + global_step_from=global_step_from if global_step_from is not None else omit, + global_step_to=global_step_to if global_step_to is not None else omit, + logged_at_from=logged_at_from if logged_at_from is not None else omit, + logged_at_to=logged_at_to if logged_at_to is not None else omit, + resolution=resolution if resolution is not None else omit, ), ) diff --git a/tests/cli/test_fine_tuning.py b/tests/cli/test_fine_tuning.py index 08d1b3d1f..f10c22692 100644 --- a/tests/cli/test_fine_tuning.py +++ b/tests/cli/test_fine_tuning.py @@ -3,12 +3,14 @@ import os import json import importlib +from typing import cast from pathlib import Path from unittest.mock import patch import httpx import pytest from respx import MockRouter +from respx.models import Call from tests.cli.utils import CliRunner @@ -70,6 +72,16 @@ "step": 5, } +_FT_METRICS_BODY = { + "metrics": [ + { + "global_step": 0, + "train_loss": 1.25, + "logged_at": "2024-01-01T00:00:00Z", + } + ] +} + class TestFineTuningList: @pytest.mark.respx(base_url=base_url) @@ -197,6 +209,40 @@ def test_list_checkpoints_empty_message(self, respx_mock: MockRouter, cli_runner assert "No checkpoints found" in result.output +class TestFineTuningListMetrics: + @pytest.mark.respx(base_url=base_url) + def test_list_metrics_json_includes_zero_step_filters(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: + route = respx_mock.get("/fine-tunes/ft-1/metrics").mock(return_value=httpx.Response(200, json=_FT_METRICS_BODY)) + + result = cli_runner.invoke( + [ + "fine-tuning", + "list-metrics", + "ft-1", + "--global-step-from", + "0", + "--global-step-to", + "0", + "--logged-at-from", + "2024-01-01T00:00:00+00:00", + "--logged-at-to", + "2024-01-02T00:00:00+00:00", + "--resolution", + "50", + "--json", + ] + ) + + assert result.exit_code == 0 + params = cast(Call, route.calls[0]).request.url.params + assert params["global_step_from"] == "0" + assert params["global_step_to"] == "0" + assert params["logged_at_from"] == "2024-01-01T00:00:00+00:00" + assert params["logged_at_to"] == "2024-01-02T00:00:00+00:00" + assert params["resolution"] == "50" + assert json.loads(result.output) == _FT_METRICS_BODY["metrics"] + + class TestFineTuningDownload: @pytest.mark.respx(base_url=base_url) def test_download_invokes_download_manager( From 6d3e1e101f6a58b9fa7dc2e3ec1d6b66aa1b885a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 17:40:23 +0000 Subject: [PATCH 28/30] codegen metadata --- .stats.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 94ed598fd..e8f2d8264 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 81 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-bab5a37892a3ada78d4cf218b2ae742fffdca6b39a1671624b47d5bf61bcb7f0.yml -openapi_spec_hash: 36032066f6699857a4e6ed02d682a21c +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai/togetherai-ce108a2095d36552bb556506de04475674f512a13bc5aa099e9750993405be14.yml +openapi_spec_hash: 4763dd426dd805306bbb38a314158cd3 config_hash: b35d5968fb07cce1c1be735f874898b1 From ac8482ebbf9fdf7e67973ff36a0178ce774963cb Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 13:50:22 -0500 Subject: [PATCH 29/30] feat(cli): add eval compare bias correction flag (#375) Co-authored-by: Cursor Agent Co-authored-by: Blaine Kasten --- src/together/lib/cli/api/evals/create.py | 8 +++++ src/together/lib/cli/utils/_help_examples.py | 3 +- tests/cli/test_evals.py | 38 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/together/lib/cli/api/evals/create.py b/src/together/lib/cli/api/evals/create.py index 400397744..345794160 100644 --- a/src/together/lib/cli/api/evals/create.py +++ b/src/together/lib/cli/api/evals/create.py @@ -77,6 +77,13 @@ async def create( pass_threshold: Annotated[ Optional[float], Parameter(help="Threshold for passing (required for score type)") ] = None, + disable_position_bias_correction: Annotated[ + bool, + Parameter( + negative=(), + help="For compare evals, run only the original-order judge pass without position-bias correction", + ), + ] = False, model_a_field: Annotated[ Optional[str], Parameter( @@ -274,6 +281,7 @@ async def create( parameters=ParametersEvaluationCompareParameters( input_data_file_path=training_file, judge=judge_config, + disable_position_bias_correction=disable_position_bias_correction, model_a=cast(ParametersEvaluationCompareParametersModelAEvaluationModelRequest, model_a_final), model_b=cast(ParametersEvaluationCompareParametersModelBEvaluationModelRequest, model_b_final), ), diff --git a/src/together/lib/cli/utils/_help_examples.py b/src/together/lib/cli/utils/_help_examples.py index 817858769..2bbe64ea8 100644 --- a/src/together/lib/cli/utils/_help_examples.py +++ b/src/together/lib/cli/utils/_help_examples.py @@ -246,7 +246,8 @@ --model-b deepseek-ai/DeepSeek-V3.1 \\ --model-b-source serverless \\ --model-b-system-template "You are a concise assistant." \\ - --model-b-input-template $'Answer the following:\\n\\n{{prompt}}'[/primary] + --model-b-input-template $'Answer the following:\\n\\n{{prompt}}' \\ + --disable-position-bias-correction[/primary] """ ## Beta clusters API commands diff --git a/tests/cli/test_evals.py b/tests/cli/test_evals.py index 319a95a61..0bc318f17 100644 --- a/tests/cli/test_evals.py +++ b/tests/cli/test_evals.py @@ -59,3 +59,41 @@ def test_status(self, respx_mock: MockRouter, cli_runner: CliRunner) -> None: result = cli_runner.invoke(["evals", "status", "eval-wf-1"]) assert result.exit_code == 0 assert "Status: completed" in result.output + + +class TestEvalsCreate: + @pytest.mark.respx(base_url=base_url) + def test_compare_passes_disable_position_bias_correction( + self, respx_mock: MockRouter, cli_runner: CliRunner + ) -> None: + route = respx_mock.post("/evaluation").mock( + return_value=httpx.Response(200, json={"workflow_id": "eval-wf-1", "status": "pending"}) + ) + + result = cli_runner.invoke( + [ + "evals", + "create", + "--type", + "compare", + "--judge-model", + "Qwen/Qwen3.5-9B", + "--judge-model-source", + "serverless", + "--judge-system-template", + "Choose the better response.", + "--input-data-file-path", + "file-123", + "--model-a-field", + "response_a", + "--model-b-field", + "response_b", + "--disable-position-bias-correction", + ] + ) + + assert result.exit_code == 0 + req = cast(Call, route.calls[0]).request + payload = json.loads(req.content) + assert payload["type"] == "compare" + assert payload["parameters"]["disable_position_bias_correction"] is True From 05010fe5c94bca5da24de248f333365d001d5cf5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 18:50:49 +0000 Subject: [PATCH 30/30] release: 2.15.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/together/_version.py | 2 +- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 586828934..cff01f26f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.14.0" + ".": "2.15.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 418ba50f1..c6d0c9f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 2.15.0 (2026-05-20) + +Full Changelog: [v2.14.0...v2.15.0](https://github.com/togethercomputer/together-py/compare/v2.14.0...v2.15.0) + +### Features + +* **api:** add cluster config/OIDC/add-ons params, project filtering, update storage types ([9a8c60e](https://github.com/togethercomputer/together-py/commit/9a8c60eb51daba174c0a4761612b3dd51fb5bee5)) +* **api:** add disable_position_bias_correction, remove num_samples from eval compare results ([27e6c2d](https://github.com/togethercomputer/together-py/commit/27e6c2db1e2549d8b2352f73265067f0eac9b44c)) +* **api:** add h200-140gb gpu_type to jig deploy/update methods ([0f34ea4](https://github.com/togethercomputer/together-py/commit/0f34ea4e1441a08b014f19d99588902b17eda1be)) +* **api:** add instance_name field to remediation model ([4c7fc66](https://github.com/togethercomputer/together-py/commit/4c7fc662363054f47d5e7a01353e3f0da98d8b6a)) +* **api:** Add node remediation APIs to clusters sdks ([029c3fd](https://github.com/togethercomputer/together-py/commit/029c3fd79c22f130dab4a46bc62a6e1410908da4)) +* **api:** add trigger param, support multiple modes in remediations list ([997deea](https://github.com/togethercomputer/together-py/commit/997deeae7c514b0ce2dc65394d262abe9bd35766)) +* **api:** manual updates ([f4de411](https://github.com/togethercomputer/together-py/commit/f4de41192250b3c609e44da6bee18309db209f35)) +* **api:** manual updates ([b5e42a0](https://github.com/togethercomputer/together-py/commit/b5e42a042c367dbe14021be3c6612155ac8f6fac)) +* **cli:** add eval compare bias correction flag ([#375](https://github.com/togethercomputer/together-py/issues/375)) ([ac8482e](https://github.com/togethercomputer/together-py/commit/ac8482ebbf9fdf7e67973ff36a0178ce774963cb)) +* **cli:** add get as alias for retrieve subcommands ([#367](https://github.com/togethercomputer/together-py/issues/367)) ([d283d11](https://github.com/togethercomputer/together-py/commit/d283d1192b1c75ad676420be4ea30137883d55a6)) +* **cli:** add remediation list filters ([#372](https://github.com/togethercomputer/together-py/issues/372)) ([1656759](https://github.com/togethercomputer/together-py/commit/16567597382f3345aff0450bcf1a257976a97139)) +* **jig:** copy and use uv.lock if exists on autogenerated dockerfile ([#370](https://github.com/togethercomputer/together-py/issues/370)) ([47e5c89](https://github.com/togethercomputer/together-py/commit/47e5c891ac2272b0d20c1c266d0e3a9527448019)) +* Sync deployments OpenAPI spec ([1caa5fa](https://github.com/togethercomputer/together-py/commit/1caa5fa4c41dff79164d8edd7436e66747eab712)) + + +### Bug Fixes + +* **api:** make duration_days optional in clusters create, size_tib optional in storage update ([899752d](https://github.com/togethercomputer/together-py/commit/899752dbebed9a75433b9ab95245c3bf15237eb3)) +* **api:** remove error field, make request_id required in jig queue submit response ([5ae0fbc](https://github.com/togethercomputer/together-py/commit/5ae0fbca3e592dafedc4638642582585f29098df)) +* **api:** remove trigger parameter from remediations list method ([d6310d8](https://github.com/togethercomputer/together-py/commit/d6310d881cc12bb67132c9446d301d1663fd9f48)) +* **jig:** honor uv default groups in autogenerated dockerfile ([#301](https://github.com/togethercomputer/together-py/issues/301)) ([85cf77b](https://github.com/togethercomputer/together-py/commit/85cf77b6dc8df8a4f5859deb543123195c484b5b)) +* **types:** correct status field to enum in cluster_storage model ([2109f0a](https://github.com/togethercomputer/together-py/commit/2109f0a0c897a7d5659f700042ec97b0843b3228)) +* **types:** remove node_name from ControlPlaneNode and GPUWorkerNode ([7a1a7c2](https://github.com/togethercomputer/together-py/commit/7a1a7c21f1cb0e898cf9c9cf12746964c5d1b978)) + + +### Documentation + +* **api:** add parameter descriptions to storage methods and types ([8c35457](https://github.com/togethercomputer/together-py/commit/8c35457b06dc6a58f0c1343accb8db09ad91b845)) + ## 2.14.0 (2026-05-12) Full Changelog: [v2.13.0...v2.14.0](https://github.com/togethercomputer/together-py/compare/v2.13.0...v2.14.0) diff --git a/pyproject.toml b/pyproject.toml index 5495bee53..5bf286440 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "together" -version = "2.14.0" +version = "2.15.0" description = "The official Python library for the together API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/together/_version.py b/src/together/_version.py index 605d05197..6656537cd 100644 --- a/src/together/_version.py +++ b/src/together/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "together" -__version__ = "2.14.0" # x-release-please-version +__version__ = "2.15.0" # x-release-please-version