link_assistant_router/cli/store_ops.rs
1//! `tokens`, `accounts` and `providers` subcommands.
2//!
3//! Split from `cli.rs` to keep that file within the repository's 1000-line
4//! limit.
5
6use std::path::PathBuf;
7
8use clap::Subcommand;
9
10use super::AuthTarget;
11
12#[derive(Debug, Subcommand)]
13pub enum TokenOp {
14 /// Issue a new token and print it to stdout.
15 ///
16 /// `create` and `add` are accepted too: creating something was `tokens
17 /// issue`, `providers add` and `clients setup` — three verbs for one idea
18 /// (issue #314).
19 #[command(alias = "create", alias = "add")]
20 Issue {
21 #[arg(long, default_value_t = 24)]
22 ttl_hours: i64,
23 #[arg(long, default_value = "")]
24 label: String,
25 #[arg(long)]
26 account: Option<String>,
27 /// Cap on the number of upstream requests this token may make.
28 /// Omit for an unlimited token.
29 #[arg(long)]
30 max_requests: Option<u64>,
31 /// Cap on actual input plus output tokens reported by upstreams.
32 /// Omit for unlimited spend.
33 #[arg(long)]
34 max_tokens: Option<u64>,
35 /// Maximum requests admitted per one-minute window.
36 #[arg(long)]
37 rate_limit_per_minute: Option<u64>,
38 /// Issue an administrative token (`scope: admin`) that unlocks the
39 /// admin endpoints instead of only the inference proxy.
40 #[arg(long)]
41 admin: bool,
42 /// Restrict this token's GitHub proxy access to `owner/repo`. Repeat
43 /// for several repositories; omit for unrestricted access, which is
44 /// the default and what every existing token keeps.
45 #[arg(long = "github-repo", value_name = "OWNER/REPO")]
46 github_repo: Vec<String>,
47 #[command(flatten)]
48 target: AuthTarget,
49 },
50 /// Replace a token, preserving its controls, and revoke the old token.
51 Rotate {
52 /// Subject id (`sub`) of the token being replaced.
53 id: String,
54 #[arg(long, default_value_t = 24)]
55 ttl_hours: i64,
56 #[arg(long, default_value = "")]
57 label: String,
58 /// Replacement request cap; omitted keeps the existing one.
59 #[arg(long)]
60 max_requests: Option<u64>,
61 /// Replacement token spend cap; omitted keeps the existing one.
62 #[arg(long)]
63 max_tokens: Option<u64>,
64 /// Replacement per-minute request rate; omitted keeps the existing one.
65 #[arg(long)]
66 rate_limit_per_minute: Option<u64>,
67 /// Replacement account pin; omitted keeps the existing one.
68 #[arg(long)]
69 account: Option<String>,
70 #[command(flatten)]
71 target: AuthTarget,
72 },
73 /// List all known tokens.
74 List {
75 /// Emit JSON instead of the table.
76 ///
77 /// Every `list` printed a table unconditionally and every `show`
78 /// printed JSON unconditionally, so neither could be asked for the
79 /// other form — and `--json` existed on two subcommands only
80 /// (issue #314).
81 #[arg(long)]
82 json: bool,
83 #[command(flatten)]
84 target: AuthTarget,
85 },
86 /// Revoke a token by id.
87 ///
88 /// `remove` and `delete` are accepted too: destroying something was
89 /// `providers remove`, `clients remove`, `server remove`, `tokens revoke`
90 /// and `tokens expire` (issue #314).
91 #[command(alias = "remove", alias = "delete")]
92 Revoke {
93 id: String,
94 #[command(flatten)]
95 target: AuthTarget,
96 },
97 /// Revoke a token by id — an alias of `revoke`, kept for scripts.
98 ///
99 /// Both arms have always collapsed into the same call and printed
100 /// `revoked <ID>`, while the help promised a distinct operation
101 /// (issue #314). It is documented as the alias it is.
102 Expire {
103 id: String,
104 #[command(flatten)]
105 target: AuthTarget,
106 },
107 /// Show metadata for one token.
108 Show {
109 id: String,
110 /// Accepted for symmetry with `list`: `show` already emits JSON, so
111 /// this changes nothing (issue #314). A script should not have to know
112 /// which verb of a family takes the flag.
113 #[arg(long)]
114 json: bool,
115 #[command(flatten)]
116 target: AuthTarget,
117 },
118 /// Mint a replacement administrative token from the local token store.
119 ///
120 /// The recovery path for a lost admin token. Every other verb in this
121 /// family authenticates with the admin credential, so losing it left an
122 /// operator who still owned the store, the volume and the machine with no
123 /// way back in — and the standing advice was to destroy the deployment and
124 /// discard every issued client token and the whole request log to recover
125 /// from having misplaced one string (issue #573).
126 ///
127 /// The admin token is a signed JWT and the store keeps only its metadata,
128 /// so the lost value cannot be re-read. What *can* be done is to sign a new
129 /// one: this reads `TOKEN_SECRET` and the store directly, exactly as the
130 /// server does at boot, and mints an admin token the running deployment
131 /// already accepts — no restart, and issued client tokens, provider
132 /// configuration and the request log are untouched.
133 ///
134 /// Gated on local ownership rather than on a credential. Reading the store
135 /// is already equivalent to full control of the deployment, so this grants
136 /// no authority its caller lacks; it only makes existing authority usable.
137 /// For that reason it is never available over HTTP: with another router
138 /// selected it refuses and names the machine it would have acted on, the
139 /// same boundary `auth import` and `auth clear` draw.
140 #[command(name = "recover-admin")]
141 RecoverAdmin {
142 /// Revoke every other admin token once the replacement is minted.
143 ///
144 /// For a credential believed to be in someone else's hands: recovery
145 /// alone adds an administrator without removing the lost one.
146 #[arg(long)]
147 revoke_others: bool,
148 #[arg(long, default_value_t = 24 * 365)]
149 ttl_hours: i64,
150 #[arg(long, default_value = "recovered-admin")]
151 label: String,
152 /// Emit the stable JSON envelope instead of human-readable output.
153 #[arg(long)]
154 json: bool,
155 #[command(flatten)]
156 target: AuthTarget,
157 },
158}
159
160#[derive(Debug, Subcommand)]
161pub enum AccountOp {
162 /// List configured accounts and their health.
163 List {
164 /// Emit JSON instead of the table (issue #314).
165 #[arg(long)]
166 json: bool,
167 #[command(flatten)]
168 target: AuthTarget,
169 },
170}
171
172#[derive(Debug, Subcommand)]
173// Keeping `AuthTarget` flattened preserves clap's public flag layout. Boxing
174// only the largest variant would leak an implementation detail into every
175// constructor and test for a command enum created once per process.
176#[allow(clippy::large_enum_variant)]
177pub enum ProviderOp {
178 /// List configured upstream providers.
179 List {
180 /// Emit JSON instead of the table (issue #314).
181 #[arg(long)]
182 json: bool,
183 #[command(flatten)]
184 target: AuthTarget,
185 },
186 /// Add or replace an API provider or policy-gated credential class.
187 ///
188 /// `create` and `issue` are accepted too (issue #314).
189 #[command(alias = "create", alias = "issue")]
190 Add {
191 /// Name this provider is referred to by, in routing and in `providers
192 /// show`. Adding an existing name replaces that record.
193 #[arg(long)]
194 name: String,
195 /// Wire protocol the upstream speaks.
196 #[arg(long, default_value = "openai-compatible")]
197 kind: String,
198 /// The upstream's own base URL — not the router's. `--server` names
199 /// the router; this names the machine it forwards to (issue #314).
200 #[arg(long)]
201 base_url: String,
202 /// Single model this provider serves, for an upstream that serves one.
203 #[arg(long)]
204 model: Option<String>,
205 /// Comma-separated models this provider serves.
206 #[arg(long, value_delimiter = ',')]
207 models: Vec<String>,
208 /// Canonical managed clients explicitly supported by this ordinary
209 /// provider adapter. Repeat or comma-separate values.
210 #[arg(long = "supported-client", value_delimiter = ',')]
211 supported_clients: Vec<String>,
212 /// Vendor API key, stored encrypted under the deployment's
213 /// `TOKEN_SECRET`.
214 ///
215 /// Prefer `--api-key-stdin`: a key given here is visible in shell
216 /// history and in `ps` (issue #314).
217 #[arg(long, hide_env_values = true, conflicts_with = "api_key_stdin")]
218 api_key: Option<String>,
219 /// Read the vendor API key as one line from standard input.
220 ///
221 /// The one secret in this tool that could travel only through argv,
222 /// while every other has had a stdin form and `clients setup --help`
223 /// warns against argv for exactly this reason (issue #314).
224 #[arg(long, conflicts_with = "api_key")]
225 api_key_stdin: bool,
226 /// Environment variable the *router process* reads the key from at
227 /// request time, instead of storing one.
228 #[arg(long)]
229 api_key_env: Option<String>,
230 /// Single subscriber allowed to spend a personal Coding Plan key.
231 #[arg(long)]
232 subscriber_id: Option<String>,
233 /// Accept the documented account risk of intermediary personal proxying.
234 #[arg(long)]
235 acknowledge_intermediary_risk: bool,
236 /// Individually risk-accept a known tool not listed by z.ai.
237 #[arg(long, value_delimiter = ',')]
238 acknowledge_unsupported_client: Vec<String>,
239 /// Whether this provider takes part in routing. Disabled records are
240 /// kept and ignored, so one can be parked without deleting it.
241 #[arg(
242 long,
243 default_value_t = true,
244 num_args = 0..=1,
245 default_missing_value = "true"
246 )]
247 enabled: bool,
248 /// Create only when the provider name is absent; never replace it.
249 #[arg(long)]
250 if_absent: bool,
251 #[command(flatten)]
252 target: AuthTarget,
253 },
254 /// Show one provider with secret material redacted.
255 Show {
256 name: String,
257 /// Accepted for symmetry with `list`: `show` already emits JSON, so
258 /// this changes nothing (issue #314). A script should not have to know
259 /// which verb of a family takes the flag.
260 #[arg(long)]
261 json: bool,
262 #[command(flatten)]
263 target: AuthTarget,
264 },
265 /// Remove one provider.
266 ///
267 /// `revoke` and `delete` are accepted too (issue #314).
268 #[command(alias = "revoke", alias = "delete")]
269 Remove {
270 name: String,
271 #[command(flatten)]
272 target: AuthTarget,
273 },
274 /// Import providers from JSON, `.lenv`, or indented Links-style config.
275 Import {
276 path: PathBuf,
277 #[command(flatten)]
278 target: AuthTarget,
279 },
280}