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,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
|
+
)
|