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.
- tokenroute-0.1.0/.gitignore +69 -0
- tokenroute-0.1.0/PKG-INFO +78 -0
- tokenroute-0.1.0/README.md +49 -0
- tokenroute-0.1.0/pyproject.toml +51 -0
- tokenroute-0.1.0/src/tokenroute_cli/__init__.py +1 -0
- tokenroute-0.1.0/src/tokenroute_cli/__main__.py +334 -0
- tokenroute-0.1.0/src/tokenroute_cli/client.py +74 -0
- tokenroute-0.1.0/src/tokenroute_cli/config.py +133 -0
- tokenroute-0.1.0/src/tokenroute_cli/device_flow.py +132 -0
- tokenroute-0.1.0/src/tokenroute_cli/output.py +65 -0
- tokenroute-0.1.0/tests/__init__.py +0 -0
- tokenroute-0.1.0/tests/test_cli.py +517 -0
|
@@ -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()
|