deepctl-cmd-api 0.0.2__tar.gz → 0.0.3__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.
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/PKG-INFO +1 -1
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/pyproject.toml +1 -1
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api/command.py +195 -17
- deepctl_cmd_api-0.0.3/src/deepctl_cmd_api/messages.py +31 -0
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api/models.py +10 -0
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api.egg-info/PKG-INFO +1 -1
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api.egg-info/SOURCES.txt +1 -0
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/README.md +0 -0
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/setup.cfg +0 -0
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api/__init__.py +0 -0
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api.egg-info/dependency_links.txt +0 -0
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api.egg-info/entry_points.txt +0 -0
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api.egg-info/requires.txt +0 -0
- {deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api.egg-info/top_level.txt +0 -0
|
@@ -5,18 +5,41 @@ import shutil
|
|
|
5
5
|
import subprocess
|
|
6
6
|
import sys
|
|
7
7
|
from typing import Any
|
|
8
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
8
9
|
|
|
9
10
|
import httpx
|
|
10
|
-
from deepctl_core import
|
|
11
|
+
from deepctl_core import (
|
|
12
|
+
AuthManager,
|
|
13
|
+
BaseCommand,
|
|
14
|
+
Config,
|
|
15
|
+
DeepgramClient,
|
|
16
|
+
is_agentic,
|
|
17
|
+
is_ai_agent,
|
|
18
|
+
)
|
|
19
|
+
from deepctl_core import messages as core_msg
|
|
20
|
+
from deepctl_core.hosts import (
|
|
21
|
+
DEFAULT_BASE_URL,
|
|
22
|
+
UrlOrigin,
|
|
23
|
+
is_allowlisted,
|
|
24
|
+
is_loopback,
|
|
25
|
+
is_regional,
|
|
26
|
+
parse_origin,
|
|
27
|
+
same_origin,
|
|
28
|
+
)
|
|
29
|
+
from deepctl_core.output import (
|
|
30
|
+
print_error,
|
|
31
|
+
print_hint,
|
|
32
|
+
print_security_warning,
|
|
33
|
+
)
|
|
11
34
|
from rich.console import Console
|
|
35
|
+
from rich.markup import escape
|
|
12
36
|
from rich.panel import Panel
|
|
13
37
|
|
|
14
|
-
from .
|
|
38
|
+
from . import messages as msg
|
|
39
|
+
from .models import ApiErrorResult, ApiResult
|
|
15
40
|
|
|
16
41
|
console = Console()
|
|
17
42
|
|
|
18
|
-
DEFAULT_BASE_URL = "https://api.deepgram.com"
|
|
19
|
-
|
|
20
43
|
|
|
21
44
|
class ApiCommand(BaseCommand):
|
|
22
45
|
"""Make authenticated requests to Deepgram APIs."""
|
|
@@ -91,25 +114,170 @@ class ApiCommand(BaseCommand):
|
|
|
91
114
|
"is_flag": True,
|
|
92
115
|
"is_option": True,
|
|
93
116
|
},
|
|
117
|
+
{
|
|
118
|
+
"names": ["--allow-foreign-host"],
|
|
119
|
+
"help": (
|
|
120
|
+
"Send your API key to this non-Deepgram host (must equal "
|
|
121
|
+
"the request URL's host)"
|
|
122
|
+
),
|
|
123
|
+
"type": str,
|
|
124
|
+
"is_option": True,
|
|
125
|
+
},
|
|
94
126
|
]
|
|
95
127
|
|
|
96
|
-
def _resolve_url(self, endpoint: str) -> str:
|
|
97
|
-
"""Resolve endpoint to full URL."""
|
|
128
|
+
def _resolve_url(self, endpoint: str, base_url: str = DEFAULT_BASE_URL) -> str:
|
|
129
|
+
"""Resolve endpoint to full URL, relative to the profile's base URL."""
|
|
98
130
|
if endpoint.startswith("http://") or endpoint.startswith("https://"):
|
|
99
131
|
return endpoint
|
|
100
|
-
base =
|
|
132
|
+
base = base_url.rstrip("/")
|
|
101
133
|
path = endpoint if endpoint.startswith("/") else f"/{endpoint}"
|
|
102
134
|
return f"{base}{path}"
|
|
103
135
|
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _display_url(url: str) -> str:
|
|
138
|
+
"""The URL without userinfo, query or fragment."""
|
|
139
|
+
try:
|
|
140
|
+
parts = urlsplit(url)
|
|
141
|
+
netloc = parts.hostname or ""
|
|
142
|
+
if ":" in netloc:
|
|
143
|
+
netloc = f"[{netloc}]"
|
|
144
|
+
if parts.port is not None:
|
|
145
|
+
netloc = f"{netloc}:{parts.port}"
|
|
146
|
+
return urlunsplit((parts.scheme, netloc, parts.path, "", ""))
|
|
147
|
+
except ValueError:
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
def pre_auth_check(self, config: Config, **kwargs: Any) -> Any | None:
|
|
151
|
+
"""Refuse a disallowed --allow-foreign-host before any request."""
|
|
152
|
+
endpoint = kwargs.get("endpoint") or ""
|
|
153
|
+
allow_host = kwargs.get("allow_foreign_host")
|
|
154
|
+
if not allow_host or not endpoint.startswith(("http://", "https://")):
|
|
155
|
+
return None
|
|
156
|
+
method = (kwargs.get("method") or "GET").upper()
|
|
157
|
+
base_url = config.get_profile().base_url or DEFAULT_BASE_URL
|
|
158
|
+
_, refusal = self._authorize(
|
|
159
|
+
endpoint, base_url, config, allow_host, method, warn=False
|
|
160
|
+
)
|
|
161
|
+
return refusal
|
|
162
|
+
|
|
163
|
+
def _authorize(
|
|
164
|
+
self,
|
|
165
|
+
url: str,
|
|
166
|
+
base_url: str,
|
|
167
|
+
config: Config,
|
|
168
|
+
allow_host: str | None,
|
|
169
|
+
method: str,
|
|
170
|
+
warn: bool = True,
|
|
171
|
+
) -> tuple[bool, ApiErrorResult | None]:
|
|
172
|
+
"""Decide whether the request to ``url`` may carry the API key.
|
|
173
|
+
|
|
174
|
+
Returns ``(attach, error)``; with an error, nothing is sent.
|
|
175
|
+
"""
|
|
176
|
+
target = parse_origin(url)
|
|
177
|
+
configured = parse_origin(base_url)
|
|
178
|
+
allowed = config.allowed_hosts()
|
|
179
|
+
if target is None:
|
|
180
|
+
return False, None
|
|
181
|
+
|
|
182
|
+
trusted = (
|
|
183
|
+
(configured is not None and same_origin(target, configured))
|
|
184
|
+
or is_regional(target)
|
|
185
|
+
or is_allowlisted(target, allowed)
|
|
186
|
+
)
|
|
187
|
+
host = core_msg.display(target.host)
|
|
188
|
+
|
|
189
|
+
if not trusted:
|
|
190
|
+
if not allow_host:
|
|
191
|
+
if not warn:
|
|
192
|
+
return False, None
|
|
193
|
+
print_security_warning(escape(msg.W_API_NOAUTH.format(host=host)))
|
|
194
|
+
# The hint names the override flag: never in agent mode.
|
|
195
|
+
if not is_agentic() and not is_ai_agent():
|
|
196
|
+
print_hint(escape(msg.W_API_NOAUTH_HINT.format(host=host)))
|
|
197
|
+
return False, None
|
|
198
|
+
# Refuse the command-line override under a hard AI-agent signal
|
|
199
|
+
# (never CI or TTY state); api.allowed_hosts is the real control.
|
|
200
|
+
if is_ai_agent():
|
|
201
|
+
return False, self._refuse(
|
|
202
|
+
msg.E_API_AGENT.format(host=host),
|
|
203
|
+
msg.E_API_AGENT_HINT.format(host=host),
|
|
204
|
+
core_msg.CODE_FOREIGN_HOST,
|
|
205
|
+
method,
|
|
206
|
+
url,
|
|
207
|
+
)
|
|
208
|
+
if not self._flag_matches(allow_host, target):
|
|
209
|
+
return False, self._refuse(
|
|
210
|
+
msg.E_API_MISMATCH.format(
|
|
211
|
+
given=core_msg.display(allow_host), host=host
|
|
212
|
+
),
|
|
213
|
+
msg.E_API_MISMATCH_HINT.format(host=host),
|
|
214
|
+
core_msg.CODE_FOREIGN_HOST,
|
|
215
|
+
method,
|
|
216
|
+
url,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Plain http only to a loopback host the user configured or
|
|
220
|
+
# allowlisted; the command-line override is https only.
|
|
221
|
+
if not target.secure and (not trusted or not is_loopback(target.host)):
|
|
222
|
+
hint = msg.E_API_INSECURE_HINT
|
|
223
|
+
return False, self._refuse(
|
|
224
|
+
msg.E_API_INSECURE.format(origin=core_msg.display(target.origin)),
|
|
225
|
+
hint,
|
|
226
|
+
core_msg.CODE_INSECURE_BASE_URL,
|
|
227
|
+
method,
|
|
228
|
+
url,
|
|
229
|
+
)
|
|
230
|
+
return True, None
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _flag_matches(given: str, target: UrlOrigin) -> bool:
|
|
234
|
+
"""``--allow-foreign-host`` names exactly the request host."""
|
|
235
|
+
value = given.strip().lower()
|
|
236
|
+
parsed = parse_origin(f"https://{value}") if value else None
|
|
237
|
+
if parsed is None or parsed.host != target.host:
|
|
238
|
+
return False
|
|
239
|
+
return parsed.port is None or parsed.port == target.effective_port
|
|
240
|
+
|
|
241
|
+
def _refuse(
|
|
242
|
+
self, error: str, hint: str, code: str, method: str, url: str
|
|
243
|
+
) -> ApiErrorResult:
|
|
244
|
+
print_error(escape(error))
|
|
245
|
+
print_hint(escape(hint))
|
|
246
|
+
return ApiErrorResult(
|
|
247
|
+
status="error",
|
|
248
|
+
message=error,
|
|
249
|
+
method=method,
|
|
250
|
+
url=self._display_url(url),
|
|
251
|
+
code=code,
|
|
252
|
+
hint=hint,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _warn_redirect(
|
|
256
|
+
self, response: httpx.Response, target: UrlOrigin | None
|
|
257
|
+
) -> None:
|
|
258
|
+
"""Note a redirect that wasn't followed."""
|
|
259
|
+
location = response.headers.get("location")
|
|
260
|
+
if not location or target is None:
|
|
261
|
+
return
|
|
262
|
+
dest = parse_origin(str(response.url.join(location)))
|
|
263
|
+
print_security_warning(
|
|
264
|
+
escape(
|
|
265
|
+
msg.W_API_REDIRECT.format(
|
|
266
|
+
origin=core_msg.display(target.origin),
|
|
267
|
+
location_origin=core_msg.display(dest.origin if dest else "?"),
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
104
272
|
def _build_headers(
|
|
105
273
|
self,
|
|
106
|
-
api_key: str,
|
|
274
|
+
api_key: str | None,
|
|
107
275
|
custom_headers: tuple[str, ...] | list[str] | None,
|
|
108
276
|
) -> dict[str, str]:
|
|
109
|
-
"""Build request headers with auth and custom headers."""
|
|
110
|
-
headers: dict[str, str] = {
|
|
111
|
-
|
|
112
|
-
|
|
277
|
+
"""Build request headers with auth (when given) and custom headers."""
|
|
278
|
+
headers: dict[str, str] = {}
|
|
279
|
+
if api_key:
|
|
280
|
+
headers["Authorization"] = f"Token {api_key}"
|
|
113
281
|
if custom_headers:
|
|
114
282
|
for header in custom_headers:
|
|
115
283
|
if ":" in header:
|
|
@@ -197,9 +365,16 @@ class ApiCommand(BaseCommand):
|
|
|
197
365
|
custom_headers = kwargs.get("header") or ()
|
|
198
366
|
jq_filter = kwargs.get("jq")
|
|
199
367
|
raw_output = kwargs.get("raw", False)
|
|
368
|
+
allow_host = kwargs.get("allow_foreign_host")
|
|
369
|
+
|
|
370
|
+
# Resolve URL against the profile's base URL. check_base_url raises
|
|
371
|
+
# (already reported) when that host isn't trusted with the key.
|
|
372
|
+
base_url = auth_manager.check_base_url()
|
|
373
|
+
url = self._resolve_url(endpoint, base_url)
|
|
200
374
|
|
|
201
|
-
|
|
202
|
-
|
|
375
|
+
attach, refusal = self._authorize(url, base_url, config, allow_host, method)
|
|
376
|
+
if refusal is not None:
|
|
377
|
+
return refusal
|
|
203
378
|
|
|
204
379
|
# Get API key
|
|
205
380
|
api_key = auth_manager.get_api_key()
|
|
@@ -212,8 +387,8 @@ class ApiCommand(BaseCommand):
|
|
|
212
387
|
url=url,
|
|
213
388
|
)
|
|
214
389
|
|
|
215
|
-
# Build headers
|
|
216
|
-
headers = self._build_headers(api_key, custom_headers)
|
|
390
|
+
# Build headers (the key only goes to a host that may receive it)
|
|
391
|
+
headers = self._build_headers(api_key if attach else None, custom_headers)
|
|
217
392
|
|
|
218
393
|
# Build body
|
|
219
394
|
try:
|
|
@@ -242,7 +417,8 @@ class ApiCommand(BaseCommand):
|
|
|
242
417
|
|
|
243
418
|
# Make request
|
|
244
419
|
try:
|
|
245
|
-
|
|
420
|
+
# Never follow redirects: a 3xx could point the key elsewhere.
|
|
421
|
+
with httpx.Client(timeout=30.0, follow_redirects=False) as http_client:
|
|
246
422
|
response = http_client.request(**request_kwargs)
|
|
247
423
|
except httpx.RequestError as e:
|
|
248
424
|
console.print(f"[red]Request failed: {e}[/red]")
|
|
@@ -254,6 +430,8 @@ class ApiCommand(BaseCommand):
|
|
|
254
430
|
)
|
|
255
431
|
|
|
256
432
|
elapsed_ms = response.elapsed.total_seconds() * 1000
|
|
433
|
+
if response.is_redirect:
|
|
434
|
+
self._warn_redirect(response, parse_origin(url))
|
|
257
435
|
|
|
258
436
|
# Handle response
|
|
259
437
|
response_text = response.text
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""User-facing strings for `dg api`, keyed by message ID.
|
|
2
|
+
|
|
3
|
+
Placeholders are filled with ``deepctl_core.messages.display`` values; a
|
|
4
|
+
composed line is markup-escaped before it is printed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from deepctl_core import messages as _core_msg
|
|
8
|
+
|
|
9
|
+
W_API_NOAUTH = (
|
|
10
|
+
"Sending the request to {host} without your API key: it is not a Deepgram API host."
|
|
11
|
+
)
|
|
12
|
+
W_API_NOAUTH_HINT = "To include your key, pass --allow-foreign-host={host}"
|
|
13
|
+
E_API_MISMATCH = (
|
|
14
|
+
"--allow-foreign-host={given} does not match the request host {host}. "
|
|
15
|
+
"Nothing was sent."
|
|
16
|
+
)
|
|
17
|
+
E_API_MISMATCH_HINT = "If you trust {host}, pass --allow-foreign-host={host}"
|
|
18
|
+
E_API_AGENT = (
|
|
19
|
+
"Not sending your API key to {host}: this session looks like an AI agent, "
|
|
20
|
+
"so command-line overrides aren't accepted. Nothing was sent."
|
|
21
|
+
)
|
|
22
|
+
E_API_AGENT_HINT = _core_msg.E_FH_AGENT_HINT
|
|
23
|
+
E_API_INSECURE = (
|
|
24
|
+
"Not sending your API key to {origin}: keys are sent over plain http only "
|
|
25
|
+
"to a loopback host you configured or approved. Nothing was sent."
|
|
26
|
+
)
|
|
27
|
+
E_API_INSECURE_HINT = "Use an https:// URL."
|
|
28
|
+
W_API_REDIRECT = (
|
|
29
|
+
"{origin} answered with a redirect to {location_origin}. dg api doesn't "
|
|
30
|
+
"follow redirects."
|
|
31
|
+
)
|
|
@@ -13,3 +13,13 @@ class ApiResult(BaseResult):
|
|
|
13
13
|
status_code: int = 0
|
|
14
14
|
response_body: Any = None
|
|
15
15
|
elapsed_ms: float | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApiErrorResult(ApiResult):
|
|
19
|
+
"""`dg api` error that also carries a machine-readable code and a hint.
|
|
20
|
+
|
|
21
|
+
A subclass so every other `dg api` payload keeps its existing keys.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
code: str
|
|
25
|
+
hint: str
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{deepctl_cmd_api-0.0.2 → deepctl_cmd_api-0.0.3}/src/deepctl_cmd_api.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|