teardrop-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,270 @@
1
+ """auth commands: login, logout, whoami."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from datetime import UTC
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ app = typer.Typer(
13
+ name="auth",
14
+ help="Authentication — login, logout, and identity.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # login
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ @app.command()
24
+ def login(
25
+ email: Annotated[str | None, typer.Option("--email", "-e", help="Email address.")] = None,
26
+ secret: Annotated[
27
+ str | None,
28
+ typer.Option("--secret", "-s", help="Password / secret.", hide_input=True),
29
+ ] = None,
30
+ client_id: Annotated[str | None, typer.Option("--client-id", help="M2M client ID.")] = None,
31
+ client_secret: Annotated[
32
+ str | None,
33
+ typer.Option("--client-secret", help="M2M client secret.", hide_input=True),
34
+ ] = None,
35
+ token: Annotated[
36
+ str | None,
37
+ typer.Option("--token", "-t", help="Pre-existing static JWT token.", hide_input=True),
38
+ ] = None,
39
+ siwe: Annotated[bool, typer.Option("--siwe", help="Sign in with Ethereum wallet.")] = False,
40
+ base_url: Annotated[
41
+ str | None, typer.Option("--base-url", help="Override the API base URL.", hidden=True)
42
+ ] = None,
43
+ ) -> None:
44
+ """Authenticate with the Teardrop API and store credentials locally."""
45
+ from teardrop import AsyncTeardropClient
46
+
47
+ from teardrop_cli import config
48
+ from teardrop_cli.formatting import print_success, spinner
49
+
50
+ url = base_url or config.get_base_url()
51
+
52
+ # ------------------------------------------------------------------ #
53
+ # Static token #
54
+ # ------------------------------------------------------------------ #
55
+ if token:
56
+ config.store_token(token)
57
+ print_success("Token stored. Run [bold]teardrop auth whoami[/bold] to verify.")
58
+ return
59
+
60
+ # ------------------------------------------------------------------ #
61
+ # SIWE (Sign-In With Ethereum) #
62
+ # ------------------------------------------------------------------ #
63
+ if siwe:
64
+ _login_siwe(url)
65
+ return
66
+
67
+ # ------------------------------------------------------------------ #
68
+ # Client credentials (M2M) #
69
+ # ------------------------------------------------------------------ #
70
+ if client_id or client_secret:
71
+ if not client_id:
72
+ client_id = typer.prompt("Client ID")
73
+ if not client_secret:
74
+ client_secret = typer.prompt("Client secret", hide_input=True)
75
+
76
+ async def _fetch_client_creds():
77
+ try:
78
+ return await client.get_me()
79
+ finally:
80
+ await client.close()
81
+
82
+ with spinner("Authenticating…"):
83
+ client = AsyncTeardropClient(url, client_id=client_id, client_secret=client_secret)
84
+ try:
85
+ me = asyncio.run(_fetch_client_creds())
86
+ except Exception as exc:
87
+ _handle_auth_error(exc)
88
+ return
89
+
90
+ config.store_client_credentials(client_id, client_secret)
91
+ print_success(f"Authenticated as [bold]{me.sub}[/bold] (client credentials).")
92
+ return
93
+
94
+ # ------------------------------------------------------------------ #
95
+ # Email + password (interactive) #
96
+ # ------------------------------------------------------------------ #
97
+ if not email:
98
+ email = typer.prompt("Email")
99
+ if not secret:
100
+ secret = typer.prompt("Password", hide_input=True)
101
+
102
+ async def _fetch_email():
103
+ try:
104
+ return await client.get_me()
105
+ finally:
106
+ await client.close()
107
+
108
+ with spinner("Authenticating…"):
109
+ client = AsyncTeardropClient(url, email=email, secret=secret)
110
+ try:
111
+ me = asyncio.run(_fetch_email())
112
+ except Exception as exc:
113
+ _handle_auth_error(exc)
114
+ return
115
+
116
+ config.store_email_credentials(email, secret)
117
+ print_success(f"Authenticated as [bold]{me.sub}[/bold].")
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # SIWE — private key (env var)
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def _login_siwe(url: str) -> None:
126
+ """SIWE login using a private key supplied via TEARDROP_SIWE_PRIVATE_KEY."""
127
+ import asyncio
128
+
129
+ from teardrop import AsyncTeardropClient
130
+
131
+ from teardrop_cli import config
132
+ from teardrop_cli.formatting import console, print_error, print_success, spinner
133
+
134
+ private_key = os.environ.get("TEARDROP_SIWE_PRIVATE_KEY")
135
+ if not private_key:
136
+ print_error(
137
+ "TEARDROP_SIWE_PRIVATE_KEY environment variable is not set.",
138
+ hint="Export your Ethereum private key: export TEARDROP_SIWE_PRIVATE_KEY=0x<key>",
139
+ )
140
+ raise typer.Exit(1)
141
+
142
+ try:
143
+ from eth_account import Account
144
+ from eth_account.messages import encode_defunct
145
+ except ImportError:
146
+ print_error(
147
+ "eth-account is required for SIWE login.",
148
+ hint="Install it with: pip install eth-account",
149
+ )
150
+ raise typer.Exit(1) from None
151
+
152
+ account = Account.from_key(private_key)
153
+ wallet_address = account.address
154
+ console.print(f"[dim]Wallet:[/dim] {wallet_address}")
155
+
156
+ async def _do_siwe() -> str:
157
+ client = AsyncTeardropClient(url)
158
+ try:
159
+ nonce_resp = await client.get_siwe_nonce()
160
+ nonce = nonce_resp.get("nonce", nonce_resp.get("value", ""))
161
+
162
+ # Construct EIP-4361 message
163
+ from datetime import datetime
164
+ from urllib.parse import urlparse
165
+
166
+ parsed = urlparse(url)
167
+ domain = parsed.netloc or parsed.path
168
+ issued_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
169
+ message = (
170
+ f"{domain} wants you to sign in with your Ethereum account:\n"
171
+ f"{wallet_address}\n\n"
172
+ f"Sign in to Teardrop\n\n"
173
+ f"URI: {url}\n"
174
+ f"Version: 1\n"
175
+ f"Chain ID: 1\n"
176
+ f"Nonce: {nonce}\n"
177
+ f"Issued At: {issued_at}"
178
+ )
179
+
180
+ signable = encode_defunct(text=message)
181
+ signed = account.sign_message(signable)
182
+ signature = signed.signature.hex()
183
+ if not signature.startswith("0x"):
184
+ signature = "0x" + signature
185
+
186
+ jwt_token = await client.authenticate_siwe(message, signature, nonce)
187
+ return jwt_token
188
+ finally:
189
+ await client.close()
190
+
191
+ with spinner("Signing in with Ethereum…"):
192
+ try:
193
+ jwt_token = asyncio.run(_do_siwe())
194
+ except Exception as exc:
195
+ _handle_auth_error(exc)
196
+ return
197
+
198
+ config.store_token(jwt_token)
199
+ print_success(f"Authenticated via SIWE as [bold]{wallet_address}[/bold].")
200
+
201
+
202
+ def _handle_auth_error(exc: Exception) -> None:
203
+ from teardrop import APIError, AuthenticationError
204
+
205
+ from teardrop_cli.formatting import print_error
206
+
207
+ if isinstance(exc, AuthenticationError):
208
+ print_error("Invalid credentials.", hint="Check your email and password.")
209
+ elif isinstance(exc, APIError):
210
+ print_error(f"API error {exc.status_code}.", hint=str(exc))
211
+ else:
212
+ print_error(str(exc))
213
+ raise typer.Exit(1)
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # logout
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ @app.command()
222
+ def logout() -> None:
223
+ """Remove stored credentials."""
224
+ from teardrop_cli import config
225
+ from teardrop_cli.formatting import print_success
226
+
227
+ config.clear_credentials()
228
+ print_success("Logged out. Credentials removed.")
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # whoami
233
+ # ---------------------------------------------------------------------------
234
+
235
+
236
+ @app.command()
237
+ def whoami(
238
+ base_url: Annotated[
239
+ str | None, typer.Option("--base-url", help="Override the API base URL.", hidden=True)
240
+ ] = None,
241
+ as_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
242
+ ) -> None:
243
+ """Show the currently authenticated user."""
244
+ import asyncio
245
+
246
+ from teardrop_cli import config
247
+ from teardrop_cli.formatting import print_json, print_table, spinner
248
+
249
+ client = config.get_client(base_url)
250
+
251
+ async def _fetch():
252
+ try:
253
+ return await client.get_me()
254
+ finally:
255
+ await client.close()
256
+
257
+ with spinner("Fetching identity…"):
258
+ me = asyncio.run(_fetch())
259
+
260
+ data = me.model_dump()
261
+ if as_json:
262
+ print_json(data)
263
+ return
264
+
265
+ rows = [[k, v] for k, v in data.items() if v is not None]
266
+ print_table(
267
+ [("Field", {"style": "bold cyan"}), "Value"],
268
+ rows,
269
+ title="Current User",
270
+ )
@@ -0,0 +1,58 @@
1
+ """billing commands: account balance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ app = typer.Typer(
11
+ name="billing",
12
+ help="Billing — account balance and usage.",
13
+ no_args_is_help=True,
14
+ )
15
+
16
+
17
+ @app.callback()
18
+ def _callback() -> None:
19
+ """Billing — account balance and usage."""
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # balance
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ @app.command()
28
+ def balance(
29
+ as_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
30
+ base_url: Annotated[str | None, typer.Option("--base-url", hidden=True)] = None,
31
+ ) -> None:
32
+ """Show your account balance."""
33
+ from teardrop_cli import config
34
+ from teardrop_cli.formatting import print_json, print_table, spinner
35
+
36
+ async def _fetch():
37
+ try:
38
+ return await client.get_balance()
39
+ finally:
40
+ await client.close()
41
+
42
+ client = config.get_client(base_url)
43
+
44
+ with spinner("Fetching balance…"):
45
+ data = asyncio.run(_fetch())
46
+
47
+ data_dict = data.model_dump() if hasattr(data, "model_dump") else data
48
+
49
+ if as_json:
50
+ print_json(data_dict)
51
+ return
52
+
53
+ rows = [[k, v] for k, v in data_dict.items()]
54
+ print_table(
55
+ [("Field", {"style": "bold cyan"}), "Value"],
56
+ rows,
57
+ title="Account Balance",
58
+ )
@@ -0,0 +1,376 @@
1
+ """llm-config commands: get, set, delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ app = typer.Typer(
12
+ name="llm-config",
13
+ help="Org LLM configuration — get, set, and delete.",
14
+ no_args_is_help=True,
15
+ )
16
+
17
+ _SUPPORTED_PROVIDERS = ["anthropic", "openai", "google", "openrouter"]
18
+ _SUPPORTED_ROUTINGS = ["default", "cost", "speed", "quality"]
19
+
20
+
21
+ def _mask_api_key(key: str) -> str:
22
+ """Return first 5 chars of *key* followed by bullet ellipsis."""
23
+ prefix = key[:5] if len(key) >= 5 else key
24
+ return f"{prefix}••••"
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # get
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ @app.command()
33
+ def get(
34
+ org_id: Annotated[str, typer.Argument(help="Organisation ID.")],
35
+ as_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
36
+ no_cache: Annotated[
37
+ bool,
38
+ typer.Option("--no-cache", "--force-refresh", help="Bypass the local 5-minute cache."),
39
+ ] = False,
40
+ base_url: Annotated[str | None, typer.Option("--base-url", hidden=True)] = None,
41
+ ) -> None:
42
+ """Display the org's current LLM configuration."""
43
+ from teardrop import AuthenticationError
44
+
45
+ from teardrop_cli import config
46
+ from teardrop_cli.formatting import print_error, print_json, print_table, spinner
47
+
48
+ client = config.get_client(base_url)
49
+
50
+ try:
51
+
52
+ async def _fetch():
53
+ try:
54
+ return await client.get_llm_config(org_id=org_id, no_cache=no_cache)
55
+ finally:
56
+ await client.close()
57
+
58
+ with spinner("Fetching LLM config…"):
59
+ cfg = asyncio.run(_fetch())
60
+ except AuthenticationError:
61
+ print_error(
62
+ "Not authenticated.",
63
+ hint="Run: `teardrop auth login`",
64
+ )
65
+ raise typer.Exit(1) from None
66
+ except Exception as exc:
67
+ _handle_common_error(exc)
68
+
69
+ data = cfg if isinstance(cfg, dict) else cfg.model_dump()
70
+
71
+ if as_json:
72
+ print_json(data)
73
+ return
74
+
75
+ # Check for 404-equivalent (no custom config)
76
+ if not data:
77
+ from teardrop_cli.formatting import console
78
+
79
+ console.print(
80
+ f"[dim]No custom LLM config found for {org_id}. Using global defaults.[/dim]"
81
+ )
82
+ return
83
+
84
+ api_key_display = "true" if data.get("has_api_key") else "false"
85
+
86
+ print_table(
87
+ [("Field", {"style": "bold cyan"}), "Value"],
88
+ [
89
+ ["Provider", data.get("provider", "—")],
90
+ ["Model", data.get("model", "—")],
91
+ ["API Key Set", api_key_display],
92
+ ["Self-Hosted URL", data.get("api_base") or "(none)"],
93
+ ["Max Tokens", data.get("max_tokens", "—")],
94
+ ["Temperature", data.get("temperature", "—")],
95
+ ["Timeout", f"{data.get('timeout_seconds', '—')}s"],
96
+ ["Routing Preference", data.get("routing_preference", "—")],
97
+ ["Is BYOK", str(data.get("is_byok", False))],
98
+ ["Created", data.get("created_at", "—")],
99
+ ["Updated", data.get("updated_at", "—")],
100
+ ],
101
+ title=f"LLM Config — {org_id}",
102
+ )
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # set
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ @app.command(name="set")
111
+ def set_config(
112
+ org_id: Annotated[str, typer.Argument(help="Organisation ID.")],
113
+ provider: Annotated[
114
+ str | None,
115
+ typer.Option("--provider", help="LLM provider (anthropic, openai, google, openrouter)."),
116
+ ] = None,
117
+ model: Annotated[str | None, typer.Option("--model", help="Model ID.")] = None,
118
+ routing: Annotated[
119
+ str | None,
120
+ typer.Option("--routing", help="Routing preference (default, cost, speed, quality)."),
121
+ ] = None,
122
+ api_base: Annotated[
123
+ str | None, typer.Option("--api-base", help="Self-hosted model base URL.")
124
+ ] = None,
125
+ max_tokens: Annotated[
126
+ int | None, typer.Option("--max-tokens", help="Max tokens (1–200,000).")
127
+ ] = None,
128
+ temperature: Annotated[
129
+ float | None, typer.Option("--temperature", help="Temperature (0.0–2.0).")
130
+ ] = None,
131
+ timeout_seconds: Annotated[
132
+ int | None, typer.Option("--timeout-seconds", help="Timeout in seconds (≥1).")
133
+ ] = None,
134
+ byok_key: Annotated[
135
+ str | None,
136
+ typer.Option("--byok-key", help="Bring-your-own API key. Pass '-' to read from stdin."),
137
+ ] = None,
138
+ rotate_key: Annotated[
139
+ bool,
140
+ typer.Option("--rotate-key", help="Rotate the existing API key (delete + re-add)."),
141
+ ] = False,
142
+ as_json: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
143
+ base_url: Annotated[str | None, typer.Option("--base-url", hidden=True)] = None,
144
+ ) -> None:
145
+ """Create or update the org's LLM configuration."""
146
+ from teardrop import AuthenticationError
147
+
148
+ from teardrop_cli import config
149
+ from teardrop_cli.formatting import (
150
+ print_error,
151
+ print_json,
152
+ print_success,
153
+ print_table,
154
+ print_warning,
155
+ spinner,
156
+ )
157
+
158
+ # ------------------------------------------------------------------
159
+ # Client-side validation
160
+ # ------------------------------------------------------------------
161
+ if provider is not None and provider not in _SUPPORTED_PROVIDERS:
162
+ print_error(
163
+ f"Unsupported provider: {provider!r}.",
164
+ hint=f"Supported: {', '.join(_SUPPORTED_PROVIDERS)}",
165
+ )
166
+ raise typer.Exit(1)
167
+
168
+ if routing is not None and routing not in _SUPPORTED_ROUTINGS:
169
+ print_error(
170
+ f"Invalid routing preference: {routing!r}.",
171
+ hint=f"Supported: {', '.join(_SUPPORTED_ROUTINGS)}",
172
+ )
173
+ raise typer.Exit(1)
174
+
175
+ if temperature is not None and not (0.0 <= temperature <= 2.0):
176
+ print_error("Temperature must be 0.0–2.0.")
177
+ raise typer.Exit(1)
178
+
179
+ if max_tokens is not None and not (1 <= max_tokens <= 200_000):
180
+ print_error("Max tokens must be 1–200,000.")
181
+ raise typer.Exit(1)
182
+
183
+ if timeout_seconds is not None and timeout_seconds < 1:
184
+ print_error("timeout-seconds must be ≥ 1.")
185
+ raise typer.Exit(1)
186
+
187
+ # ------------------------------------------------------------------
188
+ # BYOK key resolution
189
+ # ------------------------------------------------------------------
190
+ resolved_key: str | None = None
191
+
192
+ if byok_key == "-":
193
+ # Read from stdin
194
+ resolved_key = sys.stdin.read().strip()
195
+ elif byok_key is not None:
196
+ print_warning("API key passed as argument. Consider using stdin for better security.")
197
+ print_warning("Example: cat $key_file | teardrop llm-config set ... --byok-key -")
198
+ resolved_key = byok_key
199
+
200
+ # Warn if sending a key over a non-HTTPS api_base
201
+ if resolved_key and api_base and not api_base.startswith("https://"):
202
+ print_warning("api-base is not HTTPS. API keys must only be sent over TLS.")
203
+
204
+ # ------------------------------------------------------------------
205
+ # API call
206
+ # ------------------------------------------------------------------
207
+ client = config.get_client(base_url)
208
+
209
+ kwargs: dict = {"org_id": org_id}
210
+ if provider is not None:
211
+ kwargs["provider"] = provider
212
+ if model is not None:
213
+ kwargs["model"] = model
214
+ if routing is not None:
215
+ kwargs["routing_preference"] = routing
216
+ if api_base is not None:
217
+ kwargs["api_base"] = api_base
218
+ if max_tokens is not None:
219
+ kwargs["max_tokens"] = max_tokens
220
+ if temperature is not None:
221
+ kwargs["temperature"] = temperature
222
+ if timeout_seconds is not None:
223
+ kwargs["timeout_seconds"] = timeout_seconds
224
+ if rotate_key:
225
+ kwargs["rotate_key"] = True
226
+ # resolved_key may be None — server preserves existing key when omitted
227
+ if resolved_key is not None:
228
+ kwargs["api_key"] = resolved_key
229
+
230
+ try:
231
+
232
+ async def _fetch():
233
+ try:
234
+ return await client.set_llm_config(**kwargs)
235
+ finally:
236
+ await client.close()
237
+
238
+ with spinner("Updating LLM config…"):
239
+ cfg = asyncio.run(_fetch())
240
+ except AuthenticationError:
241
+ print_error("Not authenticated.", hint="Run: `teardrop auth login`")
242
+ raise typer.Exit(1) from None
243
+ except Exception as exc:
244
+ _handle_common_error(exc)
245
+
246
+ data = cfg if isinstance(cfg, dict) else cfg.model_dump()
247
+
248
+ if as_json:
249
+ # Never include raw api_key in output
250
+ data.pop("api_key", None)
251
+ print_json(data)
252
+ return
253
+
254
+ print_success(f"Updated LLM configuration for {org_id}")
255
+ summary_rows: list[list] = [
256
+ ["Provider", data.get("provider", "—")],
257
+ ["Model", data.get("model", "—")],
258
+ ["Routing", data.get("routing_preference", "—")],
259
+ ]
260
+ if data.get("api_base"):
261
+ summary_rows.append(["Self-Hosted URL", data["api_base"]])
262
+ if data.get("has_api_key"):
263
+ key_display = f"true ({_mask_api_key(resolved_key)})" if resolved_key else "true"
264
+ summary_rows.append(["API Key Set", key_display])
265
+ if data.get("is_byok"):
266
+ summary_rows.append(["Is BYOK", "true"])
267
+
268
+ print_table(
269
+ [("Field", {"style": "bold cyan"}), "Value"],
270
+ summary_rows,
271
+ )
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # delete
276
+ # ---------------------------------------------------------------------------
277
+
278
+
279
+ @app.command()
280
+ def delete(
281
+ org_id: Annotated[str, typer.Argument(help="Organisation ID.")],
282
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")] = False,
283
+ base_url: Annotated[str | None, typer.Option("--base-url", hidden=True)] = None,
284
+ ) -> None:
285
+ """Remove org's custom LLM config and revert to global defaults."""
286
+ from teardrop import AuthenticationError
287
+
288
+ from teardrop_cli import config
289
+ from teardrop_cli.formatting import confirm, print_error, print_success, spinner
290
+
291
+ if not yes:
292
+ confirmed = confirm(
293
+ f"Delete custom LLM config for {org_id}? This will revert to global defaults."
294
+ )
295
+ if not confirmed:
296
+ raise typer.Abort()
297
+
298
+ client = config.get_client(base_url)
299
+
300
+ try:
301
+
302
+ async def _fetch():
303
+ try:
304
+ await client.delete_llm_config(org_id=org_id)
305
+ finally:
306
+ await client.close()
307
+
308
+ with spinner("Deleting LLM config…"):
309
+ asyncio.run(_fetch())
310
+ except AuthenticationError:
311
+ print_error("Not authenticated.", hint="Run: `teardrop auth login`")
312
+ raise typer.Exit(1) from None
313
+ except Exception as exc:
314
+ # 404 is treated as success (already using defaults)
315
+ _handle_common_error(exc, not_found_ok=True)
316
+
317
+ print_success("Deleted")
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # Internal helpers
322
+ # ---------------------------------------------------------------------------
323
+
324
+
325
+ def _handle_common_error(exc: Exception, *, not_found_ok: bool = False) -> None:
326
+ """Translate SDK/HTTP exceptions to user-friendly messages."""
327
+ from teardrop_cli.formatting import console, print_error, print_success
328
+
329
+ exc_str = str(exc)
330
+ status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
331
+
332
+ if not_found_ok and status == 404:
333
+ print_success("No config found (already using defaults).")
334
+ return
335
+
336
+ if status == 401:
337
+ print_error("Not authenticated.", hint="Run: `teardrop auth login`")
338
+ raise typer.Exit(1)
339
+
340
+ if status == 404:
341
+ console.print("[dim]No custom LLM config found. Using global defaults.[/dim]")
342
+ raise typer.Exit(0)
343
+
344
+ if status == 400:
345
+ if "ssrf" in exc_str.lower() or "private" in exc_str.lower():
346
+ print_error(
347
+ "api_base must be a public URL (e.g., https://api.provider.com).",
348
+ hint="Contact support for private endpoint allowlisting.",
349
+ )
350
+ elif "provider" in exc_str.lower():
351
+ print_error(
352
+ "Unsupported provider.",
353
+ hint=f"Supported: {', '.join(_SUPPORTED_PROVIDERS)}",
354
+ )
355
+ elif "routing" in exc_str.lower():
356
+ print_error(
357
+ "Invalid routing preference.",
358
+ hint=f"Supported: {', '.join(_SUPPORTED_ROUTINGS)}",
359
+ )
360
+ else:
361
+ print_error(f"Bad request: {exc_str}")
362
+ raise typer.Exit(1)
363
+
364
+ if status == 422:
365
+ print_error(f"Validation error: {exc_str}")
366
+ raise typer.Exit(1)
367
+
368
+ if status == 429:
369
+ print_error(
370
+ "Too many config updates.",
371
+ hint="Maximum 10 updates per hour per org.",
372
+ )
373
+ raise typer.Exit(1)
374
+
375
+ print_error(f"Unexpected error: {exc_str}")
376
+ raise typer.Exit(1)