tokenroute 0.1.0__py3-none-any.whl

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,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,10 @@
1
+ tokenroute_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ tokenroute_cli/__main__.py,sha256=T9u3jQJMW6dSUO-GZMlpjQcelPjOzhFLezZv9FOIiz4,10332
3
+ tokenroute_cli/client.py,sha256=g0bSFiN3aXi2rEb8ku40-VmDJUXv60uDqDClahlcn80,2077
4
+ tokenroute_cli/config.py,sha256=JSM4ZHQlbrSaFS6LGGarGuauEQ_9ezYIqiAqDE_gINU,3726
5
+ tokenroute_cli/device_flow.py,sha256=s1mlTbMqge24rgnRT-TFyh1xU8XRrB9EvqJP4E8UzJ4,4241
6
+ tokenroute_cli/output.py,sha256=COk7Ah5GfszxCdFPWJX7x_Ffl1ungLGqVkgCricx4xU,1825
7
+ tokenroute-0.1.0.dist-info/METADATA,sha256=01h7V9LWmg7tnScHtVdvSwyMd1HyFU6L-hmt364Zw30,2817
8
+ tokenroute-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ tokenroute-0.1.0.dist-info/entry_points.txt,sha256=fEOMYzeUq6-96SxJq0koCld65Ojf138yKWmeTYa0DSg,59
10
+ tokenroute-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tokenroute = tokenroute_cli.__main__:app
@@ -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()
@@ -0,0 +1,133 @@
1
+ """Local config and credential storage for the CLI.
2
+
3
+ Credentials live at `~/.tokenroute/credentials.json` (owner-readable only
4
+ on POSIX). We don't use the OS keychain in Phase A — adds platform-specific
5
+ deps for marginal benefit; revisit in Phase B if users complain.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import stat
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ DEFAULT_API_URL = "https://api.tokenroute.io"
16
+
17
+
18
+ def api_url() -> str:
19
+ return os.environ.get("TOKENROUTE_API_URL", DEFAULT_API_URL).rstrip("/")
20
+
21
+
22
+ def static_api_key() -> str | None:
23
+ """If set, CLI uses it as Bearer for /v1/* (LLM) calls.
24
+
25
+ For /api/v1/me* endpoints this is NOT applicable — those need a Logto
26
+ JWT. CI / sub-agent flows that only need to *call* the LLM gateway
27
+ (not manage account) should set this and skip `tokenroute login`.
28
+ """
29
+ return os.environ.get("TOKENROUTE_API_KEY")
30
+
31
+
32
+ def _config_dir() -> Path:
33
+ return Path.home() / ".tokenroute"
34
+
35
+
36
+ def _creds_path() -> Path:
37
+ return _config_dir() / "credentials.json"
38
+
39
+
40
+ @dataclass
41
+ class Credentials:
42
+ access_token: str
43
+ refresh_token: str | None = None
44
+ expires_at: int | None = None # unix seconds
45
+ issuer: str | None = None
46
+ client_id: str | None = None
47
+
48
+
49
+ def load_credentials() -> Credentials | None:
50
+ p = _creds_path()
51
+ if not p.exists():
52
+ return None
53
+ try:
54
+ data = json.loads(p.read_text(encoding="utf-8"))
55
+ except (json.JSONDecodeError, OSError):
56
+ return None
57
+ return Credentials(
58
+ access_token=data.get("access_token", ""),
59
+ refresh_token=data.get("refresh_token"),
60
+ expires_at=data.get("expires_at"),
61
+ issuer=data.get("issuer"),
62
+ client_id=data.get("client_id"),
63
+ )
64
+
65
+
66
+ def save_credentials(creds: Credentials) -> Path:
67
+ d = _config_dir()
68
+ d.mkdir(parents=True, exist_ok=True)
69
+ p = _creds_path()
70
+ p.write_text(
71
+ json.dumps(
72
+ {
73
+ "access_token": creds.access_token,
74
+ "refresh_token": creds.refresh_token,
75
+ "expires_at": creds.expires_at,
76
+ "issuer": creds.issuer,
77
+ "client_id": creds.client_id,
78
+ },
79
+ indent=2,
80
+ ),
81
+ encoding="utf-8",
82
+ )
83
+ # Owner-only read/write on POSIX. On Windows chmod is mostly a no-op
84
+ # but doesn't error, so we just call it unconditionally.
85
+ try:
86
+ os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
87
+ except OSError:
88
+ pass
89
+ return p
90
+
91
+
92
+ def clear_credentials() -> bool:
93
+ p = _creds_path()
94
+ if p.exists():
95
+ p.unlink()
96
+ return True
97
+ return False
98
+
99
+
100
+ # ─── last-created raw key cache ────────────────────────────────────
101
+ #
102
+ # `keys create` returns a raw sk-tr-* once and never again. We optionally
103
+ # cache it locally so `env` / `test` / `models` can use it without the
104
+ # user having to copy-paste. Users who don't want the cache pass
105
+ # `--no-cache` to `keys create` (Phase B).
106
+
107
+
108
+ def _last_key_path() -> Path:
109
+ return _config_dir() / "last_key.txt"
110
+
111
+
112
+ def save_last_key(raw_key: str) -> Path:
113
+ d = _config_dir()
114
+ d.mkdir(parents=True, exist_ok=True)
115
+ p = _last_key_path()
116
+ p.write_text(raw_key, encoding="utf-8")
117
+ try:
118
+ os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
119
+ except OSError:
120
+ pass
121
+ return p
122
+
123
+
124
+ def load_last_key() -> str | None:
125
+ p = _last_key_path()
126
+ if not p.exists():
127
+ return None
128
+ return p.read_text(encoding="utf-8").strip() or None
129
+
130
+
131
+ def resolve_api_key() -> str | None:
132
+ """For `test` / `env` / `models`: prefer env var, then cached last key."""
133
+ return static_api_key() or load_last_key()
@@ -0,0 +1,132 @@
1
+ """OIDC device-flow client — talks directly to Logto.
2
+
3
+ Discovery happens via tokenroute gateway (`/api/v1/auth/discovery`), so
4
+ the CLI doesn't hardcode any Logto URL or client_id. After polling
5
+ succeeds we hand the access_token back to the caller, who saves it via
6
+ `config.save_credentials`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import webbrowser
12
+ from dataclasses import dataclass
13
+
14
+ import httpx
15
+
16
+ from .config import api_url
17
+
18
+ # Per RFC 8628 §3.5 we honor the server's `interval` field; this is just
19
+ # a sane floor in case the server returns 0 or omits it.
20
+ _MIN_POLL_INTERVAL_SECONDS = 5
21
+
22
+
23
+ @dataclass
24
+ class DiscoveryInfo:
25
+ issuer: str
26
+ client_id: str
27
+ resource: str
28
+ scopes: list[str]
29
+ device_authorization_endpoint: str
30
+ token_endpoint: str
31
+
32
+
33
+ @dataclass
34
+ class DeviceCodeResponse:
35
+ device_code: str
36
+ user_code: str
37
+ verification_uri: str
38
+ verification_uri_complete: str
39
+ expires_in: int
40
+ interval: int
41
+
42
+
43
+ def fetch_discovery() -> DiscoveryInfo:
44
+ """GET tokenroute /api/v1/auth/discovery."""
45
+ with httpx.Client(timeout=10.0) as c:
46
+ r = c.get(f"{api_url()}/api/v1/auth/discovery")
47
+ r.raise_for_status()
48
+ data = r.json()
49
+ return DiscoveryInfo(
50
+ issuer=data["issuer"],
51
+ client_id=data["client_id"],
52
+ resource=data["resource"],
53
+ scopes=data["scopes"],
54
+ device_authorization_endpoint=data["device_authorization_endpoint"],
55
+ token_endpoint=data["token_endpoint"],
56
+ )
57
+
58
+
59
+ def request_device_code(disc: DiscoveryInfo) -> DeviceCodeResponse:
60
+ """POST {device_authorization_endpoint} → device_code + user_code."""
61
+ with httpx.Client(timeout=10.0) as c:
62
+ r = c.post(
63
+ disc.device_authorization_endpoint,
64
+ data={
65
+ "client_id": disc.client_id,
66
+ "scope": " ".join(disc.scopes),
67
+ "resource": disc.resource,
68
+ },
69
+ )
70
+ r.raise_for_status()
71
+ body = r.json()
72
+ return DeviceCodeResponse(
73
+ device_code=body["device_code"],
74
+ user_code=body["user_code"],
75
+ verification_uri=body["verification_uri"],
76
+ verification_uri_complete=body.get(
77
+ "verification_uri_complete", body["verification_uri"]
78
+ ),
79
+ expires_in=body["expires_in"],
80
+ interval=max(body.get("interval", 5), _MIN_POLL_INTERVAL_SECONDS),
81
+ )
82
+
83
+
84
+ def open_browser(url: str) -> bool:
85
+ """Best-effort. False if no display / headless environment."""
86
+ try:
87
+ return webbrowser.open(url)
88
+ except webbrowser.Error:
89
+ return False
90
+
91
+
92
+ def poll_for_token(
93
+ disc: DiscoveryInfo,
94
+ code: DeviceCodeResponse,
95
+ *,
96
+ on_pending=None,
97
+ ) -> dict:
98
+ """Poll {token_endpoint} until success / expired / denied.
99
+
100
+ Returns the raw token response dict (access_token / refresh_token / ...).
101
+ Raises RuntimeError with a human-readable message on failure.
102
+ """
103
+ deadline = time.time() + code.expires_in
104
+ interval = code.interval
105
+ with httpx.Client(timeout=10.0) as client:
106
+ while time.time() < deadline:
107
+ time.sleep(interval)
108
+ r = client.post(
109
+ disc.token_endpoint,
110
+ data={
111
+ "client_id": disc.client_id,
112
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
113
+ "device_code": code.device_code,
114
+ "resource": disc.resource,
115
+ },
116
+ )
117
+ if r.is_success:
118
+ return r.json()
119
+ err = r.json().get("error") if r.headers.get("content-type", "").startswith("application/json") else None
120
+ if err == "authorization_pending":
121
+ if on_pending is not None:
122
+ on_pending()
123
+ continue
124
+ if err == "slow_down":
125
+ interval += 5
126
+ continue
127
+ if err == "expired_token":
128
+ raise RuntimeError("device code expired — run `tokenroute login` again")
129
+ if err == "access_denied":
130
+ raise RuntimeError("login denied by user")
131
+ raise RuntimeError(f"token exchange failed ({r.status_code}): {r.text}")
132
+ raise RuntimeError("device code expired before user completed login")
@@ -0,0 +1,65 @@
1
+ """Output helpers — toggle between human-friendly rich tables and --json."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from typing import Any
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ _console = Console()
12
+ _err_console = Console(stderr=True)
13
+
14
+
15
+ def is_json_mode() -> bool:
16
+ """`--json` is set globally via env var (set by Typer callback)."""
17
+ import os
18
+
19
+ return os.environ.get("_TOKENROUTE_OUTPUT_JSON") == "1"
20
+
21
+
22
+ def emit(payload: Any, *, table_columns: list[tuple[str, str]] | None = None) -> None:
23
+ """Print `payload` either as JSON (agent mode) or a rich table (human mode).
24
+
25
+ `table_columns` is a list of `(header, dict_key)` pairs. When None, we
26
+ fall back to JSON output even in human mode (e.g. for single objects
27
+ that don't fit a table).
28
+ """
29
+ if is_json_mode():
30
+ print(json.dumps(payload, indent=2, default=str))
31
+ return
32
+
33
+ if isinstance(payload, list) and table_columns:
34
+ table = Table(show_header=True, header_style="bold")
35
+ for header, _ in table_columns:
36
+ table.add_column(header)
37
+ for row in payload:
38
+ table.add_row(*[str(row.get(k, "")) for _, k in table_columns])
39
+ _console.print(table)
40
+ return
41
+
42
+ if isinstance(payload, dict):
43
+ for k, v in payload.items():
44
+ _console.print(f"[bold]{k}[/bold]: {v}")
45
+ return
46
+
47
+ _console.print(payload)
48
+
49
+
50
+ def info(msg: str) -> None:
51
+ if not is_json_mode():
52
+ _console.print(msg)
53
+
54
+
55
+ def success(msg: str) -> None:
56
+ if not is_json_mode():
57
+ _console.print(f"[green]✓[/green] {msg}")
58
+
59
+
60
+ def error(msg: str, *, code: int = 1) -> None:
61
+ if is_json_mode():
62
+ print(json.dumps({"error": msg}, indent=2))
63
+ else:
64
+ _err_console.print(f"[red]error[/red]: {msg}")
65
+ sys.exit(code)