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.
- teardrop_cli/__init__.py +1 -0
- teardrop_cli/_fixtures.py +132 -0
- teardrop_cli/_lazy.py +67 -0
- teardrop_cli/cli.py +41 -0
- teardrop_cli/commands/__init__.py +0 -0
- teardrop_cli/commands/agent.py +190 -0
- teardrop_cli/commands/auth.py +270 -0
- teardrop_cli/commands/billing.py +58 -0
- teardrop_cli/commands/llm_config.py +376 -0
- teardrop_cli/commands/marketplace.py +192 -0
- teardrop_cli/commands/mcp.py +202 -0
- teardrop_cli/commands/models.py +193 -0
- teardrop_cli/commands/tools.py +207 -0
- teardrop_cli/config.py +240 -0
- teardrop_cli/formatting.py +243 -0
- teardrop_cli-0.1.0.dist-info/METADATA +384 -0
- teardrop_cli-0.1.0.dist-info/RECORD +20 -0
- teardrop_cli-0.1.0.dist-info/WHEEL +4 -0
- teardrop_cli-0.1.0.dist-info/entry_points.txt +2 -0
- teardrop_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|