tokenroute 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,69 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ .venv/
8
+ venv/
9
+ env/
10
+ *.egg-info/
11
+ *.egg
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ htmlcov/
17
+ dist/
18
+ build/
19
+
20
+ # Node / Next.js (storefront, P3)
21
+ node_modules/
22
+ .next/
23
+ .turbo/
24
+ *.tsbuildinfo
25
+
26
+ # IDE
27
+ .vscode/
28
+ .idea/
29
+ *.swp
30
+ *.swo
31
+
32
+ # OS
33
+ .DS_Store
34
+ Thumbs.db
35
+ desktop.ini
36
+
37
+ # Env / secrets
38
+ .env
39
+ .env.local
40
+ .env.*.local
41
+ !.env.example
42
+
43
+ # Local DBs / logs
44
+ *.db
45
+ *.sqlite
46
+ *.sqlite3
47
+ *.log
48
+ logs/
49
+
50
+ # Alembic
51
+ gateway/migrations/versions/*.pyc
52
+
53
+ # pytest
54
+ .pytest_cache/
55
+
56
+ # Docker
57
+ infra/volumes/
58
+
59
+ # Local-only scratch (CI keypairs, throwaway artifacts)
60
+ .tmp/
61
+ .dev-logs/
62
+
63
+ # Claude Code session state
64
+ .claude/
65
+
66
+ # Stripe API debug dumps(手动 curl 后 saved,绝不入库)
67
+ gateway/*.json
68
+ !gateway/package.json
69
+ !gateway/tsconfig.json
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: tokenroute
3
+ Version: 0.1.0
4
+ Summary: Agent-first CLI for tokenroute — OpenAI-compatible LLM gateway with smart routing and transparent billing
5
+ Project-URL: Homepage, https://tokenroute.io
6
+ Project-URL: Documentation, https://docs.tokenroute.io
7
+ Project-URL: Repository, https://github.com/jiangjin11/tokenroute
8
+ Author: Paradigx Pte Ltd
9
+ License: MIT
10
+ Keywords: agent,ai,anthropic,cli,gateway,llm,openai
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
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: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.28
23
+ Requires-Dist: rich>=13.0
24
+ Requires-Dist: typer<1.0,>=0.15
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-mock>=3.14; extra == 'dev'
27
+ Requires-Dist: pytest>=8.3; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # tokenroute (Python CLI)
31
+
32
+ Agent-first CLI for [tokenroute](https://tokenroute.io) — the OpenAI-compatible LLM API gateway with smart routing and transparent token billing.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install tokenroute
38
+ # or, run once without installing:
39
+ uvx tokenroute --help
40
+ pipx run tokenroute --help
41
+ ```
42
+
43
+ ## Quickstart
44
+
45
+ ```bash
46
+ tokenroute login # OAuth device-flow, opens browser
47
+ tokenroute whoami # current user + balance
48
+ tokenroute keys create --name my-app # create new sk-tr-* key
49
+ tokenroute balance # current credit
50
+ ```
51
+
52
+ All commands accept `--json` (or env `TOKENROUTE_JSON=1`) for machine-parseable output that's friendly to agents and CI.
53
+
54
+ ## Agent / sub-agent usage (no interactive login)
55
+
56
+ For automation, skip `tokenroute login` and call the OpenAI-compatible API directly with a pre-issued key:
57
+
58
+ ```bash
59
+ export TOKENROUTE_API_KEY=sk-tr-...
60
+ curl https://api.tokenroute.io/v1/chat/completions \
61
+ -H "Authorization: Bearer $TOKENROUTE_API_KEY" \
62
+ -H "Content-Type: application/json" \
63
+ -d '{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
64
+ ```
65
+
66
+ ## Configuration
67
+
68
+ | Env var | Default | Purpose |
69
+ |---|---|---|
70
+ | `TOKENROUTE_API_URL` | `https://api.tokenroute.io` | Override gateway base URL |
71
+ | `TOKENROUTE_API_KEY` | _(unset)_ | sk-tr-* key for LLM calls — skips `login` |
72
+ | `TOKENROUTE_JSON` | _(unset)_ | Set to `1` to force JSON output globally |
73
+
74
+ Credentials from `tokenroute login` are stored at `~/.tokenroute/credentials.json` (owner-readable only on POSIX).
75
+
76
+ ## License
77
+
78
+ MIT
@@ -0,0 +1,49 @@
1
+ # tokenroute (Python CLI)
2
+
3
+ Agent-first CLI for [tokenroute](https://tokenroute.io) — the OpenAI-compatible LLM API gateway with smart routing and transparent token billing.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install tokenroute
9
+ # or, run once without installing:
10
+ uvx tokenroute --help
11
+ pipx run tokenroute --help
12
+ ```
13
+
14
+ ## Quickstart
15
+
16
+ ```bash
17
+ tokenroute login # OAuth device-flow, opens browser
18
+ tokenroute whoami # current user + balance
19
+ tokenroute keys create --name my-app # create new sk-tr-* key
20
+ tokenroute balance # current credit
21
+ ```
22
+
23
+ All commands accept `--json` (or env `TOKENROUTE_JSON=1`) for machine-parseable output that's friendly to agents and CI.
24
+
25
+ ## Agent / sub-agent usage (no interactive login)
26
+
27
+ For automation, skip `tokenroute login` and call the OpenAI-compatible API directly with a pre-issued key:
28
+
29
+ ```bash
30
+ export TOKENROUTE_API_KEY=sk-tr-...
31
+ curl https://api.tokenroute.io/v1/chat/completions \
32
+ -H "Authorization: Bearer $TOKENROUTE_API_KEY" \
33
+ -H "Content-Type: application/json" \
34
+ -d '{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ | Env var | Default | Purpose |
40
+ |---|---|---|
41
+ | `TOKENROUTE_API_URL` | `https://api.tokenroute.io` | Override gateway base URL |
42
+ | `TOKENROUTE_API_KEY` | _(unset)_ | sk-tr-* key for LLM calls — skips `login` |
43
+ | `TOKENROUTE_JSON` | _(unset)_ | Set to `1` to force JSON output globally |
44
+
45
+ Credentials from `tokenroute login` are stored at `~/.tokenroute/credentials.json` (owner-readable only on POSIX).
46
+
47
+ ## License
48
+
49
+ MIT
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "tokenroute"
3
+ version = "0.1.0"
4
+ description = "Agent-first CLI for tokenroute — OpenAI-compatible LLM gateway with smart routing and transparent billing"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Paradigx Pte Ltd" }]
9
+ keywords = ["llm", "openai", "anthropic", "gateway", "agent", "cli", "ai"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+
23
+ dependencies = [
24
+ "typer>=0.15,<1.0",
25
+ "httpx>=0.28",
26
+ "rich>=13.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=8.3",
32
+ "pytest-mock>=3.14",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://tokenroute.io"
37
+ Documentation = "https://docs.tokenroute.io"
38
+ Repository = "https://github.com/jiangjin11/tokenroute"
39
+
40
+ [project.scripts]
41
+ tokenroute = "tokenroute_cli.__main__:app"
42
+
43
+ [build-system]
44
+ requires = ["hatchling>=1.25"]
45
+ build-backend = "hatchling.build"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/tokenroute_cli"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,334 @@
1
+ """tokenroute CLI entry point.
2
+
3
+ tokenroute login # OAuth device-flow
4
+ tokenroute logout
5
+ tokenroute whoami # user + balance
6
+ tokenroute balance
7
+ tokenroute keys create --name <n>
8
+ tokenroute keys list
9
+ tokenroute keys revoke <id>
10
+
11
+ All commands accept `--json` (or env TOKENROUTE_JSON=1) for agent-friendly
12
+ machine-parseable output. Authentication: `tokenroute login` writes
13
+ ~/.tokenroute/credentials.json; subsequent commands re-use it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import time
19
+
20
+ import typer
21
+
22
+ from . import __version__
23
+ from .client import ApiError, request
24
+ from .config import (
25
+ Credentials,
26
+ api_url,
27
+ clear_credentials,
28
+ resolve_api_key,
29
+ save_credentials,
30
+ save_last_key,
31
+ )
32
+ from .device_flow import fetch_discovery, open_browser, poll_for_token, request_device_code
33
+ from .output import emit, error, info, success
34
+
35
+ app = typer.Typer(
36
+ name="tokenroute",
37
+ help="Agent-first CLI for tokenroute LLM gateway.",
38
+ add_completion=False,
39
+ no_args_is_help=True,
40
+ )
41
+ keys_app = typer.Typer(name="keys", help="Manage your tokenroute API keys.", no_args_is_help=True)
42
+ app.add_typer(keys_app, name="keys")
43
+
44
+
45
+ def _global_callback(
46
+ json_output: bool = typer.Option(
47
+ False, "--json", help="Machine-parseable JSON output (for agents / scripts)."
48
+ ),
49
+ ) -> None:
50
+ if json_output or os.environ.get("TOKENROUTE_JSON") == "1":
51
+ os.environ["_TOKENROUTE_OUTPUT_JSON"] = "1"
52
+
53
+
54
+ app.callback()(_global_callback)
55
+
56
+
57
+ # ─── login / logout / whoami ─────────────────────────────────────────
58
+
59
+
60
+ @app.command()
61
+ def login() -> None:
62
+ """Log in via OAuth device-flow (opens browser)."""
63
+ try:
64
+ disc = fetch_discovery()
65
+ except Exception as e:
66
+ error(f"could not reach tokenroute API: {e}", code=2)
67
+
68
+ try:
69
+ code = request_device_code(disc)
70
+ except Exception as e:
71
+ error(f"device-flow init failed: {e}", code=3)
72
+
73
+ info(f"\nVisit: [bold cyan]{code.verification_uri}[/bold cyan]")
74
+ info(f"And enter code: [bold yellow]{code.user_code}[/bold yellow]\n")
75
+ info("(opening browser automatically — if it doesn't, use the URL above)")
76
+ open_browser(code.verification_uri_complete)
77
+
78
+ info("Waiting for authorization...")
79
+ try:
80
+ token = poll_for_token(disc, code)
81
+ except RuntimeError as e:
82
+ error(str(e), code=3)
83
+
84
+ expires_at = int(time.time()) + int(token.get("expires_in", 3600))
85
+ save_credentials(
86
+ Credentials(
87
+ access_token=token["access_token"],
88
+ refresh_token=token.get("refresh_token"),
89
+ expires_at=expires_at,
90
+ issuer=disc.issuer,
91
+ client_id=disc.client_id,
92
+ )
93
+ )
94
+ success("logged in")
95
+
96
+
97
+ @app.command()
98
+ def logout() -> None:
99
+ """Forget locally stored credentials."""
100
+ if clear_credentials():
101
+ success("logged out")
102
+ else:
103
+ info("(no stored credentials)")
104
+
105
+
106
+ @app.command()
107
+ def whoami() -> None:
108
+ """Show current user + balance."""
109
+ try:
110
+ me = request("GET", "/api/v1/me")
111
+ except ApiError as e:
112
+ error(e.message, code=1 if e.status < 500 else 3)
113
+ emit(me)
114
+
115
+
116
+ @app.command()
117
+ def balance() -> None:
118
+ """Show available credit balance."""
119
+ try:
120
+ body = request("GET", "/api/v1/balance")
121
+ except ApiError as e:
122
+ error(e.message, code=1 if e.status < 500 else 3)
123
+ emit(body)
124
+
125
+
126
+ # ─── keys subcommands ────────────────────────────────────────────────
127
+
128
+
129
+ @keys_app.command("create")
130
+ def keys_create(
131
+ name: str = typer.Option(..., "--name", "-n", help="Human-friendly key label."),
132
+ no_cache: bool = typer.Option(
133
+ False, "--no-cache", help="Don't save raw key to ~/.tokenroute/last_key.txt"
134
+ ),
135
+ ) -> None:
136
+ """Create a new sk-tr-* API key. The raw key is shown ONCE."""
137
+ try:
138
+ out = request("POST", "/api/v1/me/keys", json_body={"name": name})
139
+ except ApiError as e:
140
+ error(e.message, code=1 if e.status < 500 else 3)
141
+ raw = out.get("raw")
142
+ if raw and not no_cache:
143
+ save_last_key(raw)
144
+ emit(out)
145
+ if not os.environ.get("_TOKENROUTE_OUTPUT_JSON"):
146
+ info("\n[yellow]Save the `raw` key — it won't be shown again.[/yellow]")
147
+ if raw and not no_cache:
148
+ info(
149
+ "[dim]Cached at ~/.tokenroute/last_key.txt for `env` / `test` / `models`. "
150
+ "Pass --no-cache to skip.[/dim]"
151
+ )
152
+
153
+
154
+ # ─── topup / test / env / usage / models ─────────────────────────────
155
+
156
+
157
+ @app.command()
158
+ def topup(
159
+ amount: float = typer.Option(
160
+ ..., "--amount", "-a", help="USD amount to top up (>= 1).", min=1.0
161
+ ),
162
+ open_url: bool = typer.Option(
163
+ True, "--open/--no-open", help="Open the Stripe Checkout page in browser."
164
+ ),
165
+ ) -> None:
166
+ """Get a Stripe Checkout URL to add credit. Agents must NOT auto-pay."""
167
+ try:
168
+ out = request("POST", "/api/v1/topup", json_body={"amount_usd": str(amount)})
169
+ except ApiError as e:
170
+ error(e.message, code=1 if e.status < 500 else 3)
171
+ emit(out)
172
+ url = out.get("checkout_url")
173
+ if url and open_url and not os.environ.get("_TOKENROUTE_OUTPUT_JSON"):
174
+ info(f"\nOpening browser: {url}")
175
+ open_browser(url)
176
+
177
+
178
+ @app.command()
179
+ def usage(
180
+ days: int = typer.Option(30, "--days", "-d", help="Look-back window in days.", min=1, max=365),
181
+ ) -> None:
182
+ """Summary spend over the last N days."""
183
+ try:
184
+ body = request("GET", f"/api/v1/me/usage?days={days}")
185
+ except ApiError as e:
186
+ error(e.message, code=1 if e.status < 500 else 3)
187
+ emit(body)
188
+
189
+
190
+ @app.command(name="env")
191
+ def env_cmd(
192
+ key: str = typer.Option(
193
+ None,
194
+ "--key",
195
+ "-k",
196
+ help="Override API key (default: env TOKENROUTE_API_KEY or last cached).",
197
+ ),
198
+ ) -> None:
199
+ """Print OPENAI_API_KEY + OPENAI_BASE_URL for shell sourcing.
200
+
201
+ Usage: tokenroute env >> .env
202
+ """
203
+ raw = key or resolve_api_key()
204
+ if not raw:
205
+ error(
206
+ "no API key available — run `tokenroute keys create --name <name>` first, "
207
+ "or set TOKENROUTE_API_KEY env var",
208
+ code=1,
209
+ )
210
+ base = f"{api_url()}/v1"
211
+ if os.environ.get("_TOKENROUTE_OUTPUT_JSON"):
212
+ emit({"OPENAI_API_KEY": raw, "OPENAI_BASE_URL": base})
213
+ return
214
+ # Plain printf — must work in `>> .env` redirection. No rich formatting.
215
+ print(f"OPENAI_API_KEY={raw}")
216
+ print(f"OPENAI_BASE_URL={base}")
217
+
218
+
219
+ @app.command()
220
+ def test(
221
+ model: str = typer.Option(
222
+ "openai/gpt-4o-mini", "--model", "-m", help="Model id to test against."
223
+ ),
224
+ key: str = typer.Option(
225
+ None, "--key", "-k", help="Override API key (default: cached / env)."
226
+ ),
227
+ ) -> None:
228
+ """Send a tiny chat completion to verify the gateway + your key work."""
229
+ import httpx
230
+
231
+ raw = key or resolve_api_key()
232
+ if not raw:
233
+ error(
234
+ "no API key available — run `tokenroute keys create --name <name>` first",
235
+ code=1,
236
+ )
237
+ url = f"{api_url()}/v1/chat/completions"
238
+ body = {
239
+ "model": model,
240
+ "messages": [{"role": "user", "content": "Reply with the single word 'OK'."}],
241
+ "max_tokens": 8,
242
+ }
243
+ try:
244
+ with httpx.Client(timeout=30.0) as c:
245
+ r = c.post(url, json=body, headers={"Authorization": f"Bearer {raw}"})
246
+ if not r.is_success:
247
+ error(f"chat failed ({r.status_code}): {r.text}", code=1 if r.status_code < 500 else 3)
248
+ data = r.json()
249
+ except httpx.RequestError as e:
250
+ error(f"network error: {e}", code=2)
251
+ reply = data["choices"][0]["message"]["content"]
252
+ if os.environ.get("_TOKENROUTE_OUTPUT_JSON"):
253
+ emit({"ok": True, "model": data.get("model", model), "reply": reply})
254
+ else:
255
+ success(f"connected ({data.get('model', model)})")
256
+ info(f" reply: [italic]{reply}[/italic]")
257
+
258
+
259
+ @app.command(name="models")
260
+ def models_cmd(
261
+ list_models: bool = typer.Option(
262
+ True, "--list/--no-list", help="(reserved — only `list` is implemented in Phase A)"
263
+ ),
264
+ key: str = typer.Option(
265
+ None, "--key", "-k", help="Override API key (default: cached / env)."
266
+ ),
267
+ ) -> None:
268
+ """List available models with pricing."""
269
+ import httpx
270
+
271
+ raw = key or resolve_api_key()
272
+ if not raw:
273
+ error(
274
+ "no API key available — run `tokenroute keys create --name <name>` first",
275
+ code=1,
276
+ )
277
+ try:
278
+ with httpx.Client(timeout=15.0) as c:
279
+ r = c.get(f"{api_url()}/v1/models", headers={"Authorization": f"Bearer {raw}"})
280
+ if not r.is_success:
281
+ error(f"models lookup failed ({r.status_code}): {r.text}", code=3)
282
+ items = r.json().get("data", [])
283
+ except httpx.RequestError as e:
284
+ error(f"network error: {e}", code=2)
285
+ emit(
286
+ items,
287
+ table_columns=[
288
+ ("ID", "id"),
289
+ ("Provider", "owned_by"),
290
+ ("Tier", "complexity_tier"),
291
+ ("$/1k in", "input_usd_per_1k"),
292
+ ("$/1k out", "output_usd_per_1k"),
293
+ ],
294
+ )
295
+
296
+
297
+ @keys_app.command("list")
298
+ def keys_list() -> None:
299
+ """List your API keys (raw values never shown)."""
300
+ try:
301
+ items = request("GET", "/api/v1/me/keys")
302
+ except ApiError as e:
303
+ error(e.message, code=1 if e.status < 500 else 3)
304
+ emit(
305
+ items,
306
+ table_columns=[
307
+ ("ID", "id"),
308
+ ("Name", "name"),
309
+ ("Prefix", "key_prefix"),
310
+ ("Status", "status"),
311
+ ("Balance USD", "balance_usd"),
312
+ ("Created", "created_at"),
313
+ ],
314
+ )
315
+
316
+
317
+ @keys_app.command("revoke")
318
+ def keys_revoke(key_id: str = typer.Argument(..., help="Key id (uuid).")) -> None:
319
+ """Revoke an API key. Future calls with it return 401."""
320
+ try:
321
+ out = request("DELETE", f"/api/v1/me/keys/{key_id}")
322
+ except ApiError as e:
323
+ error(e.message, code=1 if e.status < 500 else 3)
324
+ emit(out)
325
+
326
+
327
+ @app.command()
328
+ def version() -> None:
329
+ """Print CLI version."""
330
+ emit({"version": __version__})
331
+
332
+
333
+ if __name__ == "__main__":
334
+ app()
@@ -0,0 +1,74 @@
1
+ """Thin httpx wrapper for hitting the tokenroute gateway API.
2
+
3
+ For /api/v1/me* and /api/v1/topup we send the saved Logto JWT as Bearer.
4
+ For /v1/* (OpenAI-compatible chat etc.) we send the sk-tr-* API key instead.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from . import __version__
13
+ from .config import Credentials, api_url, load_credentials
14
+
15
+
16
+ class ApiError(RuntimeError):
17
+ def __init__(self, status: int, message: str, body: Any = None):
18
+ super().__init__(f"HTTP {status}: {message}")
19
+ self.status = status
20
+ self.message = message
21
+ self.body = body
22
+
23
+
24
+ def _default_headers() -> dict[str, str]:
25
+ return {
26
+ "User-Agent": f"tokenroute-cli/{__version__}",
27
+ "Accept": "application/json",
28
+ }
29
+
30
+
31
+ def _require_login() -> Credentials:
32
+ creds = load_credentials()
33
+ if creds is None or not creds.access_token:
34
+ raise ApiError(401, "not logged in — run `tokenroute login` first")
35
+ return creds
36
+
37
+
38
+ def _raise(resp: httpx.Response) -> None:
39
+ if resp.is_success:
40
+ return
41
+ try:
42
+ body = resp.json()
43
+ except (ValueError, httpx.DecodingError):
44
+ body = resp.text
45
+ if isinstance(body, dict):
46
+ msg = (
47
+ body.get("error", {}).get("message")
48
+ if isinstance(body.get("error"), dict)
49
+ else body.get("detail") or body.get("error") or resp.reason_phrase
50
+ )
51
+ else:
52
+ msg = resp.reason_phrase
53
+ raise ApiError(resp.status_code, str(msg), body)
54
+
55
+
56
+ def request(
57
+ method: str,
58
+ path: str,
59
+ *,
60
+ json_body: Any | None = None,
61
+ auth_jwt: bool = True,
62
+ timeout: float = 30.0,
63
+ ) -> Any:
64
+ headers = _default_headers()
65
+ if auth_jwt:
66
+ creds = _require_login()
67
+ headers["Authorization"] = f"Bearer {creds.access_token}"
68
+ url = f"{api_url()}{path}"
69
+ with httpx.Client(timeout=timeout) as client:
70
+ resp = client.request(method, url, json=json_body, headers=headers)
71
+ _raise(resp)
72
+ if resp.status_code == 204 or not resp.content:
73
+ return None
74
+ return resp.json()