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.
Files changed (126) hide show
  1. smartpipe/__init__.py +6 -0
  2. smartpipe/__main__.py +8 -0
  3. smartpipe/assets/probe.png +0 -0
  4. smartpipe/assets/probe.txt +1 -0
  5. smartpipe/assets/probe.wav +0 -0
  6. smartpipe/cli/__init__.py +5 -0
  7. smartpipe/cli/auth_cmd.py +78 -0
  8. smartpipe/cli/cache_cmd.py +60 -0
  9. smartpipe/cli/chart_cmd.py +60 -0
  10. smartpipe/cli/cite_cmd.py +26 -0
  11. smartpipe/cli/cluster_cmd.py +102 -0
  12. smartpipe/cli/completions.py +91 -0
  13. smartpipe/cli/config_cmd.py +234 -0
  14. smartpipe/cli/diff_cmd.py +100 -0
  15. smartpipe/cli/distinct_cmd.py +94 -0
  16. smartpipe/cli/doctor_cmd.py +207 -0
  17. smartpipe/cli/echo_cmd.py +44 -0
  18. smartpipe/cli/embed_cmd.py +80 -0
  19. smartpipe/cli/extend_cmd.py +138 -0
  20. smartpipe/cli/filter_cmd.py +87 -0
  21. smartpipe/cli/getschema_cmd.py +31 -0
  22. smartpipe/cli/input_options.py +113 -0
  23. smartpipe/cli/interrupts.py +92 -0
  24. smartpipe/cli/join_cmd.py +162 -0
  25. smartpipe/cli/map_cmd.py +150 -0
  26. smartpipe/cli/outliers_cmd.py +82 -0
  27. smartpipe/cli/probe_cmd.py +223 -0
  28. smartpipe/cli/reduce_cmd.py +129 -0
  29. smartpipe/cli/root.py +281 -0
  30. smartpipe/cli/run_cmd.py +136 -0
  31. smartpipe/cli/sample_cmd.py +35 -0
  32. smartpipe/cli/schema_cmd.py +75 -0
  33. smartpipe/cli/screens.py +231 -0
  34. smartpipe/cli/sem_file.py +453 -0
  35. smartpipe/cli/sort_cmd.py +33 -0
  36. smartpipe/cli/split_cmd.py +76 -0
  37. smartpipe/cli/summarize_cmd.py +37 -0
  38. smartpipe/cli/top_k_cmd.py +97 -0
  39. smartpipe/cli/usage_cmd.py +66 -0
  40. smartpipe/cli/where_cmd.py +36 -0
  41. smartpipe/config/__init__.py +5 -0
  42. smartpipe/config/credentials.py +118 -0
  43. smartpipe/config/display.py +70 -0
  44. smartpipe/config/doctor.py +58 -0
  45. smartpipe/config/paths.py +38 -0
  46. smartpipe/config/store.py +252 -0
  47. smartpipe/container.py +439 -0
  48. smartpipe/core/__init__.py +5 -0
  49. smartpipe/core/errors.py +57 -0
  50. smartpipe/core/jsontools.py +56 -0
  51. smartpipe/engine/__init__.py +9 -0
  52. smartpipe/engine/aggregate.py +234 -0
  53. smartpipe/engine/blocking.py +44 -0
  54. smartpipe/engine/chart.py +143 -0
  55. smartpipe/engine/chunking.py +161 -0
  56. smartpipe/engine/clustering.py +94 -0
  57. smartpipe/engine/predicate.py +330 -0
  58. smartpipe/engine/prompts.py +601 -0
  59. smartpipe/engine/ranking.py +62 -0
  60. smartpipe/engine/runner.py +175 -0
  61. smartpipe/engine/schema.py +208 -0
  62. smartpipe/engine/schema_dsl.py +144 -0
  63. smartpipe/engine/tally.py +53 -0
  64. smartpipe/engine/timebin.py +67 -0
  65. smartpipe/engine/units.py +43 -0
  66. smartpipe/engine/windows.py +78 -0
  67. smartpipe/io/__init__.py +9 -0
  68. smartpipe/io/diagnostics.py +148 -0
  69. smartpipe/io/inputs.py +44 -0
  70. smartpipe/io/items.py +149 -0
  71. smartpipe/io/leaderboard.py +52 -0
  72. smartpipe/io/metering.py +180 -0
  73. smartpipe/io/progress.py +140 -0
  74. smartpipe/io/readers.py +455 -0
  75. smartpipe/io/text.py +40 -0
  76. smartpipe/io/tty.py +88 -0
  77. smartpipe/io/usage.py +214 -0
  78. smartpipe/io/writers.py +340 -0
  79. smartpipe/models/__init__.py +5 -0
  80. smartpipe/models/anthropic_adapter.py +149 -0
  81. smartpipe/models/base.py +170 -0
  82. smartpipe/models/budget.py +94 -0
  83. smartpipe/models/cache.py +132 -0
  84. smartpipe/models/gemini_native.py +196 -0
  85. smartpipe/models/http_support.py +77 -0
  86. smartpipe/models/jina.py +98 -0
  87. smartpipe/models/local_embed.py +76 -0
  88. smartpipe/models/ollama.py +204 -0
  89. smartpipe/models/openai_codex.py +237 -0
  90. smartpipe/models/openai_compat.py +328 -0
  91. smartpipe/models/openai_oauth.py +366 -0
  92. smartpipe/models/resolve.py +78 -0
  93. smartpipe/models/retry.py +69 -0
  94. smartpipe/models/stt.py +80 -0
  95. smartpipe/models/windows.py +116 -0
  96. smartpipe/parsing/__init__.py +10 -0
  97. smartpipe/parsing/detect.py +178 -0
  98. smartpipe/parsing/extract.py +582 -0
  99. smartpipe/py.typed +0 -0
  100. smartpipe/verbs/__init__.py +5 -0
  101. smartpipe/verbs/chart.py +153 -0
  102. smartpipe/verbs/cluster.py +220 -0
  103. smartpipe/verbs/common.py +468 -0
  104. smartpipe/verbs/convert.py +251 -0
  105. smartpipe/verbs/diff.py +206 -0
  106. smartpipe/verbs/distinct.py +164 -0
  107. smartpipe/verbs/embed.py +166 -0
  108. smartpipe/verbs/extend.py +180 -0
  109. smartpipe/verbs/filter.py +191 -0
  110. smartpipe/verbs/getschema.py +135 -0
  111. smartpipe/verbs/join.py +413 -0
  112. smartpipe/verbs/map.py +315 -0
  113. smartpipe/verbs/outliers.py +119 -0
  114. smartpipe/verbs/reduce.py +428 -0
  115. smartpipe/verbs/sample.py +52 -0
  116. smartpipe/verbs/sortverb.py +63 -0
  117. smartpipe/verbs/split.py +333 -0
  118. smartpipe/verbs/summarize.py +60 -0
  119. smartpipe/verbs/top_k.py +318 -0
  120. smartpipe/verbs/where.py +47 -0
  121. smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
  122. smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
  123. smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
  124. smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
  125. smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
  126. 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
@@ -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