Skip to main content

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}