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.
- tokenroute-0.1.0.dist-info/METADATA +78 -0
- tokenroute-0.1.0.dist-info/RECORD +10 -0
- tokenroute-0.1.0.dist-info/WHEEL +4 -0
- tokenroute-0.1.0.dist-info/entry_points.txt +2 -0
- tokenroute_cli/__init__.py +1 -0
- tokenroute_cli/__main__.py +334 -0
- tokenroute_cli/client.py +74 -0
- tokenroute_cli/config.py +133 -0
- tokenroute_cli/device_flow.py +132 -0
- tokenroute_cli/output.py +65 -0
|
@@ -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 @@
|
|
|
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()
|
tokenroute_cli/client.py
ADDED
|
@@ -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()
|
tokenroute_cli/config.py
ADDED
|
@@ -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")
|
tokenroute_cli/output.py
ADDED
|
@@ -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)
|