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,237 @@
1
+ """The ChatGPT-plan chat adapter (plan/decisions.md D19).
2
+
3
+ A login token doesn't speak the platform ``/v1/chat/completions``; it speaks the
4
+ **Responses API** at ``chatgpt.com/backend-api/codex/responses`` — streamed SSE,
5
+ Codex model family, ``ChatGPT-Account-Id`` + ``originator`` headers. Transcribed
6
+ from opencode's working wire (context/opencode). Tokens self-refresh (single-flight,
7
+ 60 s skew) and rotations persist to the credential store; a 401 gets one refresh
8
+ and one retry before the "login expired" screen.
9
+
10
+ No embeddings exist on this wire — the container says so instead of pretending.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import base64
17
+ import json
18
+ import time
19
+ import uuid
20
+ from dataclasses import dataclass, field
21
+ from typing import TYPE_CHECKING
22
+
23
+ import httpx
24
+
25
+ from smartpipe.config.credentials import OAuthCredential, load_oauth, save_oauth
26
+ from smartpipe.core.errors import ItemError, SetupFault
27
+ from smartpipe.core.jsontools import as_items, as_record, as_str
28
+ from smartpipe.io import metering
29
+ from smartpipe.models.openai_oauth import refresh_tokens
30
+
31
+ if TYPE_CHECKING:
32
+ from pathlib import Path
33
+
34
+ from smartpipe.models.base import CompletionRequest, ModelRef
35
+
36
+ __all__ = ["CODEX_ENDPOINT", "LOGIN_EXPIRED", "CodexChatModel", "accumulate_sse", "build_payload"]
37
+
38
+ CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
39
+ _SKEW_MS = 60_000 # refresh when within a minute of expiry — no mid-call surprises
40
+
41
+ LOGIN_EXPIRED = (
42
+ "error: the ChatGPT login has expired and couldn't be refreshed\n Fix: smartpipe auth login"
43
+ )
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class CodexChatModel:
48
+ """Mutable by design (documented Spinner-style exception): the credential
49
+ rotates underneath us and the session id is per-run state."""
50
+
51
+ ref: ModelRef
52
+ client: httpx.AsyncClient
53
+ store_path: Path
54
+ credential: OAuthCredential
55
+ session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
56
+ _refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
57
+
58
+ async def complete(self, request: CompletionRequest) -> str:
59
+ await self._ensure_fresh()
60
+ response = await self._post(request)
61
+ if response.status_code == 401: # one refresh, one retry, then be honest
62
+ await self._force_refresh()
63
+ response = await self._post(request)
64
+ if response.status_code == 401:
65
+ raise SetupFault(LOGIN_EXPIRED)
66
+ if response.status_code == 404: # D18: dooms every item — stop at the first
67
+ from smartpipe.cli import screens
68
+
69
+ raise SetupFault(screens.cloud_model_missing(self.ref.name, "the ChatGPT wire"))
70
+ if response.status_code != 200:
71
+ detail = _detail(response)
72
+ if response.status_code == 400 and (
73
+ "response_format" in detail or "json_schema" in detail
74
+ ):
75
+ from smartpipe.cli import screens
76
+
77
+ raise SetupFault(screens.schema_rejected("the ChatGPT wire", detail))
78
+ raise ItemError(f"chatgpt wire error {response.status_code}: {detail}")
79
+ text = accumulate_sse(response.text)
80
+ if not text:
81
+ raise ItemError("the model returned an empty reply")
82
+ return text
83
+
84
+ async def _post(self, request: CompletionRequest) -> httpx.Response:
85
+ headers = {
86
+ "authorization": f"Bearer {self.credential.access}",
87
+ "originator": "smartpipe",
88
+ "User-Agent": _user_agent(),
89
+ "session-id": self.session_id,
90
+ "Accept": "text/event-stream",
91
+ }
92
+ if self.credential.account_id is not None:
93
+ headers["ChatGPT-Account-Id"] = self.credential.account_id
94
+ return await self.client.post(
95
+ CODEX_ENDPOINT, json=build_payload(self.ref.name, request), headers=headers
96
+ )
97
+
98
+ async def _ensure_fresh(self) -> None:
99
+ if self.credential.expires_ms - _SKEW_MS > time.time() * 1000:
100
+ return
101
+ await self._force_refresh()
102
+
103
+ async def _force_refresh(self) -> None:
104
+ async with self._refresh_lock: # single-flight across concurrent workers
105
+ stored = load_oauth(self.store_path, "openai")
106
+ if stored is not None and stored.access != self.credential.access:
107
+ self.credential = stored # another worker/process already rotated
108
+ if self.credential.expires_ms - _SKEW_MS > time.time() * 1000:
109
+ return
110
+ try:
111
+ rotated = await refresh_tokens(self.client, self.credential.refresh)
112
+ except SetupFault as exc:
113
+ raise SetupFault(LOGIN_EXPIRED) from exc
114
+ self.credential = rotated
115
+ save_oauth(self.store_path, "openai", rotated)
116
+
117
+
118
+ def build_payload(model: str, request: CompletionRequest) -> dict[str, object]:
119
+ content: list[dict[str, object]] = [{"type": "input_text", "text": request.user}]
120
+ from smartpipe.models.base import AudioData, ImageData
121
+
122
+ if any(isinstance(part, AudioData) for part in request.media):
123
+ # audio on the ChatGPT login wire is unverified — fail free, name the fixes
124
+ raise ItemError(
125
+ "this model can't hear audio — try an audio model "
126
+ "(voxtral, gemini) — smartpipe transcribes locally otherwise"
127
+ )
128
+ for part in request.media:
129
+ if isinstance(part, ImageData):
130
+ data_uri = f"data:{part.mime};base64,{base64.b64encode(part.data).decode()}"
131
+ content.append({"type": "input_image", "image_url": data_uri})
132
+ payload: dict[str, object] = {
133
+ "model": model,
134
+ "input": [{"role": "user", "content": content}],
135
+ "stream": True, # the codex wire streams; we accumulate to a final string
136
+ "store": False, # smartpipe is stateless — nothing parked server-side either
137
+ }
138
+ if request.system is not None:
139
+ payload["instructions"] = request.system
140
+ if request.json_schema is not None:
141
+ from smartpipe.engine.schema import is_strict_compatible
142
+
143
+ schema = dict(request.json_schema)
144
+ payload["text"] = {
145
+ "format": {
146
+ "type": "json_schema",
147
+ "name": "smartpipe_output",
148
+ "schema": schema,
149
+ # same 400 hazard as the platform wire: only claim strict when true
150
+ "strict": is_strict_compatible(schema),
151
+ }
152
+ }
153
+ return payload
154
+
155
+
156
+ def accumulate_sse(body: str) -> str:
157
+ """Fold the SSE stream to the final text: sum ``response.output_text.delta``
158
+ events, preferring the terminal ``response.completed`` payload when present."""
159
+ deltas: list[str] = []
160
+ completed: str | None = None
161
+ for line in body.splitlines():
162
+ if not line.startswith("data:"):
163
+ continue
164
+ raw = line[len("data:") :].strip()
165
+ if not raw or raw == "[DONE]":
166
+ continue
167
+ try:
168
+ parsed: object = json.loads(raw)
169
+ except json.JSONDecodeError:
170
+ continue
171
+ event = as_record(parsed)
172
+ if event is None:
173
+ continue
174
+ kind = as_str(event.get("type"))
175
+ if kind == "response.output_text.delta":
176
+ delta = as_str(event.get("delta"))
177
+ if delta is not None:
178
+ deltas.append(delta)
179
+ elif kind == "response.failed":
180
+ raise ItemError(f"the model reported a failure: {_failure_detail(dict(event))}")
181
+ elif kind == "response.completed":
182
+ completed = _completed_text(event) or completed
183
+ _meter_completed(event)
184
+ return completed if completed is not None else "".join(deltas)
185
+
186
+
187
+ def _completed_text(event: dict[str, object] | object) -> str | None:
188
+ record = as_record(event)
189
+ response = as_record(record.get("response")) if record is not None else None
190
+ output = as_items(response.get("output")) if response is not None else None
191
+ if output is None:
192
+ return None
193
+ parts: list[str] = []
194
+ for item in output:
195
+ entry = as_record(item)
196
+ if entry is None or entry.get("type") != "message":
197
+ continue
198
+ for chunk in as_items(entry.get("content")) or ():
199
+ piece = as_record(chunk)
200
+ if piece is None:
201
+ continue
202
+ text = as_str(piece.get("text"))
203
+ if text is not None:
204
+ parts.append(text)
205
+ return "".join(parts) or None
206
+
207
+
208
+ def _failure_detail(event: dict[str, object]) -> str:
209
+ response = as_record(event.get("response"))
210
+ error = as_record(response.get("error")) if response is not None else None
211
+ message = as_str(error.get("message")) if error is not None else None
212
+ return message or "no detail"
213
+
214
+
215
+ def _detail(response: httpx.Response) -> str:
216
+ text = response.text[:200].strip()
217
+ return text or "no detail"
218
+
219
+
220
+ def _user_agent() -> str:
221
+ from smartpipe import __version__
222
+
223
+ return f"smartpipe/{__version__}"
224
+
225
+
226
+ def _meter_completed(event: object) -> None:
227
+ record = as_record(event)
228
+ response = as_record(record.get("response")) if record is not None else None
229
+ usage = as_record(response.get("usage")) if response is not None else None
230
+ if usage is None:
231
+ return
232
+ tokens_in = usage.get("input_tokens")
233
+ tokens_out = usage.get("output_tokens")
234
+ metering.add_tokens(
235
+ tokens_in=tokens_in if isinstance(tokens_in, int) else 0,
236
+ tokens_out=tokens_out if isinstance(tokens_out, int) else 0,
237
+ )
@@ -0,0 +1,328 @@
1
+ """OpenAI-compatible cloud adapter (OpenAI itself, or any compatible endpoint
2
+ via ``SMARTPIPE_OPENAI_BASE_URL`` — Groq, Mistral, OpenRouter, llama.cpp, …).
3
+
4
+ Key rule (plan/stages/stage-02): a missing API key fails *before* any request
5
+ leaves the machine, with the screen that names the fix.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING, assert_never
13
+
14
+ import httpx
15
+
16
+ from smartpipe.cli import screens
17
+ from smartpipe.core.errors import ItemError, SetupFault
18
+ from smartpipe.core.jsontools import as_float_vector, as_items, as_record, as_str, record_at
19
+ from smartpipe.engine.schema import is_strict_compatible
20
+ from smartpipe.io import metering
21
+ from smartpipe.models.base import AudioData, ImageData, VideoData
22
+ from smartpipe.models.http_support import is_retryable_http, retry_after_seconds
23
+ from smartpipe.models.retry import RetryPolicy, with_retries
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Mapping, Sequence
27
+
28
+ from smartpipe.models.base import CompletionRequest, ModelRef
29
+
30
+ __all__ = [
31
+ "DEFAULT_BASE_URL",
32
+ "GEMINI_WIRE",
33
+ "MISTRAL_WIRE",
34
+ "OPENAI_WIRE",
35
+ "OPENROUTER_WIRE",
36
+ "OpenAIChatModel",
37
+ "OpenAIEmbeddingModel",
38
+ "WireConfig",
39
+ "require_api_key",
40
+ "resolve_base_url",
41
+ ]
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class WireConfig:
46
+ """Everything provider-specific about an OpenAI-wire endpoint — the adapter
47
+ itself is one; providers differ only in these strings."""
48
+
49
+ provider: str
50
+ display: str # how screens name the provider ("OpenAI", "Mistral")
51
+ default_base_url: str
52
+ base_url_env: str
53
+ key_env: str
54
+ key_hint: str # the copy-pasteable key shape in the missing-key screen
55
+ key_note: str = "add it to your shell profile to persist"
56
+
57
+
58
+ OPENAI_WIRE = WireConfig(
59
+ provider="openai",
60
+ display="OpenAI",
61
+ default_base_url="https://api.openai.com",
62
+ base_url_env="SMARTPIPE_OPENAI_BASE_URL",
63
+ key_env="OPENAI_API_KEY",
64
+ key_hint="sk-...",
65
+ )
66
+
67
+ MISTRAL_WIRE = WireConfig(
68
+ provider="mistral",
69
+ display="Mistral",
70
+ default_base_url="https://api.mistral.ai",
71
+ base_url_env="SMARTPIPE_MISTRAL_BASE_URL",
72
+ key_env="MISTRAL_API_KEY",
73
+ key_hint="...",
74
+ key_note="create one at console.mistral.ai",
75
+ )
76
+
77
+ GEMINI_WIRE = WireConfig(
78
+ provider="gemini",
79
+ display="Gemini",
80
+ # live-scouted: the compat endpoint tolerates our /v1/... path shape
81
+ default_base_url="https://generativelanguage.googleapis.com/v1beta/openai",
82
+ base_url_env="SMARTPIPE_GEMINI_BASE_URL",
83
+ key_env="GEMINI_API_KEY",
84
+ key_hint="...",
85
+ key_note="create one at aistudio.google.com",
86
+ )
87
+
88
+ OPENROUTER_WIRE = WireConfig(
89
+ provider="openrouter",
90
+ display="OpenRouter",
91
+ default_base_url="https://openrouter.ai/api",
92
+ base_url_env="SMARTPIPE_OPENROUTER_BASE_URL",
93
+ key_env="OPENROUTER_API_KEY",
94
+ key_hint="sk-or-...",
95
+ key_note="create one at openrouter.ai/keys",
96
+ )
97
+
98
+ DEFAULT_BASE_URL = OPENAI_WIRE.default_base_url
99
+
100
+
101
+ def resolve_base_url(env: Mapping[str, str], wire: WireConfig = OPENAI_WIRE) -> str:
102
+ return env.get(wire.base_url_env, "").strip().rstrip("/") or wire.default_base_url
103
+
104
+
105
+ def require_api_key(env: Mapping[str, str], model: str, wire: WireConfig = OPENAI_WIRE) -> str:
106
+ key = env.get(wire.key_env, "").strip()
107
+ if not key:
108
+ raise SetupFault(
109
+ screens.missing_api_key(
110
+ model, wire.display, wire.key_env, wire.key_hint, note=wire.key_note
111
+ )
112
+ )
113
+ return key
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class OpenAIChatModel:
118
+ ref: ModelRef
119
+ client: httpx.AsyncClient
120
+ base_url: str
121
+ api_key: str
122
+ retry: RetryPolicy = field(default_factory=RetryPolicy)
123
+ wire: WireConfig = OPENAI_WIRE # names the right env vars in error screens
124
+
125
+ async def complete(self, request: CompletionRequest) -> str:
126
+ messages = [
127
+ *(
128
+ [{"role": "system", "content": request.system}]
129
+ if request.system is not None
130
+ else []
131
+ ),
132
+ {"role": "user", "content": _user_content(request)},
133
+ ]
134
+ payload: dict[str, object] = {
135
+ "model": self.ref.name,
136
+ "messages": messages,
137
+ "temperature": request.temperature, # reproducible by default (D36)
138
+ }
139
+ if request.json_schema is not None:
140
+ schema = dict(request.json_schema)
141
+ payload["response_format"] = {
142
+ "type": "json_schema",
143
+ "json_schema": {
144
+ "name": "smartpipe_output",
145
+ "schema": schema,
146
+ # claiming strict for a schema with optional fields draws a 400;
147
+ # non-strict stays schema-guided, validate_and_coerce is the backstop
148
+ "strict": is_strict_compatible(schema),
149
+ },
150
+ }
151
+ metering.add_request_media(request.media)
152
+ try:
153
+ data = await _post(self, "/v1/chat/completions", payload, has_media=bool(request.media))
154
+ except ItemError as exc:
155
+ # o-series models reject explicit temperature — strip and retry once
156
+ # (capability by attempt, D36; no model-name sniffing)
157
+ if "temperature" not in str(exc) or "temperature" not in payload:
158
+ raise
159
+ payload.pop("temperature")
160
+ data = await _post(self, "/v1/chat/completions", payload, has_media=bool(request.media))
161
+ record = as_record(data)
162
+ choices = as_items(record.get("choices")) if record is not None else None
163
+ first = record_at(choices[0], "message") if choices else None
164
+ content = as_str(first.get("content")) if first is not None else None
165
+ if content is None:
166
+ raise ItemError(f"{self.ref.provider} returned an unexpected reply shape")
167
+ _meter_chat_usage(record)
168
+ return content
169
+
170
+
171
+ @dataclass(frozen=True, slots=True)
172
+ class OpenAIEmbeddingModel:
173
+ ref: ModelRef
174
+ client: httpx.AsyncClient
175
+ base_url: str
176
+ api_key: str
177
+ retry: RetryPolicy = field(default_factory=RetryPolicy)
178
+ wire: WireConfig = OPENAI_WIRE
179
+
180
+ async def embed(self, texts: Sequence[str]) -> tuple[tuple[float, ...], ...]:
181
+ payload: dict[str, object] = {"model": self.ref.name, "input": list(texts)}
182
+ data = await _post(self, "/v1/embeddings", payload)
183
+ record = as_record(data)
184
+ rows = as_items(record.get("data")) if record is not None else None
185
+ if rows is None:
186
+ raise ItemError("embedding endpoint returned an unexpected shape")
187
+ indexed: list[tuple[int, tuple[float, ...]]] = []
188
+ for position, row in enumerate(rows):
189
+ entry = as_record(row)
190
+ vector = as_float_vector(entry.get("embedding")) if entry is not None else None
191
+ if entry is None or vector is None:
192
+ raise ItemError("embedding endpoint returned an unexpected shape")
193
+ index = entry.get("index")
194
+ # live-caught: Gemini's compat endpoint omits "index" — arrival order then
195
+ indexed.append((index if isinstance(index, int) else position, vector))
196
+ _meter_chat_usage(record)
197
+ return tuple(vector for _, vector in sorted(indexed))
198
+
199
+
200
+ async def _post(
201
+ model: OpenAIChatModel | OpenAIEmbeddingModel,
202
+ path: str,
203
+ payload: Mapping[str, object],
204
+ *,
205
+ has_media: bool = False,
206
+ ) -> object:
207
+ headers = {"Authorization": f"Bearer {model.api_key}"}
208
+
209
+ async def attempt() -> object:
210
+ response = await model.client.post(f"{model.base_url}{path}", json=payload, headers=headers)
211
+ response.raise_for_status()
212
+ return response.json()
213
+
214
+ try:
215
+ return await with_retries(
216
+ model.retry, attempt, is_retryable=is_retryable_http, delay_hint=retry_after_seconds
217
+ )
218
+ except httpx.HTTPStatusError as exc:
219
+ status = exc.response.status_code
220
+ if status in (401, 403):
221
+ reason = _detail(exc.response)
222
+ scoped = "scope" in reason.lower() or "permission" in reason.lower()
223
+ if scoped and has_media:
224
+ # a RESTRICTED key refusing a media request is a capability
225
+ # statement, not a dead key (text works) — per-item, so the
226
+ # ladders and skip machinery take it from here (D43c)
227
+ raise ItemError(
228
+ f"this key can't send media ({reason[:80]}) — grant the "
229
+ "scope or use an unrestricted key"
230
+ ) from exc
231
+ hint = (
232
+ " This key is RESTRICTED — grant the missing scope (or use an\n"
233
+ " unrestricted project key) in your provider console."
234
+ if scoped
235
+ else f" Fix: check the key, or the endpoint in {model.wire.base_url_env}."
236
+ )
237
+ raise SetupFault(
238
+ f"error: the API key for '{model.ref.name}' was rejected "
239
+ f"({status})\n"
240
+ f" The endpoint answered but didn't accept {model.wire.key_env}.\n"
241
+ f" It said: {reason[:160]}\n" + hint
242
+ ) from exc
243
+ detail = _detail(exc.response)
244
+ # D18: failures that doom every item identically stop the run at the first
245
+ if status == 404:
246
+ raise SetupFault(screens.cloud_model_missing(model.ref.name, _host(model))) from exc
247
+ if status == 400 and ("response_format" in detail or "json_schema" in detail):
248
+ raise SetupFault(screens.schema_rejected(_host(model), detail)) from exc
249
+ raise ItemError(f"{model.ref.provider} error {status}: {detail}") from exc
250
+ except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
251
+ # ConnectTimeout is a TimeoutException, not a ConnectError — both mean
252
+ # "couldn't establish a connection", so both map to the same screen.
253
+ raise SetupFault(
254
+ f"error: can't reach {model.base_url} ({exc})\n"
255
+ f" The model '{model.ref}' needs that endpoint.\n"
256
+ f" Check your network, or {model.wire.base_url_env} if you pointed\n"
257
+ " smartpipe elsewhere."
258
+ ) from exc
259
+ except httpx.HTTPError as exc:
260
+ raise ItemError(f"request to {model.base_url} failed: {exc}") from exc
261
+
262
+
263
+ def _host(model: OpenAIChatModel | OpenAIEmbeddingModel) -> str:
264
+ return model.base_url.removeprefix("https://").removeprefix("http://")
265
+
266
+
267
+ def _detail(response: httpx.Response) -> str:
268
+ try:
269
+ record = as_record(response.json())
270
+ except ValueError:
271
+ record = None
272
+ error = record_at(record, "error") if record is not None else None
273
+ message = as_str(error.get("message")) if error is not None else None
274
+ return message if message is not None else response.text[:200].strip() or "no detail"
275
+
276
+
277
+ _AUDIO_FORMATS = { # OpenAI-wire input_audio formats by mime; anything else fails free
278
+ "audio/mpeg": "mp3",
279
+ "audio/mp3": "mp3",
280
+ "audio/wav": "wav",
281
+ "audio/x-wav": "wav",
282
+ }
283
+
284
+
285
+ def _user_content(request: CompletionRequest) -> str | list[dict[str, object]]:
286
+ """Plain string normally; the content-array form when media rides along (D20 §3:
287
+ a modality is one more renderer in this builder, never a new adapter)."""
288
+ if not request.media:
289
+ return request.user
290
+ parts: list[dict[str, object]] = [{"type": "text", "text": request.user}]
291
+ for part in request.media:
292
+ match part:
293
+ case ImageData():
294
+ data_uri = f"data:{part.mime};base64,{base64.b64encode(part.data).decode()}"
295
+ parts.append({"type": "image_url", "image_url": {"url": data_uri}})
296
+ case AudioData():
297
+ fmt = _AUDIO_FORMATS.get(part.mime)
298
+ if fmt is None:
299
+ # never guess a format at a paid endpoint — fail before the spend
300
+ raise ItemError(
301
+ f"audio format {part.mime} isn't sendable — "
302
+ "wav or mp3 reach audio models natively; "
303
+ "other formats transcribe locally"
304
+ )
305
+ encoded = base64.b64encode(part.data).decode()
306
+ parts.append(
307
+ {"type": "input_audio", "input_audio": {"data": encoded, "format": fmt}}
308
+ )
309
+ case VideoData():
310
+ raise ItemError(
311
+ "this endpoint can't watch video — map converts video to "
312
+ "frames + audio automatically; use map, or split --by seconds"
313
+ )
314
+ case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
315
+ assert_never(unreachable)
316
+ return parts
317
+
318
+
319
+ def _meter_chat_usage(record: Mapping[str, object] | None) -> None:
320
+ usage = as_record(record.get("usage")) if record is not None else None
321
+ if usage is None:
322
+ return
323
+ tokens_in = usage.get("prompt_tokens")
324
+ tokens_out = usage.get("completion_tokens")
325
+ metering.add_tokens(
326
+ tokens_in=tokens_in if isinstance(tokens_in, int) else 0,
327
+ tokens_out=tokens_out if isinstance(tokens_out, int) else 0,
328
+ )