diff --git a/.jscpd.json b/.jscpd.json index d46f055..dad8708 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -14,6 +14,6 @@ "mode": "weak", "kind": ["exact", "renamed"], "ignoreIdentifiers": true, - "threshold": 16.3, + "threshold": 16.1, "reporters": ["console"] } diff --git a/README.md b/README.md index ae70e3a..15763da 100644 --- a/README.md +++ b/README.md @@ -511,6 +511,64 @@ caller who is not a project administrator only ever gets their own connections. The routes need `connections:read`, `connections:write` and `connections:delete`. +## Administer a project + +These Control Plane namespaces need an org credential: an owner's or admin's +session (`IntrospectionClient(cp_session=...)`) or an access token minted for +one. A project API key carries none of their scopes. Pass `project=` (a slug +or id) on project-scoped calls when the credential does not name one. + +| Namespace | What it manages | +| --- | --- | +| `client.oidc.me()` | Who the credential is, with the org's resolved feature flags | +| `client.projects` | The org's projects, read by slug or id (`list`, `get`) | +| `client.api_keys` | A project's API keys: `create` returns the plaintext once, then `acknowledge`; `delete(expires_in=...)` rotates | +| `client.applications` | OAuth clients, with `.idps`, `.secrets` and `.keys` under each | +| `client.credentials` / `client.variables` | Secrets and environment variables a project's runtimes read | +| `client.integrations` | The org's own provider installs (org-wide, no `project`) | +| `client.judges` | The judges a Recipe declares: read, and toggle `enabled` / `sample_rate` | + +```python +client = IntrospectionClient(cp_session=os.environ["INTROSPECTION_CP_SESSION"]) + +created = client.api_keys.create(project="support", name="ci") +save(created.api_key.get_secret_value()) # a SecretStr: it never prints +client.api_keys.acknowledge(created.id, project="support") + +client.credentials.create(name="OPENAI_API_KEY", secret="sk-...", project="support") +``` + +A secret the API returns once (an API key, a client secret) or takes on write +(a credential, an IdP client secret, an integration's OAuth `code`) is a +pydantic `SecretStr`, so `repr` and logging show `**********`. + +## Images and task commands + +`images` and `tasks.commands` are on the Data Plane, on the client and a +Runner alike, for evaluation trials on a plane with evals enabled. An image is +what an eval task's compose services run as; a command is `exec`, `upload` or +`download` against an eval task's live sandbox. + +```python +from introspection_sdk.schemas.images import MirrorImageSource +from introspection_sdk.schemas.task_commands import ExecCommand + +image = client.images.create( + name="hello-mcp/mcp-server", + source=MirrorImageSource(reference="ghcr.io/acme/mcp@sha256:..."), +) +# Poll client.images.get(image.id) until status is "ready". + +task = client.tasks.create(prompt="Fix the failing test", kind="eval", commands=True) +command = client.tasks.commands.create(str(task.task.id), ExecCommand(command=["pytest", "-q"])) +command = client.tasks.commands.get(str(task.task.id), command.id, wait_seconds=25) +print(command.status, command.exit_code, command.stdout) +``` + +`wait_seconds` holds the read until the command finishes; it is capped at +`MAX_COMMAND_WAIT_SECONDS` (25, below the client's 30-second request timeout), +so call `get` again to wait longer. + ## Environment variables ```shell diff --git a/examples/introspection_examples/api/project_admin.py b/examples/introspection_examples/api/project_admin.py new file mode 100644 index 0000000..ba938cc --- /dev/null +++ b/examples/introspection_examples/api/project_admin.py @@ -0,0 +1,89 @@ +"""Administer a project: who you are, its API keys and runtime settings. + +Reads the signed-in member, the org's projects, and a project's API keys, +credentials, variables and judges; then, when INTROSPECTION_ADMIN_WRITE=1, +mints an API key, saves its one-time plaintext, acknowledges it and revokes +it again. + +These routes need an org credential: an owner's or admin's Control Plane +session (or an access token minted for one). A project API key carries none +of their scopes and gets a 403, which is why this example is not in +``examples.yml``. + +Run with: + INTROSPECTION_CP_SESSION=... # the intro_cp_session cookie value + INTROSPECTION_PROJECT=support # project slug or id + + uv run python -m introspection_examples.api.project_admin + +Optional env: + INTROSPECTION_ADMIN_WRITE - 1 to also mint and revoke an API key + INTROSPECTION_BASE_API_URL - CP REST API host (default https://api.introspection.dev) +""" + +from __future__ import annotations + +import os +import sys + +from dotenv import load_dotenv +from introspection_sdk import IntrospectionClient +from introspection_sdk.schemas.lanes import Environment + + +def main() -> None: + load_dotenv() + session = os.getenv("INTROSPECTION_CP_SESSION") + project = os.getenv("INTROSPECTION_PROJECT") + if not session or not project: + sys.exit( + "Set INTROSPECTION_CP_SESSION to an owner's Control Plane session " + "and INTROSPECTION_PROJECT to a project slug or id." + ) + + client = IntrospectionClient(cp_session=session) + try: + # #region docs:project-admin + me = client.oidc.me() + print(f"signed in as member {me.member_id} of org {me.org_id}") + + for each in client.projects.list(): + print(f" project {each.slug or each.id}: {each.name}") + + # `project` names the project on every project-scoped call, since a + # member session names none of its own. + for key in client.api_keys.list(project=project): + print(f" key {key.key_display} {key.status} {key.environment}") + for credential in client.credentials.list(project=project): + print( + f" credential {credential.name} {credential.secret_display}" + ) + for variable in client.variables.list(project=project): + print(f" variable {variable.name}={variable.value}") + for judge in client.judges.list(project=project): + print(f" judge {judge.name} sample_rate={judge.sample_rate}") + # #endregion docs:project-admin + + if os.getenv("INTROSPECTION_ADMIN_WRITE") == "1": + # #region docs:api-key-rotate + created = client.api_keys.create( + project=project, + name="example", + environment=Environment.DEVELOPMENT, + ) + # A SecretStr: printing `created` shows **********. This is the + # only time the API returns the plaintext. + plaintext = ( + created.api_key.get_secret_value() if created.api_key else "" + ) + print(f"minted {created.key_display} ({len(plaintext)} chars)") + client.api_keys.acknowledge(created.id, project=project) + # `expires_in` keeps the key working that long, for a rotation. + client.api_keys.delete(created.id, project=project, expires_in=0) + # #endregion docs:api-key-rotate + finally: + client.shutdown() + + +if __name__ == "__main__": + main() diff --git a/introspection_sdk/_async/client.py b/introspection_sdk/_async/client.py index 6fce233..b3bdae0 100644 --- a/introspection_sdk/_async/client.py +++ b/introspection_sdk/_async/client.py @@ -14,20 +14,29 @@ from introspection_sdk.dev_target import client_headers from introspection_sdk.resources import ( AsyncAnnotations, + AsyncApiKeys, + AsyncApplications, AsyncAutomations, AsyncConnectors, + AsyncCredentials, AsyncExperiments, + AsyncIntegrations, + AsyncJudges, AsyncMembers, + AsyncOidc, + AsyncProjects, AsyncRecipes, AsyncRepositories, AsyncRuntimes, AsyncTags, + AsyncVariables, ) from introspection_sdk.runner_resources import ( AsyncAppConnections, AsyncConversations, AsyncEvents, AsyncFiles, + AsyncImages, AsyncMetrics, AsyncShares, AsyncTasks, @@ -88,6 +97,15 @@ class AsyncIntrospectionClient: metrics: AsyncMetrics shares: AsyncShares connections: AsyncAppConnections + images: AsyncImages + projects: AsyncProjects + api_keys: AsyncApiKeys + applications: AsyncApplications + credentials: AsyncCredentials + variables: AsyncVariables + integrations: AsyncIntegrations + judges: AsyncJudges + oidc: AsyncOidc def __init__( self, @@ -147,6 +165,18 @@ def __init__( self.metrics = AsyncMetrics(self._dp_http) self.shares = AsyncShares(self._dp_http) self.connections = AsyncAppConnections(self._dp_http) + self.images = AsyncImages(self._dp_http) + # Project administration: each route needs an org credential (a + # member session or access token); a project API key carries none + # of their scopes. + self.projects = AsyncProjects(self._http) + self.api_keys = AsyncApiKeys(self._http) + self.applications = AsyncApplications(self._http) + self.credentials = AsyncCredentials(self._http) + self.variables = AsyncVariables(self._http) + self.integrations = AsyncIntegrations(self._http) + self.judges = AsyncJudges(self._http) + self.oidc = AsyncOidc(self._http) @classmethod async def from_service_account( diff --git a/introspection_sdk/_async/protocols.py b/introspection_sdk/_async/protocols.py index 79ae774..bbb95e5 100644 --- a/introspection_sdk/_async/protocols.py +++ b/introspection_sdk/_async/protocols.py @@ -44,6 +44,14 @@ ) from introspection_sdk.schemas.files import File, FileType from introspection_sdk.schemas.genai_span import GenAiSpan, GenAiSpanList +from introspection_sdk.schemas.images import ( + ContextImageSource, + Image, + ImageKind, + ImageProvenance, + ImageStatus, + MirrorImageSource, +) from introspection_sdk.schemas.metrics import ( MetricQueryRequest, MetricQueryResponse, @@ -53,12 +61,20 @@ ResourceShare, ShareResourceType, ) +from introspection_sdk.schemas.task_commands import ( + DownloadCommand, + ExecCommand, + TaskCommand, + TaskCommandStatus, + UploadCommand, +) from introspection_sdk.schemas.tasks import ( Task, TaskCancelRequest, TaskCancelResponse, TaskCreateResponse, TaskFileRef, + TaskKind, TaskPrompt, TaskRepoRequest, TaskRun, @@ -105,6 +121,37 @@ def stream( ) -> AsyncIterator[AGUIEvent]: ... +@runtime_checkable +class AsyncTaskCommandsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.tasks.AsyncTaskCommands`.""" + + async def create( + self, + task_id: str, + command: ExecCommand + | UploadCommand + | DownloadCommand + | dict[str, Any], + ) -> TaskCommand: ... + + def list( + self, + task_id: str, + *, + status: TaskCommandStatus | str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[TaskCommand, Paginated[TaskCommand]]: ... + + async def get( + self, + task_id: str, + command_id: str | UUID, + *, + wait_seconds: int | None = None, + ) -> TaskCommand: ... + + @runtime_checkable class AsyncTasksResource(Protocol): """Protocol of :class:`~introspection_sdk.runner_resources.tasks.AsyncTasks`.""" @@ -112,6 +159,9 @@ class AsyncTasksResource(Protocol): @property def runs(self) -> AsyncTaskRunsResource: ... + @property + def commands(self) -> AsyncTaskCommandsResource: ... + def list( self, *, @@ -137,6 +187,8 @@ async def create( fork_share_id: str | None = None, files: builtins.list[TaskFileRef | dict[str, Any]] | None = None, tags: builtins.list[str] | None = None, + kind: TaskKind | str | None = None, + commands: bool | None = None, ) -> TaskCreateResponse: ... async def get(self, task_id: str) -> Task: ... @@ -596,6 +648,35 @@ async def get(self, connection_id: UUID | str) -> Connection: ... async def delete(self, connection_id: UUID | str) -> None: ... +@runtime_checkable +class AsyncImagesResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.images.AsyncImages`.""" + + def list( + self, + *, + kind: ImageKind | str | None = None, + status: ImageStatus | str | None = None, + name: str | None = None, + runtime_id: UUID | None = None, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[Image, Paginated[Image]]: ... + + async def get(self, image_id: UUID | str) -> Image: ... + + async def create( + self, + *, + name: str, + source: ContextImageSource | MirrorImageSource | dict[str, Any], + provenance: ImageProvenance | dict[str, Any] | None = None, + scan: bool | None = None, + ) -> Image: ... + + async def delete(self, image_id: UUID | str) -> None: ... + + @runtime_checkable class AsyncDataPlaneResources(Protocol): """The data-plane namespaces :class:`~introspection_sdk.AsyncIntrospectionClient` @@ -624,3 +705,6 @@ def automations(self) -> AsyncAutomationsResource: ... @property def connections(self) -> AsyncConnectionsResource: ... + + @property + def images(self) -> AsyncImagesResource: ... diff --git a/introspection_sdk/_async/resources/_crud.py b/introspection_sdk/_async/resources/_crud.py new file mode 100644 index 0000000..b979c82 --- /dev/null +++ b/introspection_sdk/_async/resources/_crud.py @@ -0,0 +1,71 @@ +"""Request plumbing the administration namespaces share.""" + +from __future__ import annotations + +from typing import Any, TypeVar +from uuid import UUID + +from pydantic import BaseModel + +from introspection_sdk._http import _AsyncHttpClient +from introspection_sdk.pagination import ( + AsyncPager, + async_cursor_paginate, +) +from introspection_sdk.schemas.pagination import Paginated + +M = TypeVar("M", bound=BaseModel) + + +def _project(project: str | UUID | None) -> dict[str, Any]: + """The ``?project=`` selector (slug or id), sent only when given; needed + when the credential does not already name a project.""" + return {"project": str(project) if project is not None else None} + + +def _body(request: type[BaseModel], fields: dict[str, Any]) -> dict[str, Any]: + """Validate ``fields`` against the request model and drop the unset ones, + so an omitted field never reaches the wire as ``null``.""" + return request.model_validate(fields).model_dump( + mode="json", exclude_none=True + ) + + +class _AsyncResource: + def __init__(self, http: _AsyncHttpClient) -> None: + self._http = http + + def _paginate( + self, + path: str, + page: type[Paginated[M]], + params: dict[str, Any], + ) -> AsyncPager[M, Paginated[M]]: + start = params.pop("next", None) + + async def fetch(cursor: str | None) -> Paginated[M]: + payload = await self._http.request( + "GET", path, params={**params, "next": cursor} + ) + return page.model_validate(payload) + + return async_cursor_paginate(fetch, start=start) + + async def _call( + self, + method: str, + path: str, + response: type[M], + *, + params: dict[str, Any] | None = None, + json: Any = None, + ) -> M: + payload = await self._http.request( + method, path, params=params, json=json + ) + return response.model_validate(payload) + + async def _delete( + self, path: str, params: dict[str, Any] | None = None + ) -> None: + await self._http.request("DELETE", path, params=params, expect="empty") diff --git a/introspection_sdk/_async/resources/api_keys.py b/introspection_sdk/_async/resources/api_keys.py new file mode 100644 index 0000000..ccabd19 --- /dev/null +++ b/introspection_sdk/_async/resources/api_keys.py @@ -0,0 +1,129 @@ +"""Re-exported by :mod:`introspection_sdk.resources.api_keys`.""" + +from __future__ import annotations + +import builtins +from datetime import datetime +from uuid import UUID + +from introspection_sdk._async.resources._crud import ( + _AsyncResource, + _body, + _project, +) +from introspection_sdk.schemas.api_keys import ( + ApiKey, + ApiKeyAcknowledgement, + ApiKeyCapability, + ApiKeyCreated, + ApiKeyCreateRequest, + ApiKeyUpdateRequest, +) +from introspection_sdk.schemas.lanes import Environment + + +def _key_path(api_key_id: UUID) -> str: + return f"/v1/api-keys/{api_key_id}" + + +class AsyncApiKeys(_AsyncResource): + """CP ``/v1/api-keys`` namespace: a project's API keys. + + Needs an org credential (a member session or access token); a project + API key cannot manage keys. ``project`` (slug or id) names the project + when the credential does not. + """ + + async def list( + self, + *, + project: str | UUID | None = None, + expiring: bool | None = None, + ) -> builtins.list[ApiKey]: + """Every key of the project (the route is not paginated). + ``expiring=True`` keeps only keys close to expiry.""" + payload = await self._http.request( + "GET", + "/v1/api-keys", + params={**_project(project), "expiring": expiring}, + ) + return [ApiKey.model_validate(key) for key in payload["api_keys"]] + + async def create( + self, + *, + project: str | UUID | None = None, + name: str | None = None, + environment: Environment | str | None = None, + allow: builtins.list[ApiKeyCapability | str] | None = None, + member_id: UUID | None = None, + expires_at: datetime | None = None, + ) -> ApiKeyCreated: + """Mint a key. Its plaintext is :attr:`ApiKeyCreated.api_key` (a + ``SecretStr``) and is never returned again: save it, then call + :meth:`acknowledge`. ``environment`` defaults to ``production``.""" + body = _body( + ApiKeyCreateRequest, + { + "name": name, + "environment": environment, + "allow": allow, + "member_id": member_id, + "expires_at": expires_at, + }, + ) + return await self._call( + "POST", + "/v1/api-keys", + ApiKeyCreated, + params=_project(project), + json=body, + ) + + async def update( + self, + api_key_id: UUID, + *, + project: str | UUID | None = None, + name: str | None = None, + allow: builtins.list[ApiKeyCapability | str] | None = None, + member_id: UUID | None = None, + ) -> ApiKey: + """Rename a key or change what it may do. Only given fields change.""" + body = _body( + ApiKeyUpdateRequest, + {"name": name, "allow": allow, "member_id": member_id}, + ) + return await self._call( + "PATCH", + _key_path(api_key_id), + ApiKey, + params=_project(project), + json=body, + ) + + async def delete( + self, + api_key_id: UUID, + *, + project: str | UUID | None = None, + expires_in: int | None = None, + ) -> None: + """Revoke a key now, or keep it working ``expires_in`` more seconds + (a rotation; it reads as ``rotating`` until then).""" + await self._delete( + _key_path(api_key_id), + {**_project(project), "expires_in": expires_in}, + ) + + async def acknowledge( + self, api_key_id: UUID, *, project: str | UUID | None = None + ) -> ApiKeyAcknowledgement: + """Record that the plaintext from :meth:`create` was saved. A second + acknowledge raises :class:`~introspection_sdk.NotFoundError`.""" + return await self._call( + "POST", + f"{_key_path(api_key_id)}/acknowledge", + ApiKeyAcknowledgement, + params=_project(project), + ) diff --git a/introspection_sdk/_async/resources/applications.py b/introspection_sdk/_async/resources/applications.py new file mode 100644 index 0000000..5e42bee --- /dev/null +++ b/introspection_sdk/_async/resources/applications.py @@ -0,0 +1,267 @@ +"""Re-exported by :mod:`introspection_sdk.resources.applications`.""" + +from __future__ import annotations + +import builtins +from uuid import UUID + +from pydantic import SecretStr, TypeAdapter + +from introspection_sdk._async.resources._crud import ( + _AsyncResource, + _body, + _project, +) +from introspection_sdk._http import _AsyncHttpClient +from introspection_sdk.pagination import AsyncPager +from introspection_sdk.schemas.applications import ( + Application, + ApplicationCreateRequest, + ApplicationIdp, + ApplicationIdpCreateRequest, + ApplicationIdpProvider, + ApplicationKey, + ApplicationSecret, + ApplicationSecretCreateRequest, + ApplicationType, + ApplicationUpdateRequest, +) +from introspection_sdk.schemas.lanes import Environment +from introspection_sdk.schemas.pagination import Paginated + +_KEYS = TypeAdapter(list[ApplicationKey]) + + +def _app_path(application_id: UUID) -> str: + return f"/v1/applications/{application_id}" + + +class AsyncApplicationIdps(_AsyncResource): + """CP ``/v1/applications/{id}/idps``: the identity providers an + Application brokers, whose users sign in as customer members.""" + + def list( + self, + application_id: UUID, + *, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[ApplicationIdp, Paginated[ApplicationIdp]]: + """List an Application's identity providers, across pages.""" + return self._paginate( + f"{_app_path(application_id)}/idps", + Paginated[ApplicationIdp], + {"limit": limit, "next": next}, + ) + + async def create( + self, + application_id: UUID, + *, + idp_provider: ApplicationIdpProvider | str | None = None, + issuer: str | None = None, + client_id: str | None = None, + client_secret: str | SecretStr | None = None, + scopes: builtins.list[str] | None = None, + audience_client_id: str | None = None, + ) -> ApplicationIdp: + """Attach an identity provider (``idp_provider`` defaults to + ``generic``). ``client_secret`` is write-only.""" + body = _body( + ApplicationIdpCreateRequest, + { + "idp_provider": idp_provider, + "issuer": issuer, + "client_id": client_id, + "client_secret": client_secret, + "scopes": scopes, + "audience_client_id": audience_client_id, + }, + ) + return await self._call( + "POST", + f"{_app_path(application_id)}/idps", + ApplicationIdp, + json=body, + ) + + async def delete(self, application_id: UUID, idp_id: UUID) -> None: + await self._delete(f"{_app_path(application_id)}/idps/{idp_id}") + + +class AsyncApplicationSecrets(_AsyncResource): + """CP ``/v1/applications/{id}/secrets``: a confidential Application's + client secrets.""" + + def list( + self, + application_id: UUID, + *, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[ApplicationSecret, Paginated[ApplicationSecret]]: + """List an Application's client secrets (never the plaintext).""" + return self._paginate( + f"{_app_path(application_id)}/secrets", + Paginated[ApplicationSecret], + {"limit": limit, "next": next}, + ) + + async def create( + self, application_id: UUID, *, name: str | None = None + ) -> ApplicationSecret: + """Mint a client secret. Its plaintext is + :attr:`ApplicationSecret.secret` (a ``SecretStr``) on this response + only.""" + return await self._call( + "POST", + f"{_app_path(application_id)}/secrets", + ApplicationSecret, + json=_body(ApplicationSecretCreateRequest, {"name": name}), + ) + + async def delete( + self, + application_id: UUID, + secret_id: UUID, + *, + expires_in: int | None = None, + ) -> None: + """Disable a secret now, or after ``expires_in`` seconds for a + rotation.""" + await self._delete( + f"{_app_path(application_id)}/secrets/{secret_id}", + {"expires_in": expires_in}, + ) + + +class AsyncApplicationKeys(_AsyncResource): + """CP ``/v1/applications/{id}/keys``: a ``jwks`` Application's signing + keys.""" + + async def list( + self, application_id: UUID + ) -> builtins.list[ApplicationKey]: + """Every signing key (the route is not paginated).""" + payload = await self._http.request( + "GET", f"{_app_path(application_id)}/keys" + ) + return _KEYS.validate_python(payload) + + async def create(self, application_id: UUID) -> ApplicationKey: + """Mint a key pair; the API keeps the private half.""" + return await self._call( + "POST", f"{_app_path(application_id)}/keys", ApplicationKey + ) + + async def delete(self, application_id: UUID, key_id: UUID) -> None: + await self._delete(f"{_app_path(application_id)}/keys/{key_id}") + + +class AsyncApplications(_AsyncResource): + """CP ``/v1/applications`` namespace: a project's OAuth clients, with + ``idps``, ``secrets`` and ``keys`` under each. + + Needs an org credential; ``project`` (slug or id) names the project + when the credential does not. + """ + + def __init__(self, http: _AsyncHttpClient) -> None: + super().__init__(http) + self.idps = AsyncApplicationIdps(http) + self.secrets = AsyncApplicationSecrets(http) + self.keys = AsyncApplicationKeys(http) + + def list( + self, + *, + project: str | UUID | None = None, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[Application, Paginated[Application]]: + """List the project's Applications, across pages.""" + return self._paginate( + "/v1/applications", + Paginated[Application], + {**_project(project), "limit": limit, "next": next}, + ) + + async def get( + self, application_id: UUID, *, project: str | UUID | None = None + ) -> Application: + return await self._call( + "GET", + _app_path(application_id), + Application, + params=_project(project), + ) + + async def create( + self, + *, + name: str, + application_type: ApplicationType | str, + project: str | UUID | None = None, + environment: Environment | str | None = None, + redirect_uris: builtins.list[str] | None = None, + allowed_origins: builtins.list[str] | None = None, + allowed_scopes: builtins.list[str] | None = None, + agent_member_id: UUID | None = None, + ) -> Application: + """Register an OAuth client. Every plan may create a + ``service_account``; the browser and federated types are + plan-gated.""" + body = _body( + ApplicationCreateRequest, + { + "name": name, + "application_type": application_type, + "environment": environment, + "redirect_uris": redirect_uris, + "allowed_origins": allowed_origins, + "allowed_scopes": allowed_scopes, + "agent_member_id": agent_member_id, + }, + ) + return await self._call( + "POST", + "/v1/applications", + Application, + params=_project(project), + json=body, + ) + + async def update( + self, + application_id: UUID, + *, + project: str | UUID | None = None, + name: str | None = None, + redirect_uris: builtins.list[str] | None = None, + allowed_origins: builtins.list[str] | None = None, + allowed_scopes: builtins.list[str] | None = None, + agent_member_id: UUID | None = None, + ) -> Application: + """Only given fields change; a list replaces the whole value.""" + body = _body( + ApplicationUpdateRequest, + { + "name": name, + "redirect_uris": redirect_uris, + "allowed_origins": allowed_origins, + "allowed_scopes": allowed_scopes, + "agent_member_id": agent_member_id, + }, + ) + return await self._call( + "PATCH", + _app_path(application_id), + Application, + params=_project(project), + json=body, + ) + + async def delete( + self, application_id: UUID, *, project: str | UUID | None = None + ) -> None: + await self._delete(_app_path(application_id), _project(project)) diff --git a/introspection_sdk/_async/resources/credentials.py b/introspection_sdk/_async/resources/credentials.py new file mode 100644 index 0000000..e55116b --- /dev/null +++ b/introspection_sdk/_async/resources/credentials.py @@ -0,0 +1,155 @@ +"""Re-exported by :mod:`introspection_sdk.resources.credentials`.""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from pydantic import SecretStr + +from introspection_sdk._async.resources._crud import ( + _AsyncResource, + _body, + _project, +) +from introspection_sdk.pagination import AsyncPager +from introspection_sdk.schemas.lanes import Environment +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.runtime_settings import ( + Credential, + CredentialCreateRequest, + CredentialUpdateRequest, +) + + +def _setting_list_params( + *, + project: str | UUID | None, + runtime_id: UUID | None, + runtime: str | None, + environment: Environment | str | None, + limit: int | None, + next: str | None, +) -> dict[str, Any]: + """Filters ``GET /v1/credentials`` and ``GET /v1/variables`` share.""" + return { + **_project(project), + "runtime_id": str(runtime_id) if runtime_id is not None else None, + "runtime": runtime, + "environment": str(environment) if environment is not None else None, + "limit": limit, + "next": next, + } + + +def _credential_path(credential_id: UUID) -> str: + return f"/v1/credentials/{credential_id}" + + +class AsyncCredentials(_AsyncResource): + """CP ``/v1/credentials`` namespace: secrets a project's runtimes read, + scoped to the project, a runtime group, a runtime and/or one lane. + + The secret is write-only: no read returns it. Needs an org credential; + ``project`` (slug or id) names the project when the credential does not. + """ + + def list( + self, + *, + project: str | UUID | None = None, + runtime_id: UUID | None = None, + runtime: str | None = None, + environment: Environment | str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[Credential, Paginated[Credential]]: + """List credentials, across pages. ``runtime`` is a runtime slug.""" + return self._paginate( + "/v1/credentials", + Paginated[Credential], + _setting_list_params( + project=project, + runtime_id=runtime_id, + runtime=runtime, + environment=environment, + limit=limit, + next=next, + ), + ) + + async def get( + self, credential_id: UUID, *, project: str | UUID | None = None + ) -> Credential: + return await self._call( + "GET", + _credential_path(credential_id), + Credential, + params=_project(project), + ) + + async def create( + self, + *, + name: str, + secret: str | SecretStr, + project: str | UUID | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + environment: Environment | str | None = None, + ) -> Credential: + """Store a credential. With neither ``runtime_id`` nor + ``runtime_group_id`` it is project-wide; ``environment=None`` applies + to every lane.""" + body = _body( + CredentialCreateRequest, + { + "name": name, + "secret": secret, + "runtime_id": runtime_id, + "runtime_group_id": runtime_group_id, + "environment": environment, + }, + ) + return await self._call( + "POST", + "/v1/credentials", + Credential, + params=_project(project), + json=body, + ) + + async def update( + self, + credential_id: UUID, + *, + project: str | UUID | None = None, + name: str | None = None, + secret: str | SecretStr | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + environment: Environment | str | None = None, + ) -> Credential: + """Only given fields change; pass ``secret`` to rotate it.""" + body = _body( + CredentialUpdateRequest, + { + "name": name, + "secret": secret, + "runtime_id": runtime_id, + "runtime_group_id": runtime_group_id, + "environment": environment, + }, + ) + return await self._call( + "PATCH", + _credential_path(credential_id), + Credential, + params=_project(project), + json=body, + ) + + async def delete( + self, credential_id: UUID, *, project: str | UUID | None = None + ) -> None: + await self._delete(_credential_path(credential_id), _project(project)) diff --git a/introspection_sdk/_async/resources/integrations.py b/introspection_sdk/_async/resources/integrations.py new file mode 100644 index 0000000..33003b3 --- /dev/null +++ b/introspection_sdk/_async/resources/integrations.py @@ -0,0 +1,101 @@ +"""Re-exported by :mod:`introspection_sdk.resources.integrations`.""" + +from __future__ import annotations + +from uuid import UUID + +from pydantic import SecretStr + +from introspection_sdk._async.resources._crud import _AsyncResource, _body +from introspection_sdk.pagination import AsyncPager +from introspection_sdk.schemas.integrations import ( + Integration, + IntegrationCreateRequest, + IntegrationStatus, + IntegrationType, + IntegrationUpdateRequest, +) +from introspection_sdk.schemas.pagination import Paginated + + +def _integration_path(integration_id: UUID) -> str: + return f"/v1/integrations/{integration_id}" + + +class AsyncIntegrations(_AsyncResource): + """CP ``/v1/integrations`` namespace: the org's own provider installs. + + Org-wide, so no project selector. A create returns a ``pending`` row (or + the live pending one of that type: there is at most one live integration + per type), which the provider's activation flow completes with an + update. Needs an org credential. + """ + + def list( + self, *, limit: int | None = None, next: str | None = None + ) -> AsyncPager[Integration, Paginated[Integration]]: + """List the org's integrations, across pages.""" + return self._paginate( + "/v1/integrations", + Paginated[Integration], + {"limit": limit, "next": next}, + ) + + async def get(self, integration_id: UUID) -> Integration: + return await self._call( + "GET", _integration_path(integration_id), Integration + ) + + async def create( + self, + *, + integration_type: IntegrationType | str, + name: str | None = None, + external_id: str | None = None, + ) -> Integration: + """Record an install, sent as the wire field ``type``. Raises + :class:`~introspection_sdk.ConflictError` when an active or disabled + integration of the type already exists.""" + body = _body( + IntegrationCreateRequest, + { + "type": integration_type, + "name": name, + "external_id": external_id, + }, + ) + return await self._call( + "POST", "/v1/integrations", Integration, json=body + ) + + async def update( + self, + integration_id: UUID, + *, + name: str | None = None, + status: IntegrationStatus | str | None = None, + external_id: str | None = None, + code: str | SecretStr | None = None, + redirect_uri: str | None = None, + slack_interactivity_enabled: bool | None = None, + ) -> Integration: + """Only given fields change. ``code`` and ``redirect_uri`` complete a + provider's OAuth activation; ``slack_interactivity_enabled`` accepts + only ``False``.""" + body = _body( + IntegrationUpdateRequest, + { + "name": name, + "status": status, + "external_id": external_id, + "code": code, + "redirect_uri": redirect_uri, + "slack_interactivity_enabled": slack_interactivity_enabled, + }, + ) + return await self._call( + "PATCH", _integration_path(integration_id), Integration, json=body + ) + + async def delete(self, integration_id: UUID) -> None: + await self._delete(_integration_path(integration_id)) diff --git a/introspection_sdk/_async/resources/judges.py b/introspection_sdk/_async/resources/judges.py new file mode 100644 index 0000000..9c1b4f0 --- /dev/null +++ b/introspection_sdk/_async/resources/judges.py @@ -0,0 +1,74 @@ +"""Re-exported by :mod:`introspection_sdk.resources.judges`.""" + +from __future__ import annotations + +from uuid import UUID + +from introspection_sdk._async.resources._crud import ( + _AsyncResource, + _body, + _project, +) +from introspection_sdk.pagination import AsyncPager +from introspection_sdk.schemas.judges import Judge, JudgeUpdateRequest +from introspection_sdk.schemas.pagination import Paginated + + +class AsyncJudges(_AsyncResource): + """CP ``/v1/judges`` namespace: the judges a project's Recipes declare. + + A judge is authored in its Recipe and published with it, so there is no + create or delete; an update changes only whether it runs and how often. + ``project`` (slug or id) names the project when the credential does not. + """ + + def list( + self, + *, + project: str | UUID | None = None, + runtime_group_id: UUID | None = None, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[Judge, Paginated[Judge]]: + """List judges, across pages.""" + return self._paginate( + "/v1/judges", + Paginated[Judge], + { + **_project(project), + "runtime_group_id": ( + str(runtime_group_id) if runtime_group_id else None + ), + "limit": limit, + "next": next, + }, + ) + + async def get( + self, judge_id: UUID, *, project: str | UUID | None = None + ) -> Judge: + return await self._call( + "GET", f"/v1/judges/{judge_id}", Judge, params=_project(project) + ) + + async def update( + self, + judge_id: UUID, + *, + project: str | UUID | None = None, + enabled: bool | None = None, + sample_rate: float | None = None, + ) -> Judge: + """Turn a judge on or off, or change ``sample_rate`` (the fraction of + conversations judged, ``0.0``-``1.0``).""" + body = _body( + JudgeUpdateRequest, + {"enabled": enabled, "sample_rate": sample_rate}, + ) + return await self._call( + "PATCH", + f"/v1/judges/{judge_id}", + Judge, + params=_project(project), + json=body, + ) diff --git a/introspection_sdk/_async/resources/oidc.py b/introspection_sdk/_async/resources/oidc.py new file mode 100644 index 0000000..c82a853 --- /dev/null +++ b/introspection_sdk/_async/resources/oidc.py @@ -0,0 +1,15 @@ +"""Re-exported by :mod:`introspection_sdk.resources.oidc`.""" + +from __future__ import annotations + +from introspection_sdk._async.resources._crud import _AsyncResource +from introspection_sdk.schemas.oidc import Me + + +class AsyncOidc(_AsyncResource): + """CP ``/v1/oidc`` namespace: who the client's credential signs in as.""" + + async def me(self) -> Me: + """``GET /v1/oidc/me``: the member and org a member session or access + token belongs to, with the org's resolved feature flags.""" + return await self._call("GET", "/v1/oidc/me", Me) diff --git a/introspection_sdk/_async/resources/projects.py b/introspection_sdk/_async/resources/projects.py new file mode 100644 index 0000000..31800c7 --- /dev/null +++ b/introspection_sdk/_async/resources/projects.py @@ -0,0 +1,47 @@ +"""Re-exported by :mod:`introspection_sdk.resources.projects`.""" + +from __future__ import annotations + +from urllib.parse import quote +from uuid import UUID + +from introspection_sdk._async.resources._crud import ( + _AsyncResource, + _project, +) +from introspection_sdk.pagination import AsyncPager +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.projects import Project + + +class AsyncProjects(_AsyncResource): + """CP ``/v1/projects`` namespace: the credential's org's projects, + addressed by slug or id. Read-only.""" + + def list( + self, + *, + project: str | UUID | None = None, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[Project, Paginated[Project]]: + # unasync: async-only (the sync twin's docstring follows) + """List the org's projects; ``project`` (slug or id) narrows it to + one. ``await`` the returned :class:`AsyncPager` for the first page, + or ``async for`` it to stream every project.""" + # unasync: end + """List the org's projects; ``project`` (slug or id) narrows it to + one. Iterate the returned :class:`Pager` to stream every project, + or call ``.page()`` for the first page only.""" + return self._paginate( + "/v1/projects", + Paginated[Project], + {**_project(project), "limit": limit, "next": next}, + ) + + async def get(self, project: str | UUID) -> Project: + """Read a project by slug or id. One outside the credential's org + raises :class:`~introspection_sdk.NotFoundError`.""" + return await self._call( + "GET", f"/v1/projects/{quote(str(project), safe='')}", Project + ) diff --git a/introspection_sdk/_async/resources/variables.py b/introspection_sdk/_async/resources/variables.py new file mode 100644 index 0000000..baabcb1 --- /dev/null +++ b/introspection_sdk/_async/resources/variables.py @@ -0,0 +1,125 @@ +"""Re-exported by :mod:`introspection_sdk.resources.variables`.""" + +from __future__ import annotations + +from uuid import UUID + +from introspection_sdk._async.resources._crud import ( + _AsyncResource, + _body, + _project, +) +from introspection_sdk._async.resources.credentials import ( + _setting_list_params, +) +from introspection_sdk.pagination import AsyncPager +from introspection_sdk.schemas.lanes import Environment +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.runtime_settings import ( + Variable, + VariableCreateRequest, + VariableKind, + VariableUpdateRequest, +) + + +def _variable_path(variable_id: UUID) -> str: + return f"/v1/variables/{variable_id}" + + +class AsyncVariables(_AsyncResource): + """CP ``/v1/variables`` namespace: environment variables a project's + runtimes read, scoped like credentials. + + The API has no read-one route. Needs an org credential; ``project`` + (slug or id) names the project when the credential does not. + """ + + def list( + self, + *, + project: str | UUID | None = None, + runtime_id: UUID | None = None, + runtime: str | None = None, + environment: Environment | str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[Variable, Paginated[Variable]]: + """List variables, across pages. ``runtime`` is a runtime slug.""" + return self._paginate( + "/v1/variables", + Paginated[Variable], + _setting_list_params( + project=project, + runtime_id=runtime_id, + runtime=runtime, + environment=environment, + limit=limit, + next=next, + ), + ) + + async def create( + self, + *, + name: str, + value: str | None = None, + kind: VariableKind | str | None = None, + project: str | UUID | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + environment: Environment | str | None = None, + ) -> Variable: + """Set a variable. ``kind`` defaults to ``value``; a ``credential`` + variable's ``value`` names a credential.""" + fields = {"name": name, "kind": kind, "value": value} + return await self._call( + "POST", + "/v1/variables", + Variable, + params=_project(project), + json=_body( + VariableCreateRequest, + { + **fields, + "runtime_id": runtime_id, + "runtime_group_id": runtime_group_id, + "environment": environment, + }, + ), + ) + + async def update( + self, + variable_id: UUID, + *, + project: str | UUID | None = None, + name: str | None = None, + value: str | None = None, + kind: VariableKind | str | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + environment: Environment | str | None = None, + ) -> Variable: + """Only given fields change.""" + fields = {"name": name, "kind": kind, "value": value} + return await self._call( + "PATCH", + _variable_path(variable_id), + Variable, + params=_project(project), + json=_body( + VariableUpdateRequest, + { + **fields, + "runtime_id": runtime_id, + "runtime_group_id": runtime_group_id, + "environment": environment, + }, + ), + ) + + async def delete( + self, variable_id: UUID, *, project: str | UUID | None = None + ) -> None: + await self._delete(_variable_path(variable_id), _project(project)) diff --git a/introspection_sdk/_async/runner.py b/introspection_sdk/_async/runner.py index 4aae855..ab7211e 100644 --- a/introspection_sdk/_async/runner.py +++ b/introspection_sdk/_async/runner.py @@ -12,6 +12,7 @@ AsyncConversations, AsyncEvents, AsyncFiles, + AsyncImages, AsyncMetrics, AsyncRunnerAppConnections, AsyncShares, @@ -68,6 +69,7 @@ def __init__( self._metrics = AsyncMetrics(self._http) self._shares = AsyncShares(self._http) self._automations = AsyncAutomations(self._http) + self._images = AsyncImages(self._http) self._connections = AsyncRunnerAppConnections( self._http, spec.runtime_context.runtime_group_id ) @@ -134,6 +136,12 @@ def automations(self) -> AsyncAutomations: self._check_open() return self._automations + @property + def images(self) -> AsyncImages: + """DP ``/v1/images`` namespace bound to this Runner.""" + self._check_open() + return self._images + @property def connections(self) -> AsyncRunnerAppConnections: """DP ``/v1/connections`` namespace bound to this Runner. ``create`` @@ -190,6 +198,7 @@ async def refresh(self) -> None: self._metrics = AsyncMetrics(self._http) self._shares = AsyncShares(self._http) self._automations = AsyncAutomations(self._http) + self._images = AsyncImages(self._http) self._connections = AsyncRunnerAppConnections( self._http, new_spec.runtime_context.runtime_group_id ) diff --git a/introspection_sdk/_async/runner_resources/images.py b/introspection_sdk/_async/runner_resources/images.py new file mode 100644 index 0000000..7c1dfbd --- /dev/null +++ b/introspection_sdk/_async/runner_resources/images.py @@ -0,0 +1,85 @@ +"""Re-exported by :mod:`introspection_sdk.runner_resources.images`.""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from introspection_sdk._async.resources._crud import _AsyncResource, _body +from introspection_sdk.pagination import AsyncPager +from introspection_sdk.schemas.images import ( + ContextImageSource, + Image, + ImageCreateRequest, + ImageKind, + ImageProvenance, + ImageStatus, + MirrorImageSource, +) +from introspection_sdk.schemas.pagination import Paginated + + +class AsyncImages(_AsyncResource): + """DP ``/v1/images`` namespace: what an eval task's compose services run + as. + + An image is built from a context archive uploaded through the Files API, + or mirrored from an upstream reference pinned by digest, and always + lands in the plane's registry. A create is idempotent on content: the + same source returns the existing row, and resending it retries a failed + build. ``status`` is the only progress report, so poll :meth:`get` until + it is ``ready`` (or ``failed``). Needs ``images:read`` / + ``images:write`` on a plane with evals enabled (a create answers 422 + otherwise). + """ + + def list( + self, + *, + kind: ImageKind | str | None = None, + status: ImageStatus | str | None = None, + name: str | None = None, + runtime_id: UUID | None = None, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[Image, Paginated[Image]]: + """List images, across pages.""" + params: dict[str, Any] = { + "kind": str(kind) if kind is not None else None, + "status": str(status) if status is not None else None, + "name": name, + "runtime_id": str(runtime_id) if runtime_id is not None else None, + "limit": limit, + "next": next, + } + return self._paginate("/v1/images", Paginated[Image], params) + + async def get(self, image_id: UUID | str) -> Image: + return await self._call("GET", f"/v1/images/{image_id}", Image) + + async def create( + self, + *, + name: str, + source: ContextImageSource | MirrorImageSource | dict[str, Any], + provenance: ImageProvenance | dict[str, Any] | None = None, + scan: bool | None = None, + ) -> Image: + """Record an image and send its build. ``source`` is a + :class:`ContextImageSource` or a :class:`MirrorImageSource` (or the + same as a dict tagged by ``kind``).""" + body = _body( + ImageCreateRequest, + { + "name": name, + "source": source, + "provenance": provenance, + "scan": scan, + }, + ) + return await self._call("POST", "/v1/images", Image, json=body) + + async def delete(self, image_id: UUID | str) -> None: + """Delete an image. Deleting a runtime image rebakes it; one still + baking raises :class:`~introspection_sdk.ConflictError`.""" + await self._delete(f"/v1/images/{image_id}") diff --git a/introspection_sdk/_async/runner_resources/tasks.py b/introspection_sdk/_async/runner_resources/tasks.py index 57a4694..76f5597 100644 --- a/introspection_sdk/_async/runner_resources/tasks.py +++ b/introspection_sdk/_async/runner_resources/tasks.py @@ -5,6 +5,7 @@ import builtins from collections.abc import AsyncIterator from typing import Any +from uuid import UUID from introspection_sdk._errors import RunFailedError, StreamIncompleteError from introspection_sdk._http import _AsyncHttpClient @@ -26,12 +27,21 @@ TextMessageContentEvent, ) from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.task_commands import ( + TASK_COMMAND_SPEC_ADAPTER, + DownloadCommand, + ExecCommand, + TaskCommand, + TaskCommandStatus, + UploadCommand, +) from introspection_sdk.schemas.tasks import ( Task, TaskCancelRequest, TaskCancelResponse, TaskCreateResponse, TaskFileRef, + TaskKind, TaskPrompt, TaskRepoRequest, TaskRun, @@ -78,6 +88,12 @@ def _repo_refs( ] +#: The longest :meth:`TaskCommands.get` asks the API to hold a read. The API +#: allows 60 seconds, but it has to answer inside the SDK's own 30-second +#: request timeout, or the read fails instead of returning the command. +MAX_COMMAND_WAIT_SECONDS = 25 + + def _snapshot_text(event: MessagesSnapshotEvent) -> list[str]: """A snapshot carries the whole run so far, so it replaces what was read.""" return [ @@ -278,10 +294,90 @@ def stream( ) +class AsyncTaskCommands: + """DP ``/v1/tasks/{task_id}/commands``: ``exec``, ``upload`` and + ``download`` against an eval task's live sandbox. + + A command is a row the task's workflow executes and stamps: the create + returns it ``queued``, and :meth:`get` reads its outcome, optionally + holding the read until it finishes. Eval tasks only (create the task + with ``kind="eval"`` and ``commands=True``), behind ``tasks:exec``, on a + plane with evals enabled. + """ + + def __init__(self, http: _AsyncHttpClient) -> None: + self._http = http + + async def create( + self, + task_id: str, + command: ExecCommand + | UploadCommand + | DownloadCommand + | dict[str, Any], + ) -> TaskCommand: + """Record a command and send it to the task's workflow. ``command`` + is an :class:`ExecCommand`, :class:`UploadCommand` or + :class:`DownloadCommand` (or the same as a dict tagged by + ``kind``).""" + spec = TASK_COMMAND_SPEC_ADAPTER.validate_python(command) + payload = await self._http.request( + "POST", + f"/v1/tasks/{task_id}/commands", + json=spec.model_dump(mode="json", exclude_none=True), + ) + return TaskCommand.model_validate(payload) + + def list( + self, + task_id: str, + *, + status: TaskCommandStatus | str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> AsyncPager[TaskCommand, Paginated[TaskCommand]]: + """List a task's commands, newest first, across pages.""" + + async def fetch(cursor: str | None) -> Paginated[TaskCommand]: + payload = await self._http.request( + "GET", + f"/v1/tasks/{task_id}/commands", + params={ + "status": str(status) if status is not None else None, + "limit": limit, + "next": cursor, + }, + ) + return Paginated[TaskCommand].model_validate(payload) + + return async_cursor_paginate(fetch, start=next) + + async def get( + self, + task_id: str, + command_id: str | UUID, + *, + wait_seconds: int | None = None, + ) -> TaskCommand: + """Read a command. ``wait_seconds`` holds the read until the command + finishes or the wait runs out, and returns it in whatever state it + is then; it is capped at :data:`MAX_COMMAND_WAIT_SECONDS`, so call + again to wait longer.""" + if wait_seconds is not None: + wait_seconds = max(0, min(wait_seconds, MAX_COMMAND_WAIT_SECONDS)) + payload = await self._http.request( + "GET", + f"/v1/tasks/{task_id}/commands/{command_id}", + params={"wait_seconds": wait_seconds}, + ) + return TaskCommand.model_validate(payload) + + class AsyncTasks: def __init__(self, http: _AsyncHttpClient) -> None: self._http = http self.runs = AsyncTaskRuns(http) + self.commands = AsyncTaskCommands(http) def list( self, @@ -332,7 +428,15 @@ async def create( fork_share_id: str | None = None, files: builtins.list[TaskFileRef | dict[str, Any]] | None = None, tags: builtins.list[str] | None = None, + kind: TaskKind | str | None = None, + commands: bool | None = None, ) -> TaskCreateResponse: + """Create a task and its initial run. + + ``kind="eval"`` runs the task as an evaluation trial; with + ``commands=True`` it also accepts :attr:`commands` against its live + sandbox. Both are omitted from the body unless given. + """ body: dict[str, Any] = { "title": title, "prompt": prompt, @@ -344,6 +448,8 @@ async def create( "files": _file_refs(files) if files else None, "repositories": _repo_refs(repositories) if repositories else None, "tags": tags, + "kind": str(kind) if kind is not None else None, + "commands": commands, } body = {k: v for k, v in body.items() if v is not None} payload = await self._http.request("POST", "/v1/tasks", json=body) diff --git a/introspection_sdk/_sync/client.py b/introspection_sdk/_sync/client.py index f8f79c3..cbf1279 100644 --- a/introspection_sdk/_sync/client.py +++ b/introspection_sdk/_sync/client.py @@ -16,20 +16,29 @@ from introspection_sdk.dev_target import client_headers from introspection_sdk.resources import ( Annotations, + ApiKeys, + Applications, Automations, Connectors, + Credentials, Experiments, + Integrations, + Judges, Members, + Oidc, + Projects, Recipes, Repositories, Runtimes, Tags, + Variables, ) from introspection_sdk.runner_resources import ( AppConnections, Conversations, Events, Files, + Images, Metrics, Shares, Tasks, @@ -72,6 +81,15 @@ class IntrospectionClient: metrics: Metrics shares: Shares connections: AppConnections + images: Images + projects: Projects + api_keys: ApiKeys + applications: Applications + credentials: Credentials + variables: Variables + integrations: Integrations + judges: Judges + oidc: Oidc def __init__( self, @@ -131,6 +149,18 @@ def __init__( self.metrics = Metrics(self._dp_http) self.shares = Shares(self._dp_http) self.connections = AppConnections(self._dp_http) + self.images = Images(self._dp_http) + # Project administration: each route needs an org credential (a + # member session or access token); a project API key carries none + # of their scopes. + self.projects = Projects(self._http) + self.api_keys = ApiKeys(self._http) + self.applications = Applications(self._http) + self.credentials = Credentials(self._http) + self.variables = Variables(self._http) + self.integrations = Integrations(self._http) + self.judges = Judges(self._http) + self.oidc = Oidc(self._http) @classmethod def from_service_account( diff --git a/introspection_sdk/_sync/protocols.py b/introspection_sdk/_sync/protocols.py index fe81f60..6f45adf 100644 --- a/introspection_sdk/_sync/protocols.py +++ b/introspection_sdk/_sync/protocols.py @@ -46,6 +46,14 @@ ) from introspection_sdk.schemas.files import File, FileType from introspection_sdk.schemas.genai_span import GenAiSpan, GenAiSpanList +from introspection_sdk.schemas.images import ( + ContextImageSource, + Image, + ImageKind, + ImageProvenance, + ImageStatus, + MirrorImageSource, +) from introspection_sdk.schemas.metrics import ( MetricQueryRequest, MetricQueryResponse, @@ -55,12 +63,20 @@ ResourceShare, ShareResourceType, ) +from introspection_sdk.schemas.task_commands import ( + DownloadCommand, + ExecCommand, + TaskCommand, + TaskCommandStatus, + UploadCommand, +) from introspection_sdk.schemas.tasks import ( Task, TaskCancelRequest, TaskCancelResponse, TaskCreateResponse, TaskFileRef, + TaskKind, TaskPrompt, TaskRepoRequest, TaskRun, @@ -107,6 +123,37 @@ def stream( ) -> Iterator[AGUIEvent]: ... +@runtime_checkable +class TaskCommandsResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.tasks.TaskCommands`.""" + + def create( + self, + task_id: str, + command: ExecCommand + | UploadCommand + | DownloadCommand + | dict[str, Any], + ) -> TaskCommand: ... + + def list( + self, + task_id: str, + *, + status: TaskCommandStatus | str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[TaskCommand, Paginated[TaskCommand]]: ... + + def get( + self, + task_id: str, + command_id: str | UUID, + *, + wait_seconds: int | None = None, + ) -> TaskCommand: ... + + @runtime_checkable class TasksResource(Protocol): """Protocol of :class:`~introspection_sdk.runner_resources.tasks.Tasks`.""" @@ -114,6 +161,9 @@ class TasksResource(Protocol): @property def runs(self) -> TaskRunsResource: ... + @property + def commands(self) -> TaskCommandsResource: ... + def list( self, *, @@ -139,6 +189,8 @@ def create( fork_share_id: str | None = None, files: builtins.list[TaskFileRef | dict[str, Any]] | None = None, tags: builtins.list[str] | None = None, + kind: TaskKind | str | None = None, + commands: bool | None = None, ) -> TaskCreateResponse: ... def get(self, task_id: str) -> Task: ... @@ -596,6 +648,35 @@ def get(self, connection_id: UUID | str) -> Connection: ... def delete(self, connection_id: UUID | str) -> None: ... +@runtime_checkable +class ImagesResource(Protocol): + """Protocol of :class:`~introspection_sdk.runner_resources.images.Images`.""" + + def list( + self, + *, + kind: ImageKind | str | None = None, + status: ImageStatus | str | None = None, + name: str | None = None, + runtime_id: UUID | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Image, Paginated[Image]]: ... + + def get(self, image_id: UUID | str) -> Image: ... + + def create( + self, + *, + name: str, + source: ContextImageSource | MirrorImageSource | dict[str, Any], + provenance: ImageProvenance | dict[str, Any] | None = None, + scan: bool | None = None, + ) -> Image: ... + + def delete(self, image_id: UUID | str) -> None: ... + + @runtime_checkable class DataPlaneResources(Protocol): """The data-plane namespaces :class:`~introspection_sdk.IntrospectionClient` @@ -624,3 +705,6 @@ def automations(self) -> AutomationsResource: ... @property def connections(self) -> ConnectionsResource: ... + + @property + def images(self) -> ImagesResource: ... diff --git a/introspection_sdk/_sync/resources/_crud.py b/introspection_sdk/_sync/resources/_crud.py new file mode 100644 index 0000000..167ba88 --- /dev/null +++ b/introspection_sdk/_sync/resources/_crud.py @@ -0,0 +1,69 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/_crud.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Request plumbing the administration namespaces share.""" + +from __future__ import annotations + +from typing import Any, TypeVar +from uuid import UUID + +from pydantic import BaseModel + +from introspection_sdk._http import _HttpClient +from introspection_sdk.pagination import ( + Pager, + cursor_paginate, +) +from introspection_sdk.schemas.pagination import Paginated + +M = TypeVar("M", bound=BaseModel) + + +def _project(project: str | UUID | None) -> dict[str, Any]: + """The ``?project=`` selector (slug or id), sent only when given; needed + when the credential does not already name a project.""" + return {"project": str(project) if project is not None else None} + + +def _body(request: type[BaseModel], fields: dict[str, Any]) -> dict[str, Any]: + """Validate ``fields`` against the request model and drop the unset ones, + so an omitted field never reaches the wire as ``null``.""" + return request.model_validate(fields).model_dump( + mode="json", exclude_none=True + ) + + +class _Resource: + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def _paginate( + self, + path: str, + page: type[Paginated[M]], + params: dict[str, Any], + ) -> Pager[M, Paginated[M]]: + start = params.pop("next", None) + + def fetch(cursor: str | None) -> Paginated[M]: + payload = self._http.request( + "GET", path, params={**params, "next": cursor} + ) + return page.model_validate(payload) + + return cursor_paginate(fetch, start=start) + + def _call( + self, + method: str, + path: str, + response: type[M], + *, + params: dict[str, Any] | None = None, + json: Any = None, + ) -> M: + payload = self._http.request(method, path, params=params, json=json) + return response.model_validate(payload) + + def _delete(self, path: str, params: dict[str, Any] | None = None) -> None: + self._http.request("DELETE", path, params=params, expect="empty") diff --git a/introspection_sdk/_sync/resources/api_keys.py b/introspection_sdk/_sync/resources/api_keys.py new file mode 100644 index 0000000..5432310 --- /dev/null +++ b/introspection_sdk/_sync/resources/api_keys.py @@ -0,0 +1,131 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/api_keys.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.api_keys`.""" + +from __future__ import annotations + +import builtins +from datetime import datetime +from uuid import UUID + +from introspection_sdk._sync.resources._crud import ( + _body, + _project, + _Resource, +) +from introspection_sdk.schemas.api_keys import ( + ApiKey, + ApiKeyAcknowledgement, + ApiKeyCapability, + ApiKeyCreated, + ApiKeyCreateRequest, + ApiKeyUpdateRequest, +) +from introspection_sdk.schemas.lanes import Environment + + +def _key_path(api_key_id: UUID) -> str: + return f"/v1/api-keys/{api_key_id}" + + +class ApiKeys(_Resource): + """CP ``/v1/api-keys`` namespace: a project's API keys. + + Needs an org credential (a member session or access token); a project + API key cannot manage keys. ``project`` (slug or id) names the project + when the credential does not. + """ + + def list( + self, + *, + project: str | UUID | None = None, + expiring: bool | None = None, + ) -> builtins.list[ApiKey]: + """Every key of the project (the route is not paginated). + ``expiring=True`` keeps only keys close to expiry.""" + payload = self._http.request( + "GET", + "/v1/api-keys", + params={**_project(project), "expiring": expiring}, + ) + return [ApiKey.model_validate(key) for key in payload["api_keys"]] + + def create( + self, + *, + project: str | UUID | None = None, + name: str | None = None, + environment: Environment | str | None = None, + allow: builtins.list[ApiKeyCapability | str] | None = None, + member_id: UUID | None = None, + expires_at: datetime | None = None, + ) -> ApiKeyCreated: + """Mint a key. Its plaintext is :attr:`ApiKeyCreated.api_key` (a + ``SecretStr``) and is never returned again: save it, then call + :meth:`acknowledge`. ``environment`` defaults to ``production``.""" + body = _body( + ApiKeyCreateRequest, + { + "name": name, + "environment": environment, + "allow": allow, + "member_id": member_id, + "expires_at": expires_at, + }, + ) + return self._call( + "POST", + "/v1/api-keys", + ApiKeyCreated, + params=_project(project), + json=body, + ) + + def update( + self, + api_key_id: UUID, + *, + project: str | UUID | None = None, + name: str | None = None, + allow: builtins.list[ApiKeyCapability | str] | None = None, + member_id: UUID | None = None, + ) -> ApiKey: + """Rename a key or change what it may do. Only given fields change.""" + body = _body( + ApiKeyUpdateRequest, + {"name": name, "allow": allow, "member_id": member_id}, + ) + return self._call( + "PATCH", + _key_path(api_key_id), + ApiKey, + params=_project(project), + json=body, + ) + + def delete( + self, + api_key_id: UUID, + *, + project: str | UUID | None = None, + expires_in: int | None = None, + ) -> None: + """Revoke a key now, or keep it working ``expires_in`` more seconds + (a rotation; it reads as ``rotating`` until then).""" + self._delete( + _key_path(api_key_id), + {**_project(project), "expires_in": expires_in}, + ) + + def acknowledge( + self, api_key_id: UUID, *, project: str | UUID | None = None + ) -> ApiKeyAcknowledgement: + """Record that the plaintext from :meth:`create` was saved. A second + acknowledge raises :class:`~introspection_sdk.NotFoundError`.""" + return self._call( + "POST", + f"{_key_path(api_key_id)}/acknowledge", + ApiKeyAcknowledgement, + params=_project(project), + ) diff --git a/introspection_sdk/_sync/resources/applications.py b/introspection_sdk/_sync/resources/applications.py new file mode 100644 index 0000000..5aa34c1 --- /dev/null +++ b/introspection_sdk/_sync/resources/applications.py @@ -0,0 +1,267 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/applications.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.applications`.""" + +from __future__ import annotations + +import builtins +from uuid import UUID + +from pydantic import SecretStr, TypeAdapter + +from introspection_sdk._http import _HttpClient +from introspection_sdk._sync.resources._crud import ( + _body, + _project, + _Resource, +) +from introspection_sdk.pagination import Pager +from introspection_sdk.schemas.applications import ( + Application, + ApplicationCreateRequest, + ApplicationIdp, + ApplicationIdpCreateRequest, + ApplicationIdpProvider, + ApplicationKey, + ApplicationSecret, + ApplicationSecretCreateRequest, + ApplicationType, + ApplicationUpdateRequest, +) +from introspection_sdk.schemas.lanes import Environment +from introspection_sdk.schemas.pagination import Paginated + +_KEYS = TypeAdapter(list[ApplicationKey]) + + +def _app_path(application_id: UUID) -> str: + return f"/v1/applications/{application_id}" + + +class ApplicationIdps(_Resource): + """CP ``/v1/applications/{id}/idps``: the identity providers an + Application brokers, whose users sign in as customer members.""" + + def list( + self, + application_id: UUID, + *, + limit: int | None = None, + next: str | None = None, + ) -> Pager[ApplicationIdp, Paginated[ApplicationIdp]]: + """List an Application's identity providers, across pages.""" + return self._paginate( + f"{_app_path(application_id)}/idps", + Paginated[ApplicationIdp], + {"limit": limit, "next": next}, + ) + + def create( + self, + application_id: UUID, + *, + idp_provider: ApplicationIdpProvider | str | None = None, + issuer: str | None = None, + client_id: str | None = None, + client_secret: str | SecretStr | None = None, + scopes: builtins.list[str] | None = None, + audience_client_id: str | None = None, + ) -> ApplicationIdp: + """Attach an identity provider (``idp_provider`` defaults to + ``generic``). ``client_secret`` is write-only.""" + body = _body( + ApplicationIdpCreateRequest, + { + "idp_provider": idp_provider, + "issuer": issuer, + "client_id": client_id, + "client_secret": client_secret, + "scopes": scopes, + "audience_client_id": audience_client_id, + }, + ) + return self._call( + "POST", + f"{_app_path(application_id)}/idps", + ApplicationIdp, + json=body, + ) + + def delete(self, application_id: UUID, idp_id: UUID) -> None: + self._delete(f"{_app_path(application_id)}/idps/{idp_id}") + + +class ApplicationSecrets(_Resource): + """CP ``/v1/applications/{id}/secrets``: a confidential Application's + client secrets.""" + + def list( + self, + application_id: UUID, + *, + limit: int | None = None, + next: str | None = None, + ) -> Pager[ApplicationSecret, Paginated[ApplicationSecret]]: + """List an Application's client secrets (never the plaintext).""" + return self._paginate( + f"{_app_path(application_id)}/secrets", + Paginated[ApplicationSecret], + {"limit": limit, "next": next}, + ) + + def create( + self, application_id: UUID, *, name: str | None = None + ) -> ApplicationSecret: + """Mint a client secret. Its plaintext is + :attr:`ApplicationSecret.secret` (a ``SecretStr``) on this response + only.""" + return self._call( + "POST", + f"{_app_path(application_id)}/secrets", + ApplicationSecret, + json=_body(ApplicationSecretCreateRequest, {"name": name}), + ) + + def delete( + self, + application_id: UUID, + secret_id: UUID, + *, + expires_in: int | None = None, + ) -> None: + """Disable a secret now, or after ``expires_in`` seconds for a + rotation.""" + self._delete( + f"{_app_path(application_id)}/secrets/{secret_id}", + {"expires_in": expires_in}, + ) + + +class ApplicationKeys(_Resource): + """CP ``/v1/applications/{id}/keys``: a ``jwks`` Application's signing + keys.""" + + def list(self, application_id: UUID) -> builtins.list[ApplicationKey]: + """Every signing key (the route is not paginated).""" + payload = self._http.request( + "GET", f"{_app_path(application_id)}/keys" + ) + return _KEYS.validate_python(payload) + + def create(self, application_id: UUID) -> ApplicationKey: + """Mint a key pair; the API keeps the private half.""" + return self._call( + "POST", f"{_app_path(application_id)}/keys", ApplicationKey + ) + + def delete(self, application_id: UUID, key_id: UUID) -> None: + self._delete(f"{_app_path(application_id)}/keys/{key_id}") + + +class Applications(_Resource): + """CP ``/v1/applications`` namespace: a project's OAuth clients, with + ``idps``, ``secrets`` and ``keys`` under each. + + Needs an org credential; ``project`` (slug or id) names the project + when the credential does not. + """ + + def __init__(self, http: _HttpClient) -> None: + super().__init__(http) + self.idps = ApplicationIdps(http) + self.secrets = ApplicationSecrets(http) + self.keys = ApplicationKeys(http) + + def list( + self, + *, + project: str | UUID | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Application, Paginated[Application]]: + """List the project's Applications, across pages.""" + return self._paginate( + "/v1/applications", + Paginated[Application], + {**_project(project), "limit": limit, "next": next}, + ) + + def get( + self, application_id: UUID, *, project: str | UUID | None = None + ) -> Application: + return self._call( + "GET", + _app_path(application_id), + Application, + params=_project(project), + ) + + def create( + self, + *, + name: str, + application_type: ApplicationType | str, + project: str | UUID | None = None, + environment: Environment | str | None = None, + redirect_uris: builtins.list[str] | None = None, + allowed_origins: builtins.list[str] | None = None, + allowed_scopes: builtins.list[str] | None = None, + agent_member_id: UUID | None = None, + ) -> Application: + """Register an OAuth client. Every plan may create a + ``service_account``; the browser and federated types are + plan-gated.""" + body = _body( + ApplicationCreateRequest, + { + "name": name, + "application_type": application_type, + "environment": environment, + "redirect_uris": redirect_uris, + "allowed_origins": allowed_origins, + "allowed_scopes": allowed_scopes, + "agent_member_id": agent_member_id, + }, + ) + return self._call( + "POST", + "/v1/applications", + Application, + params=_project(project), + json=body, + ) + + def update( + self, + application_id: UUID, + *, + project: str | UUID | None = None, + name: str | None = None, + redirect_uris: builtins.list[str] | None = None, + allowed_origins: builtins.list[str] | None = None, + allowed_scopes: builtins.list[str] | None = None, + agent_member_id: UUID | None = None, + ) -> Application: + """Only given fields change; a list replaces the whole value.""" + body = _body( + ApplicationUpdateRequest, + { + "name": name, + "redirect_uris": redirect_uris, + "allowed_origins": allowed_origins, + "allowed_scopes": allowed_scopes, + "agent_member_id": agent_member_id, + }, + ) + return self._call( + "PATCH", + _app_path(application_id), + Application, + params=_project(project), + json=body, + ) + + def delete( + self, application_id: UUID, *, project: str | UUID | None = None + ) -> None: + self._delete(_app_path(application_id), _project(project)) diff --git a/introspection_sdk/_sync/resources/credentials.py b/introspection_sdk/_sync/resources/credentials.py new file mode 100644 index 0000000..9fa76d8 --- /dev/null +++ b/introspection_sdk/_sync/resources/credentials.py @@ -0,0 +1,157 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/credentials.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.credentials`.""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from pydantic import SecretStr + +from introspection_sdk._sync.resources._crud import ( + _body, + _project, + _Resource, +) +from introspection_sdk.pagination import Pager +from introspection_sdk.schemas.lanes import Environment +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.runtime_settings import ( + Credential, + CredentialCreateRequest, + CredentialUpdateRequest, +) + + +def _setting_list_params( + *, + project: str | UUID | None, + runtime_id: UUID | None, + runtime: str | None, + environment: Environment | str | None, + limit: int | None, + next: str | None, +) -> dict[str, Any]: + """Filters ``GET /v1/credentials`` and ``GET /v1/variables`` share.""" + return { + **_project(project), + "runtime_id": str(runtime_id) if runtime_id is not None else None, + "runtime": runtime, + "environment": str(environment) if environment is not None else None, + "limit": limit, + "next": next, + } + + +def _credential_path(credential_id: UUID) -> str: + return f"/v1/credentials/{credential_id}" + + +class Credentials(_Resource): + """CP ``/v1/credentials`` namespace: secrets a project's runtimes read, + scoped to the project, a runtime group, a runtime and/or one lane. + + The secret is write-only: no read returns it. Needs an org credential; + ``project`` (slug or id) names the project when the credential does not. + """ + + def list( + self, + *, + project: str | UUID | None = None, + runtime_id: UUID | None = None, + runtime: str | None = None, + environment: Environment | str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Credential, Paginated[Credential]]: + """List credentials, across pages. ``runtime`` is a runtime slug.""" + return self._paginate( + "/v1/credentials", + Paginated[Credential], + _setting_list_params( + project=project, + runtime_id=runtime_id, + runtime=runtime, + environment=environment, + limit=limit, + next=next, + ), + ) + + def get( + self, credential_id: UUID, *, project: str | UUID | None = None + ) -> Credential: + return self._call( + "GET", + _credential_path(credential_id), + Credential, + params=_project(project), + ) + + def create( + self, + *, + name: str, + secret: str | SecretStr, + project: str | UUID | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + environment: Environment | str | None = None, + ) -> Credential: + """Store a credential. With neither ``runtime_id`` nor + ``runtime_group_id`` it is project-wide; ``environment=None`` applies + to every lane.""" + body = _body( + CredentialCreateRequest, + { + "name": name, + "secret": secret, + "runtime_id": runtime_id, + "runtime_group_id": runtime_group_id, + "environment": environment, + }, + ) + return self._call( + "POST", + "/v1/credentials", + Credential, + params=_project(project), + json=body, + ) + + def update( + self, + credential_id: UUID, + *, + project: str | UUID | None = None, + name: str | None = None, + secret: str | SecretStr | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + environment: Environment | str | None = None, + ) -> Credential: + """Only given fields change; pass ``secret`` to rotate it.""" + body = _body( + CredentialUpdateRequest, + { + "name": name, + "secret": secret, + "runtime_id": runtime_id, + "runtime_group_id": runtime_group_id, + "environment": environment, + }, + ) + return self._call( + "PATCH", + _credential_path(credential_id), + Credential, + params=_project(project), + json=body, + ) + + def delete( + self, credential_id: UUID, *, project: str | UUID | None = None + ) -> None: + self._delete(_credential_path(credential_id), _project(project)) diff --git a/introspection_sdk/_sync/resources/integrations.py b/introspection_sdk/_sync/resources/integrations.py new file mode 100644 index 0000000..e5aad34 --- /dev/null +++ b/introspection_sdk/_sync/resources/integrations.py @@ -0,0 +1,101 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/integrations.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.integrations`.""" + +from __future__ import annotations + +from uuid import UUID + +from pydantic import SecretStr + +from introspection_sdk._sync.resources._crud import _body, _Resource +from introspection_sdk.pagination import Pager +from introspection_sdk.schemas.integrations import ( + Integration, + IntegrationCreateRequest, + IntegrationStatus, + IntegrationType, + IntegrationUpdateRequest, +) +from introspection_sdk.schemas.pagination import Paginated + + +def _integration_path(integration_id: UUID) -> str: + return f"/v1/integrations/{integration_id}" + + +class Integrations(_Resource): + """CP ``/v1/integrations`` namespace: the org's own provider installs. + + Org-wide, so no project selector. A create returns a ``pending`` row (or + the live pending one of that type: there is at most one live integration + per type), which the provider's activation flow completes with an + update. Needs an org credential. + """ + + def list( + self, *, limit: int | None = None, next: str | None = None + ) -> Pager[Integration, Paginated[Integration]]: + """List the org's integrations, across pages.""" + return self._paginate( + "/v1/integrations", + Paginated[Integration], + {"limit": limit, "next": next}, + ) + + def get(self, integration_id: UUID) -> Integration: + return self._call( + "GET", _integration_path(integration_id), Integration + ) + + def create( + self, + *, + integration_type: IntegrationType | str, + name: str | None = None, + external_id: str | None = None, + ) -> Integration: + """Record an install, sent as the wire field ``type``. Raises + :class:`~introspection_sdk.ConflictError` when an active or disabled + integration of the type already exists.""" + body = _body( + IntegrationCreateRequest, + { + "type": integration_type, + "name": name, + "external_id": external_id, + }, + ) + return self._call("POST", "/v1/integrations", Integration, json=body) + + def update( + self, + integration_id: UUID, + *, + name: str | None = None, + status: IntegrationStatus | str | None = None, + external_id: str | None = None, + code: str | SecretStr | None = None, + redirect_uri: str | None = None, + slack_interactivity_enabled: bool | None = None, + ) -> Integration: + """Only given fields change. ``code`` and ``redirect_uri`` complete a + provider's OAuth activation; ``slack_interactivity_enabled`` accepts + only ``False``.""" + body = _body( + IntegrationUpdateRequest, + { + "name": name, + "status": status, + "external_id": external_id, + "code": code, + "redirect_uri": redirect_uri, + "slack_interactivity_enabled": slack_interactivity_enabled, + }, + ) + return self._call( + "PATCH", _integration_path(integration_id), Integration, json=body + ) + + def delete(self, integration_id: UUID) -> None: + self._delete(_integration_path(integration_id)) diff --git a/introspection_sdk/_sync/resources/judges.py b/introspection_sdk/_sync/resources/judges.py new file mode 100644 index 0000000..0d576f8 --- /dev/null +++ b/introspection_sdk/_sync/resources/judges.py @@ -0,0 +1,76 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/judges.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.judges`.""" + +from __future__ import annotations + +from uuid import UUID + +from introspection_sdk._sync.resources._crud import ( + _body, + _project, + _Resource, +) +from introspection_sdk.pagination import Pager +from introspection_sdk.schemas.judges import Judge, JudgeUpdateRequest +from introspection_sdk.schemas.pagination import Paginated + + +class Judges(_Resource): + """CP ``/v1/judges`` namespace: the judges a project's Recipes declare. + + A judge is authored in its Recipe and published with it, so there is no + create or delete; an update changes only whether it runs and how often. + ``project`` (slug or id) names the project when the credential does not. + """ + + def list( + self, + *, + project: str | UUID | None = None, + runtime_group_id: UUID | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Judge, Paginated[Judge]]: + """List judges, across pages.""" + return self._paginate( + "/v1/judges", + Paginated[Judge], + { + **_project(project), + "runtime_group_id": ( + str(runtime_group_id) if runtime_group_id else None + ), + "limit": limit, + "next": next, + }, + ) + + def get( + self, judge_id: UUID, *, project: str | UUID | None = None + ) -> Judge: + return self._call( + "GET", f"/v1/judges/{judge_id}", Judge, params=_project(project) + ) + + def update( + self, + judge_id: UUID, + *, + project: str | UUID | None = None, + enabled: bool | None = None, + sample_rate: float | None = None, + ) -> Judge: + """Turn a judge on or off, or change ``sample_rate`` (the fraction of + conversations judged, ``0.0``-``1.0``).""" + body = _body( + JudgeUpdateRequest, + {"enabled": enabled, "sample_rate": sample_rate}, + ) + return self._call( + "PATCH", + f"/v1/judges/{judge_id}", + Judge, + params=_project(project), + json=body, + ) diff --git a/introspection_sdk/_sync/resources/oidc.py b/introspection_sdk/_sync/resources/oidc.py new file mode 100644 index 0000000..31e40ba --- /dev/null +++ b/introspection_sdk/_sync/resources/oidc.py @@ -0,0 +1,17 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/oidc.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.oidc`.""" + +from __future__ import annotations + +from introspection_sdk._sync.resources._crud import _Resource +from introspection_sdk.schemas.oidc import Me + + +class Oidc(_Resource): + """CP ``/v1/oidc`` namespace: who the client's credential signs in as.""" + + def me(self) -> Me: + """``GET /v1/oidc/me``: the member and org a member session or access + token belongs to, with the org's resolved feature flags.""" + return self._call("GET", "/v1/oidc/me", Me) diff --git a/introspection_sdk/_sync/resources/projects.py b/introspection_sdk/_sync/resources/projects.py new file mode 100644 index 0000000..984e7df --- /dev/null +++ b/introspection_sdk/_sync/resources/projects.py @@ -0,0 +1,44 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/projects.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.projects`.""" + +from __future__ import annotations + +from urllib.parse import quote +from uuid import UUID + +from introspection_sdk._sync.resources._crud import ( + _project, + _Resource, +) +from introspection_sdk.pagination import Pager +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.projects import Project + + +class Projects(_Resource): + """CP ``/v1/projects`` namespace: the credential's org's projects, + addressed by slug or id. Read-only.""" + + def list( + self, + *, + project: str | UUID | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Project, Paginated[Project]]: + """List the org's projects; ``project`` (slug or id) narrows it to + one. Iterate the returned :class:`Pager` to stream every project, + or call ``.page()`` for the first page only.""" + return self._paginate( + "/v1/projects", + Paginated[Project], + {**_project(project), "limit": limit, "next": next}, + ) + + def get(self, project: str | UUID) -> Project: + """Read a project by slug or id. One outside the credential's org + raises :class:`~introspection_sdk.NotFoundError`.""" + return self._call( + "GET", f"/v1/projects/{quote(str(project), safe='')}", Project + ) diff --git a/introspection_sdk/_sync/resources/variables.py b/introspection_sdk/_sync/resources/variables.py new file mode 100644 index 0000000..cf77b8d --- /dev/null +++ b/introspection_sdk/_sync/resources/variables.py @@ -0,0 +1,127 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/resources/variables.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.resources.variables`.""" + +from __future__ import annotations + +from uuid import UUID + +from introspection_sdk._sync.resources._crud import ( + _body, + _project, + _Resource, +) +from introspection_sdk._sync.resources.credentials import ( + _setting_list_params, +) +from introspection_sdk.pagination import Pager +from introspection_sdk.schemas.lanes import Environment +from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.runtime_settings import ( + Variable, + VariableCreateRequest, + VariableKind, + VariableUpdateRequest, +) + + +def _variable_path(variable_id: UUID) -> str: + return f"/v1/variables/{variable_id}" + + +class Variables(_Resource): + """CP ``/v1/variables`` namespace: environment variables a project's + runtimes read, scoped like credentials. + + The API has no read-one route. Needs an org credential; ``project`` + (slug or id) names the project when the credential does not. + """ + + def list( + self, + *, + project: str | UUID | None = None, + runtime_id: UUID | None = None, + runtime: str | None = None, + environment: Environment | str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Variable, Paginated[Variable]]: + """List variables, across pages. ``runtime`` is a runtime slug.""" + return self._paginate( + "/v1/variables", + Paginated[Variable], + _setting_list_params( + project=project, + runtime_id=runtime_id, + runtime=runtime, + environment=environment, + limit=limit, + next=next, + ), + ) + + def create( + self, + *, + name: str, + value: str | None = None, + kind: VariableKind | str | None = None, + project: str | UUID | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + environment: Environment | str | None = None, + ) -> Variable: + """Set a variable. ``kind`` defaults to ``value``; a ``credential`` + variable's ``value`` names a credential.""" + fields = {"name": name, "kind": kind, "value": value} + return self._call( + "POST", + "/v1/variables", + Variable, + params=_project(project), + json=_body( + VariableCreateRequest, + { + **fields, + "runtime_id": runtime_id, + "runtime_group_id": runtime_group_id, + "environment": environment, + }, + ), + ) + + def update( + self, + variable_id: UUID, + *, + project: str | UUID | None = None, + name: str | None = None, + value: str | None = None, + kind: VariableKind | str | None = None, + runtime_id: UUID | None = None, + runtime_group_id: UUID | None = None, + environment: Environment | str | None = None, + ) -> Variable: + """Only given fields change.""" + fields = {"name": name, "kind": kind, "value": value} + return self._call( + "PATCH", + _variable_path(variable_id), + Variable, + params=_project(project), + json=_body( + VariableUpdateRequest, + { + **fields, + "runtime_id": runtime_id, + "runtime_group_id": runtime_group_id, + "environment": environment, + }, + ), + ) + + def delete( + self, variable_id: UUID, *, project: str | UUID | None = None + ) -> None: + self._delete(_variable_path(variable_id), _project(project)) diff --git a/introspection_sdk/_sync/runner.py b/introspection_sdk/_sync/runner.py index 2d55860..6723519 100644 --- a/introspection_sdk/_sync/runner.py +++ b/introspection_sdk/_sync/runner.py @@ -14,6 +14,7 @@ Conversations, Events, Files, + Images, Metrics, RunnerAppConnections, Shares, @@ -58,6 +59,7 @@ def __init__( self._metrics = Metrics(self._http) self._shares = Shares(self._http) self._automations = Automations(self._http) + self._images = Images(self._http) self._connections = RunnerAppConnections( self._http, spec.runtime_context.runtime_group_id ) @@ -120,6 +122,12 @@ def automations(self) -> Automations: self._check_open() return self._automations + @property + def images(self) -> Images: + """DP ``/v1/images`` namespace bound to this Runner.""" + self._check_open() + return self._images + @property def connections(self) -> RunnerAppConnections: """DP ``/v1/connections`` namespace bound to this Runner. ``create`` @@ -176,6 +184,7 @@ def refresh(self) -> None: self._metrics = Metrics(self._http) self._shares = Shares(self._http) self._automations = Automations(self._http) + self._images = Images(self._http) self._connections = RunnerAppConnections( self._http, new_spec.runtime_context.runtime_group_id ) diff --git a/introspection_sdk/_sync/runner_resources/images.py b/introspection_sdk/_sync/runner_resources/images.py new file mode 100644 index 0000000..087366c --- /dev/null +++ b/introspection_sdk/_sync/runner_resources/images.py @@ -0,0 +1,87 @@ +# Generated by scripts/gen_sync.py from introspection_sdk/_async/runner_resources/images.py. Do not edit: +# change the async source and run `uv run python scripts/gen_sync.py`. +"""Re-exported by :mod:`introspection_sdk.runner_resources.images`.""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from introspection_sdk._sync.resources._crud import _body, _Resource +from introspection_sdk.pagination import Pager +from introspection_sdk.schemas.images import ( + ContextImageSource, + Image, + ImageCreateRequest, + ImageKind, + ImageProvenance, + ImageStatus, + MirrorImageSource, +) +from introspection_sdk.schemas.pagination import Paginated + + +class Images(_Resource): + """DP ``/v1/images`` namespace: what an eval task's compose services run + as. + + An image is built from a context archive uploaded through the Files API, + or mirrored from an upstream reference pinned by digest, and always + lands in the plane's registry. A create is idempotent on content: the + same source returns the existing row, and resending it retries a failed + build. ``status`` is the only progress report, so poll :meth:`get` until + it is ``ready`` (or ``failed``). Needs ``images:read`` / + ``images:write`` on a plane with evals enabled (a create answers 422 + otherwise). + """ + + def list( + self, + *, + kind: ImageKind | str | None = None, + status: ImageStatus | str | None = None, + name: str | None = None, + runtime_id: UUID | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[Image, Paginated[Image]]: + """List images, across pages.""" + params: dict[str, Any] = { + "kind": str(kind) if kind is not None else None, + "status": str(status) if status is not None else None, + "name": name, + "runtime_id": str(runtime_id) if runtime_id is not None else None, + "limit": limit, + "next": next, + } + return self._paginate("/v1/images", Paginated[Image], params) + + def get(self, image_id: UUID | str) -> Image: + return self._call("GET", f"/v1/images/{image_id}", Image) + + def create( + self, + *, + name: str, + source: ContextImageSource | MirrorImageSource | dict[str, Any], + provenance: ImageProvenance | dict[str, Any] | None = None, + scan: bool | None = None, + ) -> Image: + """Record an image and send its build. ``source`` is a + :class:`ContextImageSource` or a :class:`MirrorImageSource` (or the + same as a dict tagged by ``kind``).""" + body = _body( + ImageCreateRequest, + { + "name": name, + "source": source, + "provenance": provenance, + "scan": scan, + }, + ) + return self._call("POST", "/v1/images", Image, json=body) + + def delete(self, image_id: UUID | str) -> None: + """Delete an image. Deleting a runtime image rebakes it; one still + baking raises :class:`~introspection_sdk.ConflictError`.""" + self._delete(f"/v1/images/{image_id}") diff --git a/introspection_sdk/_sync/runner_resources/tasks.py b/introspection_sdk/_sync/runner_resources/tasks.py index 333cc38..db75227 100644 --- a/introspection_sdk/_sync/runner_resources/tasks.py +++ b/introspection_sdk/_sync/runner_resources/tasks.py @@ -7,6 +7,7 @@ import builtins from collections.abc import Iterator from typing import Any +from uuid import UUID from introspection_sdk._errors import RunFailedError, StreamIncompleteError from introspection_sdk._http import _HttpClient @@ -28,12 +29,21 @@ TextMessageContentEvent, ) from introspection_sdk.schemas.pagination import Paginated +from introspection_sdk.schemas.task_commands import ( + TASK_COMMAND_SPEC_ADAPTER, + DownloadCommand, + ExecCommand, + TaskCommand, + TaskCommandStatus, + UploadCommand, +) from introspection_sdk.schemas.tasks import ( Task, TaskCancelRequest, TaskCancelResponse, TaskCreateResponse, TaskFileRef, + TaskKind, TaskPrompt, TaskRepoRequest, TaskRun, @@ -80,6 +90,12 @@ def _repo_refs( ] +#: The longest :meth:`TaskCommands.get` asks the API to hold a read. The API +#: allows 60 seconds, but it has to answer inside the SDK's own 30-second +#: request timeout, or the read fails instead of returning the command. +MAX_COMMAND_WAIT_SECONDS = 25 + + def _snapshot_text(event: MessagesSnapshotEvent) -> list[str]: """A snapshot carries the whole run so far, so it replaces what was read.""" return [ @@ -265,10 +281,90 @@ def stream( ) +class TaskCommands: + """DP ``/v1/tasks/{task_id}/commands``: ``exec``, ``upload`` and + ``download`` against an eval task's live sandbox. + + A command is a row the task's workflow executes and stamps: the create + returns it ``queued``, and :meth:`get` reads its outcome, optionally + holding the read until it finishes. Eval tasks only (create the task + with ``kind="eval"`` and ``commands=True``), behind ``tasks:exec``, on a + plane with evals enabled. + """ + + def __init__(self, http: _HttpClient) -> None: + self._http = http + + def create( + self, + task_id: str, + command: ExecCommand + | UploadCommand + | DownloadCommand + | dict[str, Any], + ) -> TaskCommand: + """Record a command and send it to the task's workflow. ``command`` + is an :class:`ExecCommand`, :class:`UploadCommand` or + :class:`DownloadCommand` (or the same as a dict tagged by + ``kind``).""" + spec = TASK_COMMAND_SPEC_ADAPTER.validate_python(command) + payload = self._http.request( + "POST", + f"/v1/tasks/{task_id}/commands", + json=spec.model_dump(mode="json", exclude_none=True), + ) + return TaskCommand.model_validate(payload) + + def list( + self, + task_id: str, + *, + status: TaskCommandStatus | str | None = None, + limit: int | None = None, + next: str | None = None, + ) -> Pager[TaskCommand, Paginated[TaskCommand]]: + """List a task's commands, newest first, across pages.""" + + def fetch(cursor: str | None) -> Paginated[TaskCommand]: + payload = self._http.request( + "GET", + f"/v1/tasks/{task_id}/commands", + params={ + "status": str(status) if status is not None else None, + "limit": limit, + "next": cursor, + }, + ) + return Paginated[TaskCommand].model_validate(payload) + + return cursor_paginate(fetch, start=next) + + def get( + self, + task_id: str, + command_id: str | UUID, + *, + wait_seconds: int | None = None, + ) -> TaskCommand: + """Read a command. ``wait_seconds`` holds the read until the command + finishes or the wait runs out, and returns it in whatever state it + is then; it is capped at :data:`MAX_COMMAND_WAIT_SECONDS`, so call + again to wait longer.""" + if wait_seconds is not None: + wait_seconds = max(0, min(wait_seconds, MAX_COMMAND_WAIT_SECONDS)) + payload = self._http.request( + "GET", + f"/v1/tasks/{task_id}/commands/{command_id}", + params={"wait_seconds": wait_seconds}, + ) + return TaskCommand.model_validate(payload) + + class Tasks: def __init__(self, http: _HttpClient) -> None: self._http = http self.runs = TaskRuns(http) + self.commands = TaskCommands(http) def list( self, @@ -313,7 +409,15 @@ def create( fork_share_id: str | None = None, files: builtins.list[TaskFileRef | dict[str, Any]] | None = None, tags: builtins.list[str] | None = None, + kind: TaskKind | str | None = None, + commands: bool | None = None, ) -> TaskCreateResponse: + """Create a task and its initial run. + + ``kind="eval"`` runs the task as an evaluation trial; with + ``commands=True`` it also accepts :attr:`commands` against its live + sandbox. Both are omitted from the body unless given. + """ body: dict[str, Any] = { "title": title, "prompt": prompt, @@ -325,6 +429,8 @@ def create( "files": _file_refs(files) if files else None, "repositories": _repo_refs(repositories) if repositories else None, "tags": tags, + "kind": str(kind) if kind is not None else None, + "commands": commands, } body = {k: v for k, v in body.items() if v is not None} payload = self._http.request("POST", "/v1/tasks", json=body) diff --git a/introspection_sdk/protocols.py b/introspection_sdk/protocols.py index 9a20cf3..fec3c88 100644 --- a/introspection_sdk/protocols.py +++ b/introspection_sdk/protocols.py @@ -24,8 +24,10 @@ AsyncEventsResource, AsyncFilesResource, AsyncFileVersionsResource, + AsyncImagesResource, AsyncMetricsResource, AsyncSharesResource, + AsyncTaskCommandsResource, AsyncTaskRunsResource, AsyncTasksResource, ) @@ -38,8 +40,10 @@ EventsResource, FilesResource, FileVersionsResource, + ImagesResource, MetricsResource, SharesResource, + TaskCommandsResource, TaskRunsResource, TasksResource, ) @@ -53,8 +57,10 @@ "AsyncEventsResource", "AsyncFileVersionsResource", "AsyncFilesResource", + "AsyncImagesResource", "AsyncMetricsResource", "AsyncSharesResource", + "AsyncTaskCommandsResource", "AsyncTaskRunsResource", "AsyncTasksResource", "AutomationsResource", @@ -65,8 +71,10 @@ "EventsResource", "FileVersionsResource", "FilesResource", + "ImagesResource", "MetricsResource", "SharesResource", + "TaskCommandsResource", "TaskRunsResource", "TasksResource", ] diff --git a/introspection_sdk/resources/__init__.py b/introspection_sdk/resources/__init__.py index e972761..af1c1c1 100644 --- a/introspection_sdk/resources/__init__.py +++ b/introspection_sdk/resources/__init__.py @@ -4,19 +4,41 @@ Annotations, AsyncAnnotations, ) +from introspection_sdk.resources.api_keys import ApiKeys, AsyncApiKeys +from introspection_sdk.resources.applications import ( + ApplicationIdps, + ApplicationKeys, + Applications, + ApplicationSecrets, + AsyncApplicationIdps, + AsyncApplicationKeys, + AsyncApplications, + AsyncApplicationSecrets, +) from introspection_sdk.resources.connectors import ( AsyncConnections, AsyncConnectors, Connections, Connectors, ) +from introspection_sdk.resources.credentials import ( + AsyncCredentials, + Credentials, +) from introspection_sdk.resources.experiments import ( AsyncExperimentHandle, AsyncExperiments, ExperimentHandle, Experiments, ) +from introspection_sdk.resources.integrations import ( + AsyncIntegrations, + Integrations, +) +from introspection_sdk.resources.judges import AsyncJudges, Judges from introspection_sdk.resources.members import AsyncMembers, Members +from introspection_sdk.resources.oidc import AsyncOidc, Oidc +from introspection_sdk.resources.projects import AsyncProjects, Projects from introspection_sdk.resources.recipes import AsyncRecipes, Recipes from introspection_sdk.resources.repositories import ( AsyncRepositories, @@ -33,12 +55,35 @@ Runtimes, ) from introspection_sdk.resources.tags import AsyncTags, Tags +from introspection_sdk.resources.variables import AsyncVariables, Variables from introspection_sdk.runner_resources.automations import ( AsyncAutomations, Automations, ) __all__ = [ + "ApiKeys", + "AsyncApiKeys", + "ApplicationIdps", + "ApplicationKeys", + "Applications", + "ApplicationSecrets", + "AsyncApplicationIdps", + "AsyncApplicationKeys", + "AsyncApplications", + "AsyncApplicationSecrets", + "AsyncCredentials", + "Credentials", + "AsyncIntegrations", + "Integrations", + "AsyncJudges", + "Judges", + "AsyncOidc", + "Oidc", + "AsyncProjects", + "Projects", + "AsyncVariables", + "Variables", "Annotations", "Automations", "AsyncAnnotations", diff --git a/introspection_sdk/resources/api_keys.py b/introspection_sdk/resources/api_keys.py new file mode 100644 index 0000000..481d14a --- /dev/null +++ b/introspection_sdk/resources/api_keys.py @@ -0,0 +1,20 @@ +"""``client.api_keys`` — CP namespace for ``/v1/api-keys``. + +A project's API keys. Needs an org credential (a member session or access +token); a project API key cannot manage keys. The plaintext of a new key is +returned once, as a ``SecretStr``. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.api_keys import ( + AsyncApiKeys, +) +from introspection_sdk._sync.resources.api_keys import ( + ApiKeys, +) + +__all__ = [ + "ApiKeys", + "AsyncApiKeys", +] diff --git a/introspection_sdk/resources/applications.py b/introspection_sdk/resources/applications.py new file mode 100644 index 0000000..79f2209 --- /dev/null +++ b/introspection_sdk/resources/applications.py @@ -0,0 +1,32 @@ +"""``client.applications`` — CP namespace for ``/v1/applications``. + +A project's OAuth clients, and under each the identity providers it brokers +(``applications.idps``), its client secrets (``applications.secrets``) and +its signing keys (``applications.keys``). Needs an org credential. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.applications import ( + AsyncApplicationIdps, + AsyncApplicationKeys, + AsyncApplications, + AsyncApplicationSecrets, +) +from introspection_sdk._sync.resources.applications import ( + ApplicationIdps, + ApplicationKeys, + Applications, + ApplicationSecrets, +) + +__all__ = [ + "ApplicationIdps", + "ApplicationKeys", + "ApplicationSecrets", + "Applications", + "AsyncApplicationIdps", + "AsyncApplicationKeys", + "AsyncApplicationSecrets", + "AsyncApplications", +] diff --git a/introspection_sdk/resources/credentials.py b/introspection_sdk/resources/credentials.py new file mode 100644 index 0000000..27e08f7 --- /dev/null +++ b/introspection_sdk/resources/credentials.py @@ -0,0 +1,20 @@ +"""``client.credentials`` — CP namespace for ``/v1/credentials``. + +Secrets a project's runtimes read. The secret is write-only: no read returns +it. Not to be confused with the ``credentials=`` constructor argument, which +authenticates the client itself. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.credentials import ( + AsyncCredentials, +) +from introspection_sdk._sync.resources.credentials import ( + Credentials, +) + +__all__ = [ + "AsyncCredentials", + "Credentials", +] diff --git a/introspection_sdk/resources/integrations.py b/introspection_sdk/resources/integrations.py new file mode 100644 index 0000000..08a2812 --- /dev/null +++ b/introspection_sdk/resources/integrations.py @@ -0,0 +1,19 @@ +"""``client.integrations`` — CP namespace for ``/v1/integrations``. + +The org's own provider installs (a GitHub App installation, a Slack +workspace). Org-wide, so no project selector. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.integrations import ( + AsyncIntegrations, +) +from introspection_sdk._sync.resources.integrations import ( + Integrations, +) + +__all__ = [ + "AsyncIntegrations", + "Integrations", +] diff --git a/introspection_sdk/resources/judges.py b/introspection_sdk/resources/judges.py new file mode 100644 index 0000000..65b4600 --- /dev/null +++ b/introspection_sdk/resources/judges.py @@ -0,0 +1,19 @@ +"""``client.judges`` — CP namespace for ``/v1/judges``. + +The judges a project's Recipes declare. Read and toggle only: a judge is +authored in its Recipe. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.judges import ( + AsyncJudges, +) +from introspection_sdk._sync.resources.judges import ( + Judges, +) + +__all__ = [ + "AsyncJudges", + "Judges", +] diff --git a/introspection_sdk/resources/oidc.py b/introspection_sdk/resources/oidc.py new file mode 100644 index 0000000..b55065a --- /dev/null +++ b/introspection_sdk/resources/oidc.py @@ -0,0 +1,17 @@ +"""``client.oidc`` — CP ``/v1/oidc/me``: who the client's credential +signs in as. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.oidc import ( + AsyncOidc, +) +from introspection_sdk._sync.resources.oidc import ( + Oidc, +) + +__all__ = [ + "AsyncOidc", + "Oidc", +] diff --git a/introspection_sdk/resources/projects.py b/introspection_sdk/resources/projects.py new file mode 100644 index 0000000..b049213 --- /dev/null +++ b/introspection_sdk/resources/projects.py @@ -0,0 +1,20 @@ +"""``client.projects`` — CP namespace for ``/v1/projects``. + +The org's projects, read by slug or id. Read-only here: writing a project +needs ``projects:write``, which no API key, service account or CLI login +carries. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.projects import ( + AsyncProjects, +) +from introspection_sdk._sync.resources.projects import ( + Projects, +) + +__all__ = [ + "AsyncProjects", + "Projects", +] diff --git a/introspection_sdk/resources/variables.py b/introspection_sdk/resources/variables.py new file mode 100644 index 0000000..86e80b9 --- /dev/null +++ b/introspection_sdk/resources/variables.py @@ -0,0 +1,18 @@ +"""``client.variables`` — CP namespace for ``/v1/variables``. + +Environment variables a project's runtimes read, scoped like credentials. +""" + +from __future__ import annotations + +from introspection_sdk._async.resources.variables import ( + AsyncVariables, +) +from introspection_sdk._sync.resources.variables import ( + Variables, +) + +__all__ = [ + "AsyncVariables", + "Variables", +] diff --git a/introspection_sdk/runner_resources/__init__.py b/introspection_sdk/runner_resources/__init__.py index cada6a2..61a6465 100644 --- a/introspection_sdk/runner_resources/__init__.py +++ b/introspection_sdk/runner_resources/__init__.py @@ -28,6 +28,7 @@ Files, FileVersions, ) +from introspection_sdk.runner_resources.images import AsyncImages, Images from introspection_sdk.runner_resources.metrics import ( AsyncMetrics, Metrics, @@ -37,15 +38,23 @@ Shares, ) from introspection_sdk.runner_resources.tasks import ( + MAX_COMMAND_WAIT_SECONDS, AsyncRunHandle, + AsyncTaskCommands, AsyncTaskRuns, AsyncTasks, RunHandle, + TaskCommands, TaskRuns, Tasks, ) __all__ = [ + "MAX_COMMAND_WAIT_SECONDS", + "AsyncImages", + "AsyncTaskCommands", + "Images", + "TaskCommands", "AppConnections", "AsyncAppConnections", "AsyncAutomations", diff --git a/introspection_sdk/runner_resources/images.py b/introspection_sdk/runner_resources/images.py new file mode 100644 index 0000000..4f355f2 --- /dev/null +++ b/introspection_sdk/runner_resources/images.py @@ -0,0 +1,13 @@ +"""``images`` — DP namespace for ``/v1/images``, on the client and a Runner. + +What an eval task's compose services run as: built from a context archive +uploaded through the Files API, or mirrored from a digest-pinned upstream +reference, into the plane's registry. +""" + +from __future__ import annotations + +from introspection_sdk._async.runner_resources.images import AsyncImages +from introspection_sdk._sync.runner_resources.images import Images + +__all__ = ["AsyncImages", "Images"] diff --git a/introspection_sdk/runner_resources/tasks.py b/introspection_sdk/runner_resources/tasks.py index 0f2c6ea..b933101 100644 --- a/introspection_sdk/runner_resources/tasks.py +++ b/introspection_sdk/runner_resources/tasks.py @@ -1,4 +1,4 @@ -"""`runner.tasks.*` namespace: tasks + runs + cursor-style sugar. +"""`runner.tasks.*` namespace: tasks + runs + commands + cursor-style sugar. Bound to a :class:`~introspection_sdk.runner.Runner` — every call targets the runner's DP endpoint with its short-lived JWT. @@ -7,21 +7,27 @@ from __future__ import annotations from introspection_sdk._async.runner_resources.tasks import ( + MAX_COMMAND_WAIT_SECONDS, AsyncRunHandle, + AsyncTaskCommands, AsyncTaskRuns, AsyncTasks, ) from introspection_sdk._sync.runner_resources.tasks import ( RunHandle, + TaskCommands, TaskRuns, Tasks, ) __all__ = [ + "MAX_COMMAND_WAIT_SECONDS", "AsyncRunHandle", + "AsyncTaskCommands", "AsyncTaskRuns", "AsyncTasks", "RunHandle", + "TaskCommands", "TaskRuns", "Tasks", ] diff --git a/introspection_sdk/schemas/_secrets.py b/introspection_sdk/schemas/_secrets.py new file mode 100644 index 0000000..be0ebd3 --- /dev/null +++ b/introspection_sdk/schemas/_secrets.py @@ -0,0 +1,23 @@ +"""Secrets the API returns once or takes on write. + +Both directions use pydantic's :class:`~pydantic.SecretStr`, so ``repr``, +``str`` and logging print ``**********`` rather than the plaintext; read it +with ``.get_secret_value()``. :data:`WireSecret` is the request-body form: it +serializes to the plaintext in a JSON dump, which is the one place it has to +appear. +""" + +from __future__ import annotations + +from typing import Annotated + +from pydantic import PlainSerializer, SecretStr + +WireSecret = Annotated[ + SecretStr, + PlainSerializer( + lambda value: value.get_secret_value(), + return_type=str, + when_used="json", + ), +] diff --git a/introspection_sdk/schemas/api_keys.py b/introspection_sdk/schemas/api_keys.py new file mode 100644 index 0000000..2177f28 --- /dev/null +++ b/introspection_sdk/schemas/api_keys.py @@ -0,0 +1,107 @@ +"""Pydantic mirrors of CP ``/v1/api-keys`` request/response models. + +A key's plaintext is returned once, on create, as :attr:`ApiKeyCreated.api_key` +(a :class:`~pydantic.SecretStr`, so it never prints); no read returns it again. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, SecretStr + +from introspection_sdk.schemas.lanes import Environment + + +class ApiKeyCapability(StrEnum): + """What a project API key may do beyond the runner set.""" + + RUNTIMES = "runtimes" + EVALS = "evals" + + +class ApiKeyStatus(StrEnum): + ACTIVE = "active" + ROTATING = "rotating" + """Deleted with a grace period: still valid until ``expires_at``.""" + EXPIRED = "expired" + REVOKED = "revoked" + + +class ApiKey(BaseModel): + """A project API key. The plaintext is never readable after create.""" + + model_config = ConfigDict(extra="allow") + + id: UUID + org_id: UUID + project_id: UUID + created_at: datetime | None = None + name: str | None = None + environment: Environment = Environment.PRODUCTION + allow: list[ApiKeyCapability] = [] + scope: str = "*" + member_id: UUID | None = None + """The agent member the key acts as, when it is not the default one.""" + key_prefix: str + key_suffix: str + redacted_key: str | None = None + status: ApiKeyStatus = ApiKeyStatus.ACTIVE + expires_at: datetime | None = None + last_used_at: datetime | None = None + acknowledged_at: datetime | None = None + """Set once ``api_keys.acknowledge`` records the key as saved.""" + key_display: str + prefix: str + + +class ApiKeyCreated(ApiKey): + """Response of ``POST /v1/api-keys``: the key plus its plaintext, the + only time the API returns it.""" + + api_key: SecretStr | None = None + + +class ApiKeyCreateRequest(BaseModel): + """Body of ``POST /v1/api-keys``.""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = None + environment: Environment | None = None + """Defaults to ``production`` server-side.""" + allow: list[ApiKeyCapability] | None = None + member_id: UUID | None = None + """Act as this external-credential agent rather than the default one.""" + expires_at: datetime | None = None + + +class ApiKeyUpdateRequest(BaseModel): + """Body of ``PATCH /v1/api-keys/{id}``.""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = None + allow: list[ApiKeyCapability] | None = None + member_id: UUID | None = None + + +class ApiKeyAcknowledgement(BaseModel): + """Response of ``POST /v1/api-keys/{id}/acknowledge``.""" + + model_config = ConfigDict(extra="allow") + + acknowledged: bool + + +__all__ = [ + "ApiKey", + "ApiKeyAcknowledgement", + "ApiKeyCapability", + "ApiKeyCreateRequest", + "ApiKeyCreated", + "ApiKeyStatus", + "ApiKeyUpdateRequest", +] diff --git a/introspection_sdk/schemas/applications.py b/introspection_sdk/schemas/applications.py new file mode 100644 index 0000000..c4211c4 --- /dev/null +++ b/introspection_sdk/schemas/applications.py @@ -0,0 +1,201 @@ +"""Pydantic mirrors of CP ``/v1/applications`` request/response models. + +An Application is an OAuth client of a project. Under it live the identity +providers it brokers, its client secrets and its signing keys. A client +secret's plaintext rides only the create response, and an IdP's +``client_secret`` is write-only in practice; both are +:class:`~pydantic.SecretStr`, so neither prints. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, SecretStr + +from introspection_sdk.schemas._secrets import WireSecret +from introspection_sdk.schemas.lanes import Environment + + +class _ApiModel(BaseModel): + model_config = ConfigDict(extra="allow") + + +class _Request(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class ApplicationType(StrEnum): + NATIVE = "native" + SPA = "spa" + JWKS = "jwks" + SERVICE_ACCOUNT = "service_account" + + +class ApplicationGrant(StrEnum): + AUTHORIZATION_CODE = "authorization_code" + REFRESH_TOKEN = "refresh_token" + CLIENT_CREDENTIALS = "client_credentials" + DEVICE_CODE = "device_code" + EMAIL_CODE = "email_code" + + +class Application(_ApiModel): + """An OAuth client registered in a project.""" + + id: UUID + org_id: UUID + project_id: UUID + created_at: datetime | None = None + updated_at: datetime | None = None + name: str + application_type: ApplicationType + environment: Environment = Environment.PRODUCTION + redirect_uris: list[str] = [] + allowed_origins: list[str] = [] + allowed_scopes: list[str] = [] + client_id: str + agent_member_id: UUID | None = None + allowed_grants: list[ApplicationGrant] = [] + last_used_at: datetime | None = None + revoked_at: datetime | None = None + created_by_member_id: UUID + + +class ApplicationCreateRequest(_Request): + """Body of ``POST /v1/applications``.""" + + name: str + application_type: ApplicationType + environment: Environment | None = None + redirect_uris: list[str] | None = None + allowed_origins: list[str] | None = None + allowed_scopes: list[str] | None = None + agent_member_id: UUID | None = None + + +class ApplicationUpdateRequest(_Request): + """Body of ``PATCH /v1/applications/{id}``. A list replaces the whole + value.""" + + name: str | None = None + redirect_uris: list[str] | None = None + allowed_origins: list[str] | None = None + allowed_scopes: list[str] | None = None + agent_member_id: UUID | None = None + + +class ApplicationIdpProvider(StrEnum): + AUTH0 = "auth0" + OKTA = "okta" + SUPABASE = "supabase" + NEON = "neon" + GENERIC = "generic" + ZITADEL = "zitadel" + + +class ApplicationIdpStatus(StrEnum): + PENDING = "pending" + ACTIVE = "active" + FAILED = "failed" + + +class ApplicationIdp(_ApiModel): + """An identity provider brokered into an Application, whose users sign + in as customer members.""" + + id: UUID + org_id: UUID + application_id: UUID + created_at: datetime | None = None + updated_at: datetime | None = None + idp_provider: ApplicationIdpProvider = ApplicationIdpProvider.GENERIC + issuer: str + client_id: str + scopes: list[str] = [] + external_org_id: str | None = None + external_idp_id: str | None = None + audience_client_id: str | None = None + status: ApplicationIdpStatus = ApplicationIdpStatus.PENDING + error_message: str | None = None + disabled_at: datetime | None = None + client_secret: SecretStr | None = None + is_active: bool + + +class ApplicationIdpCreateRequest(_Request): + """Body of ``POST /v1/applications/{id}/idps``.""" + + idp_provider: ApplicationIdpProvider | None = None + """Defaults to ``generic`` server-side.""" + issuer: str | None = None + client_id: str | None = None + client_secret: WireSecret | None = None + scopes: list[str] | None = None + audience_client_id: str | None = None + + +class ApplicationSecretStatus(StrEnum): + ACTIVE = "active" + DISABLED = "disabled" + EXPIRED = "expired" + + +class ApplicationSecret(_ApiModel): + """A client secret of a confidential Application. ``secret`` is set only + on the create response.""" + + id: UUID + org_id: UUID + application_id: UUID + created_at: datetime | None = None + updated_at: datetime | None = None + secret_prefix: str + secret_suffix: str + name: str | None = None + expires_at: datetime | None = None + disabled_at: datetime | None = None + last_used_at: datetime | None = None + created_by_member_id: UUID + secret: SecretStr | None = None + status: ApplicationSecretStatus + + +class ApplicationSecretCreateRequest(_Request): + """Body of ``POST /v1/applications/{id}/secrets``.""" + + name: str | None = None + + +class ApplicationKey(_ApiModel): + """A signing key of a ``jwks`` Application: the public half, published + at the Application's JWKS URL. The API keeps the private half.""" + + id: UUID + org_id: UUID + application_id: UUID + created_at: datetime | None = None + updated_at: datetime | None = None + kid: str + algorithm: str + public_key: str + created_by_member_id: UUID | None = None + + +__all__ = [ + "Application", + "ApplicationCreateRequest", + "ApplicationGrant", + "ApplicationIdp", + "ApplicationIdpCreateRequest", + "ApplicationIdpProvider", + "ApplicationIdpStatus", + "ApplicationKey", + "ApplicationSecret", + "ApplicationSecretCreateRequest", + "ApplicationSecretStatus", + "ApplicationType", + "ApplicationUpdateRequest", +] diff --git a/introspection_sdk/schemas/images.py b/introspection_sdk/schemas/images.py new file mode 100644 index 0000000..90b783b --- /dev/null +++ b/introspection_sdk/schemas/images.py @@ -0,0 +1,178 @@ +"""Pydantic mirrors of DP ``/v1/images`` request/response models. + +An image lands in the plane's registry and is what an eval task's compose +services run as. It is built from a context archive uploaded through the +Files API, or mirrored from an upstream reference pinned by digest. A create +is idempotent on content, and ``status`` is the only progress report. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Annotated, Any, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + + +class ImageKind(StrEnum): + """What produced an image.""" + + RUNTIME = "runtime" + """A Recipe bake.""" + SERVICE = "service" + """A caller's ``POST /v1/images``.""" + + +class ImageStatus(StrEnum): + QUEUED = "queued" + BUILDING = "building" + READY = "ready" + FAILED = "failed" + CANCELLED = "cancelled" + EVICTED = "evicted" + + +class ImageScanStatus(StrEnum): + PENDING = "pending" + PASSED = "passed" + FINDINGS = "findings" + FAILED = "failed" + SKIPPED = "skipped" + + +class ImageSourceKind(StrEnum): + CONTEXT = "context" + MIRROR = "mirror" + BAKE = "bake" + + +class Image(BaseModel): + """An image in the plane's registry.""" + + model_config = ConfigDict(extra="allow") + + id: UUID + kind: ImageKind + name: str + content_id: str + """What a create dedupes on: a repeat create of the same content returns + this row.""" + source_kind: ImageSourceKind + source: dict[str, Any] + build_platform: str + service: str | None = None + repository_id: UUID | None = None + git_sha: str | None = None + git_ref: str | None = None + path: str = "" + runtime_id: UUID | None = None + task_id: UUID | None = None + trigger: str | None = None + base_image_digest: str | None = None + base_image_tag: str | None = None + external_artifacts: dict[str, str] | None = None + external_image_name: str | None = None + status: ImageStatus + image_ref: str | None = None + image_digest: str | None = None + size_bytes: int | None = None + warnings: list[str] = [] + error: str | None = None + scan_status: ImageScanStatus | None = None + scan_started_at: datetime | None = None + scanned_at: datetime | None = None + scan_db_built_at: datetime | None = None + scan_summary: dict[str, Any] | None = None + scan_report_file_id: UUID | None = None + created_at: datetime + updated_at: datetime + started_at: datetime | None = None + finished_at: datetime | None = None + last_used_at: datetime | None = None + evicted_at: datetime | None = None + retired_at: datetime | None = None + + @property + def pull_reference(self) -> str | None: + """The digest-pinned reference a compose service names, once + ready.""" + if self.image_ref and self.image_digest: + return f"{self.image_ref}@{self.image_digest}" + return None + + +class ContextImageSource(BaseModel): + """Build from a Dockerfile inside a ``.tar.gz`` uploaded through the + Files API.""" + + model_config = ConfigDict(extra="forbid") + + kind: Literal["context"] = "context" + file_id: UUID + dockerfile: str | None = None + """A path inside the archive; defaults to ``Dockerfile``.""" + platform: str | None = None + """``linux/amd64`` or ``linux/arm64``; defaults to the plane's sandbox + platform.""" + + +class MirrorImageSource(BaseModel): + """Copy an upstream image, pinned by digest (``...@sha256:...``), into the + plane's registry.""" + + model_config = ConfigDict(extra="forbid") + + kind: Literal["mirror"] = "mirror" + reference: str + + +ImageSource = Annotated[ + ContextImageSource | MirrorImageSource, Field(discriminator="kind") +] +"""Where an image's bytes come from, tagged by ``kind``.""" + + +class ImageProvenance(BaseModel): + """Where a Recipe publish built an image from, and the compose service it + runs as.""" + + model_config = ConfigDict(extra="forbid") + + service: str + """The compose service name, a DNS label.""" + repository_id: UUID | None = None + git_sha: str | None = None + path: str | None = None + + +class ImageCreateRequest(BaseModel): + """Body of ``POST /v1/images``.""" + + model_config = ConfigDict(extra="forbid") + + name: str + """Lowercase path segments, e.g. ``hello-mcp/mcp-server``.""" + source: ImageSource + provenance: ImageProvenance | None = None + scan: bool | None = None + """Ask for (``True``) or opt out of (``False``) a vulnerability scan + within what the plane's policy allows; ``None`` takes the policy.""" + + +IMAGE_SOURCE_ADAPTER: TypeAdapter[ImageSource] = TypeAdapter(ImageSource) + +__all__ = [ + "IMAGE_SOURCE_ADAPTER", + "ContextImageSource", + "Image", + "ImageCreateRequest", + "ImageKind", + "ImageProvenance", + "ImageScanStatus", + "ImageSource", + "ImageSourceKind", + "ImageStatus", + "MirrorImageSource", +] diff --git a/introspection_sdk/schemas/integrations.py b/introspection_sdk/schemas/integrations.py new file mode 100644 index 0000000..89f91be --- /dev/null +++ b/introspection_sdk/schemas/integrations.py @@ -0,0 +1,90 @@ +"""Pydantic mirrors of CP ``/v1/integrations`` request/response models. + +An integration is the org's own install of a provider (a GitHub App +installation, a Slack workspace): org-wide, at most one live per type. The +wire field naming the provider is ``type``. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from introspection_sdk.schemas._secrets import WireSecret + + +class IntegrationType(StrEnum): + GITHUB = "github" + SLACK = "slack" + LINEAR = "linear" + JIRA = "jira" + NOTION = "notion" + VERCEL = "vercel" + CUSTOM = "custom" + + +class IntegrationStatus(StrEnum): + PENDING = "pending" + ACTIVE = "active" + ERROR = "error" + DISABLED = "disabled" + + +class Integration(BaseModel): + """An org-wide provider install.""" + + model_config = ConfigDict(extra="allow") + + id: UUID + org_id: UUID + created_at: datetime | None = None + updated_at: datetime | None = None + type: IntegrationType + name: str | None = None + external_id: str | None = None + """The provider's id for the install, set once it is activated.""" + status: IntegrationStatus = IntegrationStatus.PENDING + created_by_member_id: UUID + token_expires_at: datetime | None = None + data: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None + + +class IntegrationCreateRequest(BaseModel): + """Body of ``POST /v1/integrations``. The row is always created + ``pending``; the provider's activation flow completes it with an + update.""" + + model_config = ConfigDict(extra="forbid") + + type: IntegrationType + name: str | None = None + external_id: str | None = None + + +class IntegrationUpdateRequest(BaseModel): + """Body of ``PATCH /v1/integrations/{id}``. ``code`` and + ``redirect_uri`` complete a provider's OAuth activation.""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = None + status: IntegrationStatus | None = None + external_id: str | None = None + code: WireSecret | None = None + redirect_uri: str | None = None + slack_interactivity_enabled: bool | None = None + """Only ``False`` is accepted: turns Slack interactivity off.""" + + +__all__ = [ + "Integration", + "IntegrationCreateRequest", + "IntegrationStatus", + "IntegrationType", + "IntegrationUpdateRequest", +] diff --git a/introspection_sdk/schemas/judges.py b/introspection_sdk/schemas/judges.py new file mode 100644 index 0000000..d0fe487 --- /dev/null +++ b/introspection_sdk/schemas/judges.py @@ -0,0 +1,48 @@ +"""Pydantic mirrors of CP ``/v1/judges`` request/response models. + +A judge is authored in its Recipe and published with it, so the API has no +create or delete; an update changes only ``enabled`` and ``sample_rate``. +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class Judge(BaseModel): + """A judge a Recipe declares.""" + + model_config = ConfigDict(extra="allow") + + id: UUID + org_id: UUID + project_id: UUID + runtime_group_id: UUID + created_at: datetime | None = None + updated_at: datetime | None = None + name: str + description: str | None = None + enabled: bool = True + sample_rate: float = 1.0 + source: str = "recipe" + repository_id: UUID | None = None + recipe_git_commit_sha: str | None = None + source_path: str | None = None + definition_hash: str + created_by_member_id: UUID | None = None + + +class JudgeUpdateRequest(BaseModel): + """Body of ``PATCH /v1/judges/{id}``.""" + + model_config = ConfigDict(extra="forbid") + + enabled: bool | None = None + sample_rate: float | None = Field(default=None, ge=0.0, le=1.0) + """The fraction of conversations judged, ``0.0``–``1.0``.""" + + +__all__ = ["Judge", "JudgeUpdateRequest"] diff --git a/introspection_sdk/schemas/lanes.py b/introspection_sdk/schemas/lanes.py new file mode 100644 index 0000000..5d8c690 --- /dev/null +++ b/introspection_sdk/schemas/lanes.py @@ -0,0 +1,17 @@ +"""The deployment lane a Control Plane resource applies to.""" + +from __future__ import annotations + +from enum import StrEnum + + +class Environment(StrEnum): + """A lane: what an API key, Application, credential or variable is + scoped to (wire field ``environment``).""" + + DEVELOPMENT = "development" + STAGING = "staging" + PRODUCTION = "production" + + +__all__ = ["Environment"] diff --git a/introspection_sdk/schemas/oidc.py b/introspection_sdk/schemas/oidc.py new file mode 100644 index 0000000..a08b9d0 --- /dev/null +++ b/introspection_sdk/schemas/oidc.py @@ -0,0 +1,31 @@ +"""Pydantic mirror of CP ``GET /v1/oidc/me``.""" + +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +FeatureFlagValue = bool | int | float | str +"""A feature flag's resolved value.""" + + +class Me(BaseModel): + """Who the credential is: the member and org a member session or access + token belongs to, with the org's resolved feature flags.""" + + model_config = ConfigDict(extra="allow") + + authenticated: bool = True + org_id: UUID | None = None + member_id: UUID | None = None + email: str | None = None + name: str | None = None + picture: str | None = None + feature_flags: dict[str, FeatureFlagValue] = {} + impersonating_member_id: UUID | None = None + """Set while an operator impersonates this member.""" + impersonator_email: str | None = None + + +__all__ = ["FeatureFlagValue", "Me"] diff --git a/introspection_sdk/schemas/projects.py b/introspection_sdk/schemas/projects.py new file mode 100644 index 0000000..59738fe --- /dev/null +++ b/introspection_sdk/schemas/projects.py @@ -0,0 +1,33 @@ +"""Pydantic mirror of the CP ``/v1/projects`` read model. + +Unknown fields are tolerated via ``extra="allow"`` so CP additions don't +break the SDK. +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class Project(BaseModel): + """A project of the credential's organization.""" + + model_config = ConfigDict(extra="allow") + + id: UUID + org_id: UUID + deployment_id: UUID + """The Data Plane deployment the project lives on.""" + name: str + slug: str | None = None + """The stable identifier the CLI and dashboard URLs use. A project + created before slugs existed may have none.""" + description: str | None = None + settings: dict[str, Any] | None = None + created_by_member_id: UUID | None = None + + +__all__ = ["Project"] diff --git a/introspection_sdk/schemas/runtime_settings.py b/introspection_sdk/schemas/runtime_settings.py new file mode 100644 index 0000000..28f6d41 --- /dev/null +++ b/introspection_sdk/schemas/runtime_settings.py @@ -0,0 +1,107 @@ +"""Pydantic mirrors of CP ``/v1/credentials`` and ``/v1/variables``. + +Both are settings a project's runtimes read, scoped to the whole project, a +runtime group, one runtime and/or one lane. A credential's secret is +write-only: no read returns it, only whether one is set and a display hint. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from introspection_sdk.schemas._secrets import WireSecret +from introspection_sdk.schemas.lanes import Environment + + +class _Scope(BaseModel): + """Where a setting applies. With neither ``runtime_id`` nor + ``runtime_group_id`` it is project-wide; ``environment=None`` applies to + every lane.""" + + runtime_id: UUID | None = None + runtime_group_id: UUID | None = None + environment: Environment | None = None + + +class _Setting(_Scope): + model_config = ConfigDict(extra="allow") + + id: UUID + org_id: UUID + project_id: UUID + created_at: datetime | None = None + updated_at: datetime | None = None + name: str + created_by_member_id: UUID + + +class _Request(_Scope): + model_config = ConfigDict(extra="forbid") + + +class Credential(_Setting): + """A runtime credential. The secret itself is never returned.""" + + has_secret: bool = False + secret_display: str | None = None + secret_set_at: datetime | None = None + + +class CredentialCreateRequest(_Request): + """Body of ``POST /v1/credentials``.""" + + name: str + secret: WireSecret + + +class CredentialUpdateRequest(_Request): + """Body of ``PATCH /v1/credentials/{id}``. Set ``secret`` to rotate.""" + + name: str | None = None + secret: WireSecret | None = None + + +class VariableKind(StrEnum): + VALUE = "value" + """A plain value, returned on read.""" + CREDENTIAL = "credential" + """A reference to a credential by name.""" + + +class Variable(_Setting): + """A runtime environment variable.""" + + kind: VariableKind = VariableKind.VALUE + value: str | None = None + + +class VariableCreateRequest(_Request): + """Body of ``POST /v1/variables``.""" + + name: str + kind: VariableKind | None = None + """Defaults to ``value`` server-side.""" + value: str | None = None + + +class VariableUpdateRequest(_Request): + """Body of ``PATCH /v1/variables/{id}``.""" + + name: str | None = None + kind: VariableKind | None = None + value: str | None = None + + +__all__ = [ + "Credential", + "CredentialCreateRequest", + "CredentialUpdateRequest", + "Variable", + "VariableCreateRequest", + "VariableKind", + "VariableUpdateRequest", +] diff --git a/introspection_sdk/schemas/task_commands.py b/introspection_sdk/schemas/task_commands.py new file mode 100644 index 0000000..22d54ad --- /dev/null +++ b/introspection_sdk/schemas/task_commands.py @@ -0,0 +1,113 @@ +"""Pydantic mirrors of DP ``/v1/tasks/{id}/commands`` models. + +A command is ``exec``, ``upload`` or ``download`` against an eval task's live +sandbox: a row the task's workflow executes and stamps. The create returns it +``queued``; a read reports its outcome. ``service`` names a compose service; +``None`` (or ``main``) is the agent's own container. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Annotated, Any, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + + +class TaskCommandKind(StrEnum): + EXEC = "exec" + UPLOAD = "upload" + DOWNLOAD = "download" + + +class TaskCommandStatus(StrEnum): + QUEUED = "queued" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + @property + def is_terminal(self) -> bool: + """Whether the command has finished, either way.""" + return self in (TaskCommandStatus.COMPLETED, TaskCommandStatus.FAILED) + + +class ExecCommand(BaseModel): + """Run an argv (never a shell string) and record its exit and output.""" + + model_config = ConfigDict(extra="forbid") + + kind: Literal["exec"] = "exec" + command: list[str] = Field(min_length=1) + cwd: str | None = None + timeout_seconds: int | None = None + """Defaults to 300 server-side.""" + service: str | None = None + + +class UploadCommand(BaseModel): + """Copy a Files API object to an absolute ``path`` inside the sandbox.""" + + model_config = ConfigDict(extra="forbid") + + kind: Literal["upload"] = "upload" + file_id: UUID + path: str + + +class DownloadCommand(BaseModel): + """Copy the file at an absolute ``path`` out into a new Files API object, + reported as :attr:`TaskCommand.output_file_id`.""" + + model_config = ConfigDict(extra="forbid") + + kind: Literal["download"] = "download" + path: str + service: str | None = None + + +TaskCommandSpec = Annotated[ + ExecCommand | UploadCommand | DownloadCommand, + Field(discriminator="kind"), +] +"""Body of ``POST /v1/tasks/{id}/commands``, tagged by ``kind``.""" + +TASK_COMMAND_SPEC_ADAPTER: TypeAdapter[TaskCommandSpec] = TypeAdapter( + TaskCommandSpec +) + + +class TaskCommand(BaseModel): + """A recorded command and, once terminal, its outcome.""" + + model_config = ConfigDict(extra="allow") + + id: UUID + task_id: UUID + kind: TaskCommandKind + spec: dict[str, Any] + """The spec as the API normalized it.""" + status: TaskCommandStatus + exit_code: int | None = None + stdout: str | None = None + stderr: str | None = None + output_file_id: UUID | None = None + error: str | None = None + created_at: datetime + updated_at: datetime + started_at: datetime | None = None + finished_at: datetime | None = None + + +__all__ = [ + "TASK_COMMAND_SPEC_ADAPTER", + "DownloadCommand", + "ExecCommand", + "TaskCommand", + "TaskCommandKind", + "TaskCommandSpec", + "TaskCommandStatus", + "UploadCommand", +] diff --git a/introspection_sdk/schemas/tasks.py b/introspection_sdk/schemas/tasks.py index a456738..1f51bce 100644 --- a/introspection_sdk/schemas/tasks.py +++ b/introspection_sdk/schemas/tasks.py @@ -23,13 +23,15 @@ class TaskKind(StrEnum): """The execution shape of a task. ``AGENT`` boots the runtime-agent image and runs an interactive LLM agent; - ``PROCESS`` runs a one-shot baked script and reports through the same + ``EVAL`` is the same shape run as an evaluation trial, admitted and + governed apart from production conversations; ``PROCESS`` runs a one-shot baked script and reports through the same completion path. This replaced the retired ``TaskMode``: there are no task modes any more — every agent task is a conversation, and the recipe agent is selected by ``agent_name``. """ AGENT = "agent" + EVAL = "eval" PROCESS = "process" @@ -170,6 +172,21 @@ class TaskCreateRequest(_ApiModel): "omit to use the deployment default. Clamped to the task timeout." ), ) + kind: TaskKind | None = Field( + default=None, + description=( + "Execution shape: omit for an agent conversation, or `eval` to " + "run the task as an evaluation trial. `process` is rejected." + ), + ) + commands: bool | None = Field( + default=None, + description=( + "Accept commands (exec, upload, download) on " + "/v1/tasks/{id}/commands for this evaluation trial. Requires " + "`kind: eval`." + ), + ) fork_share_id: str | None = Field( default=None, description=( diff --git a/pyproject.toml b/pyproject.toml index fb23f81..cc232b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,9 +124,10 @@ ignore_missing_imports = true [tool.pytest.ini_options] asyncio_mode = "auto" -addopts = "-m 'not integration'" +addopts = "-m 'not integration and not live'" markers = [ "integration: requires real API keys and makes live API calls", + "live: CRUD walks against a seeded deployment (tests/live; INTROSPECTION_LIVE=1)", ] filterwarnings = [ "ignore::DeprecationWarning:pydantic.*", diff --git a/scripts/check_api_contract.py b/scripts/check_api_contract.py index e8a4e71..b2ff43d 100644 --- a/scripts/check_api_contract.py +++ b/scripts/check_api_contract.py @@ -49,14 +49,27 @@ from dataclasses import dataclass, field from pathlib import Path +from pydantic import BaseModel + +from introspection_sdk.resources.api_keys import ApiKeys +from introspection_sdk.resources.applications import ( + ApplicationIdps, + Applications, + ApplicationSecrets, +) from introspection_sdk.resources.connectors import ( Connections, Connectors, ) +from introspection_sdk.resources.credentials import Credentials from introspection_sdk.resources.experiments import Experiments +from introspection_sdk.resources.integrations import Integrations +from introspection_sdk.resources.judges import Judges from introspection_sdk.resources.members import Members +from introspection_sdk.resources.projects import Projects from introspection_sdk.resources.recipes import Recipes from introspection_sdk.resources.runtimes import Runtimes +from introspection_sdk.resources.variables import Variables from introspection_sdk.runner_resources.automations import Automations from introspection_sdk.runner_resources.connections import AppConnections from introspection_sdk.runner_resources.conversations import ( @@ -65,8 +78,25 @@ ) from introspection_sdk.runner_resources.events import Events from introspection_sdk.runner_resources.files import Files +from introspection_sdk.runner_resources.images import Images from introspection_sdk.runner_resources.shares import Shares -from introspection_sdk.runner_resources.tasks import Tasks +from introspection_sdk.runner_resources.tasks import TaskCommands, Tasks +from introspection_sdk.schemas.api_keys import ( + ApiKey, + ApiKeyCreated, + ApiKeyCreateRequest, + ApiKeyUpdateRequest, +) +from introspection_sdk.schemas.applications import ( + Application, + ApplicationCreateRequest, + ApplicationIdp, + ApplicationIdpCreateRequest, + ApplicationKey, + ApplicationSecret, + ApplicationSecretCreateRequest, + ApplicationUpdateRequest, +) from introspection_sdk.schemas.automations import ( Automation, AutomationCreateRequest, @@ -80,13 +110,42 @@ FeedbackEvent, ) from introspection_sdk.schemas.files import File, FileUpdateRequest +from introspection_sdk.schemas.images import ( + ContextImageSource, + Image, + ImageCreateRequest, + ImageProvenance, + MirrorImageSource, +) +from introspection_sdk.schemas.integrations import ( + Integration, + IntegrationCreateRequest, + IntegrationUpdateRequest, +) +from introspection_sdk.schemas.judges import Judge, JudgeUpdateRequest from introspection_sdk.schemas.members import Member, MemberUpdateRequest from introspection_sdk.schemas.metrics import MetricQueryRequest +from introspection_sdk.schemas.oidc import Me +from introspection_sdk.schemas.projects import Project +from introspection_sdk.schemas.runtime_settings import ( + Credential, + CredentialCreateRequest, + CredentialUpdateRequest, + Variable, + VariableCreateRequest, + VariableUpdateRequest, +) from introspection_sdk.schemas.shares import ( ResourceShare, ShareCreateRequest, ShareUpdateRequest, ) +from introspection_sdk.schemas.task_commands import ( + DownloadCommand, + ExecCommand, + TaskCommand, + UploadCommand, +) from introspection_sdk.schemas.tasks import ( Task, TaskCancelRequest, @@ -676,6 +735,399 @@ class Surface: ) +# Lane and project selection on the Data Plane: the browser session's +# selectors. An SDK credential carries its own project, so they are exempt. +_DP_SESSION_PARAMS = frozenset({"lane", "project_id"}) + + +def _model( + name: str, + where: str, + model: type[BaseModel], + schema: str, + *, + plane: str = "cp", + read: bool = False, +) -> Surface: + """A request body or read model compared field for field.""" + return Surface( + name=name, + where=where, + plane=plane, + sdk=lambda: set(model.model_fields), + server=lambda spec: schema_properties(spec, schema), + extra_means=( + "invented — the API does not return it" + if read + else "sent but not declared by the API" + ), + missing_means=( + "returned by the API but not surfaced here" + if read + else "cannot be sent by callers of this SDK" + ), + ) + + +def _params( + name: str, + method: Callable[..., object], + path: str, + *, + plane: str = "cp", + http_method: str = "get", + client_side: frozenset[str] = frozenset(), + exempt: frozenset[str] = frozenset(), + rename: dict[str, str] | None = None, +) -> Surface: + """A method's keyword surface compared with the route's query + parameters. Path segments the caller passes positionally go in + ``client_side``.""" + renamed = rename or {} + return Surface( + name=name, + where=f"{http_method.upper()} {path} query parameters", + plane=plane, + sdk=lambda: {renamed.get(p, p) for p in signature_params(method)}, + server=lambda spec: query_parameters(spec, path, http_method), + client_side=client_side, + exempt=exempt, + extra_means="sent as a query parameter the API does not accept", + missing_means="accepted by the API but not exposed here", + ) + + +def _body_params( + name: str, + method: Callable[..., object], + schema: str, + where: str, + *, + plane: str = "cp", + client_side: frozenset[str] = frozenset(), + rename: dict[str, str] | None = None, +) -> Surface: + """A create/update method's keyword surface compared with the body. + ``project`` rides the query string, so it is client-side here and + checked by the matching ``_params`` surface.""" + renamed = rename or {} + return Surface( + name=name, + where=where, + plane=plane, + sdk=lambda: {renamed.get(p, p) for p in signature_params(method)}, + server=lambda spec: schema_properties(spec, schema), + client_side=client_side + | ({"project"} & set(signature_params(method))), + extra_means="sent but not declared by the API", + missing_means="cannot be sent by callers of this SDK", + ) + + +_APP = frozenset({"application_id"}) + +# Project administration (CP) and the eval resources (DP). The published +# reference lagged these routes when they landed; check against a spec +# generated from introspection-cloud's source with --spec / --cp-spec. +ADMIN_SURFACES = ( + _model( + "Project", + "the project read model", + Project, + "ProjectResponse", + read=True, + ), + _params("project list filters", Projects.list, "/v1/projects"), + _model("ApiKey", "the API key read model", ApiKey, "APIKey", read=True), + _model( + "ApiKeyCreated", + "POST /v1/api-keys response", + ApiKeyCreated, + "APIKeyResponse", + read=True, + ), + _model( + "ApiKeyCreateRequest", + "POST /v1/api-keys body", + ApiKeyCreateRequest, + "ApiKeyCreateRequest", + ), + _model( + "ApiKeyUpdateRequest", + "PATCH /v1/api-keys/{id} body", + ApiKeyUpdateRequest, + "ApiKeyUpdateRequest", + ), + _params("api key list filters", ApiKeys.list, "/v1/api-keys"), + _params( + "api key delete parameters", + ApiKeys.delete, + "/v1/api-keys/{api_key_id}", + http_method="delete", + client_side=frozenset({"api_key_id"}), + ), + _model( + "Application", + "the Application read model", + Application, + "Application", + read=True, + ), + _model( + "ApplicationCreate", + "POST /v1/applications body", + ApplicationCreateRequest, + "ApplicationCreate", + ), + _model( + "ApplicationUpdate", + "PATCH /v1/applications/{id} body", + ApplicationUpdateRequest, + "ApplicationUpdate", + ), + _body_params( + "Applications.create", + Applications.create, + "ApplicationCreate", + "POST /v1/applications keyword surface", + ), + _params("application list filters", Applications.list, "/v1/applications"), + _model( + "ApplicationIdp", + "the brokered IdP read model", + ApplicationIdp, + "ApplicationIdp", + read=True, + ), + _model( + "BrokeredIdpConfig", + "POST /v1/applications/{id}/idps body", + ApplicationIdpCreateRequest, + "BrokeredIdpConfig", + ), + _params( + "application idp list filters", + ApplicationIdps.list, + "/v1/applications/{application_id}/idps", + client_side=_APP, + ), + _model( + "ApplicationSecret", + "the client secret read model", + ApplicationSecret, + "ApplicationSecretResponse", + read=True, + ), + _model( + "ApplicationSecretCreate", + "POST /v1/applications/{id}/secrets body", + ApplicationSecretCreateRequest, + "ApplicationSecretCreate", + ), + _params( + "application secret list filters", + ApplicationSecrets.list, + "/v1/applications/{application_id}/secrets", + client_side=_APP, + ), + _params( + "application secret delete parameters", + ApplicationSecrets.delete, + "/v1/applications/{application_id}/secrets/{secret_id}", + http_method="delete", + client_side=_APP | {"secret_id"}, + ), + _model( + "ApplicationKey", + "the signing key read model", + ApplicationKey, + "ApplicationKeyResponse", + read=True, + ), + _model( + "Credential", + "the credential read model", + Credential, + "CredentialResponse", + read=True, + ), + _model( + "CredentialCreate", + "POST /v1/credentials body", + CredentialCreateRequest, + "CredentialCreate", + ), + _model( + "CredentialUpdate", + "PATCH /v1/credentials/{id} body", + CredentialUpdateRequest, + "CredentialUpdate", + ), + _body_params( + "Credentials.create", + Credentials.create, + "CredentialCreate", + "POST /v1/credentials keyword surface", + ), + _params("credential list filters", Credentials.list, "/v1/credentials"), + _model( + "Variable", + "the variable read model", + Variable, + "VariableResponse", + read=True, + ), + _model( + "VariableCreate", + "POST /v1/variables body", + VariableCreateRequest, + "VariableCreate", + ), + _model( + "VariableUpdate", + "PATCH /v1/variables/{id} body", + VariableUpdateRequest, + "VariableUpdate", + ), + _body_params( + "Variables.create", + Variables.create, + "VariableCreate", + "POST /v1/variables keyword surface", + ), + _params("variable list filters", Variables.list, "/v1/variables"), + _model( + "Integration", + "the integration read model", + Integration, + "IntegrationResponse", + read=True, + ), + _model( + "IntegrationCreate", + "POST /v1/integrations body", + IntegrationCreateRequest, + "IntegrationCreate", + ), + _model( + "IntegrationUpdate", + "PATCH /v1/integrations/{id} body", + IntegrationUpdateRequest, + "IntegrationUpdate", + ), + _body_params( + "Integrations.create", + Integrations.create, + "IntegrationCreate", + "POST /v1/integrations keyword surface", + # The wire's `type` is spelled `integration_type` here. + rename={"integration_type": "type"}, + ), + _params("integration list filters", Integrations.list, "/v1/integrations"), + _model("Judge", "the judge read model", Judge, "Judge", read=True), + _model( + "JudgeOperationalUpdate", + "PATCH /v1/judges/{id} body", + JudgeUpdateRequest, + "JudgeOperationalUpdate", + ), + _params("judge list filters", Judges.list, "/v1/judges"), + _model( + "OIDCMeResponse", + "GET /v1/oidc/me response", + Me, + "OIDCMeResponse", + read=True, + ), + # --- images and task commands (DP) --------------------------------- + _model( + "Image", "the image read model", Image, "Image", plane="dp", read=True + ), + _model( + "ImageCreate", + "POST /v1/images body", + ImageCreateRequest, + "ImageCreate", + plane="dp", + ), + _model( + "ContextImageSource", + "POST /v1/images source (context)", + ContextImageSource, + "ContextImageSource", + plane="dp", + ), + _model( + "MirrorImageSource", + "POST /v1/images source (mirror)", + MirrorImageSource, + "MirrorImageSource", + plane="dp", + ), + _model( + "ImageProvenance", + "POST /v1/images provenance", + ImageProvenance, + "ImageProvenance", + plane="dp", + ), + _params( + "image list filters", + Images.list, + "/v1/images", + plane="dp", + exempt=_DP_SESSION_PARAMS, + ), + _model( + "TaskCommand", + "the task command read model", + TaskCommand, + "TaskCommand", + plane="dp", + read=True, + ), + _model( + "ExecCommand", + "POST /v1/tasks/{id}/commands exec body", + ExecCommand, + "ExecCommand", + plane="dp", + ), + _model( + "UploadCommand", + "POST /v1/tasks/{id}/commands upload body", + UploadCommand, + "UploadCommand", + plane="dp", + ), + _model( + "DownloadCommand", + "POST /v1/tasks/{id}/commands download body", + DownloadCommand, + "DownloadCommand", + plane="dp", + ), + _params( + "task command list filters", + TaskCommands.list, + "/v1/tasks/{task_id}/commands", + plane="dp", + client_side=frozenset({"task_id"}), + exempt=_DP_SESSION_PARAMS, + ), + _params( + "task command read parameters", + TaskCommands.get, + "/v1/tasks/{task_id}/commands/{command_id}", + plane="dp", + client_side=frozenset({"task_id", "command_id"}), + exempt=_DP_SESSION_PARAMS, + ), +) + +SURFACES = SURFACES + ADMIN_SURFACES + + def load_spec(source: str) -> dict: if source.startswith(("http://", "https://")): with urllib.request.urlopen(source, timeout=30) as response: # noqa: S310 diff --git a/tests/live/__init__.py b/tests/live/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/live/test_live_admin.py b/tests/live/test_live_admin.py new file mode 100644 index 0000000..746351c --- /dev/null +++ b/tests/live/test_live_admin.py @@ -0,0 +1,351 @@ +"""Live walks of the project administration and eval resources. + +These pin that a deployed API answers what ``tests/rest/test_project_admin.py`` +and ``tests/rest/test_images.py`` pin the SDK sends. They mirror the walks of +the same names in ``introspection-rust-sdk``'s ``tests/live_api_test.rs``. +Each walk creates what it reads and deletes it again. Marked ``live`` and +deselected by default; run with:: + + INTROSPECTION_LIVE=1 uv run pytest tests/live -m live -p no:cacheprovider + +Settings, each skipping the walks that need it when unset: + +- ``INTROSPECTION_BASE_API_URL``: the Control Plane. +- ``INTROSPECTION_CP_SESSION``: an owner's Control Plane session cookie. The + administration routes need an org credential, which no project API key or + service account is; a bearer beside the cookie is refused, so it is sent + alone. +- ``INTROSPECTION_PROJECT``: the seeded project's slug, which a member session + must name on project-scoped routes. +- ``INTROSPECTION_DP_URL`` and ``INTROSPECTION_MEMBER_TOKEN`` (an owner's Data + Plane token): ``images`` and ``tasks.commands``, which need ``images:*`` / + ``tasks:exec``. +""" + +from __future__ import annotations + +import contextlib +import os +import secrets +import time +from collections.abc import Iterable, Iterator +from itertools import islice +from typing import TypeVar +from uuid import UUID + +import pytest + +from introspection_sdk import IntrospectionClient +from introspection_sdk._errors import ( + ConflictError, + IntrospectionAPIError, + NotFoundError, + ValidationError, +) +from introspection_sdk.schemas.api_keys import ApiKeyStatus +from introspection_sdk.schemas.applications import ApplicationType +from introspection_sdk.schemas.images import ImageKind, MirrorImageSource +from introspection_sdk.schemas.integrations import ( + IntegrationStatus, + IntegrationType, +) +from introspection_sdk.schemas.lanes import Environment +from introspection_sdk.schemas.task_commands import ExecCommand + +pytestmark = pytest.mark.live + +T = TypeVar("T") + +#: Upper bound on the rows a walk reads back while looking for what it made. +ROWS = 500 + + +def collect(rows: Iterable[T]) -> list[T]: + return list(islice(rows, ROWS)) + + +def uuid7() -> UUID: + """A UUIDv7; ``uuid.uuid7`` is 3.14+ and this package supports 3.11.""" + value = ((int(time.time() * 1000) & ((1 << 48) - 1)) << 80) | ( + secrets.randbits(80) + ) + value = (value & ~(0xF << 76)) | (0x7 << 76) + value = (value & ~(0x3 << 62)) | (0x2 << 62) + return UUID(int=value) + + +def unique(prefix: str) -> str: + """A name no other run shares, so concurrent runs never collide and a + leftover from a crashed run is recognisable.""" + return f"sdk-{prefix}-{uuid7().hex}" + + +def env_name() -> str: + """An environment-variable-shaped unique name.""" + return f"SDK_{uuid7().hex}".upper() + + +def setting(name: str) -> str: + value = os.environ.get(name) + if not value: + pytest.skip(f"{name} is not set") + return value + + +@pytest.fixture(autouse=True) +def _require_live() -> None: + if os.environ.get("INTROSPECTION_LIVE") != "1": + pytest.skip("INTROSPECTION_LIVE is not 1") + + +@pytest.fixture +def owner_session() -> Iterator[IntrospectionClient]: + """An owner's Control Plane session cookie alone.""" + client = IntrospectionClient( + cp_session=setting("INTROSPECTION_CP_SESSION"), + base_api_url=setting("INTROSPECTION_BASE_API_URL"), + ) + yield client + client.shutdown() + + +@pytest.fixture +def owner_dp() -> Iterator[IntrospectionClient]: + """An owner's Data Plane token.""" + client = IntrospectionClient( + token=setting("INTROSPECTION_MEMBER_TOKEN"), + base_api_url=setting("INTROSPECTION_BASE_API_URL"), + dp_url=setting("INTROSPECTION_DP_URL"), + ) + yield client + client.shutdown() + + +@pytest.fixture +def project() -> str: + return setting("INTROSPECTION_PROJECT") + + +def test_oidc_me_with_session(owner_session: IntrospectionClient) -> None: + me = owner_session.oidc.me() + assert me.authenticated + assert me.member_id is not None, "a member session names its member" + assert me.org_id is not None + + +def test_api_keys_crud_walk( + owner_session: IntrospectionClient, project: str +) -> None: + keys = owner_session.api_keys + created = keys.create( + project=project, + name=unique("key"), + environment=Environment.DEVELOPMENT, + ) + try: + assert created.api_key is not None, "the plaintext rides the create" + assert keys.acknowledge(created.id, project=project).acknowledged + with pytest.raises(NotFoundError): + keys.acknowledge(created.id, project=project) + + assert any(k.id == created.id for k in keys.list(project=project)) + renamed = unique("key-renamed") + updated = keys.update(created.id, project=project, name=renamed) + assert updated.name == renamed + + keys.delete(created.id, project=project) + assert not any( + k.id == created.id and k.status is ApiKeyStatus.ACTIVE + for k in keys.list(project=project) + ) + finally: + with contextlib.suppress(NotFoundError): + keys.delete(created.id, project=project) + + +def test_applications_crud_walk( + owner_session: IntrospectionClient, project: str +) -> None: + applications = owner_session.applications + # Every plan may create a service account; browser and federated types + # are plan-gated. + created = applications.create( + name=unique("app"), + application_type=ApplicationType.SERVICE_ACCOUNT, + project=project, + ) + app_id = created.id + try: + applications.get(app_id, project=project) + listed = collect(applications.list(project=project)) + assert any(a.id == app_id for a in listed) + renamed = unique("app-renamed") + assert ( + applications.update(app_id, project=project, name=renamed).name + == renamed + ) + + secret = applications.secrets.create(app_id, name=unique("secret")) + assert secret.secret is not None, "the plaintext rides the create" + secrets_listed = collect(applications.secrets.list(app_id)) + assert any(s.id == secret.id for s in secrets_listed) + applications.secrets.delete(app_id, secret.id) + + key = applications.keys.create(app_id) + assert any(k.id == key.id for k in applications.keys.list(app_id)) + applications.keys.delete(app_id, key.id) + + assert collect(applications.idps.list(app_id)) == [] + # A service account federates no end users, so the attach is + # refused; reaching that refusal is what pins the route and body. + with pytest.raises(IntrospectionAPIError) as refused: + applications.idps.create(app_id, issuer="https://idp.example.com") + assert refused.value.status_code in (400, 403) + + applications.delete(app_id, project=project) + with pytest.raises(NotFoundError): + applications.get(app_id, project=project) + finally: + with contextlib.suppress(NotFoundError): + applications.delete(app_id, project=project) + + +def test_credentials_crud_walk( + owner_session: IntrospectionClient, project: str +) -> None: + credentials = owner_session.credentials + name = env_name() + created = credentials.create( + name=name, secret="sk-live-suite", project=project + ) + try: + assert created.has_secret + assert credentials.get(created.id, project=project).name == name + listed = collect(credentials.list(project=project)) + assert any(c.id == created.id for c in listed) + credentials.update(created.id, project=project, secret="sk-rotated") + + credentials.delete(created.id, project=project) + with pytest.raises(NotFoundError): + credentials.get(created.id, project=project) + finally: + with contextlib.suppress(NotFoundError): + credentials.delete(created.id, project=project) + + +def test_variables_crud_walk( + owner_session: IntrospectionClient, project: str +) -> None: + variables = owner_session.variables + created = variables.create(name=env_name(), value="one", project=project) + try: + assert created.value == "one" + updated = variables.update(created.id, project=project, value="two") + assert updated.value == "two" + listed = collect(variables.list(project=project)) + assert any(v.id == created.id for v in listed) + + variables.delete(created.id, project=project) + listed = collect(variables.list(project=project)) + assert all(v.id != created.id for v in listed) + finally: + with contextlib.suppress(NotFoundError): + variables.delete(created.id, project=project) + + +def test_integrations_crud_walk(owner_session: IntrospectionClient) -> None: + integrations = owner_session.integrations + # One live integration per type per org: a create returns the pending + # one if it exists, and 409s on an active one. + try: + created = integrations.create( + integration_type=IntegrationType.CUSTOM, name=unique("integration") + ) + except ConflictError: + pytest.skip("the org already has an active custom integration") + try: + assert created.status is IntegrationStatus.PENDING + integrations.get(created.id) + assert any(i.id == created.id for i in collect(integrations.list())) + renamed = unique("integration-renamed") + assert integrations.update(created.id, name=renamed).name == renamed + + integrations.delete(created.id) + with pytest.raises(NotFoundError): + integrations.get(created.id) + finally: + with contextlib.suppress(NotFoundError): + integrations.delete(created.id) + + +def test_judges_read_and_update_walk( + owner_session: IntrospectionClient, project: str +) -> None: + judges = owner_session.judges + listed = collect(judges.list(project=project)) + with pytest.raises(NotFoundError): + judges.get(uuid7(), project=project) + # Judges are published with a Recipe, so a project without one has none + # to read; the update writes back the judge's own values. + if not listed: + pytest.skip("the project has no judges") + fetched = judges.get(listed[0].id, project=project) + updated = judges.update( + fetched.id, + project=project, + enabled=fetched.enabled, + sample_rate=fetched.sample_rate, + ) + assert updated.enabled == fetched.enabled + + +def test_projects_read_walk( + owner_session: IntrospectionClient, project: str +) -> None: + projects = owner_session.projects + seeded = projects.get(project) + assert seeded.slug == project + assert any(p.id == seeded.id for p in collect(projects.list())) + + +def test_images_crud_walk(owner_dp: IntrospectionClient) -> None: + images = owner_dp.images + collect(images.list()) + with pytest.raises(NotFoundError): + images.get(uuid7()) + + # A digest no registry serves: the create only records intent, and the + # build it sends fails on the row without touching anything else. + source = MirrorImageSource( + reference=f"registry.invalid/sdk/live@sha256:{'0' * 64}" + ) + try: + created = images.create(name=unique("image"), source=source) + except ValidationError as error: + if error.status_code != 422: + raise + pytest.skip(f"evals are not enabled on this plane ({error})") + try: + assert created.kind is ImageKind.SERVICE + images.get(created.id) + images.delete(created.id) + with pytest.raises(NotFoundError): + images.get(created.id) + finally: + with contextlib.suppress(NotFoundError): + images.delete(created.id) + + +def test_task_commands_reads(owner_dp: IntrospectionClient) -> None: + commands = owner_dp.tasks.commands + task_id = str(uuid7()) + + # A command needs an eval task with a live sandbox, which the seed does + # not run; these pin the routes, the credential and the error shapes. + assert collect(commands.list(task_id)) == [] + with pytest.raises(NotFoundError): + commands.get(task_id, uuid7()) + with pytest.raises(IntrospectionAPIError) as refused: + commands.create(task_id, ExecCommand(command=["true"])) + # 404 for the missing task, or 422 on a plane without evals. + assert refused.value.status_code in (404, 422) diff --git a/tests/rest/test_images.py b/tests/rest/test_images.py new file mode 100644 index 0000000..1946b41 --- /dev/null +++ b/tests/rest/test_images.py @@ -0,0 +1,344 @@ +"""Contract tests for ``images`` (DP ``/v1/images``) and ``tasks.commands`` +(DP ``/v1/tasks/{id}/commands``): the paths, list filters, the ``kind``-tagged +source and command bodies, and the wait cap.""" + +from __future__ import annotations + +import inspect +from typing import Any +from uuid import UUID + +import pytest +from pydantic import ValidationError as PydanticValidationError + +from introspection_sdk._errors import ConflictError +from introspection_sdk._http import _HttpClient +from introspection_sdk.runner_resources import ( + MAX_COMMAND_WAIT_SECONDS, + AsyncImages, + AsyncTasks, + Images, + Tasks, +) +from introspection_sdk.schemas.images import ( + ContextImageSource, + ImageKind, + ImageProvenance, + ImageStatus, + MirrorImageSource, +) +from introspection_sdk.schemas.task_commands import ( + DownloadCommand, + ExecCommand, + TaskCommandStatus, + UploadCommand, +) +from introspection_sdk.schemas.tasks import TaskKind + +from .conftest import ( + FILE_ID, + REPOSITORY_ID, + RUNTIME_ID, + TASK_ID, + FakeAPI, + task_create_response, +) + +NOW = "2026-10-21T12:00:00Z" +IMAGE_ID = "0199a1b2-0000-7000-8000-0000000000b1" +COMMAND_ID = "0199a1b2-0000-7000-8000-0000000000b2" +DIGEST = "sha256:" + "0" * 64 +COMMANDS_PATH = f"/v1/tasks/{TASK_ID}/commands" +COMMAND_PATH = f"{COMMANDS_PATH}/{COMMAND_ID}" + + +def image_payload(**over: Any) -> dict[str, Any]: + return { + "id": IMAGE_ID, + "kind": "service", + "name": "hello-mcp/mcp-server", + "content_id": "c1", + "source_kind": "mirror", + "source": {"kind": "mirror", "reference": f"ghcr.io/x@{DIGEST}"}, + "build_platform": "linux/amd64", + "status": "queued", + "created_at": NOW, + "updated_at": NOW, + **over, + } + + +def command_payload(**over: Any) -> dict[str, Any]: + return { + "id": COMMAND_ID, + "task_id": TASK_ID, + "kind": "exec", + "spec": {"kind": "exec", "command": ["true"], "timeout_seconds": 300}, + "status": "queued", + "created_at": NOW, + "updated_at": NOW, + **over, + } + + +# --- images ---------------------------------------------------------- + + +def test_image_list_sends_its_filters(fake_api: FakeAPI): + fake_api.add( + "GET", + "/v1/images", + json_body={"records": [image_payload()], "count": 1}, + ) + + images = list( + Images(fake_api.client()).list( + kind=ImageKind.SERVICE, + status="ready", + name="hello-mcp/mcp-server", + runtime_id=UUID(RUNTIME_ID), + limit=5, + ) + ) + + assert [i.id for i in images] == [UUID(IMAGE_ID)] + assert dict(fake_api.last_request.params) == { + "kind": "service", + "status": "ready", + "name": "hello-mcp/mcp-server", + "runtime_id": RUNTIME_ID, + "limit": "5", + } + + +def test_image_create_from_a_context_archive(fake_api: FakeAPI): + fake_api.add("POST", "/v1/images", status=201, json_body=image_payload()) + + Images(fake_api.client()).create( + name="hello-mcp/mcp-server", + source=ContextImageSource( + file_id=UUID(FILE_ID), platform="linux/arm64" + ), + provenance=ImageProvenance( + service="mcp-server", repository_id=UUID(REPOSITORY_ID) + ), + scan=False, + ) + + assert fake_api.last_request.json() == { + "name": "hello-mcp/mcp-server", + "source": { + "kind": "context", + "file_id": FILE_ID, + "platform": "linux/arm64", + }, + "provenance": { + "service": "mcp-server", + "repository_id": REPOSITORY_ID, + }, + "scan": False, + } + + +def test_image_create_takes_a_tagged_dict(fake_api: FakeAPI): + fake_api.add("POST", "/v1/images", status=201, json_body=image_payload()) + + created = Images(fake_api.client()).create( + name="mirror", + source={"kind": "mirror", "reference": f"ghcr.io/x@{DIGEST}"}, + ) + + assert fake_api.last_request.json() == { + "name": "mirror", + "source": {"kind": "mirror", "reference": f"ghcr.io/x@{DIGEST}"}, + } + assert created.status is ImageStatus.QUEUED + assert created.pull_reference is None + + +def test_image_create_refuses_an_untagged_source(fake_api: FakeAPI): + with pytest.raises(PydanticValidationError): + Images(fake_api.client()).create( + name="x", source={"reference": "ghcr.io/x"} + ) + with pytest.raises(PydanticValidationError): + MirrorImageSource.model_validate( + {"kind": "mirror", "reference": "r", "file_id": FILE_ID} + ) + assert fake_api.requests == [] + + +def test_image_get_delete_and_pull_reference(fake_api: FakeAPI): + path = f"/v1/images/{IMAGE_ID}" + fake_api.add( + "GET", + path, + json_body=image_payload( + status="ready", + image_ref="registry.test/sdk/mirror", + image_digest=DIGEST, + ), + ) + fake_api.add("DELETE", path, status=204) + images = Images(fake_api.client()) + + image = images.get(UUID(IMAGE_ID)) + assert image.pull_reference == f"registry.test/sdk/mirror@{DIGEST}" + images.delete(UUID(IMAGE_ID)) + assert fake_api.last_request.method == "DELETE" + + +def test_image_delete_while_baking_is_a_conflict(fake_api: FakeAPI): + fake_api.add("DELETE", f"/v1/images/{IMAGE_ID}", status=409, json_body={}) + + with pytest.raises(ConflictError): + Images(fake_api.client()).delete(IMAGE_ID) + + +async def test_async_images_get(fake_api: FakeAPI): + fake_api.add("GET", f"/v1/images/{IMAGE_ID}", json_body=image_payload()) + + image = await AsyncImages(fake_api.async_client()).get(IMAGE_ID) + + assert image.kind is ImageKind.SERVICE + + +# --- task commands --------------------------------------------------- + + +@pytest.mark.parametrize( + ("command", "body"), + [ + ( + ExecCommand(command=["pytest", "-q"], cwd="/app", service="db"), + { + "kind": "exec", + "command": ["pytest", "-q"], + "cwd": "/app", + "service": "db", + }, + ), + ( + UploadCommand(file_id=UUID(FILE_ID), path="/app/in.txt"), + {"kind": "upload", "file_id": FILE_ID, "path": "/app/in.txt"}, + ), + ( + DownloadCommand(path="/app/out.txt"), + {"kind": "download", "path": "/app/out.txt"}, + ), + ( + {"kind": "exec", "command": ["true"], "timeout_seconds": 5}, + {"kind": "exec", "command": ["true"], "timeout_seconds": 5}, + ), + ], +) +def test_command_create_sends_the_tagged_body( + fake_api: FakeAPI, command: Any, body: dict[str, Any] +): + fake_api.add( + "POST", COMMANDS_PATH, status=201, json_body=command_payload() + ) + + created = Tasks(fake_api.client()).commands.create(TASK_ID, command) + + assert fake_api.last_request.json() == body + assert created.status is TaskCommandStatus.QUEUED + assert not created.status.is_terminal + + +def test_command_create_refuses_a_shell_string(fake_api: FakeAPI): + commands = Tasks(fake_api.client()).commands + with pytest.raises(PydanticValidationError): + commands.create(TASK_ID, {"kind": "exec", "command": "rm -rf /"}) + with pytest.raises(PydanticValidationError): + commands.create(TASK_ID, {"kind": "shell", "command": ["true"]}) + assert fake_api.requests == [] + + +def test_command_list_filters_by_status(fake_api: FakeAPI): + fake_api.add( + "GET", + COMMANDS_PATH, + json_body={"records": [command_payload()], "count": 1}, + ) + + listed = list( + Tasks(fake_api.client()).commands.list( + TASK_ID, status=TaskCommandStatus.RUNNING, limit=2 + ) + ) + + assert [c.id for c in listed] == [UUID(COMMAND_ID)] + assert dict(fake_api.last_request.params) == { + "status": "running", + "limit": "2", + } + + +@pytest.mark.parametrize( + ("asked", "sent"), + [(None, None), (10, "10"), (60, str(MAX_COMMAND_WAIT_SECONDS)), (-1, "0")], +) +def test_command_get_caps_the_wait( + fake_api: FakeAPI, asked: int | None, sent: str | None +): + fake_api.add( + "GET", + COMMAND_PATH, + json_body=command_payload( + status="completed", exit_code=0, stdout="ok\n" + ), + ) + + command = Tasks(fake_api.client()).commands.get( + TASK_ID, UUID(COMMAND_ID), wait_seconds=asked + ) + + assert command.status.is_terminal + assert command.exit_code == 0 + assert fake_api.last_request.params.get("wait_seconds") == sent + + +def test_wait_cap_stays_under_the_client_timeout(): + timeout = inspect.signature(_HttpClient).parameters["timeout"].default + assert MAX_COMMAND_WAIT_SECONDS < timeout + + +async def test_async_command_get(fake_api: FakeAPI): + fake_api.add("GET", COMMAND_PATH, json_body=command_payload()) + + command = await AsyncTasks(fake_api.async_client()).commands.get( + TASK_ID, COMMAND_ID, wait_seconds=120 + ) + + assert command.kind == "exec" + assert fake_api.last_request.params.get("wait_seconds") == "25" + + +# --- eval task create ------------------------------------------------ + + +def test_task_create_sends_eval_kind_and_commands(fake_api: FakeAPI): + fake_api.add( + "POST", "/v1/tasks", status=201, json_body=task_create_response() + ) + + Tasks(fake_api.client()).create( + prompt="run the suite", kind=TaskKind.EVAL, commands=True + ) + + assert fake_api.last_request.json() == { + "prompt": "run the suite", + "kind": "eval", + "commands": True, + } + + +def test_task_create_omits_kind_and_commands_by_default(fake_api: FakeAPI): + fake_api.add( + "POST", "/v1/tasks", status=201, json_body=task_create_response() + ) + + Tasks(fake_api.client()).create(prompt="hi") + + assert fake_api.last_request.json() == {"prompt": "hi"} diff --git a/tests/rest/test_project_admin.py b/tests/rest/test_project_admin.py new file mode 100644 index 0000000..b5b725a --- /dev/null +++ b/tests/rest/test_project_admin.py @@ -0,0 +1,787 @@ +"""Contract tests for the project administration namespaces. + +``projects``, ``api_keys``, ``applications`` (with ``idps`` / ``secrets`` / +``keys``), ``credentials``, ``variables``, ``integrations``, ``judges`` and +``oidc``: each pins the path, the ``?project=`` selector, the body's wire +names, and that a secret the API returns once or takes on write never +prints. +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +import httpx2 as httpx +import pytest +from pydantic import SecretStr +from pydantic import ValidationError as PydanticValidationError + +from introspection_sdk import IntrospectionClient +from introspection_sdk._errors import ConflictError, NotFoundError +from introspection_sdk.resources import ( + ApiKeys, + Applications, + AsyncApiKeys, + AsyncApplications, + Credentials, + Integrations, + Judges, + Oidc, + Projects, + Variables, +) +from introspection_sdk.schemas.api_keys import ( + ApiKeyCapability, + ApiKeyStatus, +) +from introspection_sdk.schemas.applications import ApplicationType +from introspection_sdk.schemas.integrations import ( + IntegrationStatus, + IntegrationType, + IntegrationUpdateRequest, +) +from introspection_sdk.schemas.lanes import Environment +from introspection_sdk.schemas.runtime_settings import ( + CredentialCreateRequest, + VariableKind, +) + +from .conftest import ( + MEMBER_ID, + ORG_ID, + PROJECT_ID, + RUNTIME_GROUP_ID, + RUNTIME_ID, + FakeAPI, +) + +NOW = "2026-10-21T12:00:00Z" +KEY_ID = "0199a1b2-0000-7000-8000-0000000000a1" +APP_ID = "0199a1b2-0000-7000-8000-0000000000a2" +IDP_ID = "0199a1b2-0000-7000-8000-0000000000a3" +SECRET_ID = "0199a1b2-0000-7000-8000-0000000000a4" +SIGNING_KEY_ID = "0199a1b2-0000-7000-8000-0000000000a5" +CREDENTIAL_ID = "0199a1b2-0000-7000-8000-0000000000a6" +VARIABLE_ID = "0199a1b2-0000-7000-8000-0000000000a7" +INTEGRATION_ID = "0199a1b2-0000-7000-8000-0000000000a8" +JUDGE_ID = "0199a1b2-0000-7000-8000-0000000000a9" +DEPLOYMENT_ID = "0199a1b2-0000-7000-8000-0000000000aa" +PLAINTEXT = "intro_sk_plaintext_never_printed" + + +def page(*records: dict[str, Any], next: str | None = None) -> dict: + return {"records": list(records), "count": len(records), "next": next} + + +def project_payload(**over: Any) -> dict[str, Any]: + return { + "id": PROJECT_ID, + "org_id": ORG_ID, + "deployment_id": DEPLOYMENT_ID, + "name": "Support", + "slug": "support", + "description": None, + **over, + } + + +def api_key_payload(**over: Any) -> dict[str, Any]: + return { + "id": KEY_ID, + "org_id": ORG_ID, + "project_id": PROJECT_ID, + "created_at": NOW, + "name": "ci", + "environment": "development", + "allow": ["evals"], + "scope": "*", + "key_prefix": "intro_dev_", + "key_suffix": "abcd", + "status": "active", + "key_display": "intro_dev_...abcd", + "prefix": "intro_dev_", + **over, + } + + +def application_payload(**over: Any) -> dict[str, Any]: + return { + "id": APP_ID, + "org_id": ORG_ID, + "project_id": PROJECT_ID, + "created_at": NOW, + "updated_at": NOW, + "name": "backend", + "application_type": "service_account", + "environment": "production", + "client_id": "app_client", + "allowed_grants": ["client_credentials"], + "created_by_member_id": MEMBER_ID, + **over, + } + + +def credential_payload(**over: Any) -> dict[str, Any]: + return { + "id": CREDENTIAL_ID, + "org_id": ORG_ID, + "project_id": PROJECT_ID, + "created_at": NOW, + "updated_at": NOW, + "name": "OPENAI_API_KEY", + "runtime_group_id": RUNTIME_GROUP_ID, + "environment": None, + "has_secret": True, + "secret_display": "sk-...wxyz", + "created_by_member_id": MEMBER_ID, + **over, + } + + +def variable_payload(**over: Any) -> dict[str, Any]: + return { + "id": VARIABLE_ID, + "org_id": ORG_ID, + "project_id": PROJECT_ID, + "created_at": NOW, + "updated_at": NOW, + "name": "LOG_LEVEL", + "kind": "value", + "value": "debug", + "created_by_member_id": MEMBER_ID, + **over, + } + + +def integration_payload(**over: Any) -> dict[str, Any]: + return { + "id": INTEGRATION_ID, + "org_id": ORG_ID, + "created_at": NOW, + "updated_at": NOW, + "type": "custom", + "name": "internal", + "status": "pending", + "created_by_member_id": MEMBER_ID, + **over, + } + + +def judge_payload(**over: Any) -> dict[str, Any]: + return { + "id": JUDGE_ID, + "org_id": ORG_ID, + "project_id": PROJECT_ID, + "runtime_group_id": RUNTIME_GROUP_ID, + "created_at": NOW, + "updated_at": NOW, + "name": "helpfulness", + "enabled": True, + "sample_rate": 0.5, + "source": "recipe", + "definition_hash": "sha256:abc", + **over, + } + + +# --- client wiring --------------------------------------------------- + + +def test_client_hangs_every_namespace_off_the_control_plane(): + client = IntrospectionClient( + token="t", + base_api_url="https://cp.test", + dp_url="https://dp.test", + ) + try: + for name in ( + "projects", + "api_keys", + "applications", + "credentials", + "variables", + "integrations", + "judges", + "oidc", + ): + namespace = getattr(client, name) + assert namespace._http is client._http, name + assert client.images._http is client._dp_http + assert client.tasks.commands._http is client._dp_http + finally: + client.shutdown() + + +# --- projects -------------------------------------------------------- + + +def test_project_get_percent_encodes_the_slug(fake_api: FakeAPI): + fake_api.add( + "GET", "/v1/projects/acme/support desk", json_body=project_payload() + ) + + project = Projects(fake_api.client()).get("acme/support desk") + + assert project.slug == "support" + assert ( + fake_api.last_request.url.raw_path + == b"/v1/projects/acme%2Fsupport%20desk" + ) + + +def test_project_get_by_id_and_missing_is_typed(fake_api: FakeAPI): + fake_api.add( + "GET", f"/v1/projects/{PROJECT_ID}", json_body=project_payload() + ) + projects = Projects(fake_api.client()) + + assert projects.get(UUID(PROJECT_ID)).deployment_id == UUID(DEPLOYMENT_ID) + with pytest.raises(NotFoundError): + projects.get("gone") + + +def test_project_list_pages_and_narrows_by_project(fake_api: FakeAPI): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.params.get("next") == "c2": + body = page(project_payload(id=DEPLOYMENT_ID, slug="two")) + else: + body = page(project_payload(), next="c2") + return httpx.Response(200, json=body) + + fake_api.add_handler("GET", "/v1/projects", handler) + + slugs = [ + p.slug for p in Projects(fake_api.client()).list(project="support") + ] + + assert slugs == ["support", "two"] + first, second = fake_api.requests + assert first.params.get("project") == "support" + assert "next" not in first.params + assert second.params.get("next") == "c2" + + +def test_projects_has_no_writes(): + # projects:write / projects:delete are in no API-key, service-account or + # CLI-login ceiling, so the SDK exposes reads only. + for verb in ("create", "update", "delete"): + assert not hasattr(Projects, verb) + + +# --- oidc ------------------------------------------------------------ + + +def test_oidc_me_keeps_each_feature_flag_type(fake_api: FakeAPI): + fake_api.add( + "GET", + "/v1/oidc/me", + json_body={ + "authenticated": True, + "org_id": ORG_ID, + "member_id": MEMBER_ID, + "email": "owner@example.com", + "feature_flags": { + "evals": True, + "max_runtimes": 3, + "ratio": 0.25, + "tier": "pro", + }, + }, + ) + + me = Oidc(fake_api.client()).me() + + assert me.member_id == UUID(MEMBER_ID) + assert me.feature_flags == { + "evals": True, + "max_runtimes": 3, + "ratio": 0.25, + "tier": "pro", + } + assert type(me.feature_flags["evals"]) is bool + assert type(me.feature_flags["max_runtimes"]) is int + + +# --- api keys -------------------------------------------------------- + + +def test_api_key_list_unwraps_the_envelope_and_sends_filters( + fake_api: FakeAPI, +): + fake_api.add( + "GET", "/v1/api-keys", json_body={"api_keys": [api_key_payload()]} + ) + + keys = ApiKeys(fake_api.client()).list(project="support", expiring=True) + + assert [k.id for k in keys] == [UUID(KEY_ID)] + assert keys[0].allow == [ApiKeyCapability.EVALS] + assert dict(fake_api.last_request.params) == { + "project": "support", + "expiring": "true", + } + + +def test_api_key_create_returns_a_redacted_plaintext(fake_api: FakeAPI): + fake_api.add( + "POST", + "/v1/api-keys", + status=201, + json_body=api_key_payload(api_key=PLAINTEXT), + ) + + created = ApiKeys(fake_api.client()).create( + project="support", + name="ci", + environment=Environment.DEVELOPMENT, + allow=["evals"], + ) + + assert fake_api.last_request.json() == { + "name": "ci", + "environment": "development", + "allow": ["evals"], + } + assert fake_api.last_request.params.get("project") == "support" + assert isinstance(created.api_key, SecretStr) + assert created.api_key.get_secret_value() == PLAINTEXT + assert PLAINTEXT not in repr(created) + assert PLAINTEXT not in str(created) + assert PLAINTEXT not in created.model_dump_json() + + +def test_api_key_update_delete_and_acknowledge(fake_api: FakeAPI): + path = f"/v1/api-keys/{KEY_ID}" + fake_api.add("PATCH", path, json_body=api_key_payload(name="renamed")) + fake_api.add("DELETE", path, status=204) + fake_api.add( + "POST", f"{path}/acknowledge", json_body={"acknowledged": True} + ) + keys = ApiKeys(fake_api.client()) + + updated = keys.update(UUID(KEY_ID), name="renamed") + assert updated.name == "renamed" + assert fake_api.last_request.json() == {"name": "renamed"} + assert "project" not in fake_api.last_request.params + + keys.delete(UUID(KEY_ID), project="support", expires_in=3600) + assert dict(fake_api.last_request.params) == { + "project": "support", + "expires_in": "3600", + } + + assert keys.acknowledge(UUID(KEY_ID), project="support").acknowledged + assert fake_api.last_request.content == b"" + assert fake_api.last_request.params.get("project") == "support" + + +def test_api_key_delete_without_grace_sends_no_expiry(fake_api: FakeAPI): + fake_api.add("DELETE", f"/v1/api-keys/{KEY_ID}", status=204) + + ApiKeys(fake_api.client()).delete(UUID(KEY_ID)) + + assert dict(fake_api.last_request.params) == {} + + +def test_api_key_rotating_status_parses(fake_api: FakeAPI): + fake_api.add( + "GET", + "/v1/api-keys", + json_body={"api_keys": [api_key_payload(status="rotating")]}, + ) + + (key,) = ApiKeys(fake_api.client()).list() + + assert key.status is ApiKeyStatus.ROTATING + + +async def test_async_api_keys_create(fake_api: FakeAPI): + fake_api.add( + "POST", + "/v1/api-keys", + status=201, + json_body=api_key_payload(api_key=PLAINTEXT), + ) + + created = await AsyncApiKeys(fake_api.async_client()).create(name="ci") + + assert created.api_key is not None + assert PLAINTEXT not in repr(created) + assert fake_api.last_request.json() == {"name": "ci"} + + +# --- applications ---------------------------------------------------- + + +def test_application_crud(fake_api: FakeAPI): + path = f"/v1/applications/{APP_ID}" + fake_api.add( + "POST", "/v1/applications", status=201, json_body=application_payload() + ) + fake_api.add("GET", path, json_body=application_payload()) + fake_api.add("PATCH", path, json_body=application_payload(name="b2")) + fake_api.add("DELETE", path, status=204) + fake_api.add( + "GET", "/v1/applications", json_body=page(application_payload()) + ) + applications = Applications(fake_api.client()) + + created = applications.create( + name="backend", + application_type=ApplicationType.SERVICE_ACCOUNT, + project="support", + allowed_scopes=["tasks:write"], + ) + assert created.application_type is ApplicationType.SERVICE_ACCOUNT + assert fake_api.last_request.json() == { + "name": "backend", + "application_type": "service_account", + "allowed_scopes": ["tasks:write"], + } + assert fake_api.last_request.params.get("project") == "support" + + applications.get(UUID(APP_ID), project="support") + assert fake_api.last_request.params.get("project") == "support" + + applications.update(UUID(APP_ID), name="b2", redirect_uris=[]) + # An explicit [] replaces the list, so it must reach the wire. + assert fake_api.last_request.json() == {"name": "b2", "redirect_uris": []} + + listed = list(applications.list(project="support", limit=10)) + assert [a.id for a in listed] == [UUID(APP_ID)] + assert fake_api.last_request.params.get("limit") == "10" + + applications.delete(UUID(APP_ID), project="support") + assert fake_api.last_request.method == "DELETE" + assert fake_api.last_request.params.get("project") == "support" + + +def test_application_idps_send_the_secret_and_never_print_it( + fake_api: FakeAPI, +): + idps_path = f"/v1/applications/{APP_ID}/idps" + idp = { + "id": IDP_ID, + "org_id": ORG_ID, + "application_id": APP_ID, + "idp_provider": "okta", + "issuer": "https://idp.example.com", + "client_id": "okta-client", + "client_secret": PLAINTEXT, + "is_active": False, + } + fake_api.add("POST", idps_path, status=201, json_body=idp) + fake_api.add("GET", idps_path, json_body=page(idp)) + fake_api.add("DELETE", f"{idps_path}/{IDP_ID}", status=204) + idps = Applications(fake_api.client()).idps + + created = idps.create( + UUID(APP_ID), + idp_provider="okta", + issuer="https://idp.example.com", + client_id="okta-client", + client_secret=SecretStr(PLAINTEXT), + ) + + assert fake_api.last_request.json() == { + "idp_provider": "okta", + "issuer": "https://idp.example.com", + "client_id": "okta-client", + "client_secret": PLAINTEXT, + } + assert PLAINTEXT not in repr(created) + assert [i.id for i in idps.list(UUID(APP_ID))] == [UUID(IDP_ID)] + idps.delete(UUID(APP_ID), UUID(IDP_ID)) + assert fake_api.last_request.path == f"{idps_path}/{IDP_ID}" + + +def test_application_secrets_redact_the_one_time_plaintext( + fake_api: FakeAPI, +): + secrets_path = f"/v1/applications/{APP_ID}/secrets" + secret = { + "id": SECRET_ID, + "org_id": ORG_ID, + "application_id": APP_ID, + "secret_prefix": "intro_sk_", + "secret_suffix": "wxyz", + "name": "deploy", + "created_by_member_id": MEMBER_ID, + "status": "active", + } + fake_api.add( + "POST", + secrets_path, + status=201, + json_body={**secret, "secret": PLAINTEXT}, + ) + fake_api.add("GET", secrets_path, json_body=page(secret)) + fake_api.add("DELETE", f"{secrets_path}/{SECRET_ID}", status=204) + secrets = Applications(fake_api.client()).secrets + + created = secrets.create(UUID(APP_ID), name="deploy") + assert fake_api.last_request.json() == {"name": "deploy"} + assert created.secret is not None + assert created.secret.get_secret_value() == PLAINTEXT + assert PLAINTEXT not in repr(created) + + (listed,) = list(secrets.list(UUID(APP_ID))) + assert listed.secret is None + + secrets.delete(UUID(APP_ID), UUID(SECRET_ID), expires_in=60) + assert dict(fake_api.last_request.params) == {"expires_in": "60"} + + +def test_application_keys_are_an_unpaginated_array(fake_api: FakeAPI): + keys_path = f"/v1/applications/{APP_ID}/keys" + key = { + "id": SIGNING_KEY_ID, + "org_id": ORG_ID, + "application_id": APP_ID, + "kid": "k1", + "algorithm": "RS256", + "public_key": "-----BEGIN PUBLIC KEY-----", + } + fake_api.add("GET", keys_path, json_body=[key]) + fake_api.add("POST", keys_path, status=201, json_body=key) + fake_api.add("DELETE", f"{keys_path}/{SIGNING_KEY_ID}", status=204) + keys = Applications(fake_api.client()).keys + + assert [k.kid for k in keys.list(UUID(APP_ID))] == ["k1"] + assert keys.create(UUID(APP_ID)).algorithm == "RS256" + assert fake_api.last_request.content == b"" + keys.delete(UUID(APP_ID), UUID(SIGNING_KEY_ID)) + assert fake_api.last_request.method == "DELETE" + + +async def test_async_application_sub_namespaces(fake_api: FakeAPI): + keys_path = f"/v1/applications/{APP_ID}/keys" + fake_api.add("GET", keys_path, json_body=[]) + fake_api.add( + "GET", + f"/v1/applications/{APP_ID}/secrets", + json_body=page(), + ) + applications = AsyncApplications(fake_api.async_client()) + + assert await applications.keys.list(UUID(APP_ID)) == [] + assert [s async for s in applications.secrets.list(UUID(APP_ID))] == [] + + +# --- credentials ----------------------------------------------------- + + +def test_credential_create_sends_the_plaintext_once(fake_api: FakeAPI): + fake_api.add("POST", "/v1/credentials", json_body=credential_payload()) + + created = Credentials(fake_api.client()).create( + name="OPENAI_API_KEY", + secret="sk-live-value", + project="support", + runtime_group_id=UUID(RUNTIME_GROUP_ID), + ) + + assert fake_api.last_request.json() == { + "name": "OPENAI_API_KEY", + "secret": "sk-live-value", + "runtime_group_id": RUNTIME_GROUP_ID, + } + assert created.has_secret + assert not hasattr(created, "secret") + + +def test_credential_request_never_prints_the_secret(): + request = CredentialCreateRequest(name="K", secret=SecretStr("sk-live")) + + assert "sk-live" not in repr(request) + assert request.model_dump(mode="json")["secret"] == "sk-live" + + +def test_credential_list_get_update_delete(fake_api: FakeAPI): + path = f"/v1/credentials/{CREDENTIAL_ID}" + fake_api.add( + "GET", "/v1/credentials", json_body=page(credential_payload()) + ) + fake_api.add("GET", path, json_body=credential_payload()) + fake_api.add("PATCH", path, json_body=credential_payload()) + fake_api.add("DELETE", path, status=204) + credentials = Credentials(fake_api.client()) + + list( + credentials.list( + project="support", + runtime_id=UUID(RUNTIME_ID), + runtime="support-agent", + environment=Environment.STAGING, + ) + ) + assert dict(fake_api.last_request.params) == { + "project": "support", + "runtime_id": RUNTIME_ID, + "runtime": "support-agent", + "environment": "staging", + } + + assert credentials.get(UUID(CREDENTIAL_ID)).runtime_group_id == UUID( + RUNTIME_GROUP_ID + ) + credentials.update(UUID(CREDENTIAL_ID), secret="sk-rotated") + assert fake_api.last_request.json() == {"secret": "sk-rotated"} + credentials.delete(UUID(CREDENTIAL_ID), project="support") + assert fake_api.last_request.params.get("project") == "support" + + +# --- variables ------------------------------------------------------- + + +def test_variable_crud(fake_api: FakeAPI): + path = f"/v1/variables/{VARIABLE_ID}" + fake_api.add("POST", "/v1/variables", json_body=variable_payload()) + fake_api.add("PATCH", path, json_body=variable_payload(value="info")) + fake_api.add("DELETE", path, status=204) + fake_api.add("GET", "/v1/variables", json_body=page(variable_payload())) + variables = Variables(fake_api.client()) + + created = variables.create(name="LOG_LEVEL", value="debug") + # `kind` defaults server-side, so an omitted one stays off the wire. + assert fake_api.last_request.json() == { + "name": "LOG_LEVEL", + "value": "debug", + } + assert created.kind is VariableKind.VALUE + + variables.create( + name="OPENAI_API_KEY", + kind=VariableKind.CREDENTIAL, + value="OPENAI_API_KEY", + environment="production", + ) + assert fake_api.last_request.json() == { + "name": "OPENAI_API_KEY", + "kind": "credential", + "value": "OPENAI_API_KEY", + "environment": "production", + } + + assert variables.update(UUID(VARIABLE_ID), value="info").value == "info" + assert fake_api.last_request.json() == {"value": "info"} + assert [v.name for v in variables.list(project="support")] == ["LOG_LEVEL"] + variables.delete(UUID(VARIABLE_ID), project="support") + assert fake_api.last_request.method == "DELETE" + assert not hasattr(variables, "get") + + +def test_variable_rejects_an_unknown_lane_before_sending(fake_api: FakeAPI): + with pytest.raises(PydanticValidationError): + Variables(fake_api.client()).create( + name="X", value="1", environment="qa" + ) + assert fake_api.requests == [] + + +# --- integrations ---------------------------------------------------- + + +def test_integration_create_sends_the_wire_type(fake_api: FakeAPI): + fake_api.add( + "POST", + "/v1/integrations", + status=201, + json_body=integration_payload(), + ) + + created = Integrations(fake_api.client()).create( + integration_type=IntegrationType.CUSTOM, name="internal" + ) + + assert fake_api.last_request.json() == { + "type": "custom", + "name": "internal", + } + assert created.type is IntegrationType.CUSTOM + assert created.status is IntegrationStatus.PENDING + assert "project" not in fake_api.last_request.params + + +def test_integration_create_conflict_is_typed(fake_api: FakeAPI): + fake_api.add( + "POST", "/v1/integrations", status=409, json_body={"detail": "exists"} + ) + + with pytest.raises(ConflictError): + Integrations(fake_api.client()).create(integration_type="github") + + +def test_integration_read_update_delete(fake_api: FakeAPI): + path = f"/v1/integrations/{INTEGRATION_ID}" + fake_api.add("GET", path, json_body=integration_payload()) + fake_api.add("PATCH", path, json_body=integration_payload(status="active")) + fake_api.add("DELETE", path, status=204) + fake_api.add( + "GET", "/v1/integrations", json_body=page(integration_payload()) + ) + integrations = Integrations(fake_api.client()) + + assert integrations.get(UUID(INTEGRATION_ID)).name == "internal" + assert [i.id for i in integrations.list()] == [UUID(INTEGRATION_ID)] + + updated = integrations.update( + UUID(INTEGRATION_ID), + code="oauth-code", + redirect_uri="https://app.test/cb", + slack_interactivity_enabled=False, + ) + assert updated.status is IntegrationStatus.ACTIVE + # `False` is a value, not an omission, so it is sent. + assert fake_api.last_request.json() == { + "code": "oauth-code", + "redirect_uri": "https://app.test/cb", + "slack_interactivity_enabled": False, + } + integrations.delete(UUID(INTEGRATION_ID)) + assert fake_api.last_request.method == "DELETE" + + +def test_integration_oauth_code_never_prints(): + request = IntegrationUpdateRequest(code=SecretStr("oauth-code")) + + assert "oauth-code" not in repr(request) + + +# --- judges ---------------------------------------------------------- + + +def test_judge_list_get_update(fake_api: FakeAPI): + path = f"/v1/judges/{JUDGE_ID}" + fake_api.add("GET", "/v1/judges", json_body=page(judge_payload())) + fake_api.add("GET", path, json_body=judge_payload()) + fake_api.add("PATCH", path, json_body=judge_payload(enabled=False)) + judges = Judges(fake_api.client()) + + listed = list( + judges.list(project="support", runtime_group_id=UUID(RUNTIME_GROUP_ID)) + ) + assert [j.sample_rate for j in listed] == [0.5] + assert dict(fake_api.last_request.params) == { + "project": "support", + "runtime_group_id": RUNTIME_GROUP_ID, + } + + judges.get(UUID(JUDGE_ID), project="support") + assert fake_api.last_request.params.get("project") == "support" + + updated = judges.update(UUID(JUDGE_ID), enabled=False, sample_rate=0.0) + assert not updated.enabled + assert fake_api.last_request.json() == { + "enabled": False, + "sample_rate": 0.0, + } + assert not hasattr(judges, "create") + assert not hasattr(judges, "delete") + + +def test_judge_sample_rate_is_a_fraction(fake_api: FakeAPI): + with pytest.raises(PydanticValidationError): + Judges(fake_api.client()).update(UUID(JUDGE_ID), sample_rate=1.5) + assert fake_api.requests == []