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.
Files changed (45) hide show
  1. {tokensaver_cli-0.3.0/src/tokensaver_cli.egg-info → tokensaver_cli-0.3.2}/PKG-INFO +6 -2
  2. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/README.md +5 -1
  3. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/__init__.py +1 -1
  4. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/agents.py +31 -3
  5. tokensaver_cli-0.3.2/src/tokensaver_cli/approve_cmd.py +206 -0
  6. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/auth_cmd.py +22 -2
  7. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/.claude-plugin/plugin.json +2 -2
  8. tokensaver_cli-0.3.2/src/tokensaver_cli/claude_plugin/commands/approve.md +25 -0
  9. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/model.md +10 -1
  10. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/hooks/hooks.json +4 -0
  11. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/credentials.py +24 -0
  12. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/main.py +54 -1
  13. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/profiles.py +35 -11
  14. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2/src/tokensaver_cli.egg-info}/PKG-INFO +6 -2
  15. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/SOURCES.txt +3 -0
  16. tokensaver_cli-0.3.2/tests/test_approve_cli.py +83 -0
  17. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/tests/test_route_cli.py +35 -2
  18. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/LICENSE +0 -0
  19. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/pyproject.toml +0 -0
  20. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/setup.cfg +0 -0
  21. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/api_client.py +0 -0
  22. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/banner.py +0 -0
  23. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/.claude-plugin/marketplace.json +0 -0
  24. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/.mcp.json +0 -0
  25. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/pipeline.md +0 -0
  26. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/setup.md +0 -0
  27. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/status.md +0 -0
  28. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/claude_plugin/commands/unroute.md +0 -0
  29. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/config.py +0 -0
  30. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/doctor.py +0 -0
  31. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/json_merge.py +0 -0
  32. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/keys_cmd.py +0 -0
  33. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/logo.py +0 -0
  34. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/plugin_install.py +0 -0
  35. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/route_frame.py +0 -0
  36. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/state.py +0 -0
  37. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli/status.py +0 -0
  38. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/dependency_links.txt +0 -0
  39. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/entry_points.txt +0 -0
  40. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/requires.txt +0 -0
  41. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/src/tokensaver_cli.egg-info/top_level.txt +0 -0
  42. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/tests/test_auth_cli.py +0 -0
  43. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/tests/test_banner.py +0 -0
  44. {tokensaver_cli-0.3.0 → tokensaver_cli-0.3.2}/tests/test_deploy_mode.py +0 -0
  45. {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.0
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
 
@@ -5,4 +5,4 @@ Published on PyPI as ``tokensaver-cli``; installs the ``tokensaver`` console com
5
5
  No server-side pipeline code — configures local agents to call api.tokensaver.fr.
6
6
  """
7
7
 
8
- __version__ = "0.3.0"
8
+ __version__ = "0.3.2"
@@ -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
- if plan.get("name") or plan.get("slug"):
302
- print(f" Plan: {plan.get('name') or plan.get('slug')}")
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.0",
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`.
@@ -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, run `tokensaver status --line` and tell the user which model is now active.
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.
@@ -5,6 +5,10 @@
5
5
  {
6
6
  "type": "command",
7
7
  "command": "tokensaver status --hook"
8
+ },
9
+ {
10
+ "type": "command",
11
+ "command": "tokensaver approve --current --quiet || true"
8
12
  }
9
13
  ]
10
14
  }
@@ -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
- # Sensible defaults aligned with TokenSaver catalogue ids (provider/model_id).
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 load_profiles() -> dict[str, str]:
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 merged
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
- merged[key.strip().lower()] = value.strip()
37
- return merged
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(*, model: str | None, profile: str | None) -> tuple[str | None, str | None]:
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 BUILTIN_PROFILES and profiles[name] == BUILTIN_PROFILES[name],
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.0
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(monkeypatch: pytest.MonkeyPatch) -> None:
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