tokensaver-cli 0.3.0__tar.gz → 0.3.2__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.
- {tokensaver_cli-0.3.0/src/tokensaver_cli.egg-info → tokensaver_cli-0.3.2}/PKG-INFO +6 -2
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/README.md +5 -1
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/__init__.py +1 -1
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/agents.py +31 -3
- tokensaver_cli-0.3.2/src/tokensaver_cli/approve_cmd.py +206 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/auth_cmd.py +22 -2
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/.claude-plugin/plugin.json +2 -2
- tokensaver_cli-0.3.2/src/tokensaver_cli/claude_plugin/commands/approve.md +25 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/model.md +10 -1
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/hooks/hooks.json +4 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/credentials.py +24 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/main.py +54 -1
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/profiles.py +35 -11
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2/src/tokensaver_cli.egg-info}/PKG-INFO +6 -2
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/SOURCES.txt +3 -0
- tokensaver_cli-0.3.2/tests/test_approve_cli.py +83 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/tests/test_route_cli.py +35 -2
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/LICENSE +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/pyproject.toml +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/setup.cfg +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/api_client.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/banner.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/.claude-plugin/marketplace.json +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/.mcp.json +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/pipeline.md +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/setup.md +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/status.md +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/unroute.md +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/config.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/doctor.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/json_merge.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/keys_cmd.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/logo.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/plugin_install.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/route_frame.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/state.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/status.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/dependency_links.txt +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/entry_points.txt +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/requires.txt +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/top_level.txt +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/tests/test_auth_cli.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/tests/test_banner.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/tests/test_deploy_mode.py +0 -0
- {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/tests/test_main_args.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tokensaver-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: TokenSaver CLI — route AI agents (Claude Code, Cursor, Codex) through the control plane
|
|
5
5
|
Author: TokenSaver
|
|
6
6
|
License-Expression: MIT
|
|
@@ -68,6 +68,7 @@ pip install -e ".[dev]"
|
|
|
68
68
|
| `tokensaver login --key ts_…` | Import an existing key (no JWT) |
|
|
69
69
|
| `tokensaver logout` | Remove local credentials |
|
|
70
70
|
| `tokensaver whoami` | Email, plan, key prefix, Flux IA link |
|
|
71
|
+
| `tokensaver approve [model]` | Approve model in Agent Registry (zero-trust) |
|
|
71
72
|
| `tokensaver keys list` | List API keys |
|
|
72
73
|
| `tokensaver keys create --name CLI --use` | Create key and set as default |
|
|
73
74
|
| `tokensaver keys revoke <id>` | Delete a key |
|
|
@@ -82,7 +83,7 @@ pip install -e ".[dev]"
|
|
|
82
83
|
| `tokensaver unroute <target>` | Restore backups |
|
|
83
84
|
| `tokensaver doctor [--claude] [--local]` | Health checks |
|
|
84
85
|
| `tokensaver status [--line\|--hook]` | Route status + Flux IA |
|
|
85
|
-
| `tokensaver profiles` | cheap / default / strong |
|
|
86
|
+
| `tokensaver profiles` | cheap / default / strong (plan-aware) |
|
|
86
87
|
|
|
87
88
|
### Claude Code options
|
|
88
89
|
|
|
@@ -94,11 +95,14 @@ pip install -e ".[dev]"
|
|
|
94
95
|
| `--no-plugin` | Skip `~/.claude/skills/tokensaver-router` |
|
|
95
96
|
| `--local` | Self-host URL defaults |
|
|
96
97
|
|
|
98
|
+
On **Free**, default profile uses `openrouter/z-ai/glm-4.7-flash` (allowlist). Paid keeps Sonnet/Opus. Plan is stored at login (`plan_slug`); refresh with `tokensaver whoami`.
|
|
99
|
+
|
|
97
100
|
## Environment
|
|
98
101
|
|
|
99
102
|
```bash
|
|
100
103
|
export TOKENSAVER_API_KEY=ts_… # optional if logged in
|
|
101
104
|
export TOKENSAVER_MODE=local # or --local
|
|
105
|
+
export TOKENSAVER_PLAN=free # optional override for Free defaults
|
|
102
106
|
export TOKENSAVER_CONSOLE_URL=…
|
|
103
107
|
```
|
|
104
108
|
|
|
@@ -41,6 +41,7 @@ pip install -e ".[dev]"
|
|
|
41
41
|
| `tokensaver login --key ts_…` | Import an existing key (no JWT) |
|
|
42
42
|
| `tokensaver logout` | Remove local credentials |
|
|
43
43
|
| `tokensaver whoami` | Email, plan, key prefix, Flux IA link |
|
|
44
|
+
| `tokensaver approve [model]` | Approve model in Agent Registry (zero-trust) |
|
|
44
45
|
| `tokensaver keys list` | List API keys |
|
|
45
46
|
| `tokensaver keys create --name CLI --use` | Create key and set as default |
|
|
46
47
|
| `tokensaver keys revoke <id>` | Delete a key |
|
|
@@ -55,7 +56,7 @@ pip install -e ".[dev]"
|
|
|
55
56
|
| `tokensaver unroute <target>` | Restore backups |
|
|
56
57
|
| `tokensaver doctor [--claude] [--local]` | Health checks |
|
|
57
58
|
| `tokensaver status [--line\|--hook]` | Route status + Flux IA |
|
|
58
|
-
| `tokensaver profiles` | cheap / default / strong |
|
|
59
|
+
| `tokensaver profiles` | cheap / default / strong (plan-aware) |
|
|
59
60
|
|
|
60
61
|
### Claude Code options
|
|
61
62
|
|
|
@@ -67,11 +68,14 @@ pip install -e ".[dev]"
|
|
|
67
68
|
| `--no-plugin` | Skip `~/.claude/skills/tokensaver-router` |
|
|
68
69
|
| `--local` | Self-host URL defaults |
|
|
69
70
|
|
|
71
|
+
On **Free**, default profile uses `openrouter/z-ai/glm-4.7-flash` (allowlist). Paid keeps Sonnet/Opus. Plan is stored at login (`plan_slug`); refresh with `tokensaver whoami`.
|
|
72
|
+
|
|
70
73
|
## Environment
|
|
71
74
|
|
|
72
75
|
```bash
|
|
73
76
|
export TOKENSAVER_API_KEY=ts_… # optional if logged in
|
|
74
77
|
export TOKENSAVER_MODE=local # or --local
|
|
78
|
+
export TOKENSAVER_PLAN=free # optional override for Free defaults
|
|
75
79
|
export TOKENSAVER_CONSOLE_URL=…
|
|
76
80
|
```
|
|
77
81
|
|
|
@@ -17,8 +17,9 @@ from tokensaver_cli.config import (
|
|
|
17
17
|
RouteConfig,
|
|
18
18
|
)
|
|
19
19
|
from tokensaver_cli.json_merge import deep_merge, read_json, write_json
|
|
20
|
+
from tokensaver_cli.credentials import resolve_plan_slug
|
|
20
21
|
from tokensaver_cli.plugin_install import install_claude_plugin
|
|
21
|
-
from tokensaver_cli.profiles import resolve_model
|
|
22
|
+
from tokensaver_cli.profiles import is_free_plan, resolve_model
|
|
22
23
|
from tokensaver_cli.route_frame import print_route_frame
|
|
23
24
|
from tokensaver_cli.state import RoutedFile, backup_file, clear_route, load_state, record_route, restore_file
|
|
24
25
|
|
|
@@ -205,7 +206,12 @@ def _tokensaver_hooks_patch() -> dict[str, Any]:
|
|
|
205
206
|
{
|
|
206
207
|
"type": "command",
|
|
207
208
|
"command": "tokensaver status --hook",
|
|
208
|
-
}
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"type": "command",
|
|
212
|
+
# Unblock zero-trust before the first LLM call (shell, no model needed).
|
|
213
|
+
"command": "tokensaver approve --current --quiet || true",
|
|
214
|
+
},
|
|
209
215
|
]
|
|
210
216
|
}
|
|
211
217
|
]
|
|
@@ -260,11 +266,17 @@ def route_claude(
|
|
|
260
266
|
with_fs: bool = False,
|
|
261
267
|
install_plugin: bool = True,
|
|
262
268
|
) -> int:
|
|
269
|
+
plan_slug = resolve_plan_slug()
|
|
263
270
|
try:
|
|
264
|
-
model_id, profile_name = resolve_model(model=model, profile=profile)
|
|
271
|
+
model_id, profile_name = resolve_model(model=model, profile=profile, plan_slug=plan_slug)
|
|
265
272
|
except ValueError as exc:
|
|
266
273
|
print(str(exc), file=sys.stderr)
|
|
267
274
|
return 2
|
|
275
|
+
if is_free_plan(plan_slug) and not (model and model.strip()):
|
|
276
|
+
print(
|
|
277
|
+
f"Free plan → default model {model_id} (override with --model / --profile)",
|
|
278
|
+
file=sys.stderr,
|
|
279
|
+
)
|
|
268
280
|
|
|
269
281
|
settings_path = _claude_settings_path(scope)
|
|
270
282
|
mcp_path = _claude_mcp_path(scope)
|
|
@@ -283,6 +295,22 @@ def route_claude(
|
|
|
283
295
|
# Optional full-capture hint; user can unset. Documented in frame.
|
|
284
296
|
env_patch.setdefault("HTTPS_PROXY", cfg.egress_proxy_url)
|
|
285
297
|
|
|
298
|
+
if model_id:
|
|
299
|
+
try:
|
|
300
|
+
from tokensaver_cli.approve_cmd import approve_catalog_ref
|
|
301
|
+
|
|
302
|
+
approve_catalog_ref(
|
|
303
|
+
model_id,
|
|
304
|
+
force_local=cfg.deploy_mode == "local",
|
|
305
|
+
)
|
|
306
|
+
print(f"Agent Registry → approved {model_id}", file=sys.stderr)
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
print(
|
|
309
|
+
f"WARN could not auto-approve {model_id} ({exc}). "
|
|
310
|
+
f"Run: tokensaver approve {model_id}",
|
|
311
|
+
file=sys.stderr,
|
|
312
|
+
)
|
|
313
|
+
|
|
286
314
|
settings = read_json(settings_path)
|
|
287
315
|
settings = deep_merge(
|
|
288
316
|
settings,
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Approve Agent Registry (catalog) models from the CLI — unblocks zero-trust 403."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from tokensaver_cli.api_client import ApiError, request_json
|
|
13
|
+
from tokensaver_cli.auth_cmd import _api_host, _api_v1
|
|
14
|
+
from tokensaver_cli.config import resolve_route_config
|
|
15
|
+
from tokensaver_cli.credentials import load_credentials, resolve_api_key
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _auth_headers() -> dict[str, str]:
|
|
19
|
+
key = resolve_api_key()
|
|
20
|
+
if not key:
|
|
21
|
+
raise ApiError(401, "No API key. Run: tokensaver login")
|
|
22
|
+
return {"Authorization": f"Bearer {key}"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _api_base(*, force_local: bool = False) -> str:
|
|
26
|
+
creds = load_credentials()
|
|
27
|
+
host = (creds.api_host if creds and creds.api_host else None) or _api_host(force_local=force_local)
|
|
28
|
+
# Prefer resolved route host when env/credentials agree (local vs SaaS).
|
|
29
|
+
try:
|
|
30
|
+
host = resolve_route_config(force_local=force_local).api_host
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
return _api_v1(host)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_current_model_ref() -> str | None:
|
|
37
|
+
"""ANTHROPIC_MODEL env, else ~/.claude/settings.json env patch."""
|
|
38
|
+
env = (os.environ.get("ANTHROPIC_MODEL") or "").strip()
|
|
39
|
+
if env:
|
|
40
|
+
return env
|
|
41
|
+
settings = Path.home() / ".claude" / "settings.json"
|
|
42
|
+
if not settings.is_file():
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
46
|
+
except (json.JSONDecodeError, OSError):
|
|
47
|
+
return None
|
|
48
|
+
env_block = data.get("env") if isinstance(data, dict) else None
|
|
49
|
+
if isinstance(env_block, dict):
|
|
50
|
+
mid = env_block.get("ANTHROPIC_MODEL")
|
|
51
|
+
if isinstance(mid, str) and mid.strip():
|
|
52
|
+
return mid.strip()
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_catalog_asset(
|
|
57
|
+
api_v1: str,
|
|
58
|
+
ref: str,
|
|
59
|
+
*,
|
|
60
|
+
asset_type: str = "model",
|
|
61
|
+
) -> dict[str, Any] | None:
|
|
62
|
+
url = f"{api_v1}/sdk/catalog/assets?q={quote(ref)}&type={quote(asset_type)}&limit=20"
|
|
63
|
+
data = request_json("GET", url, headers=_auth_headers())
|
|
64
|
+
items = data.get("items") if isinstance(data, dict) else None
|
|
65
|
+
if not isinstance(items, list):
|
|
66
|
+
return None
|
|
67
|
+
exact = [
|
|
68
|
+
i
|
|
69
|
+
for i in items
|
|
70
|
+
if isinstance(i, dict) and str(i.get("ref") or "").lower() == ref.lower()
|
|
71
|
+
]
|
|
72
|
+
if exact:
|
|
73
|
+
return exact[0]
|
|
74
|
+
return items[0] if items and isinstance(items[0], dict) else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _find_via_jwt(api_v1: str, ref: str, *, asset_type: str) -> dict[str, Any] | None:
|
|
78
|
+
from tokensaver_cli.credentials import resolve_access_token
|
|
79
|
+
|
|
80
|
+
token = resolve_access_token()
|
|
81
|
+
if not token:
|
|
82
|
+
return None
|
|
83
|
+
url = f"{api_v1}/catalog/assets?q={quote(ref)}&type={quote(asset_type)}&limit=20"
|
|
84
|
+
data = request_json("GET", url, headers={"Authorization": f"Bearer {token}"})
|
|
85
|
+
items = data.get("items") if isinstance(data, dict) else None
|
|
86
|
+
if not isinstance(items, list):
|
|
87
|
+
return None
|
|
88
|
+
exact = [
|
|
89
|
+
i
|
|
90
|
+
for i in items
|
|
91
|
+
if isinstance(i, dict) and str(i.get("ref") or "").lower() == ref.lower()
|
|
92
|
+
]
|
|
93
|
+
if exact:
|
|
94
|
+
return exact[0]
|
|
95
|
+
return items[0] if items and isinstance(items[0], dict) else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def approve_catalog_ref(
|
|
99
|
+
ref: str,
|
|
100
|
+
*,
|
|
101
|
+
asset_type: str = "model",
|
|
102
|
+
force_local: bool = False,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Create (approved) or patch status=approved for ``ref``. Returns asset dict."""
|
|
105
|
+
api_v1 = _api_base(force_local=force_local)
|
|
106
|
+
headers = _auth_headers()
|
|
107
|
+
ref = ref.strip()
|
|
108
|
+
if not ref:
|
|
109
|
+
raise ApiError(400, "Empty model ref")
|
|
110
|
+
|
|
111
|
+
existing: dict[str, Any] | None = None
|
|
112
|
+
try:
|
|
113
|
+
existing = find_catalog_asset(api_v1, ref, asset_type=asset_type)
|
|
114
|
+
except ApiError:
|
|
115
|
+
existing = _find_via_jwt(api_v1, ref, asset_type=asset_type)
|
|
116
|
+
|
|
117
|
+
if existing and existing.get("status") == "approved":
|
|
118
|
+
return existing
|
|
119
|
+
|
|
120
|
+
if existing and existing.get("id"):
|
|
121
|
+
asset_id = str(existing["id"])
|
|
122
|
+
if existing.get("status") == "disabled":
|
|
123
|
+
raise ApiError(
|
|
124
|
+
403,
|
|
125
|
+
f"Asset {ref!r} is disabled — re-enable from Agent Registry first.",
|
|
126
|
+
)
|
|
127
|
+
return request_json(
|
|
128
|
+
"PATCH",
|
|
129
|
+
f"{api_v1}/sdk/catalog/assets/{asset_id}",
|
|
130
|
+
headers=headers,
|
|
131
|
+
body={"status": "approved"},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
return request_json(
|
|
136
|
+
"POST",
|
|
137
|
+
f"{api_v1}/sdk/catalog/assets",
|
|
138
|
+
headers=headers,
|
|
139
|
+
body={
|
|
140
|
+
"type": asset_type,
|
|
141
|
+
"ref": ref,
|
|
142
|
+
"display_name": ref,
|
|
143
|
+
"status": "approved",
|
|
144
|
+
"description": "Approved via tokensaver CLI",
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
except ApiError as exc:
|
|
148
|
+
# Race / already observed as quarantined
|
|
149
|
+
detail = str(exc.detail).lower() if exc.detail else str(exc).lower()
|
|
150
|
+
if exc.status not in (409, 400) and "already exists" not in detail:
|
|
151
|
+
raise
|
|
152
|
+
existing = None
|
|
153
|
+
try:
|
|
154
|
+
existing = find_catalog_asset(api_v1, ref, asset_type=asset_type)
|
|
155
|
+
except ApiError:
|
|
156
|
+
existing = _find_via_jwt(api_v1, ref, asset_type=asset_type)
|
|
157
|
+
if not existing or not existing.get("id"):
|
|
158
|
+
raise
|
|
159
|
+
return request_json(
|
|
160
|
+
"PATCH",
|
|
161
|
+
f"{api_v1}/sdk/catalog/assets/{existing['id']}",
|
|
162
|
+
headers=headers,
|
|
163
|
+
body={"status": "approved"},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def run_approve(
|
|
168
|
+
ref: str | None = None,
|
|
169
|
+
*,
|
|
170
|
+
current: bool = False,
|
|
171
|
+
asset_type: str = "model",
|
|
172
|
+
force_local: bool = False,
|
|
173
|
+
quiet: bool = False,
|
|
174
|
+
) -> int:
|
|
175
|
+
target = (ref or "").strip()
|
|
176
|
+
if current or not target:
|
|
177
|
+
target = resolve_current_model_ref() or target
|
|
178
|
+
if not target:
|
|
179
|
+
print(
|
|
180
|
+
"Usage: tokensaver approve <provider/model>\n"
|
|
181
|
+
" or: tokensaver approve --current\n"
|
|
182
|
+
"Example: tokensaver approve openrouter/z-ai/glm-4.7-flash",
|
|
183
|
+
file=sys.stderr,
|
|
184
|
+
)
|
|
185
|
+
return 2
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
asset = approve_catalog_ref(target, asset_type=asset_type, force_local=force_local)
|
|
189
|
+
except ApiError as exc:
|
|
190
|
+
if not quiet:
|
|
191
|
+
print(f"FAIL approve {target} — {exc}", file=sys.stderr)
|
|
192
|
+
print(
|
|
193
|
+
"Need admin API key (tokensaver login). Console: Governance → Agent Registry.",
|
|
194
|
+
file=sys.stderr,
|
|
195
|
+
)
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
status = asset.get("status") or "approved"
|
|
199
|
+
asset_id = asset.get("id") or "?"
|
|
200
|
+
if quiet:
|
|
201
|
+
print(f"approved:{target}:{status}")
|
|
202
|
+
else:
|
|
203
|
+
print(f"OK Agent Registry → {target}")
|
|
204
|
+
print(f" status={status} id={asset_id}")
|
|
205
|
+
print(" Retry your Claude Code message — zero-trust should allow this model.")
|
|
206
|
+
return 0
|
|
@@ -209,12 +209,17 @@ def run_login(
|
|
|
209
209
|
|
|
210
210
|
workspace_id = None
|
|
211
211
|
organisation_id = None
|
|
212
|
+
plan_slug = None
|
|
212
213
|
try:
|
|
213
214
|
me = _fetch_me(api_v1, str(access_token))
|
|
214
215
|
ws = me.get("workspace") if isinstance(me.get("workspace"), dict) else {}
|
|
215
216
|
org = me.get("organisation") if isinstance(me.get("organisation"), dict) else {}
|
|
217
|
+
plan = me.get("plan") if isinstance(me.get("plan"), dict) else {}
|
|
216
218
|
workspace_id = ws.get("id")
|
|
217
219
|
organisation_id = org.get("id")
|
|
220
|
+
plan_slug = plan.get("slug") or plan.get("name")
|
|
221
|
+
if isinstance(plan_slug, str):
|
|
222
|
+
plan_slug = plan_slug.strip().lower() or None
|
|
218
223
|
if me.get("email"):
|
|
219
224
|
email = str(me["email"])
|
|
220
225
|
except ApiError as exc:
|
|
@@ -232,12 +237,17 @@ def run_login(
|
|
|
232
237
|
key_prefix=key_prefix,
|
|
233
238
|
api_host=api_host,
|
|
234
239
|
console_url=console,
|
|
240
|
+
plan_slug=str(plan_slug) if plan_slug else None,
|
|
235
241
|
)
|
|
236
242
|
path = save_credentials(creds)
|
|
237
243
|
|
|
238
244
|
print()
|
|
239
245
|
print(f"OK Logged in as {email}")
|
|
240
246
|
print(f" Credentials → {path}")
|
|
247
|
+
if plan_slug:
|
|
248
|
+
print(f" Plan → {plan_slug}")
|
|
249
|
+
if plan_slug == "free":
|
|
250
|
+
print(" Default model for Free → openrouter/z-ai/glm-4.7-flash")
|
|
241
251
|
if plain_key:
|
|
242
252
|
print(f" API key → {key_prefix}… (saved; shown once at create)")
|
|
243
253
|
if data.get("email_verified") is False:
|
|
@@ -298,8 +308,16 @@ def run_whoami(*, force_local: bool = False) -> int:
|
|
|
298
308
|
print(f" Org: {org.get('name')} ({org.get('id', '')})")
|
|
299
309
|
if ws.get("name"):
|
|
300
310
|
print(f" Workspace: {ws.get('name')} ({ws.get('id', '')})")
|
|
301
|
-
|
|
302
|
-
|
|
311
|
+
slug = plan.get("slug") or plan.get("name")
|
|
312
|
+
if isinstance(slug, str) and slug.strip():
|
|
313
|
+
slug_norm = slug.strip().lower()
|
|
314
|
+
print(f" Plan: {plan.get('name') or slug_norm}")
|
|
315
|
+
if creds is not None and creds.plan_slug != slug_norm:
|
|
316
|
+
from tokensaver_cli.credentials import update_credentials
|
|
317
|
+
|
|
318
|
+
update_credentials(plan_slug=slug_norm)
|
|
319
|
+
if slug_norm == "free":
|
|
320
|
+
print(" Default: openrouter/z-ai/glm-4.7-flash (Free allowlist)")
|
|
303
321
|
trial = org.get("free_trial") if isinstance(org.get("free_trial"), dict) else None
|
|
304
322
|
if trial:
|
|
305
323
|
print(f" Trial: ends {trial.get('ends_at') or trial.get('free_trial_ends_at') or '—'}")
|
|
@@ -308,5 +326,7 @@ def run_whoami(*, force_local: bool = False) -> int:
|
|
|
308
326
|
print(f" Session: JWT present but /users/me failed ({exc})")
|
|
309
327
|
else:
|
|
310
328
|
print(" Session: API key only (no JWT — run tokensaver login for keys management)")
|
|
329
|
+
if creds and creds.plan_slug:
|
|
330
|
+
print(f" Plan: {creds.plan_slug} (from credentials)")
|
|
311
331
|
|
|
312
332
|
return 0
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokensaver-router",
|
|
3
3
|
"displayName": "TokenSaver Router",
|
|
4
|
-
"version": "0.3.
|
|
5
|
-
"description": "Route Claude Code through TokenSaver — login, model profiles, MCP Trust Gateway, Flux IA traces.",
|
|
4
|
+
"version": "0.3.2",
|
|
5
|
+
"description": "Route Claude Code through TokenSaver — login, model profiles, Agent Registry approve, MCP Trust Gateway, Flux IA traces.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "TokenSaver",
|
|
8
8
|
"url": "https://tokensaver.fr"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Approve a model in TokenSaver Agent Registry (unblocks zero-trust 403)
|
|
3
|
+
argument-hint: "[provider/model]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Zero-trust blocks LLM calls until the model is **approved** in the Agent Registry.
|
|
7
|
+
Approving does **not** need a working chat model — use the CLI (shell).
|
|
8
|
+
|
|
9
|
+
If the user named a model (or `$ARGUMENTS` is set), run:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
tokensaver approve $ARGUMENTS
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Otherwise approve the currently routed Claude model:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
tokensaver approve --current
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Also available via MCP (when tools MCP is connected):
|
|
22
|
+
|
|
23
|
+
- `tokensaver_approve_catalog_asset` with `ref` = `openrouter/z-ai/glm-4.7-flash` (creates then approves if missing)
|
|
24
|
+
|
|
25
|
+
After success, tell the user to retry their message. Optional: open Flux IA / Agent Registry from `tokensaver status`.
|
{tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/model.md
RENAMED
|
@@ -17,4 +17,13 @@ tokensaver route claude --profile strong
|
|
|
17
17
|
tokensaver route claude --model openai/gpt-4.1-mini
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
After changing,
|
|
20
|
+
After changing, **approve** the model in the Agent Registry (required under zero-trust):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
tokensaver approve --current
|
|
24
|
+
# or: tokensaver approve openrouter/z-ai/glm-4.7-flash
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Slash command: `/tokensaver-router:approve` (or with a ref argument).
|
|
28
|
+
|
|
29
|
+
Then run `tokensaver status --line` and tell the user which model is now active.
|
|
@@ -35,6 +35,7 @@ class Credentials:
|
|
|
35
35
|
key_prefix: str | None = None
|
|
36
36
|
api_host: str | None = None
|
|
37
37
|
console_url: str | None = None
|
|
38
|
+
plan_slug: str | None = None
|
|
38
39
|
updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
39
40
|
|
|
40
41
|
def to_dict(self) -> dict[str, Any]:
|
|
@@ -113,3 +114,26 @@ def resolve_access_token() -> str | None:
|
|
|
113
114
|
if creds and creds.access_token:
|
|
114
115
|
return creds.access_token.strip() or None
|
|
115
116
|
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_plan_slug() -> str | None:
|
|
120
|
+
"""Env ``TOKENSAVER_PLAN`` wins, then credentials ``plan_slug``."""
|
|
121
|
+
env = (os.environ.get("TOKENSAVER_PLAN") or "").strip().lower()
|
|
122
|
+
if env:
|
|
123
|
+
return env
|
|
124
|
+
creds = load_credentials()
|
|
125
|
+
if creds and creds.plan_slug:
|
|
126
|
+
return creds.plan_slug.strip().lower() or None
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def update_credentials(**kwargs: Any) -> Credentials | None:
|
|
131
|
+
"""Merge fields into the credentials file (no-op if missing)."""
|
|
132
|
+
creds = load_credentials()
|
|
133
|
+
if creds is None:
|
|
134
|
+
return None
|
|
135
|
+
for key, value in kwargs.items():
|
|
136
|
+
if hasattr(creds, key):
|
|
137
|
+
setattr(creds, key, value)
|
|
138
|
+
save_credentials(creds)
|
|
139
|
+
return creds
|
|
@@ -17,6 +17,7 @@ from tokensaver_cli.agents import (
|
|
|
17
17
|
unroute_mcp,
|
|
18
18
|
unroute_proxy,
|
|
19
19
|
)
|
|
20
|
+
from tokensaver_cli.approve_cmd import run_approve
|
|
20
21
|
from tokensaver_cli.auth_cmd import run_login, run_logout, run_whoami
|
|
21
22
|
from tokensaver_cli.banner import print_banner
|
|
22
23
|
from tokensaver_cli.config import RouteConfigError, resolve_route_config
|
|
@@ -37,6 +38,7 @@ COMMANDS = (
|
|
|
37
38
|
"logout",
|
|
38
39
|
"whoami",
|
|
39
40
|
"keys",
|
|
41
|
+
"approve",
|
|
40
42
|
"help",
|
|
41
43
|
)
|
|
42
44
|
KEYS_ACTIONS = ("list", "create", "revoke")
|
|
@@ -52,6 +54,7 @@ Usage:
|
|
|
52
54
|
tokensaver login [--email …] [--signup|--key ts_…]
|
|
53
55
|
tokensaver logout | whoami
|
|
54
56
|
tokensaver keys list | create [--name …] [--use] | revoke <id>
|
|
57
|
+
tokensaver approve [<provider/model>] [--current]
|
|
55
58
|
tokensaver route <target> [options] [-- extra-args...]
|
|
56
59
|
tokensaver unroute <target>
|
|
57
60
|
tokensaver doctor [--claude] [--local]
|
|
@@ -63,6 +66,7 @@ Auth (no UI required):
|
|
|
63
66
|
logout Remove local credentials
|
|
64
67
|
whoami Show email, plan, API key prefix
|
|
65
68
|
keys List / create / revoke TokenSaver API keys (needs login JWT)
|
|
69
|
+
approve Agent Registry: approve a model (unblocks zero-trust 403)
|
|
66
70
|
|
|
67
71
|
Route targets:
|
|
68
72
|
claude Claude Code — settings + MCP (+ optional launch / plugin)
|
|
@@ -132,6 +136,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
132
136
|
parser.add_argument("--key", default=None, help="Import existing ts_… API key")
|
|
133
137
|
parser.add_argument("--description", default=None)
|
|
134
138
|
parser.add_argument("--use", action="store_true", help="keys create: set as default")
|
|
139
|
+
parser.add_argument(
|
|
140
|
+
"--current",
|
|
141
|
+
action="store_true",
|
|
142
|
+
help="approve: use ANTHROPIC_MODEL / Claude settings model",
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument("--quiet", action="store_true", help="approve: compact output")
|
|
145
|
+
parser.add_argument("--type", dest="asset_type", default="model", help="approve asset type")
|
|
135
146
|
parser.add_argument("-y", "--yes", action="store_true")
|
|
136
147
|
parser.add_argument("-h", "--help", action="store_true")
|
|
137
148
|
return parser
|
|
@@ -215,6 +226,22 @@ def _parse_unknown_route_flags(unknown: list[str]) -> tuple[bool, list[str], dic
|
|
|
215
226
|
extras["use"] = True
|
|
216
227
|
i += 1
|
|
217
228
|
continue
|
|
229
|
+
if tok == "--current":
|
|
230
|
+
extras["current"] = True
|
|
231
|
+
i += 1
|
|
232
|
+
continue
|
|
233
|
+
if tok == "--quiet":
|
|
234
|
+
extras["quiet"] = True
|
|
235
|
+
i += 1
|
|
236
|
+
continue
|
|
237
|
+
if tok == "--type" and i + 1 < len(unknown):
|
|
238
|
+
extras["asset_type"] = unknown[i + 1]
|
|
239
|
+
i += 2
|
|
240
|
+
continue
|
|
241
|
+
if tok.startswith("--type="):
|
|
242
|
+
extras["asset_type"] = tok.split("=", 1)[1]
|
|
243
|
+
i += 1
|
|
244
|
+
continue
|
|
218
245
|
if tok == "--email" and i + 1 < len(unknown):
|
|
219
246
|
extras["email"] = unknown[i + 1]
|
|
220
247
|
i += 2
|
|
@@ -254,9 +281,19 @@ def _parse_route_tail(*, launch_flag: bool, remainder: list[str]) -> tuple[bool,
|
|
|
254
281
|
|
|
255
282
|
|
|
256
283
|
def _print_profiles() -> int:
|
|
284
|
+
from tokensaver_cli.credentials import resolve_plan_slug
|
|
285
|
+
from tokensaver_cli.profiles import is_free_plan
|
|
286
|
+
|
|
287
|
+
plan = resolve_plan_slug()
|
|
257
288
|
print("TokenSaver model profiles")
|
|
289
|
+
if is_free_plan(plan):
|
|
290
|
+
print(f" (plan: {plan} — Free allowlist defaults)")
|
|
291
|
+
elif plan:
|
|
292
|
+
print(f" (plan: {plan})")
|
|
293
|
+
else:
|
|
294
|
+
print(" (plan unknown — paid/BYOK defaults; login to detect Free)")
|
|
258
295
|
print()
|
|
259
|
-
for row in profiles_summary():
|
|
296
|
+
for row in profiles_summary(plan_slug=plan):
|
|
260
297
|
tag = "builtin" if row["builtin"] else "custom"
|
|
261
298
|
print(f" {row['name']:12} {row['model']} ({tag})")
|
|
262
299
|
print()
|
|
@@ -332,6 +369,22 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
332
369
|
raise SystemExit(2)
|
|
333
370
|
raise SystemExit(run_keys_revoke(key_id, force_local=force_local))
|
|
334
371
|
|
|
372
|
+
if args.command == "approve":
|
|
373
|
+
quiet = bool(args.quiet or extras.get("quiet"))
|
|
374
|
+
if not quiet:
|
|
375
|
+
print_banner()
|
|
376
|
+
# `tokensaver approve openrouter/…` → target; `--current` uses settings model
|
|
377
|
+
ref = args.target or args.arg or (extra_unknown[0] if extra_unknown else None)
|
|
378
|
+
raise SystemExit(
|
|
379
|
+
run_approve(
|
|
380
|
+
ref,
|
|
381
|
+
current=bool(args.current or extras.get("current") or not ref),
|
|
382
|
+
asset_type=str(extras.get("asset_type") or args.asset_type or "model"),
|
|
383
|
+
force_local=force_local,
|
|
384
|
+
quiet=quiet,
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
|
|
335
388
|
if args.command == "status":
|
|
336
389
|
line = bool(args.line or extras.get("line"))
|
|
337
390
|
hook = bool(args.hook or extras.get("hook"))
|
|
@@ -8,13 +8,23 @@ from typing import Any
|
|
|
8
8
|
|
|
9
9
|
from tokensaver_cli.state import route_state_dir
|
|
10
10
|
|
|
11
|
-
#
|
|
11
|
+
# Paid / BYOK defaults (catalogue provider/model_id).
|
|
12
12
|
BUILTIN_PROFILES: dict[str, str] = {
|
|
13
13
|
"cheap": "openai/gpt-4.1-mini",
|
|
14
14
|
"default": "anthropic/claude-sonnet-4-6",
|
|
15
15
|
"strong": "anthropic/claude-opus-4-6",
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
# Hosted Free allowlist — must stay inside apps/backend/data/plan_model_access.json.
|
|
19
|
+
# Default Free chat UX: GLM 4.7 Flash (openrouter/z-ai/glm-4.7-flash).
|
|
20
|
+
FREE_PROFILES: dict[str, str] = {
|
|
21
|
+
"cheap": "openrouter/openai/gpt-5-nano",
|
|
22
|
+
"default": "openrouter/z-ai/glm-4.7-flash",
|
|
23
|
+
"strong": "openrouter/deepseek/deepseek-chat-v3-0324",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
FREE_DEFAULT_MODEL = FREE_PROFILES["default"]
|
|
27
|
+
|
|
18
28
|
PROFILE_NAMES = ("cheap", "default", "strong")
|
|
19
29
|
|
|
20
30
|
|
|
@@ -22,19 +32,26 @@ def profiles_file() -> Path:
|
|
|
22
32
|
return route_state_dir() / "profiles.json"
|
|
23
33
|
|
|
24
34
|
|
|
25
|
-
def
|
|
35
|
+
def is_free_plan(plan_slug: str | None) -> bool:
|
|
36
|
+
if not plan_slug:
|
|
37
|
+
return False
|
|
38
|
+
return plan_slug.strip().lower() in {"free", "trial", "free_trial"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_profiles(*, plan_slug: str | None = None) -> dict[str, str]:
|
|
42
|
+
"""Merge builtins (plan-aware) with optional user overrides in profiles.json."""
|
|
43
|
+
base = dict(FREE_PROFILES) if is_free_plan(plan_slug) else dict(BUILTIN_PROFILES)
|
|
26
44
|
path = profiles_file()
|
|
27
|
-
merged = dict(BUILTIN_PROFILES)
|
|
28
45
|
if path.is_file():
|
|
29
46
|
try:
|
|
30
47
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
31
48
|
except json.JSONDecodeError:
|
|
32
|
-
return
|
|
49
|
+
return base
|
|
33
50
|
if isinstance(raw, dict):
|
|
34
51
|
for key, value in raw.items():
|
|
35
52
|
if isinstance(key, str) and isinstance(value, str) and value.strip():
|
|
36
|
-
|
|
37
|
-
return
|
|
53
|
+
base[key.strip().lower()] = value.strip()
|
|
54
|
+
return base
|
|
38
55
|
|
|
39
56
|
|
|
40
57
|
def save_profiles(profiles: dict[str, str]) -> None:
|
|
@@ -42,9 +59,14 @@ def save_profiles(profiles: dict[str, str]) -> None:
|
|
|
42
59
|
path.write_text(json.dumps(profiles, indent=2) + "\n", encoding="utf-8")
|
|
43
60
|
|
|
44
61
|
|
|
45
|
-
def resolve_model(
|
|
62
|
+
def resolve_model(
|
|
63
|
+
*,
|
|
64
|
+
model: str | None,
|
|
65
|
+
profile: str | None,
|
|
66
|
+
plan_slug: str | None = None,
|
|
67
|
+
) -> tuple[str | None, str | None]:
|
|
46
68
|
"""Return ``(model_id, profile_name)``. Explicit ``--model`` wins over ``--profile``."""
|
|
47
|
-
profiles = load_profiles()
|
|
69
|
+
profiles = load_profiles(plan_slug=plan_slug)
|
|
48
70
|
if model and model.strip():
|
|
49
71
|
mid = model.strip()
|
|
50
72
|
matched = next((name for name, value in profiles.items() if value == mid), None)
|
|
@@ -58,15 +80,17 @@ def resolve_model(*, model: str | None, profile: str | None) -> tuple[str | None
|
|
|
58
80
|
return profiles.get("default"), "default"
|
|
59
81
|
|
|
60
82
|
|
|
61
|
-
def profiles_summary() -> list[dict[str, Any]]:
|
|
62
|
-
profiles = load_profiles()
|
|
83
|
+
def profiles_summary(*, plan_slug: str | None = None) -> list[dict[str, Any]]:
|
|
84
|
+
profiles = load_profiles(plan_slug=plan_slug)
|
|
85
|
+
reference = FREE_PROFILES if is_free_plan(plan_slug) else BUILTIN_PROFILES
|
|
63
86
|
rows: list[dict[str, Any]] = []
|
|
64
87
|
for name in sorted(profiles):
|
|
65
88
|
rows.append(
|
|
66
89
|
{
|
|
67
90
|
"name": name,
|
|
68
91
|
"model": profiles[name],
|
|
69
|
-
"builtin": name in
|
|
92
|
+
"builtin": name in reference and profiles[name] == reference[name],
|
|
93
|
+
"plan": "free" if is_free_plan(plan_slug) else (plan_slug or "paid"),
|
|
70
94
|
}
|
|
71
95
|
)
|
|
72
96
|
return rows
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tokensaver-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: TokenSaver CLI — route AI agents (Claude Code, Cursor, Codex) through the control plane
|
|
5
5
|
Author: TokenSaver
|
|
6
6
|
License-Expression: MIT
|
|
@@ -68,6 +68,7 @@ pip install -e ".[dev]"
|
|
|
68
68
|
| `tokensaver login --key ts_…` | Import an existing key (no JWT) |
|
|
69
69
|
| `tokensaver logout` | Remove local credentials |
|
|
70
70
|
| `tokensaver whoami` | Email, plan, key prefix, Flux IA link |
|
|
71
|
+
| `tokensaver approve [model]` | Approve model in Agent Registry (zero-trust) |
|
|
71
72
|
| `tokensaver keys list` | List API keys |
|
|
72
73
|
| `tokensaver keys create --name CLI --use` | Create key and set as default |
|
|
73
74
|
| `tokensaver keys revoke <id>` | Delete a key |
|
|
@@ -82,7 +83,7 @@ pip install -e ".[dev]"
|
|
|
82
83
|
| `tokensaver unroute <target>` | Restore backups |
|
|
83
84
|
| `tokensaver doctor [--claude] [--local]` | Health checks |
|
|
84
85
|
| `tokensaver status [--line\|--hook]` | Route status + Flux IA |
|
|
85
|
-
| `tokensaver profiles` | cheap / default / strong |
|
|
86
|
+
| `tokensaver profiles` | cheap / default / strong (plan-aware) |
|
|
86
87
|
|
|
87
88
|
### Claude Code options
|
|
88
89
|
|
|
@@ -94,11 +95,14 @@ pip install -e ".[dev]"
|
|
|
94
95
|
| `--no-plugin` | Skip `~/.claude/skills/tokensaver-router` |
|
|
95
96
|
| `--local` | Self-host URL defaults |
|
|
96
97
|
|
|
98
|
+
On **Free**, default profile uses `openrouter/z-ai/glm-4.7-flash` (allowlist). Paid keeps Sonnet/Opus. Plan is stored at login (`plan_slug`); refresh with `tokensaver whoami`.
|
|
99
|
+
|
|
97
100
|
## Environment
|
|
98
101
|
|
|
99
102
|
```bash
|
|
100
103
|
export TOKENSAVER_API_KEY=ts_… # optional if logged in
|
|
101
104
|
export TOKENSAVER_MODE=local # or --local
|
|
105
|
+
export TOKENSAVER_PLAN=free # optional override for Free defaults
|
|
102
106
|
export TOKENSAVER_CONSOLE_URL=…
|
|
103
107
|
```
|
|
104
108
|
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
src/tokensaver_cli/__init__.py
|
|
5
5
|
src/tokensaver_cli/agents.py
|
|
6
6
|
src/tokensaver_cli/api_client.py
|
|
7
|
+
src/tokensaver_cli/approve_cmd.py
|
|
7
8
|
src/tokensaver_cli/auth_cmd.py
|
|
8
9
|
src/tokensaver_cli/banner.py
|
|
9
10
|
src/tokensaver_cli/config.py
|
|
@@ -27,12 +28,14 @@ src/tokensaver_cli.egg-info/top_level.txt
|
|
|
27
28
|
src/tokensaver_cli/claude_plugin/.mcp.json
|
|
28
29
|
src/tokensaver_cli/claude_plugin/.claude-plugin/marketplace.json
|
|
29
30
|
src/tokensaver_cli/claude_plugin/.claude-plugin/plugin.json
|
|
31
|
+
src/tokensaver_cli/claude_plugin/commands/approve.md
|
|
30
32
|
src/tokensaver_cli/claude_plugin/commands/model.md
|
|
31
33
|
src/tokensaver_cli/claude_plugin/commands/pipeline.md
|
|
32
34
|
src/tokensaver_cli/claude_plugin/commands/setup.md
|
|
33
35
|
src/tokensaver_cli/claude_plugin/commands/status.md
|
|
34
36
|
src/tokensaver_cli/claude_plugin/commands/unroute.md
|
|
35
37
|
src/tokensaver_cli/claude_plugin/hooks/hooks.json
|
|
38
|
+
tests/test_approve_cli.py
|
|
36
39
|
tests/test_auth_cli.py
|
|
37
40
|
tests/test_banner.py
|
|
38
41
|
tests/test_deploy_mode.py
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Tests for tokensaver approve (Agent Registry)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from tokensaver_cli.approve_cmd import approve_catalog_ref, resolve_current_model_ref, run_approve
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def api_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
15
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
16
|
+
monkeypatch.setenv("TOKENSAVER_API_KEY", "ts_test_key_approve")
|
|
17
|
+
monkeypatch.setenv("TOKENSAVER_API_URL", "https://api.example.test")
|
|
18
|
+
monkeypatch.delenv("TOKENSAVER_MODE", raising=False)
|
|
19
|
+
return tmp_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_resolve_current_model_from_settings(api_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
23
|
+
monkeypatch.delenv("ANTHROPIC_MODEL", raising=False)
|
|
24
|
+
settings = Path.home() / ".claude" / "settings.json"
|
|
25
|
+
settings.parent.mkdir(parents=True)
|
|
26
|
+
settings.write_text(
|
|
27
|
+
'{"env": {"ANTHROPIC_MODEL": "openrouter/z-ai/glm-4.7-flash"}}\n',
|
|
28
|
+
encoding="utf-8",
|
|
29
|
+
)
|
|
30
|
+
assert resolve_current_model_ref() == "openrouter/z-ai/glm-4.7-flash"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_approve_creates_when_missing(api_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
34
|
+
calls: list[tuple[str, str, Any]] = []
|
|
35
|
+
|
|
36
|
+
def fake_request(method: str, url: str, *, headers=None, body=None, timeout=30.0):
|
|
37
|
+
calls.append((method, url, body))
|
|
38
|
+
if method == "GET":
|
|
39
|
+
return {"items": []}
|
|
40
|
+
if method == "POST":
|
|
41
|
+
return {"id": "asset-1", "ref": body["ref"], "status": "approved", "type": "model"}
|
|
42
|
+
raise AssertionError(f"unexpected {method} {url}")
|
|
43
|
+
|
|
44
|
+
monkeypatch.setattr("tokensaver_cli.approve_cmd.request_json", fake_request)
|
|
45
|
+
out = approve_catalog_ref("openrouter/z-ai/glm-4.7-flash")
|
|
46
|
+
assert out["status"] == "approved"
|
|
47
|
+
assert any(c[0] == "POST" for c in calls)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_approve_patches_quarantined(api_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
51
|
+
def fake_request(method: str, url: str, *, headers=None, body=None, timeout=30.0):
|
|
52
|
+
if method == "GET":
|
|
53
|
+
return {
|
|
54
|
+
"items": [
|
|
55
|
+
{
|
|
56
|
+
"id": "uuid-q",
|
|
57
|
+
"ref": "openrouter/z-ai/glm-4.7-flash",
|
|
58
|
+
"status": "quarantined",
|
|
59
|
+
"type": "model",
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
if method == "PATCH":
|
|
64
|
+
assert "uuid-q" in url
|
|
65
|
+
assert body == {"status": "approved"}
|
|
66
|
+
return {"id": "uuid-q", "ref": "openrouter/z-ai/glm-4.7-flash", "status": "approved"}
|
|
67
|
+
raise AssertionError(method)
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr("tokensaver_cli.approve_cmd.request_json", fake_request)
|
|
70
|
+
out = approve_catalog_ref("openrouter/z-ai/glm-4.7-flash")
|
|
71
|
+
assert out["status"] == "approved"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_run_approve_current(api_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
75
|
+
monkeypatch.setenv("ANTHROPIC_MODEL", "openrouter/openai/gpt-5-nano")
|
|
76
|
+
|
|
77
|
+
def fake_request(method: str, url: str, *, headers=None, body=None, timeout=30.0):
|
|
78
|
+
if method == "GET":
|
|
79
|
+
return {"items": []}
|
|
80
|
+
return {"id": "x", "ref": body["ref"], "status": "approved"}
|
|
81
|
+
|
|
82
|
+
monkeypatch.setattr("tokensaver_cli.approve_cmd.request_json", fake_request)
|
|
83
|
+
assert run_approve(None, current=True, quiet=True) == 0
|
|
@@ -11,7 +11,7 @@ import pytest
|
|
|
11
11
|
from tokensaver_cli.agents import route_claude, route_cursor, unroute_claude, unroute_cursor
|
|
12
12
|
from tokensaver_cli.config import RouteConfig, RouteConfigError, resolve_route_config
|
|
13
13
|
from tokensaver_cli.plugin_install import install_claude_plugin, plugin_install_dir
|
|
14
|
-
from tokensaver_cli.profiles import BUILTIN_PROFILES, resolve_model
|
|
14
|
+
from tokensaver_cli.profiles import BUILTIN_PROFILES, FREE_PROFILES, resolve_model
|
|
15
15
|
from tokensaver_cli.state import load_state, route_state_dir
|
|
16
16
|
|
|
17
17
|
|
|
@@ -48,7 +48,12 @@ def test_resolve_route_config_requires_api_key(monkeypatch: pytest.MonkeyPatch,
|
|
|
48
48
|
resolve_route_config()
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
def test_resolve_route_config_strips_api_v1_suffix(
|
|
51
|
+
def test_resolve_route_config_strips_api_v1_suffix(
|
|
52
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
53
|
+
) -> None:
|
|
54
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
55
|
+
monkeypatch.delenv("TOKENSAVER_MODE", raising=False)
|
|
56
|
+
monkeypatch.delenv("TOKENSAVER_API_URL", raising=False)
|
|
52
57
|
monkeypatch.setenv("TOKENSAVER_API_KEY", "ts_x")
|
|
53
58
|
monkeypatch.setenv("TOKENSAVER_API_BASE_URL", "https://api.example.test/api/v1")
|
|
54
59
|
resolved = resolve_route_config()
|
|
@@ -65,6 +70,15 @@ def test_resolve_model_profile_and_override() -> None:
|
|
|
65
70
|
assert profile2 is None
|
|
66
71
|
|
|
67
72
|
|
|
73
|
+
def test_resolve_model_free_plan_defaults() -> None:
|
|
74
|
+
model, profile = resolve_model(model=None, profile=None, plan_slug="free")
|
|
75
|
+
assert model == FREE_PROFILES["default"]
|
|
76
|
+
assert model == "openrouter/z-ai/glm-4.7-flash"
|
|
77
|
+
assert profile == "default"
|
|
78
|
+
cheap, _ = resolve_model(model=None, profile="cheap", plan_slug="free")
|
|
79
|
+
assert cheap == FREE_PROFILES["cheap"]
|
|
80
|
+
|
|
81
|
+
|
|
68
82
|
def test_route_claude_writes_settings_and_mcp(route_home: Path, cfg: RouteConfig, tmp_path: Path) -> None:
|
|
69
83
|
project = tmp_path / "project"
|
|
70
84
|
project.mkdir()
|
|
@@ -96,6 +110,25 @@ def test_route_claude_writes_settings_and_mcp(route_home: Path, cfg: RouteConfig
|
|
|
96
110
|
assert route_state_dir().is_dir()
|
|
97
111
|
|
|
98
112
|
|
|
113
|
+
def test_route_claude_free_plan_default_model(
|
|
114
|
+
route_home: Path,
|
|
115
|
+
cfg: RouteConfig,
|
|
116
|
+
tmp_path: Path,
|
|
117
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
118
|
+
) -> None:
|
|
119
|
+
monkeypatch.setenv("TOKENSAVER_PLAN", "free")
|
|
120
|
+
project = tmp_path / "project"
|
|
121
|
+
project.mkdir()
|
|
122
|
+
old_cwd = os.getcwd()
|
|
123
|
+
try:
|
|
124
|
+
os.chdir(project)
|
|
125
|
+
assert route_claude(cfg, launch=False, extra_args=[], install_plugin=False) == 0
|
|
126
|
+
finally:
|
|
127
|
+
os.chdir(old_cwd)
|
|
128
|
+
settings = json.loads((route_home / ".claude" / "settings.json").read_text())
|
|
129
|
+
assert settings["env"]["ANTHROPIC_MODEL"] == FREE_PROFILES["default"]
|
|
130
|
+
|
|
131
|
+
|
|
99
132
|
def test_route_claude_project_scope(route_home: Path, cfg: RouteConfig, tmp_path: Path) -> None:
|
|
100
133
|
project = tmp_path / "project"
|
|
101
134
|
project.mkdir()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/pipeline.md
RENAMED
|
File without changes
|
{tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/setup.md
RENAMED
|
File without changes
|
{tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/status.md
RENAMED
|
File without changes
|
{tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/unroute.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|