smartpipe-cli 1.3.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.
- smartpipe/__init__.py +6 -0
- smartpipe/__main__.py +8 -0
- smartpipe/assets/probe.png +0 -0
- smartpipe/assets/probe.txt +1 -0
- smartpipe/assets/probe.wav +0 -0
- smartpipe/cli/__init__.py +5 -0
- smartpipe/cli/auth_cmd.py +78 -0
- smartpipe/cli/cache_cmd.py +60 -0
- smartpipe/cli/chart_cmd.py +60 -0
- smartpipe/cli/cite_cmd.py +26 -0
- smartpipe/cli/cluster_cmd.py +102 -0
- smartpipe/cli/completions.py +91 -0
- smartpipe/cli/config_cmd.py +234 -0
- smartpipe/cli/diff_cmd.py +100 -0
- smartpipe/cli/distinct_cmd.py +94 -0
- smartpipe/cli/doctor_cmd.py +207 -0
- smartpipe/cli/echo_cmd.py +44 -0
- smartpipe/cli/embed_cmd.py +80 -0
- smartpipe/cli/extend_cmd.py +138 -0
- smartpipe/cli/filter_cmd.py +87 -0
- smartpipe/cli/getschema_cmd.py +31 -0
- smartpipe/cli/input_options.py +113 -0
- smartpipe/cli/interrupts.py +92 -0
- smartpipe/cli/join_cmd.py +162 -0
- smartpipe/cli/map_cmd.py +150 -0
- smartpipe/cli/outliers_cmd.py +82 -0
- smartpipe/cli/probe_cmd.py +223 -0
- smartpipe/cli/reduce_cmd.py +129 -0
- smartpipe/cli/root.py +281 -0
- smartpipe/cli/run_cmd.py +136 -0
- smartpipe/cli/sample_cmd.py +35 -0
- smartpipe/cli/schema_cmd.py +75 -0
- smartpipe/cli/screens.py +231 -0
- smartpipe/cli/sem_file.py +453 -0
- smartpipe/cli/sort_cmd.py +33 -0
- smartpipe/cli/split_cmd.py +76 -0
- smartpipe/cli/summarize_cmd.py +37 -0
- smartpipe/cli/top_k_cmd.py +97 -0
- smartpipe/cli/usage_cmd.py +66 -0
- smartpipe/cli/where_cmd.py +36 -0
- smartpipe/config/__init__.py +5 -0
- smartpipe/config/credentials.py +118 -0
- smartpipe/config/display.py +70 -0
- smartpipe/config/doctor.py +58 -0
- smartpipe/config/paths.py +38 -0
- smartpipe/config/store.py +252 -0
- smartpipe/container.py +439 -0
- smartpipe/core/__init__.py +5 -0
- smartpipe/core/errors.py +57 -0
- smartpipe/core/jsontools.py +56 -0
- smartpipe/engine/__init__.py +9 -0
- smartpipe/engine/aggregate.py +234 -0
- smartpipe/engine/blocking.py +44 -0
- smartpipe/engine/chart.py +143 -0
- smartpipe/engine/chunking.py +161 -0
- smartpipe/engine/clustering.py +94 -0
- smartpipe/engine/predicate.py +330 -0
- smartpipe/engine/prompts.py +601 -0
- smartpipe/engine/ranking.py +62 -0
- smartpipe/engine/runner.py +175 -0
- smartpipe/engine/schema.py +208 -0
- smartpipe/engine/schema_dsl.py +144 -0
- smartpipe/engine/tally.py +53 -0
- smartpipe/engine/timebin.py +67 -0
- smartpipe/engine/units.py +43 -0
- smartpipe/engine/windows.py +78 -0
- smartpipe/io/__init__.py +9 -0
- smartpipe/io/diagnostics.py +148 -0
- smartpipe/io/inputs.py +44 -0
- smartpipe/io/items.py +149 -0
- smartpipe/io/leaderboard.py +52 -0
- smartpipe/io/metering.py +180 -0
- smartpipe/io/progress.py +140 -0
- smartpipe/io/readers.py +455 -0
- smartpipe/io/text.py +40 -0
- smartpipe/io/tty.py +88 -0
- smartpipe/io/usage.py +214 -0
- smartpipe/io/writers.py +340 -0
- smartpipe/models/__init__.py +5 -0
- smartpipe/models/anthropic_adapter.py +149 -0
- smartpipe/models/base.py +170 -0
- smartpipe/models/budget.py +94 -0
- smartpipe/models/cache.py +132 -0
- smartpipe/models/gemini_native.py +196 -0
- smartpipe/models/http_support.py +77 -0
- smartpipe/models/jina.py +98 -0
- smartpipe/models/local_embed.py +76 -0
- smartpipe/models/ollama.py +204 -0
- smartpipe/models/openai_codex.py +237 -0
- smartpipe/models/openai_compat.py +328 -0
- smartpipe/models/openai_oauth.py +366 -0
- smartpipe/models/resolve.py +78 -0
- smartpipe/models/retry.py +69 -0
- smartpipe/models/stt.py +80 -0
- smartpipe/models/windows.py +116 -0
- smartpipe/parsing/__init__.py +10 -0
- smartpipe/parsing/detect.py +178 -0
- smartpipe/parsing/extract.py +582 -0
- smartpipe/py.typed +0 -0
- smartpipe/verbs/__init__.py +5 -0
- smartpipe/verbs/chart.py +153 -0
- smartpipe/verbs/cluster.py +220 -0
- smartpipe/verbs/common.py +468 -0
- smartpipe/verbs/convert.py +251 -0
- smartpipe/verbs/diff.py +206 -0
- smartpipe/verbs/distinct.py +164 -0
- smartpipe/verbs/embed.py +166 -0
- smartpipe/verbs/extend.py +180 -0
- smartpipe/verbs/filter.py +191 -0
- smartpipe/verbs/getschema.py +135 -0
- smartpipe/verbs/join.py +413 -0
- smartpipe/verbs/map.py +315 -0
- smartpipe/verbs/outliers.py +119 -0
- smartpipe/verbs/reduce.py +428 -0
- smartpipe/verbs/sample.py +52 -0
- smartpipe/verbs/sortverb.py +63 -0
- smartpipe/verbs/split.py +333 -0
- smartpipe/verbs/summarize.py +60 -0
- smartpipe/verbs/top_k.py +318 -0
- smartpipe/verbs/where.py +47 -0
- smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
- smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
- smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
- smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
- smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
- smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""OpenAI "Log in with ChatGPT" — the OAuth engine (plan/decisions.md D19).
|
|
2
|
+
|
|
3
|
+
Protocol transcribed from opencode's working implementation (context/opencode):
|
|
4
|
+
OpenAI's public Codex OAuth client, PKCE S256, a localhost:1455 callback for the
|
|
5
|
+
browser flow, and a device-code flow for headless machines. smartpipe self-identifies
|
|
6
|
+
with ``originator: smartpipe`` exactly as opencode does with its own name.
|
|
7
|
+
|
|
8
|
+
The pure pieces (PKCE, URLs, JWT claims) are unit-tested; the HTTP pieces take an
|
|
9
|
+
injected ``httpx.AsyncClient`` so respx pins every wire shape.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import base64
|
|
16
|
+
import contextlib
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import secrets
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from smartpipe.config.credentials import OAuthCredential
|
|
25
|
+
from smartpipe.core.errors import SetupFault
|
|
26
|
+
from smartpipe.core.jsontools import as_items, as_record, as_str
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"CALLBACK_PORT",
|
|
33
|
+
"CLIENT_ID",
|
|
34
|
+
"ISSUER",
|
|
35
|
+
"DeviceStart",
|
|
36
|
+
"Pkce",
|
|
37
|
+
"authorize_url",
|
|
38
|
+
"credential_from_tokens",
|
|
39
|
+
"exchange_code",
|
|
40
|
+
"extract_account_id",
|
|
41
|
+
"generate_pkce",
|
|
42
|
+
"login_via_browser",
|
|
43
|
+
"poll_device",
|
|
44
|
+
"refresh_tokens",
|
|
45
|
+
"start_device_flow",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" # OpenAI's public Codex OAuth client
|
|
49
|
+
ISSUER = "https://auth.openai.com"
|
|
50
|
+
CALLBACK_PORT = 1455
|
|
51
|
+
_SCOPE = "openid profile email offline_access"
|
|
52
|
+
_ORIGINATOR = "smartpipe"
|
|
53
|
+
_LOGIN_TIMEOUT_S = 300.0 # the browser flow's 5-minute cap
|
|
54
|
+
_DEVICE_PENDING = (403, 404) # "keep polling" statuses, per the wire
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class Pkce:
|
|
59
|
+
verifier: str
|
|
60
|
+
challenge: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class DeviceStart:
|
|
65
|
+
device_auth_id: str
|
|
66
|
+
user_code: str
|
|
67
|
+
interval_s: float
|
|
68
|
+
verify_url: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def generate_pkce() -> Pkce:
|
|
72
|
+
charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
73
|
+
verifier = "".join(secrets.choice(charset) for _ in range(43))
|
|
74
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
75
|
+
return Pkce(verifier=verifier, challenge=_b64url(digest))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def authorize_url(redirect_uri: str, pkce: Pkce, state: str) -> str:
|
|
79
|
+
from urllib.parse import urlencode
|
|
80
|
+
|
|
81
|
+
params = {
|
|
82
|
+
"response_type": "code",
|
|
83
|
+
"client_id": CLIENT_ID,
|
|
84
|
+
"redirect_uri": redirect_uri,
|
|
85
|
+
"scope": _SCOPE,
|
|
86
|
+
"code_challenge": pkce.challenge,
|
|
87
|
+
"code_challenge_method": "S256",
|
|
88
|
+
"id_token_add_organizations": "true",
|
|
89
|
+
"codex_cli_simplified_flow": "true",
|
|
90
|
+
"state": state,
|
|
91
|
+
"originator": _ORIGINATOR,
|
|
92
|
+
}
|
|
93
|
+
return f"{ISSUER}/oauth/authorize?{urlencode(params)}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def extract_account_id(id_token: str | None, access_token: str | None) -> str | None:
|
|
97
|
+
for token in (id_token, access_token):
|
|
98
|
+
if token is None:
|
|
99
|
+
continue
|
|
100
|
+
claims = _jwt_claims(token)
|
|
101
|
+
if claims is None:
|
|
102
|
+
continue
|
|
103
|
+
direct = as_str(claims.get("chatgpt_account_id"))
|
|
104
|
+
if direct is not None:
|
|
105
|
+
return direct
|
|
106
|
+
nested = as_record(claims.get("https://api.openai.com/auth"))
|
|
107
|
+
if nested is not None:
|
|
108
|
+
nested_id = as_str(nested.get("chatgpt_account_id"))
|
|
109
|
+
if nested_id is not None:
|
|
110
|
+
return nested_id
|
|
111
|
+
organizations = as_items(claims.get("organizations"))
|
|
112
|
+
if organizations:
|
|
113
|
+
first = as_record(organizations[0])
|
|
114
|
+
if first is not None:
|
|
115
|
+
org = as_str(first.get("id"))
|
|
116
|
+
if org is not None:
|
|
117
|
+
return org
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def credential_from_tokens(payload: object) -> OAuthCredential:
|
|
122
|
+
record = as_record(payload)
|
|
123
|
+
access = as_str(record.get("access_token")) if record is not None else None
|
|
124
|
+
refresh = as_str(record.get("refresh_token")) if record is not None else None
|
|
125
|
+
if record is None or access is None or refresh is None:
|
|
126
|
+
raise SetupFault("error: the login reply was missing tokens — try again")
|
|
127
|
+
expires_in = record.get("expires_in")
|
|
128
|
+
seconds = expires_in if isinstance(expires_in, int) else 3600
|
|
129
|
+
return OAuthCredential(
|
|
130
|
+
access=access,
|
|
131
|
+
refresh=refresh,
|
|
132
|
+
expires_ms=int(time.time() * 1000) + seconds * 1000,
|
|
133
|
+
account_id=extract_account_id(as_str(record.get("id_token")), access),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def exchange_code(
|
|
138
|
+
client: httpx.AsyncClient, code: str, redirect_uri: str, verifier: str
|
|
139
|
+
) -> OAuthCredential:
|
|
140
|
+
return credential_from_tokens(
|
|
141
|
+
await _token_request(
|
|
142
|
+
client,
|
|
143
|
+
{
|
|
144
|
+
"grant_type": "authorization_code",
|
|
145
|
+
"code": code,
|
|
146
|
+
"redirect_uri": redirect_uri,
|
|
147
|
+
"client_id": CLIENT_ID,
|
|
148
|
+
"code_verifier": verifier,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def refresh_tokens(client: httpx.AsyncClient, refresh_token: str) -> OAuthCredential:
|
|
155
|
+
return credential_from_tokens(
|
|
156
|
+
await _token_request(
|
|
157
|
+
client,
|
|
158
|
+
{
|
|
159
|
+
"grant_type": "refresh_token",
|
|
160
|
+
"refresh_token": refresh_token,
|
|
161
|
+
"client_id": CLIENT_ID,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def start_device_flow(client: httpx.AsyncClient) -> DeviceStart:
|
|
168
|
+
response = await client.post(
|
|
169
|
+
f"{ISSUER}/api/accounts/deviceauth/usercode",
|
|
170
|
+
json={"client_id": CLIENT_ID},
|
|
171
|
+
headers={"User-Agent": _user_agent()},
|
|
172
|
+
)
|
|
173
|
+
if response.status_code != 200:
|
|
174
|
+
raise SetupFault(
|
|
175
|
+
f"error: couldn't start the device login (HTTP {response.status_code})\n"
|
|
176
|
+
" Check your network and try again: smartpipe auth login --headless"
|
|
177
|
+
)
|
|
178
|
+
record = as_record(response.json())
|
|
179
|
+
device_id = as_str(record.get("device_auth_id")) if record is not None else None
|
|
180
|
+
user_code = as_str(record.get("user_code")) if record is not None else None
|
|
181
|
+
if record is None or device_id is None or user_code is None:
|
|
182
|
+
raise SetupFault("error: the device login reply was malformed — try again")
|
|
183
|
+
raw_interval = as_str(record.get("interval")) or "5"
|
|
184
|
+
try:
|
|
185
|
+
interval = max(float(raw_interval), 1.0)
|
|
186
|
+
except ValueError:
|
|
187
|
+
interval = 5.0
|
|
188
|
+
return DeviceStart(
|
|
189
|
+
device_auth_id=device_id,
|
|
190
|
+
user_code=user_code,
|
|
191
|
+
interval_s=interval + 3.0, # the wire's polling safety margin
|
|
192
|
+
verify_url=f"{ISSUER}/codex/device",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def poll_device(
|
|
197
|
+
client: httpx.AsyncClient,
|
|
198
|
+
start: DeviceStart,
|
|
199
|
+
*,
|
|
200
|
+
sleep: object = None,
|
|
201
|
+
) -> OAuthCredential:
|
|
202
|
+
"""Poll until the user finishes in the browser; 403/404 mean "not yet"."""
|
|
203
|
+
do_sleep = asyncio.sleep if sleep is None else sleep
|
|
204
|
+
while True:
|
|
205
|
+
response = await client.post(
|
|
206
|
+
f"{ISSUER}/api/accounts/deviceauth/token",
|
|
207
|
+
json={"device_auth_id": start.device_auth_id, "user_code": start.user_code},
|
|
208
|
+
headers={"User-Agent": _user_agent()},
|
|
209
|
+
)
|
|
210
|
+
if response.status_code == 200:
|
|
211
|
+
record = as_record(response.json())
|
|
212
|
+
code = as_str(record.get("authorization_code")) if record is not None else None
|
|
213
|
+
verifier = as_str(record.get("code_verifier")) if record is not None else None
|
|
214
|
+
if record is None or code is None or verifier is None:
|
|
215
|
+
raise SetupFault("error: the device login reply was malformed — try again")
|
|
216
|
+
return await exchange_code(client, code, f"{ISSUER}/deviceauth/callback", verifier)
|
|
217
|
+
if response.status_code not in _DEVICE_PENDING:
|
|
218
|
+
raise SetupFault(
|
|
219
|
+
f"error: device login failed (HTTP {response.status_code})\n"
|
|
220
|
+
" Start over: smartpipe auth login --headless"
|
|
221
|
+
)
|
|
222
|
+
await do_sleep(start.interval_s) # type: ignore[operator]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def login_via_browser(
|
|
226
|
+
client: httpx.AsyncClient,
|
|
227
|
+
*,
|
|
228
|
+
open_browser: object = None,
|
|
229
|
+
announce: object = None,
|
|
230
|
+
port: int = CALLBACK_PORT,
|
|
231
|
+
) -> OAuthCredential:
|
|
232
|
+
"""The PKCE browser flow: local callback server, state check, code exchange.
|
|
233
|
+
|
|
234
|
+
The callback server is stdlib ``http.server`` on a daemon thread; the code
|
|
235
|
+
lands in an ``asyncio`` future via ``call_soon_threadsafe`` — same shutdown
|
|
236
|
+
discipline as the stdin pump (nothing can wedge exit).
|
|
237
|
+
"""
|
|
238
|
+
import sys
|
|
239
|
+
import threading
|
|
240
|
+
import webbrowser
|
|
241
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
242
|
+
|
|
243
|
+
do_open = webbrowser.open if open_browser is None else open_browser
|
|
244
|
+
pkce = generate_pkce()
|
|
245
|
+
state = _b64url(secrets.token_bytes(32))
|
|
246
|
+
redirect_uri = f"http://localhost:{port}/auth/callback"
|
|
247
|
+
loop = asyncio.get_running_loop()
|
|
248
|
+
code_future: asyncio.Future[str] = loop.create_future()
|
|
249
|
+
|
|
250
|
+
def resolve(value: str | None, error: str | None) -> None:
|
|
251
|
+
def apply() -> None:
|
|
252
|
+
if code_future.done():
|
|
253
|
+
return
|
|
254
|
+
if error is not None:
|
|
255
|
+
code_future.set_exception(SetupFault(f"error: login failed — {error}"))
|
|
256
|
+
else:
|
|
257
|
+
assert value is not None
|
|
258
|
+
code_future.set_result(value)
|
|
259
|
+
|
|
260
|
+
loop.call_soon_threadsafe(apply)
|
|
261
|
+
|
|
262
|
+
class Handler(BaseHTTPRequestHandler):
|
|
263
|
+
def do_GET(self) -> None:
|
|
264
|
+
from urllib.parse import parse_qs, urlparse
|
|
265
|
+
|
|
266
|
+
parsed = urlparse(self.path)
|
|
267
|
+
if parsed.path != "/auth/callback":
|
|
268
|
+
self._respond(404, "Not found")
|
|
269
|
+
return
|
|
270
|
+
query = parse_qs(parsed.query)
|
|
271
|
+
error = (query.get("error_description") or query.get("error") or [None])[0]
|
|
272
|
+
code = (query.get("code") or [None])[0]
|
|
273
|
+
returned_state = (query.get("state") or [None])[0]
|
|
274
|
+
if error is not None:
|
|
275
|
+
resolve(None, error)
|
|
276
|
+
self._respond(400, f"Login failed: {error}. You can close this window.")
|
|
277
|
+
return
|
|
278
|
+
if code is None or returned_state != state:
|
|
279
|
+
message = "invalid OAuth state" if code else "missing authorization code"
|
|
280
|
+
resolve(None, message)
|
|
281
|
+
self._respond(400, f"Login failed: {message}. You can close this window.")
|
|
282
|
+
return
|
|
283
|
+
resolve(code, None)
|
|
284
|
+
self._respond(200, "smartpipe is logged in. You can close this window.")
|
|
285
|
+
|
|
286
|
+
def _respond(self, status: int, text: str) -> None:
|
|
287
|
+
body = text.encode("utf-8")
|
|
288
|
+
self.send_response(status)
|
|
289
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
290
|
+
self.send_header("Content-Length", str(len(body)))
|
|
291
|
+
self.end_headers()
|
|
292
|
+
self.wfile.write(body)
|
|
293
|
+
|
|
294
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
|
295
|
+
del format, args # the CLI narrates; the server stays quiet
|
|
296
|
+
|
|
297
|
+
class LoopbackServer(HTTPServer):
|
|
298
|
+
# POSIX: rebind through TIME_WAIT so back-to-back logins work. Windows:
|
|
299
|
+
# SO_REUSEADDR means HIJACK a live port - a second login would silently
|
|
300
|
+
# steal the callback instead of erroring - so the flag stays off there.
|
|
301
|
+
allow_reuse_address = sys.platform != "win32"
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
# bind the v4 loopback explicitly: "localhost" resolution order varies by
|
|
305
|
+
# host (macOS CI resolves ::1 first) while browsers try both families
|
|
306
|
+
server = LoopbackServer(("127.0.0.1", port), Handler)
|
|
307
|
+
except OSError as exc:
|
|
308
|
+
raise SetupFault(
|
|
309
|
+
f"error: couldn't open the login callback port {port} ({exc.strerror or exc})\n"
|
|
310
|
+
" Something else is using it. Try the headless flow: smartpipe auth login --headless"
|
|
311
|
+
) from exc
|
|
312
|
+
threading.Thread(target=server.serve_forever, name="smartpipe-oauth", daemon=True).start()
|
|
313
|
+
try:
|
|
314
|
+
url = authorize_url(redirect_uri, pkce, state)
|
|
315
|
+
if announce is not None:
|
|
316
|
+
announce(url) # type: ignore[operator]
|
|
317
|
+
with contextlib.suppress(Exception): # a headless box just uses the printed URL
|
|
318
|
+
do_open(url) # type: ignore[operator]
|
|
319
|
+
try:
|
|
320
|
+
code = await asyncio.wait_for(code_future, timeout=_LOGIN_TIMEOUT_S)
|
|
321
|
+
except TimeoutError:
|
|
322
|
+
raise SetupFault(
|
|
323
|
+
"error: the login timed out after 5 minutes\n Run it again: smartpipe auth login"
|
|
324
|
+
) from None
|
|
325
|
+
return await exchange_code(client, code, redirect_uri, pkce.verifier)
|
|
326
|
+
finally:
|
|
327
|
+
server.shutdown()
|
|
328
|
+
server.server_close()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def _token_request(client: httpx.AsyncClient, form: dict[str, str]) -> object:
|
|
332
|
+
response = await client.post(
|
|
333
|
+
f"{ISSUER}/oauth/token",
|
|
334
|
+
data=form,
|
|
335
|
+
headers={"User-Agent": _user_agent()},
|
|
336
|
+
)
|
|
337
|
+
if response.status_code != 200:
|
|
338
|
+
raise SetupFault(
|
|
339
|
+
f"error: the login token request failed (HTTP {response.status_code})\n"
|
|
340
|
+
" Fix: smartpipe auth login"
|
|
341
|
+
)
|
|
342
|
+
return response.json()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _user_agent() -> str:
|
|
346
|
+
from smartpipe import __version__
|
|
347
|
+
|
|
348
|
+
return f"smartpipe/{__version__}"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _jwt_claims(token: str) -> dict[str, object] | None:
|
|
352
|
+
parts = token.split(".")
|
|
353
|
+
if len(parts) != 3:
|
|
354
|
+
return None
|
|
355
|
+
try:
|
|
356
|
+
padded = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
357
|
+
decoded = base64.urlsafe_b64decode(padded)
|
|
358
|
+
parsed: object = json.loads(decoded)
|
|
359
|
+
except (ValueError, json.JSONDecodeError):
|
|
360
|
+
return None
|
|
361
|
+
record = as_record(parsed)
|
|
362
|
+
return dict(record) if record is not None else None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _b64url(data: bytes) -> str:
|
|
366
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Model resolution chains (plan/architecture.md "Provider abstraction").
|
|
2
|
+
|
|
3
|
+
Chat: flag > SMARTPIPE_MODEL > config.model > ollama autodetect > NO_MODEL screen.
|
|
4
|
+
Embed: flag > SMARTPIPE_EMBED_MODEL > config.embed_model > nomic-embed-text.
|
|
5
|
+
|
|
6
|
+
The ollama probe is injected as a first-class async function so this module has
|
|
7
|
+
no I/O of its own — the container passes the real probe, tests pass a fake.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from smartpipe.cli import screens
|
|
16
|
+
from smartpipe.core.errors import SetupFault
|
|
17
|
+
from smartpipe.models.base import ModelRef, parse_model_ref
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
21
|
+
|
|
22
|
+
from smartpipe.config.store import Config
|
|
23
|
+
|
|
24
|
+
__all__ = ["Resolved", "resolve_chat_ref", "resolve_embed_ref"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _default_embed_model() -> str:
|
|
28
|
+
"""D44: on-device fastembed when its wheels exist (all pythons < 3.14
|
|
29
|
+
today); the Ollama default otherwise — same model family either way."""
|
|
30
|
+
from importlib.util import find_spec
|
|
31
|
+
|
|
32
|
+
if find_spec("fastembed") is not None:
|
|
33
|
+
return "local/nomic-embed-text-v1.5"
|
|
34
|
+
return "nomic-embed-text"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Resolved:
|
|
39
|
+
ref: ModelRef
|
|
40
|
+
notice: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def resolve_chat_ref(
|
|
44
|
+
flag: str | None,
|
|
45
|
+
env: Mapping[str, str],
|
|
46
|
+
config: Config,
|
|
47
|
+
probe: Callable[[], Awaitable[tuple[str, ...] | None]],
|
|
48
|
+
) -> Resolved:
|
|
49
|
+
explicit = _first_configured(flag, env.get("SMARTPIPE_MODEL"), config.model)
|
|
50
|
+
if explicit is not None:
|
|
51
|
+
return Resolved(parse_model_ref(explicit))
|
|
52
|
+
names = await probe()
|
|
53
|
+
chosen = _first_chat_model(names)
|
|
54
|
+
if chosen is None:
|
|
55
|
+
raise SetupFault(screens.NO_MODEL)
|
|
56
|
+
notice = (
|
|
57
|
+
f"using ollama/{chosen} (no model configured — "
|
|
58
|
+
f"pin one with: smartpipe config model ollama/{chosen})"
|
|
59
|
+
)
|
|
60
|
+
return Resolved(ModelRef(provider="ollama", name=chosen), notice=notice)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_embed_ref(flag: str | None, env: Mapping[str, str], config: Config) -> ModelRef:
|
|
64
|
+
explicit = _first_configured(flag, env.get("SMARTPIPE_EMBED_MODEL"), config.embed_model)
|
|
65
|
+
return parse_model_ref(explicit if explicit is not None else _default_embed_model())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _first_configured(*candidates: str | None) -> str | None:
|
|
69
|
+
for candidate in candidates:
|
|
70
|
+
if candidate is not None and candidate.strip():
|
|
71
|
+
return candidate.strip()
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _first_chat_model(names: tuple[str, ...] | None) -> str | None:
|
|
76
|
+
if not names:
|
|
77
|
+
return None
|
|
78
|
+
return next((name for name in names if "embed" not in name.lower()), None)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Backoff for transient provider failures (429/5xx/connection drops).
|
|
2
|
+
|
|
3
|
+
The sleep and jitter sources are injectable so tests run on a fake clock.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import random
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
|
|
16
|
+
__all__ = ["RetryPolicy", "with_retries"]
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
# Ceiling for a server-supplied delay hint (Retry-After). max_delay keeps governing
|
|
21
|
+
# only the computed backoff — a server may legitimately ask for more than 8 s;
|
|
22
|
+
# 60 s is where a request stops being a hint and starts being abuse.
|
|
23
|
+
_HINT_CEILING_SECONDS = 60.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class RetryPolicy:
|
|
28
|
+
attempts: int = 3
|
|
29
|
+
base_delay: float = 0.5
|
|
30
|
+
max_delay: float = 8.0
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if self.attempts < 1:
|
|
34
|
+
raise ValueError(f"RetryPolicy.attempts must be >= 1, got {self.attempts}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _default_sleep(seconds: float) -> None:
|
|
38
|
+
await asyncio.sleep(seconds)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def with_retries(
|
|
42
|
+
policy: RetryPolicy,
|
|
43
|
+
operation: Callable[[], Awaitable[T]],
|
|
44
|
+
*,
|
|
45
|
+
is_retryable: Callable[[Exception], bool],
|
|
46
|
+
sleep: Callable[[float], Awaitable[None]] | None = None,
|
|
47
|
+
rand: Callable[[], float] | None = None,
|
|
48
|
+
delay_hint: Callable[[Exception], float | None] | None = None,
|
|
49
|
+
) -> T:
|
|
50
|
+
"""Retry with jittered exponential backoff, unless the failure carries its own
|
|
51
|
+
delay (``delay_hint``, e.g. a ``Retry-After`` header) — the server's number is
|
|
52
|
+
authoritative: used as-is, no jitter, capped only by the 60 s abuse ceiling."""
|
|
53
|
+
do_sleep = _default_sleep if sleep is None else sleep
|
|
54
|
+
do_rand = random.random if rand is None else rand
|
|
55
|
+
for attempt in range(1, policy.attempts + 1):
|
|
56
|
+
try:
|
|
57
|
+
return await operation()
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
if attempt == policy.attempts or not is_retryable(exc):
|
|
60
|
+
raise
|
|
61
|
+
hinted = delay_hint(exc) if delay_hint is not None else None
|
|
62
|
+
if hinted is not None:
|
|
63
|
+
await do_sleep(min(hinted, _HINT_CEILING_SECONDS))
|
|
64
|
+
continue
|
|
65
|
+
raw = policy.base_delay * 2 ** (attempt - 1)
|
|
66
|
+
# jitter in [0.5x, 1.5x), then a HARD cap: max_delay bounds the
|
|
67
|
+
# actual wait, so jitter can never push the sleep above it.
|
|
68
|
+
await do_sleep(min(raw * (0.5 + do_rand()), policy.max_delay))
|
|
69
|
+
raise AssertionError("unreachable: the loop always returns or raises") # pragma: no cover
|
smartpipe/models/stt.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Remote speech-to-text (D39/05): the ``stt-model`` role's wire.
|
|
2
|
+
|
|
3
|
+
A configured transcriber signals wanting VERBATIM text — LLM hearing
|
|
4
|
+
paraphrases; whisper transcribes. v1 implements the openai wire
|
|
5
|
+
(``/v1/audio/transcriptions``); the role key accepts ``provider/model`` so
|
|
6
|
+
more wires can land behind the same seam.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from smartpipe.core.errors import ItemError, SetupFault
|
|
17
|
+
from smartpipe.io import metering
|
|
18
|
+
from smartpipe.models.http_support import is_retryable_http, retry_after_seconds
|
|
19
|
+
from smartpipe.models.retry import RetryPolicy, with_retries
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from smartpipe.models.base import AudioData, ModelRef
|
|
23
|
+
|
|
24
|
+
__all__ = ["RemoteTranscriber"]
|
|
25
|
+
|
|
26
|
+
_EXTENSIONS = {
|
|
27
|
+
"audio/mpeg": "mp3",
|
|
28
|
+
"audio/mp4": "m4a",
|
|
29
|
+
"audio/wav": "wav",
|
|
30
|
+
"audio/x-wav": "wav",
|
|
31
|
+
"audio/ogg": "ogg",
|
|
32
|
+
"audio/webm": "webm",
|
|
33
|
+
"audio/flac": "flac",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class RemoteTranscriber:
|
|
39
|
+
ref: ModelRef
|
|
40
|
+
client: httpx.AsyncClient
|
|
41
|
+
api_key: str
|
|
42
|
+
base_url: str = "https://api.openai.com"
|
|
43
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
44
|
+
|
|
45
|
+
async def transcribe(self, audio: AudioData) -> str:
|
|
46
|
+
extension = _EXTENSIONS.get(audio.mime, "mp3")
|
|
47
|
+
files = {"file": (f"audio.{extension}", audio.data, audio.mime)}
|
|
48
|
+
data = {"model": self.ref.name, "response_format": "text"}
|
|
49
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
50
|
+
metering.add_request_media((audio,))
|
|
51
|
+
|
|
52
|
+
async def attempt() -> str:
|
|
53
|
+
response = await self.client.post(
|
|
54
|
+
f"{self.base_url}/v1/audio/transcriptions",
|
|
55
|
+
files=files,
|
|
56
|
+
data=data,
|
|
57
|
+
headers=headers,
|
|
58
|
+
)
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
return response.text
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
return (
|
|
64
|
+
await with_retries(
|
|
65
|
+
self.retry,
|
|
66
|
+
attempt,
|
|
67
|
+
is_retryable=is_retryable_http,
|
|
68
|
+
delay_hint=retry_after_seconds,
|
|
69
|
+
)
|
|
70
|
+
).strip()
|
|
71
|
+
except httpx.HTTPStatusError as exc:
|
|
72
|
+
status = exc.response.status_code
|
|
73
|
+
if status in (401, 403):
|
|
74
|
+
raise SetupFault(
|
|
75
|
+
"error: the STT wire rejected the API key\n"
|
|
76
|
+
" Remote transcription uses OPENAI_API_KEY — set it and retry."
|
|
77
|
+
) from exc
|
|
78
|
+
raise ItemError(f"stt error {status}: {exc.response.text[:200]}") from exc
|
|
79
|
+
except httpx.HTTPError as exc:
|
|
80
|
+
raise ItemError(f"stt request failed ({exc})") from exc
|