openrelay-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ # Dependencies
2
+ node_modules
3
+ .pnpm-store
4
+
5
+ # Next.js
6
+ .next
7
+ out
8
+
9
+ # Build
10
+ dist
11
+ build
12
+
13
+ # Environment
14
+ .env
15
+ .env.local
16
+ .env.*.local
17
+ # scripts/local-stack.sh backs up an existing .env.local here on its first run,
18
+ # so the copy needs the same guard as the original.
19
+ .env.local.bak
20
+
21
+ # Local stack state (scripts/local-stack.sh): ops org id, JWT secret, logs
22
+ .local-stack/
23
+ # Legacy state dir from the pre-rename script; migrated to .local-stack on `up`.
24
+ # Kept ignored so its JWT secret is never swept into a commit before migration.
25
+ .local-toolbox/
26
+
27
+ # IDE
28
+ .idea
29
+ .vscode
30
+ *.swp
31
+ *.swo
32
+ .DS_Store
33
+
34
+ # Claude Code per-developer state (MCP server enablement, permission approvals):
35
+ # differs per machine, never commit
36
+ .claude/settings.local.json
37
+ # Agent-local state: worktrees (`git add -A` would commit each one as a gitlink),
38
+ # task lists and caches
39
+ .claude/worktrees/
40
+ .claude/tasks/
41
+ .claude/cache/
42
+
43
+ # A saved terraform plan holds sensitive values in cleartext and must never be
44
+ # committed. infra/terraform/scripts/tf-run.sh writes its plans outside the
45
+ # worktree entirely; this catches one run by hand with -out= inside it.
46
+ *.tfplan
47
+
48
+ # Logs
49
+ *.log
50
+ npm-debug.log*
51
+ yarn-debug.log*
52
+ yarn-error.log*
53
+
54
+ # TypeScript
55
+ *.tsbuildinfo
56
+
57
+ # Vercel
58
+ .vercel
59
+
60
+ .env.prod-local
61
+
62
+ # Secrets
63
+ secrets/
64
+ infra/.secrets/
65
+ **/.secrets/
66
+ *-key.pem
67
+ # age private decryption key: NEVER commit (only the encrypted .env.claude.* files + the public .age-recipients are)
68
+ .env.keys
69
+ # local Stripe API keys (holds sk_live/pk_live): NEVER commit
70
+ .stripe.keys
71
+ # local Cerebras API key (csk-... bearer token; lives in SSM in beta/prod). NEVER commit
72
+ .cerebras.key
73
+
74
+ # Python SDK
75
+ sdks/python/.venv/
76
+ sdks/python/dist/
77
+ sdks/python/*.egg-info/
78
+ sdks/python/dev.py
79
+ __pycache__/
80
+ *.pyc
81
+ .pytest_cache/
82
+
83
+ # Test results
84
+ e2e/test-results/
85
+
86
+ # Schemathesis local example database, generated config, and reports
87
+ # (created by `st run` / `schemathesis run`; schemathesis.toml is written
88
+ # fresh by .github/workflows/schemathesis.yml on every run, never committed)
89
+ .schemathesis/
90
+ schemathesis.toml
91
+ schemathesis-report/
92
+
93
+ # Compiled binaries
94
+ infra/nomad-plugins/vm-templates/vm-templates
95
+
96
+ # Cloudflare
97
+ .wrangler/
98
+
99
+ # Google service-account keys (never commit)
100
+ scripts/.google-sheets-key.json
101
+ *.google-sheets-key.json
102
+
103
+ # Outreach recipient lists (PII: never commit)
104
+ **/recipients*.txt
105
+ *.recipients.txt
106
+ # local GLM-5.2 provider API keys (never commit)
107
+ .glm52.keys
108
+
109
+ # Lead lists / customer data (never commit)
110
+ gpu_customers_over_100.csv
111
+ email_flags.csv
112
+ customers_segmented.csv
113
+ campaign_exclusions.csv
114
+ attio_import.csv
115
+
116
+ # Terraform (never commit state or real tfvars)
117
+ **/.terraform/*
118
+ *.tfstate
119
+ *.tfstate.*
120
+ *.tfvars
121
+ !*.tfvars.example
122
+ # Module test fixtures (fake values), loaded by terraform test.
123
+ !infra/terraform/modules/*/tests/terraform.tfvars
124
+ crash.log
125
+
126
+ # Cloudflare Worker local dev secrets
127
+ **/.dev.vars
128
+ .nebius
129
+ # Lambda Cloud API key (both spellings; never commit)
130
+ .lambda
131
+ .lamda
132
+
133
+ # built Go binaries (never commit)
134
+ /services/fabric/fabric-nodeagent
135
+ /tools/brownout/brownout
136
+
137
+ # patch/merge backups (never commit)
138
+ *.orig
139
+ *.rej
140
+
141
+ # Generated by `next dev` (node_modules/next/dist/server/lib/generate-agent-files.js).
142
+ apps/dashboard/AGENTS.md
143
+ apps/dashboard/CLAUDE.md
144
+ apps/toolbox/AGENTS.md
145
+ apps/toolbox/CLAUDE.md
146
+ apps/linkedin-outreach/AGENTS.md
147
+ apps/linkedin-outreach/CLAUDE.md
148
+
149
+ # Regenerated by `next typegen` (which every app's `typecheck` runs first), `next build` and `next dev`, with import paths that follow the dist dir of whichever ran last.
150
+ apps/*/next-env.d.ts
151
+
152
+ # Generated from services/control-plane-go/openapi.yaml by `pnpm --filter <app> openapi:gen`, which each app's typecheck, dev and build all run first.
153
+ apps/toolbox/src/lib/api-types.gen.d.ts
154
+ apps/dashboard/src/lib/api-types.gen.d.ts
155
+
156
+ # Local Google OAuth client creds (never commit)
157
+ .google.creds
158
+ .eve
159
+ .output
160
+
161
+ # Lean build output (formal/)
162
+ .lake/
163
+ .abuse-harness/
164
+ e2e/abuse/artifacts/
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.5
2
+ Name: openrelay-sdk
3
+ Version: 0.1.0
4
+ Summary: The official Python SDK for the OpenRelay API
5
+ Project-URL: Homepage, https://openrelay.inc
6
+ Project-URL: Documentation, https://docs.openrelay.inc
7
+ Project-URL: Repository, https://github.com/OpenRelayInc/OpenRelay
8
+ Project-URL: Issues, https://github.com/OpenRelayInc/OpenRelay/issues
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.8
22
+ Requires-Dist: httpx<1,>=0.25.0
23
+ Requires-Dist: pydantic<3,>=2.0.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.0; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: respx>=0.20; extra == 'dev'
29
+ Requires-Dist: ruff>=0.1; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # OpenRelay Python SDK
33
+
34
+ The official Python SDK for the [OpenRelay](https://openrelay.inc) API.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install openrelay-sdk
40
+ ```
41
+
42
+ The package imports as `openrelay`. The unrelated `openrelay` distribution on
43
+ PyPI also provides a top-level `openrelay` module, so install `openrelay-sdk`,
44
+ and do not install both into the same environment.
45
+
46
+ From a checkout of this repository: `pip install ./sdks/python`.
47
+
48
+ ## Quick Start
49
+
50
+ ```python
51
+ import openrelay
52
+
53
+ client = openrelay.Client(api_key="or_your_api_key")
54
+
55
+ # List available GPUs
56
+ gpus = client.gpus.list()
57
+
58
+ # Create a VM
59
+ vm = client.vms.create(
60
+ name="dev-box",
61
+ ssh_key_ids=["your-ssh-key-id"],
62
+ gpu_type="rtx-4090",
63
+ gpu_count=1,
64
+ disk_size_gb=100,
65
+ )
66
+
67
+ # Wait for it to be ready
68
+ vm = client.vms.wait_for_status(vm.id)
69
+ print(f"VM running at: {vm.endpoint_url}")
70
+ ```
71
+
72
+ ## Async Usage
73
+
74
+ ```python
75
+ import asyncio
76
+ import openrelay
77
+
78
+ async def main():
79
+ async with openrelay.AsyncClient(api_key="or_your_api_key") as client:
80
+ vm = await client.vms.get("your-vm-id")
81
+ print(vm.status)
82
+
83
+ asyncio.run(main())
84
+ ```
85
+
86
+ ## Configuration
87
+
88
+ The client accepts these parameters:
89
+
90
+ | Parameter | Env Variable | Default |
91
+ |-----------|-------------|---------|
92
+ | `api_key` | `OPENRELAY_API_KEY` | Required |
93
+ | `base_url` | `OPENRELAY_API_URL` | `https://api.openrelay.inc` |
94
+ | `timeout` | (none) | `60.0` seconds |
95
+ | `max_retries` | (none) | `2` |
96
+
97
+ The `orl` CLI reads the same two variables.
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,70 @@
1
+ # OpenRelay Python SDK
2
+
3
+ The official Python SDK for the [OpenRelay](https://openrelay.inc) API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install openrelay-sdk
9
+ ```
10
+
11
+ The package imports as `openrelay`. The unrelated `openrelay` distribution on
12
+ PyPI also provides a top-level `openrelay` module, so install `openrelay-sdk`,
13
+ and do not install both into the same environment.
14
+
15
+ From a checkout of this repository: `pip install ./sdks/python`.
16
+
17
+ ## Quick Start
18
+
19
+ ```python
20
+ import openrelay
21
+
22
+ client = openrelay.Client(api_key="or_your_api_key")
23
+
24
+ # List available GPUs
25
+ gpus = client.gpus.list()
26
+
27
+ # Create a VM
28
+ vm = client.vms.create(
29
+ name="dev-box",
30
+ ssh_key_ids=["your-ssh-key-id"],
31
+ gpu_type="rtx-4090",
32
+ gpu_count=1,
33
+ disk_size_gb=100,
34
+ )
35
+
36
+ # Wait for it to be ready
37
+ vm = client.vms.wait_for_status(vm.id)
38
+ print(f"VM running at: {vm.endpoint_url}")
39
+ ```
40
+
41
+ ## Async Usage
42
+
43
+ ```python
44
+ import asyncio
45
+ import openrelay
46
+
47
+ async def main():
48
+ async with openrelay.AsyncClient(api_key="or_your_api_key") as client:
49
+ vm = await client.vms.get("your-vm-id")
50
+ print(vm.status)
51
+
52
+ asyncio.run(main())
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ The client accepts these parameters:
58
+
59
+ | Parameter | Env Variable | Default |
60
+ |-----------|-------------|---------|
61
+ | `api_key` | `OPENRELAY_API_KEY` | Required |
62
+ | `base_url` | `OPENRELAY_API_URL` | `https://api.openrelay.inc` |
63
+ | `timeout` | (none) | `60.0` seconds |
64
+ | `max_retries` | (none) | `2` |
65
+
66
+ The `orl` CLI reads the same two variables.
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "openrelay-sdk"
7
+ dynamic = ["version"]
8
+ description = "The official Python SDK for the OpenRelay API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.8"
12
+ dependencies = [
13
+ "httpx>=0.25.0,<1",
14
+ "pydantic>=2.0.0,<3",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.8",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Typing :: Typed",
28
+ ]
29
+
30
+ # PyPI renders these as links on the package page, the page's route back to
31
+ # the site.
32
+ [project.urls]
33
+ Homepage = "https://openrelay.inc"
34
+ Documentation = "https://docs.openrelay.inc"
35
+ Repository = "https://github.com/OpenRelayInc/OpenRelay"
36
+ Issues = "https://github.com/OpenRelayInc/OpenRelay/issues"
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest>=7.0",
41
+ "pytest-asyncio>=0.21",
42
+ "respx>=0.20",
43
+ "ruff>=0.1",
44
+ "mypy>=1.0",
45
+ ]
46
+
47
+ [tool.hatch.version]
48
+ path = "src/openrelay/_version.py"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/openrelay"]
52
+
53
+ [tool.pytest.ini_options]
54
+ asyncio_mode = "auto"
55
+
56
+ [tool.ruff]
57
+ target-version = "py38"
58
+
59
+ [tool.mypy]
60
+ python_version = "3.8"
61
+ strict = true
@@ -0,0 +1,54 @@
1
+ """OpenRelay Python SDK."""
2
+
3
+ from ._client import AsyncClient, Client
4
+ from ._exceptions import (
5
+ APIConnectionError,
6
+ APIError,
7
+ APITimeoutError,
8
+ AuthenticationError,
9
+ InternalServerError,
10
+ InvalidRequestError,
11
+ NotFoundError,
12
+ OpenRelayError,
13
+ PermissionDeniedError,
14
+ RateLimitError,
15
+ )
16
+ from ._version import __version__
17
+ from .types.gpus import (
18
+ GpuAvailability,
19
+ GpuAvailabilityRegion,
20
+ GpuModel,
21
+ GpuModelAvailability,
22
+ GpuPricing,
23
+ GpuPricingTier,
24
+ )
25
+ from .types.vms import Vm, VmActionResponse, VmCreateResponse
26
+
27
+ __all__ = [
28
+ "__version__",
29
+ # Clients
30
+ "Client",
31
+ "AsyncClient",
32
+ # Exceptions
33
+ "OpenRelayError",
34
+ "APIError",
35
+ "AuthenticationError",
36
+ "PermissionDeniedError",
37
+ "NotFoundError",
38
+ "InvalidRequestError",
39
+ "RateLimitError",
40
+ "InternalServerError",
41
+ "APIConnectionError",
42
+ "APITimeoutError",
43
+ # GPU types
44
+ "GpuModel",
45
+ "GpuModelAvailability",
46
+ "GpuAvailability",
47
+ "GpuAvailabilityRegion",
48
+ "GpuPricing",
49
+ "GpuPricingTier",
50
+ # VM types
51
+ "Vm",
52
+ "VmCreateResponse",
53
+ "VmActionResponse",
54
+ ]
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import random
5
+ import re
6
+ from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar
7
+
8
+ import httpx
9
+ from pydantic import BaseModel
10
+
11
+ from ._constants import (
12
+ API_KEY_ENV_VAR,
13
+ CAPACITY_CODES,
14
+ BASE_URL_ENV_VAR,
15
+ DEFAULT_BASE_URL,
16
+ DEFAULT_MAX_RETRIES,
17
+ DEFAULT_TIMEOUT,
18
+ INITIAL_RETRY_DELAY,
19
+ MAX_RETRY_DELAY,
20
+ )
21
+ from ._exceptions import (
22
+ APIError,
23
+ AuthenticationError,
24
+ InternalServerError,
25
+ InvalidRequestError,
26
+ NotFoundError,
27
+ OpenRelayError,
28
+ PermissionDeniedError,
29
+ RateLimitError,
30
+ )
31
+ from ._version import __version__
32
+
33
+ T = TypeVar("T", bound=BaseModel)
34
+
35
+ _UUID_RE = re.compile(
36
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
37
+ )
38
+
39
+ _ERROR_TYPE_MAP: Dict[str, Type[APIError]] = {
40
+ "authentication_error": AuthenticationError,
41
+ "permission_error": PermissionDeniedError,
42
+ "not_found_error": NotFoundError,
43
+ "invalid_request_error": InvalidRequestError,
44
+ "rate_limit_error": RateLimitError,
45
+ "server_error": InternalServerError,
46
+ }
47
+
48
+
49
+ def _is_uuid(value: str) -> bool:
50
+ return bool(_UUID_RE.match(value))
51
+
52
+
53
+ # The error type a flat control-plane body implies, which carries no type field.
54
+ _STATUS_ERROR_TYPE: Dict[int, str] = {
55
+ 400: "invalid_request_error",
56
+ 401: "authentication_error",
57
+ 403: "permission_error",
58
+ 404: "not_found_error",
59
+ 429: "rate_limit_error",
60
+ }
61
+
62
+
63
+ def _parse_error(response: httpx.Response) -> Tuple[str, str, Optional[str]]:
64
+ """(type, message, code) from an error response. The control plane writes a
65
+ flat {"error": "<message>", "code": "<CODE>"}, which carries no type, so the
66
+ type follows from the status. The nested {"error": {"type", "message",
67
+ "code"}} shape is read as it comes. A body that is neither yields a
68
+ server_error with a status-only message and no code."""
69
+ try:
70
+ body = response.json()
71
+ except Exception:
72
+ body = None
73
+ if isinstance(body, dict):
74
+ error = body.get("error", {})
75
+ if isinstance(error, str):
76
+ status = response.status_code
77
+ code = body.get("code")
78
+ return (
79
+ "server_error" if status >= 500 else _STATUS_ERROR_TYPE.get(status, "api_error"),
80
+ error,
81
+ code if isinstance(code, str) else None,
82
+ )
83
+ if isinstance(error, dict):
84
+ code = error.get("code")
85
+ return (
86
+ error.get("type", "server_error"),
87
+ error.get("message", "Unknown error"),
88
+ code if isinstance(code, str) else None,
89
+ )
90
+ return "server_error", f"HTTP {response.status_code}", None
91
+
92
+
93
+ class BaseClient:
94
+ """Shared configuration for sync and async clients."""
95
+
96
+ _gpu_name_cache: Optional[Dict[str, str]]
97
+
98
+ def __init__(
99
+ self,
100
+ *,
101
+ api_key: Optional[str] = None,
102
+ base_url: Optional[str] = None,
103
+ timeout: Optional[float] = None,
104
+ max_retries: Optional[int] = None,
105
+ ) -> None:
106
+ self.api_key = api_key or os.environ.get(API_KEY_ENV_VAR)
107
+ if not self.api_key:
108
+ raise OpenRelayError(
109
+ f"API key is required. Pass api_key= or set {API_KEY_ENV_VAR}."
110
+ )
111
+ self.base_url = (
112
+ base_url or os.environ.get(BASE_URL_ENV_VAR) or DEFAULT_BASE_URL
113
+ ).rstrip("/")
114
+ self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
115
+ self.max_retries = (
116
+ max_retries if max_retries is not None else DEFAULT_MAX_RETRIES
117
+ )
118
+ self._gpu_name_cache = None
119
+
120
+ def _build_headers(self) -> Dict[str, str]:
121
+ return {
122
+ "Authorization": f"Bearer {self.api_key}",
123
+ "Content-Type": "application/json",
124
+ "User-Agent": f"openrelay-python/{__version__}",
125
+ }
126
+
127
+ def _build_url(self, path: str) -> str:
128
+ return f"{self.base_url}{path}"
129
+
130
+ def _should_retry(self, response: httpx.Response) -> bool:
131
+ if response.status_code == 503 and _parse_error(response)[2] in CAPACITY_CODES:
132
+ return False
133
+ return response.status_code in (429, 500, 502, 503, 504)
134
+
135
+ def _retry_delay(
136
+ self, attempt: int, response_headers: Optional[Dict[str, str]] = None
137
+ ) -> float:
138
+ if response_headers:
139
+ retry_after = response_headers.get("retry-after")
140
+ if retry_after:
141
+ try:
142
+ return float(retry_after)
143
+ except (ValueError, TypeError):
144
+ pass
145
+ delay = min(INITIAL_RETRY_DELAY * (2**attempt), MAX_RETRY_DELAY)
146
+ jitter = random.random() * 0.5 * delay
147
+ return delay + jitter
148
+
149
+ def _raise_for_status(self, response: httpx.Response) -> None:
150
+ if response.is_success:
151
+ return
152
+
153
+ error_type, message, code = _parse_error(response)
154
+ exc_class = _ERROR_TYPE_MAP.get(error_type, APIError)
155
+ raise exc_class(
156
+ message,
157
+ status_code=response.status_code,
158
+ type=error_type,
159
+ code=code,
160
+ headers=dict(response.headers),
161
+ )
162
+
163
+ def _parse_response(self, response: httpx.Response, model: Type[T]) -> T:
164
+ body = response.json()
165
+ data = body.get("data", body)
166
+ return model.model_validate(data)
167
+
168
+ def _parse_response_list(
169
+ self, response: httpx.Response, model: Type[T]
170
+ ) -> List[T]:
171
+ body = response.json()
172
+ data = body.get("data", [])
173
+ return [model.model_validate(item) for item in data]