diff --git a/.stats.yml b/.stats.yml index 67859756b..0f519e53d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 74 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai%2Ftogetherai-31893d157d3c85caa1d8615b73a5fa431ea2cc126bd2410e0f84f3defd5c7dec.yml -openapi_spec_hash: b652a4d504b4a3dbf585ab803b0f59fc +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/togetherai%2Ftogetherai-607d55fc5cb3aef533d26c8d23cad27948a494cc847df66650004f72905dcdc1.yml +openapi_spec_hash: 77793253c4dfc99f5bf1db9103213740 config_hash: 52d213100a0ca1a4b2cdcd2718936b51 diff --git a/src/together/_utils/__init__.py b/src/together/_utils/__init__.py index dc64e29a1..10cb66d2d 100644 --- a/src/together/_utils/__init__.py +++ b/src/together/_utils/__init__.py @@ -1,3 +1,4 @@ +from ._path import path_template as path_template from ._sync import asyncify as asyncify from ._proxy import LazyProxy as LazyProxy from ._utils import ( diff --git a/src/together/_utils/_path.py b/src/together/_utils/_path.py new file mode 100644 index 000000000..4d6e1e4cb --- /dev/null +++ b/src/together/_utils/_path.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import re +from typing import ( + Any, + Mapping, + Callable, +) +from urllib.parse import quote + +# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E). +_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$") + +_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") + + +def _quote_path_segment_part(value: str) -> str: + """Percent-encode `value` for use in a URI path segment. + + Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 + """ + # quote() already treats unreserved characters (letters, digits, and -._~) + # as safe, so we only need to add sub-delims, ':', and '@'. + # Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted. + return quote(value, safe="!$&'()*+,;=:@") + + +def _quote_query_part(value: str) -> str: + """Percent-encode `value` for use in a URI query string. + + Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.4 + """ + return quote(value, safe="!$'()*+,;:@/?") + + +def _quote_fragment_part(value: str) -> str: + """Percent-encode `value` for use in a URI fragment. + + Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe. + https://datatracker.ietf.org/doc/html/rfc3986#section-3.5 + """ + return quote(value, safe="!$&'()*+,;=:@/?") + + +def _interpolate( + template: str, + values: Mapping[str, Any], + quoter: Callable[[str], str], +) -> str: + """Replace {name} placeholders in `template`, quoting each value with `quoter`. + + Placeholder names are looked up in `values`. + + Raises: + KeyError: If a placeholder is not found in `values`. + """ + # re.split with a capturing group returns alternating + # [text, name, text, name, ..., text] elements. + parts = _PLACEHOLDER_RE.split(template) + + for i in range(1, len(parts), 2): + name = parts[i] + if name not in values: + raise KeyError(f"a value for placeholder {{{name}}} was not provided") + val = values[name] + if val is None: + parts[i] = "null" + elif isinstance(val, bool): + parts[i] = "true" if val else "false" + else: + parts[i] = quoter(str(values[name])) + + return "".join(parts) + + +def path_template(template: str, /, **kwargs: Any) -> str: + """Interpolate {name} placeholders in `template` from keyword arguments. + + Args: + template: The template string containing {name} placeholders. + **kwargs: Keyword arguments to interpolate into the template. + + Returns: + The template with placeholders interpolated and percent-encoded. + + Safe characters for percent-encoding are dependent on the URI component. + Placeholders in path and fragment portions are percent-encoded where the `segment` + and `fragment` sets from RFC 3986 respectively are considered safe. + Placeholders in the query portion are percent-encoded where the `query` set from + RFC 3986 §3.3 is considered safe except for = and & characters. + + Raises: + KeyError: If a placeholder is not found in `kwargs`. + ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments). + """ + # Split the template into path, query, and fragment portions. + fragment_template: str | None = None + query_template: str | None = None + + rest = template + if "#" in rest: + rest, fragment_template = rest.split("#", 1) + if "?" in rest: + rest, query_template = rest.split("?", 1) + path_template = rest + + # Interpolate each portion with the appropriate quoting rules. + path_result = _interpolate(path_template, kwargs, _quote_path_segment_part) + + # Reject dot-segments (. and ..) in the final assembled path. The check + # runs after interpolation so that adjacent placeholders or a mix of static + # text and placeholders that together form a dot-segment are caught. + # Also reject percent-encoded dot-segments to protect against incorrectly + # implemented normalization in servers/proxies. + for segment in path_result.split("/"): + if _DOT_SEGMENT_RE.match(segment): + raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed") + + result = path_result + if query_template is not None: + result += "?" + _interpolate(query_template, kwargs, _quote_query_part) + if fragment_template is not None: + result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part) + + return result diff --git a/src/together/lib/cli/api/beta/jig/jig.py b/src/together/lib/cli/api/beta/jig/jig.py index 23aca141f..24d930c16 100644 --- a/src/together/lib/cli/api/beta/jig/jig.py +++ b/src/together/lib/cli/api/beta/jig/jig.py @@ -148,7 +148,7 @@ def validate(value: Any, value_type: type, path: str = "") -> str | None: return err return None - if origin is Union or origin is getattr(types, "UnionType", None): + if origin is Union or origin is getattr(types, "UnionType", Union): errs = [validate(value, a, path) for a in args if a is not type(None)] if not all(errs): return None @@ -505,7 +505,7 @@ def state(self) -> State: def registry(self) -> str: """Get registry and namespace for current user""" if not self.state.registry_base_path: - response = self.together.get("/image-repositories/base-path", cast_to=dict[str, str]) + response = self.together.get("/image-repositories/base-path", cast_to=typing.Dict[str, str]) # strip protocol for docker image format self.state.registry_base_path = response["base-path"].split("://", 1)[-1] self.state.save() @@ -890,6 +890,9 @@ def _command(f: Callable[..., Any]) -> Callable[..., Any]: @click.option("-c", "--config", "config_path", default=None, help="Configuration file path") @wraps(f) def wrapper(ctx: Context, config_path: str | None, *args: Any, **kwargs: Any) -> None: + if sys.version_info < (3, 10): + click.secho("Jig requires Python 3.10+", fg="red") + raise Exit(1) try: result = f(Jig(ctx.obj, config_path), *args, **kwargs) except (Exit, click.Abort, click.ClickException): diff --git a/src/together/resources/batches.py b/src/together/resources/batches.py index cb9067598..58c61accc 100644 --- a/src/together/resources/batches.py +++ b/src/together/resources/batches.py @@ -6,7 +6,7 @@ from ..types import batch_create_params from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .._utils import maybe_transform, async_maybe_transform +from .._utils import path_template, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -126,7 +126,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/batches/{id}", + path_template("/batches/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -180,7 +180,7 @@ def cancel( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._post( - f"/batches/{id}/cancel", + path_template("/batches/{id}/cancel", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -291,7 +291,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/batches/{id}", + path_template("/batches/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -345,7 +345,7 @@ async def cancel( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._post( - f"/batches/{id}/cancel", + path_template("/batches/{id}/cancel", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/together/resources/beta/clusters/clusters.py b/src/together/resources/beta/clusters/clusters.py index 6f5eebcc0..fe8c85508 100644 --- a/src/together/resources/beta/clusters/clusters.py +++ b/src/together/resources/beta/clusters/clusters.py @@ -15,7 +15,7 @@ AsyncStorageResourceWithStreamingResponse, ) from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ...._utils import maybe_transform, async_maybe_transform +from ...._utils import path_template, maybe_transform, async_maybe_transform from ...._compat import cached_property from ...._resource import SyncAPIResource, AsyncAPIResource from ...._response import ( @@ -170,7 +170,7 @@ def retrieve( if not cluster_id: raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") return self._get( - f"/compute/clusters/{cluster_id}", + path_template("/compute/clusters/{cluster_id}", cluster_id=cluster_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -212,7 +212,7 @@ def update( if not cluster_id: raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") return self._put( - f"/compute/clusters/{cluster_id}", + path_template("/compute/clusters/{cluster_id}", cluster_id=cluster_id), body=maybe_transform( { "cluster_type": cluster_type, @@ -273,7 +273,7 @@ def delete( if not cluster_id: raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") return self._delete( - f"/compute/clusters/{cluster_id}", + path_template("/compute/clusters/{cluster_id}", cluster_id=cluster_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -436,7 +436,7 @@ async def retrieve( if not cluster_id: raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") return await self._get( - f"/compute/clusters/{cluster_id}", + path_template("/compute/clusters/{cluster_id}", cluster_id=cluster_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -478,7 +478,7 @@ async def update( if not cluster_id: raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") return await self._put( - f"/compute/clusters/{cluster_id}", + path_template("/compute/clusters/{cluster_id}", cluster_id=cluster_id), body=await async_maybe_transform( { "cluster_type": cluster_type, @@ -539,7 +539,7 @@ async def delete( if not cluster_id: raise ValueError(f"Expected a non-empty value for `cluster_id` but received {cluster_id!r}") return await self._delete( - f"/compute/clusters/{cluster_id}", + path_template("/compute/clusters/{cluster_id}", cluster_id=cluster_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/together/resources/beta/clusters/storage.py b/src/together/resources/beta/clusters/storage.py index 0d6260e03..c6abf44e8 100644 --- a/src/together/resources/beta/clusters/storage.py +++ b/src/together/resources/beta/clusters/storage.py @@ -5,7 +5,7 @@ import httpx from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ...._utils import maybe_transform, async_maybe_transform +from ...._utils import path_template, maybe_transform, async_maybe_transform from ...._compat import cached_property from ...._resource import SyncAPIResource, AsyncAPIResource from ...._response import ( @@ -122,7 +122,7 @@ def retrieve( if not volume_id: raise ValueError(f"Expected a non-empty value for `volume_id` but received {volume_id!r}") return self._get( - f"/compute/clusters/storage/volumes/{volume_id}", + path_template("/compute/clusters/storage/volumes/{volume_id}", volume_id=volume_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -221,7 +221,7 @@ def delete( if not volume_id: raise ValueError(f"Expected a non-empty value for `volume_id` but received {volume_id!r}") return self._delete( - f"/compute/clusters/storage/volumes/{volume_id}", + path_template("/compute/clusters/storage/volumes/{volume_id}", volume_id=volume_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -328,7 +328,7 @@ async def retrieve( if not volume_id: raise ValueError(f"Expected a non-empty value for `volume_id` but received {volume_id!r}") return await self._get( - f"/compute/clusters/storage/volumes/{volume_id}", + path_template("/compute/clusters/storage/volumes/{volume_id}", volume_id=volume_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -427,7 +427,7 @@ async def delete( if not volume_id: raise ValueError(f"Expected a non-empty value for `volume_id` but received {volume_id!r}") return await self._delete( - f"/compute/clusters/storage/volumes/{volume_id}", + path_template("/compute/clusters/storage/volumes/{volume_id}", volume_id=volume_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/together/resources/beta/jig/jig.py b/src/together/resources/beta/jig/jig.py index 953eb2eb4..90595bb4e 100644 --- a/src/together/resources/beta/jig/jig.py +++ b/src/together/resources/beta/jig/jig.py @@ -32,7 +32,7 @@ AsyncVolumesResourceWithStreamingResponse, ) from ...._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given -from ...._utils import maybe_transform, async_maybe_transform +from ...._utils import path_template, maybe_transform, async_maybe_transform from ...._compat import cached_property from ...._resource import SyncAPIResource, AsyncAPIResource from ...._response import ( @@ -110,7 +110,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/deployments/{id}", + path_template("/deployments/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -211,7 +211,7 @@ def update( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._patch( - f"/deployments/{id}", + path_template("/deployments/{id}", id=id), body=maybe_transform( { "args": args, @@ -411,7 +411,7 @@ def destroy( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._delete( - f"/deployments/{id}", + path_template("/deployments/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -449,7 +449,7 @@ def retrieve_logs( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/deployments/{id}/logs", + path_template("/deployments/{id}/logs", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -521,7 +521,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/deployments/{id}", + path_template("/deployments/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -622,7 +622,7 @@ async def update( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._patch( - f"/deployments/{id}", + path_template("/deployments/{id}", id=id), body=await async_maybe_transform( { "args": args, @@ -822,7 +822,7 @@ async def destroy( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._delete( - f"/deployments/{id}", + path_template("/deployments/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -860,7 +860,7 @@ async def retrieve_logs( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/deployments/{id}/logs", + path_template("/deployments/{id}/logs", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/together/resources/beta/jig/secrets.py b/src/together/resources/beta/jig/secrets.py index f1b874ca2..64c80bcae 100644 --- a/src/together/resources/beta/jig/secrets.py +++ b/src/together/resources/beta/jig/secrets.py @@ -5,7 +5,7 @@ import httpx from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ...._utils import maybe_transform, async_maybe_transform +from ...._utils import path_template, maybe_transform, async_maybe_transform from ...._compat import cached_property from ...._resource import SyncAPIResource, AsyncAPIResource from ...._response import ( @@ -126,7 +126,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/deployments/secrets/{id}", + path_template("/deployments/secrets/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -178,7 +178,7 @@ def update( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._patch( - f"/deployments/secrets/{id}", + path_template("/deployments/secrets/{id}", id=id), body=maybe_transform( { "description": description, @@ -241,7 +241,7 @@ def delete( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._delete( - f"/deployments/secrets/{id}", + path_template("/deployments/secrets/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -353,7 +353,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/deployments/secrets/{id}", + path_template("/deployments/secrets/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -405,7 +405,7 @@ async def update( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._patch( - f"/deployments/secrets/{id}", + path_template("/deployments/secrets/{id}", id=id), body=await async_maybe_transform( { "description": description, @@ -468,7 +468,7 @@ async def delete( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._delete( - f"/deployments/secrets/{id}", + path_template("/deployments/secrets/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/together/resources/beta/jig/volumes.py b/src/together/resources/beta/jig/volumes.py index f93447ee2..817058984 100644 --- a/src/together/resources/beta/jig/volumes.py +++ b/src/together/resources/beta/jig/volumes.py @@ -7,7 +7,7 @@ import httpx from ...._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from ...._utils import maybe_transform, async_maybe_transform +from ...._utils import path_template, maybe_transform, async_maybe_transform from ...._compat import cached_property from ...._resource import SyncAPIResource, AsyncAPIResource from ...._response import ( @@ -119,7 +119,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/deployments/storage/volumes/{id}", + 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 ), @@ -163,7 +163,7 @@ def update( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._patch( - f"/deployments/storage/volumes/{id}", + path_template("/deployments/storage/volumes/{id}", id=id), body=maybe_transform( { "content": content, @@ -225,7 +225,7 @@ def delete( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._delete( - f"/deployments/storage/volumes/{id}", + 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 ), @@ -328,7 +328,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/deployments/storage/volumes/{id}", + 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 ), @@ -372,7 +372,7 @@ async def update( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._patch( - f"/deployments/storage/volumes/{id}", + path_template("/deployments/storage/volumes/{id}", id=id), body=await async_maybe_transform( { "content": content, @@ -434,7 +434,7 @@ async def delete( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._delete( - f"/deployments/storage/volumes/{id}", + 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 ), diff --git a/src/together/resources/endpoints.py b/src/together/resources/endpoints.py index 573e82bcf..afa3cfce3 100644 --- a/src/together/resources/endpoints.py +++ b/src/together/resources/endpoints.py @@ -14,7 +14,7 @@ endpoint_list_hardware_params, ) from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given -from .._utils import maybe_transform, async_maybe_transform +from .._utils import path_template, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -158,7 +158,7 @@ def retrieve( if not endpoint_id: raise ValueError(f"Expected a non-empty value for `endpoint_id` but received {endpoint_id!r}") return self._get( - f"/endpoints/{endpoint_id}", + path_template("/endpoints/{endpoint_id}", endpoint_id=endpoint_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -208,7 +208,7 @@ def update( if not endpoint_id: raise ValueError(f"Expected a non-empty value for `endpoint_id` but received {endpoint_id!r}") return self._patch( - f"/endpoints/{endpoint_id}", + path_template("/endpoints/{endpoint_id}", endpoint_id=endpoint_id), body=maybe_transform( { "autoscaling": autoscaling, @@ -306,7 +306,7 @@ def delete( raise ValueError(f"Expected a non-empty value for `endpoint_id` but received {endpoint_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return self._delete( - f"/endpoints/{endpoint_id}", + path_template("/endpoints/{endpoint_id}", endpoint_id=endpoint_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -500,7 +500,7 @@ async def retrieve( if not endpoint_id: raise ValueError(f"Expected a non-empty value for `endpoint_id` but received {endpoint_id!r}") return await self._get( - f"/endpoints/{endpoint_id}", + path_template("/endpoints/{endpoint_id}", endpoint_id=endpoint_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -550,7 +550,7 @@ async def update( if not endpoint_id: raise ValueError(f"Expected a non-empty value for `endpoint_id` but received {endpoint_id!r}") return await self._patch( - f"/endpoints/{endpoint_id}", + path_template("/endpoints/{endpoint_id}", endpoint_id=endpoint_id), body=await async_maybe_transform( { "autoscaling": autoscaling, @@ -648,7 +648,7 @@ async def delete( raise ValueError(f"Expected a non-empty value for `endpoint_id` but received {endpoint_id!r}") extra_headers = {"Accept": "*/*", **(extra_headers or {})} return await self._delete( - f"/endpoints/{endpoint_id}", + path_template("/endpoints/{endpoint_id}", endpoint_id=endpoint_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/together/resources/evals.py b/src/together/resources/evals.py index b3ebd5634..16b93c22d 100644 --- a/src/together/resources/evals.py +++ b/src/together/resources/evals.py @@ -8,7 +8,7 @@ from ..types import eval_list_params, eval_create_params from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .._utils import maybe_transform, async_maybe_transform +from .._utils import path_template, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -117,7 +117,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/evaluation/{id}", + path_template("/evaluation/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -198,7 +198,7 @@ def status( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/evaluation/{id}/status", + path_template("/evaluation/{id}/status", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -297,7 +297,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/evaluation/{id}", + path_template("/evaluation/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -378,7 +378,7 @@ async def status( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/evaluation/{id}/status", + path_template("/evaluation/{id}/status", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/together/resources/files.py b/src/together/resources/files.py index 74121dab1..a72af09fc 100644 --- a/src/together/resources/files.py +++ b/src/together/resources/files.py @@ -14,6 +14,7 @@ from ..lib import FileTypeError, UploadManager, AsyncUploadManager, check_file from ..types import FilePurpose from .._types import Body, Query, Headers, NotGiven, not_given +from .._utils import path_template from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -86,7 +87,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/files/{id}", + path_template("/files/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -140,7 +141,7 @@ def delete( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._delete( - f"/files/{id}", + path_template("/files/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -213,7 +214,7 @@ def content( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") extra_headers = {"Accept": "application/binary", **(extra_headers or {})} return self._get( - f"/files/{id}/content", + path_template("/files/{id}/content", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -269,7 +270,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/files/{id}", + path_template("/files/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -323,7 +324,7 @@ async def delete( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._delete( - f"/files/{id}", + path_template("/files/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -396,7 +397,7 @@ async def content( raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") extra_headers = {"Accept": "application/binary", **(extra_headers or {})} return await self._get( - f"/files/{id}/content", + path_template("/files/{id}/content", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/together/resources/fine_tuning.py b/src/together/resources/fine_tuning.py index 025b73ce8..e057a151b 100644 --- a/src/together/resources/fine_tuning.py +++ b/src/together/resources/fine_tuning.py @@ -10,7 +10,7 @@ from ..types import fine_tuning_delete_params, fine_tuning_content_params, fine_tuning_estimate_price_params from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from .._utils import maybe_transform, async_maybe_transform +from .._utils import path_template, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -311,7 +311,7 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/fine-tunes/{id}", + path_template("/fine-tunes/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -372,7 +372,7 @@ def delete( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._delete( - f"/fine-tunes/{id}", + path_template("/fine-tunes/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -413,7 +413,7 @@ def cancel( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._post( - f"/fine-tunes/{id}/cancel", + path_template("/fine-tunes/{id}/cancel", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -574,7 +574,7 @@ def list_checkpoints( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/fine-tunes/{id}/checkpoints", + path_template("/fine-tunes/{id}/checkpoints", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -609,7 +609,7 @@ def list_events( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/fine-tunes/{id}/events", + path_template("/fine-tunes/{id}/events", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -868,7 +868,7 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/fine-tunes/{id}", + path_template("/fine-tunes/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -929,7 +929,7 @@ async def delete( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._delete( - f"/fine-tunes/{id}", + path_template("/fine-tunes/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, @@ -970,7 +970,7 @@ async def cancel( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._post( - f"/fine-tunes/{id}/cancel", + path_template("/fine-tunes/{id}/cancel", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -1131,7 +1131,7 @@ async def list_checkpoints( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/fine-tunes/{id}/checkpoints", + path_template("/fine-tunes/{id}/checkpoints", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -1166,7 +1166,7 @@ async def list_events( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/fine-tunes/{id}/events", + path_template("/fine-tunes/{id}/events", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/together/resources/models/uploads.py b/src/together/resources/models/uploads.py index f0cacfcd4..95260eff3 100644 --- a/src/together/resources/models/uploads.py +++ b/src/together/resources/models/uploads.py @@ -5,6 +5,7 @@ import httpx from ..._types import Body, Query, Headers, NotGiven, not_given +from ..._utils import path_template from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( @@ -67,7 +68,7 @@ def status( if not job_id: raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}") return self._get( - f"/jobs/{job_id}", + path_template("/jobs/{job_id}", job_id=job_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -123,7 +124,7 @@ async def status( if not job_id: raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}") return await self._get( - f"/jobs/{job_id}", + path_template("/jobs/{job_id}", job_id=job_id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/together/resources/videos.py b/src/together/resources/videos.py index 81a7a78aa..d721fb980 100644 --- a/src/together/resources/videos.py +++ b/src/together/resources/videos.py @@ -9,7 +9,7 @@ from ..types import video_create_params from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given -from .._utils import maybe_transform, async_maybe_transform +from .._utils import path_template, maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -169,7 +169,8 @@ def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return self._get( - f"/videos/{id}" if self._client._base_url_overridden else f"https://api.together.xyz/v2/videos/{id}", + ("https://api.together.xyz/v2" if not self._client._base_url_overridden else "") + + path_template("/videos/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), @@ -322,7 +323,8 @@ async def retrieve( if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") return await self._get( - f"/videos/{id}" if self._client._base_url_overridden else f"https://api.together.xyz/v2/videos/{id}", + ("https://api.together.xyz/v2" if not self._client._base_url_overridden else "") + + path_template("/videos/{id}", id=id), options=make_request_options( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout ), diff --git a/src/together/types/fine_tuning_cancel_response.py b/src/together/types/fine_tuning_cancel_response.py index 345973c5c..a2e31a719 100644 --- a/src/together/types/fine_tuning_cancel_response.py +++ b/src/together/types/fine_tuning_cancel_response.py @@ -177,6 +177,13 @@ class FineTuningCancelResponse(BaseModel): progress: Optional[Progress] = None """Progress information for the fine-tuning job""" + random_seed: Optional[int] = None + """Random seed used for training. + + Integer when set; null if not stored (e.g. legacy jobs) or no explicit seed was + recorded. + """ + started_at: Optional[datetime] = None """Start timestamp of the current stage of the fine-tune job""" diff --git a/src/together/types/fine_tuning_list_response.py b/src/together/types/fine_tuning_list_response.py index 5b87c9461..2691302b5 100644 --- a/src/together/types/fine_tuning_list_response.py +++ b/src/together/types/fine_tuning_list_response.py @@ -178,6 +178,13 @@ class Data(BaseModel): progress: Optional[DataProgress] = None """Progress information for the fine-tuning job""" + random_seed: Optional[int] = None + """Random seed used for training. + + Integer when set; null if not stored (e.g. legacy jobs) or no explicit seed was + recorded. + """ + started_at: Optional[datetime] = None """Start timestamp of the current stage of the fine-tune job""" diff --git a/tests/test_utils/test_path.py b/tests/test_utils/test_path.py new file mode 100644 index 000000000..b450312bb --- /dev/null +++ b/tests/test_utils/test_path.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from together._utils._path import path_template + + +@pytest.mark.parametrize( + "template, kwargs, expected", + [ + ("/v1/{id}", dict(id="abc"), "/v1/abc"), + ("/v1/{a}/{b}", dict(a="x", b="y"), "/v1/x/y"), + ("/v1/{a}{b}/path/{c}?val={d}#{e}", dict(a="x", b="y", c="z", d="u", e="v"), "/v1/xy/path/z?val=u#v"), + ("/{w}/{w}", dict(w="echo"), "/echo/echo"), + ("/v1/static", {}, "/v1/static"), + ("", {}, ""), + ("/v1/?q={n}&count=10", dict(n=42), "/v1/?q=42&count=10"), + ("/v1/{v}", dict(v=None), "/v1/null"), + ("/v1/{v}", dict(v=True), "/v1/true"), + ("/v1/{v}", dict(v=False), "/v1/false"), + ("/v1/{v}", dict(v=".hidden"), "/v1/.hidden"), # dot prefix ok + ("/v1/{v}", dict(v="file.txt"), "/v1/file.txt"), # dot in middle ok + ("/v1/{v}", dict(v="..."), "/v1/..."), # triple dot ok + ("/v1/{a}{b}", dict(a=".", b="txt"), "/v1/.txt"), # dot var combining with adjacent to be ok + ("/items?q={v}#{f}", dict(v=".", f=".."), "/items?q=.#.."), # dots in query/fragment are fine + ( + "/v1/{a}?query={b}", + dict(a="../../other/endpoint", b="a&bad=true"), + "/v1/..%2F..%2Fother%2Fendpoint?query=a%26bad%3Dtrue", + ), + ("/v1/{val}", dict(val="a/b/c"), "/v1/a%2Fb%2Fc"), + ("/v1/{val}", dict(val="a/b/c?query=value"), "/v1/a%2Fb%2Fc%3Fquery=value"), + ("/v1/{val}", dict(val="a/b/c?query=value&bad=true"), "/v1/a%2Fb%2Fc%3Fquery=value&bad=true"), + ("/v1/{val}", dict(val="%20"), "/v1/%2520"), # escapes escape sequences in input + # Query: slash and ? are safe, # is not + ("/items?q={v}", dict(v="a/b"), "/items?q=a/b"), + ("/items?q={v}", dict(v="a?b"), "/items?q=a?b"), + ("/items?q={v}", dict(v="a#b"), "/items?q=a%23b"), + ("/items?q={v}", dict(v="a b"), "/items?q=a%20b"), + # Fragment: slash and ? are safe + ("/docs#{v}", dict(v="a/b"), "/docs#a/b"), + ("/docs#{v}", dict(v="a?b"), "/docs#a?b"), + # Path: slash, ? and # are all encoded + ("/v1/{v}", dict(v="a/b"), "/v1/a%2Fb"), + ("/v1/{v}", dict(v="a?b"), "/v1/a%3Fb"), + ("/v1/{v}", dict(v="a#b"), "/v1/a%23b"), + # same var encoded differently by component + ( + "/v1/{v}?q={v}#{v}", + dict(v="a/b?c#d"), + "/v1/a%2Fb%3Fc%23d?q=a/b?c%23d#a/b?c%23d", + ), + ("/v1/{val}", dict(val="x?admin=true"), "/v1/x%3Fadmin=true"), # query injection + ("/v1/{val}", dict(val="x#admin"), "/v1/x%23admin"), # fragment injection + ], +) +def test_interpolation(template: str, kwargs: dict[str, Any], expected: str) -> None: + assert path_template(template, **kwargs) == expected + + +def test_missing_kwarg_raises_key_error() -> None: + with pytest.raises(KeyError, match="org_id"): + path_template("/v1/{org_id}") + + +@pytest.mark.parametrize( + "template, kwargs", + [ + ("{a}/path", dict(a=".")), + ("{a}/path", dict(a="..")), + ("/v1/{a}", dict(a=".")), + ("/v1/{a}", dict(a="..")), + ("/v1/{a}/path", dict(a=".")), + ("/v1/{a}/path", dict(a="..")), + ("/v1/{a}{b}", dict(a=".", b=".")), # adjacent vars → ".." + ("/v1/{a}.", dict(a=".")), # var + static → ".." + ("/v1/{a}{b}", dict(a="", b=".")), # empty + dot → "." + ("/v1/%2e/{x}", dict(x="ok")), # encoded dot in static text + ("/v1/%2e./{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/.%2E/{x}", dict(x="ok")), # mixed encoded ".." in static + ("/v1/{v}?q=1", dict(v="..")), + ("/v1/{v}#frag", dict(v="..")), + ], +) +def test_dot_segment_rejected(template: str, kwargs: dict[str, Any]) -> None: + with pytest.raises(ValueError, match="dot-segment"): + path_template(template, **kwargs)