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,196 @@
|
|
|
1
|
+
"""Gemini's native ``:generateContent`` wire (D34) — the only endpoint that
|
|
2
|
+
watches video.
|
|
3
|
+
|
|
4
|
+
Chat rides here (the compat shim carries text/images/audio but not video);
|
|
5
|
+
embeddings stay on the compat wire. Same taxonomy as every adapter: 401/403 →
|
|
6
|
+
key screen, 404 → model-missing at first sight (D18), schema 400 → the schema
|
|
7
|
+
screen, 429/5xx retried with ``Retry-After`` honored. Our JSON Schema subset is
|
|
8
|
+
translated to Gemini's response schema dialect; ``validate_and_coerce`` stays
|
|
9
|
+
the client-side backstop.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING, assert_never
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from smartpipe.cli import screens
|
|
21
|
+
from smartpipe.core.errors import ItemError, SetupFault
|
|
22
|
+
from smartpipe.core.jsontools import as_items, as_record, as_str
|
|
23
|
+
from smartpipe.io import metering
|
|
24
|
+
from smartpipe.models.base import AudioData, ImageData, VideoData
|
|
25
|
+
from smartpipe.models.http_support import is_retryable_http, retry_after_seconds
|
|
26
|
+
from smartpipe.models.openai_compat import GEMINI_WIRE, resolve_base_url
|
|
27
|
+
from smartpipe.models.retry import RetryPolicy, with_retries
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Mapping
|
|
31
|
+
|
|
32
|
+
from smartpipe.models.base import CompletionRequest, ModelRef
|
|
33
|
+
|
|
34
|
+
__all__ = ["GeminiNativeChatModel", "native_base_url", "to_gemini_schema"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def native_base_url(env: Mapping[str, str]) -> str:
|
|
38
|
+
"""The native root sits one path segment above the OpenAI-compat root."""
|
|
39
|
+
return resolve_base_url(env, GEMINI_WIRE).removesuffix("/openai")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class GeminiNativeChatModel:
|
|
44
|
+
ref: ModelRef
|
|
45
|
+
client: httpx.AsyncClient
|
|
46
|
+
base_url: str # the native root (…/v1beta)
|
|
47
|
+
api_key: str
|
|
48
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
49
|
+
|
|
50
|
+
async def complete(self, request: CompletionRequest) -> str:
|
|
51
|
+
payload: dict[str, object] = {"contents": [{"role": "user", "parts": _parts(request)}]}
|
|
52
|
+
if request.system is not None:
|
|
53
|
+
payload["systemInstruction"] = {"parts": [{"text": request.system}]}
|
|
54
|
+
# NB: gemini-2.5 rejects presence/frequency penalties outright
|
|
55
|
+
# ("Penalty is not enabled") — the anti-rambling fields are ollama-only (D35)
|
|
56
|
+
config: dict[str, object] = {
|
|
57
|
+
"maxOutputTokens": request.max_tokens,
|
|
58
|
+
"temperature": request.temperature,
|
|
59
|
+
}
|
|
60
|
+
if request.json_schema is not None:
|
|
61
|
+
config["responseMimeType"] = "application/json"
|
|
62
|
+
config["responseSchema"] = to_gemini_schema(request.json_schema)
|
|
63
|
+
payload["generationConfig"] = config
|
|
64
|
+
metering.add_request_media(request.media)
|
|
65
|
+
data = await self._post(payload)
|
|
66
|
+
record = as_record(data)
|
|
67
|
+
candidates = as_items(record.get("candidates")) if record is not None else None
|
|
68
|
+
content = as_record(candidates[0]) if candidates else None
|
|
69
|
+
inner = as_record(content.get("content")) if content is not None else None
|
|
70
|
+
parts = as_items(inner.get("parts")) if inner is not None else None
|
|
71
|
+
if parts is None:
|
|
72
|
+
raise ItemError("gemini returned an unexpected reply shape")
|
|
73
|
+
texts = [
|
|
74
|
+
text
|
|
75
|
+
for part in parts
|
|
76
|
+
if (entry := as_record(part)) is not None
|
|
77
|
+
and (text := as_str(entry.get("text"))) is not None
|
|
78
|
+
]
|
|
79
|
+
if not texts:
|
|
80
|
+
raise ItemError("gemini returned an unexpected reply shape")
|
|
81
|
+
usage = as_record(record.get("usageMetadata")) if record is not None else None
|
|
82
|
+
if usage is not None:
|
|
83
|
+
tokens_in = usage.get("promptTokenCount")
|
|
84
|
+
tokens_out = usage.get("candidatesTokenCount")
|
|
85
|
+
metering.add_tokens(
|
|
86
|
+
tokens_in=tokens_in if isinstance(tokens_in, int) else 0,
|
|
87
|
+
tokens_out=tokens_out if isinstance(tokens_out, int) else 0,
|
|
88
|
+
)
|
|
89
|
+
return "".join(texts)
|
|
90
|
+
|
|
91
|
+
async def _post(self, payload: Mapping[str, object]) -> object:
|
|
92
|
+
url = f"{self.base_url}/models/{self.ref.name}:generateContent"
|
|
93
|
+
headers = {"x-goog-api-key": self.api_key}
|
|
94
|
+
|
|
95
|
+
async def attempt() -> object:
|
|
96
|
+
response = await self.client.post(url, json=payload, headers=headers)
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
return response.json()
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
return await with_retries(
|
|
102
|
+
self.retry, attempt, is_retryable=is_retryable_http, delay_hint=retry_after_seconds
|
|
103
|
+
)
|
|
104
|
+
except httpx.HTTPStatusError as exc:
|
|
105
|
+
status = exc.response.status_code
|
|
106
|
+
if status in (401, 403):
|
|
107
|
+
raise SetupFault(
|
|
108
|
+
f"error: the API key for '{self.ref.name}' was rejected ({status})\n"
|
|
109
|
+
f" The endpoint answered, but it didn't accept {GEMINI_WIRE.key_env}.\n"
|
|
110
|
+
f" Fix: check the key, or the endpoint in {GEMINI_WIRE.base_url_env}."
|
|
111
|
+
) from exc
|
|
112
|
+
detail = _detail(exc.response)
|
|
113
|
+
# D18: failures that doom every item identically stop at the first
|
|
114
|
+
if status == 404:
|
|
115
|
+
raise SetupFault(
|
|
116
|
+
screens.cloud_model_missing(self.ref.name, _host(self.base_url))
|
|
117
|
+
) from exc
|
|
118
|
+
if status == 400 and ("responseSchema" in detail or "response_schema" in detail):
|
|
119
|
+
raise SetupFault(screens.schema_rejected(_host(self.base_url), detail)) from exc
|
|
120
|
+
raise ItemError(f"gemini error {status}: {detail}") from exc
|
|
121
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
|
122
|
+
raise SetupFault(
|
|
123
|
+
f"error: can't reach {self.base_url} ({exc})\n"
|
|
124
|
+
f" The model '{self.ref}' needs that endpoint.\n"
|
|
125
|
+
f" Check your network, or {GEMINI_WIRE.base_url_env} if you pointed "
|
|
126
|
+
"smartpipe elsewhere."
|
|
127
|
+
) from exc
|
|
128
|
+
except httpx.HTTPError as exc:
|
|
129
|
+
raise ItemError(f"request to {self.base_url} failed: {exc}") from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parts(request: CompletionRequest) -> list[dict[str, object]]:
|
|
133
|
+
parts: list[dict[str, object]] = [{"text": request.user}]
|
|
134
|
+
for part in request.media:
|
|
135
|
+
match part:
|
|
136
|
+
case ImageData() | AudioData() | VideoData():
|
|
137
|
+
parts.append(
|
|
138
|
+
{
|
|
139
|
+
"inline_data": {
|
|
140
|
+
"mime_type": part.mime,
|
|
141
|
+
"data": base64.b64encode(part.data).decode("ascii"),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
|
|
146
|
+
assert_never(unreachable)
|
|
147
|
+
return parts
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_SCHEMA_KEYS = ("type", "properties", "required", "items", "enum", "description")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def to_gemini_schema(schema: Mapping[str, object]) -> dict[str, object]:
|
|
154
|
+
"""Our JSON Schema subset → Gemini's response-schema dialect: the shared
|
|
155
|
+
keys carry over (types uppercased), everything else is dropped —
|
|
156
|
+
``validate_and_coerce`` remains the real guarantee."""
|
|
157
|
+
translated: dict[str, object] = {}
|
|
158
|
+
for key in _SCHEMA_KEYS:
|
|
159
|
+
value = schema.get(key)
|
|
160
|
+
if value is None:
|
|
161
|
+
continue
|
|
162
|
+
if key == "type" and isinstance(value, str):
|
|
163
|
+
translated["type"] = value.upper()
|
|
164
|
+
elif key == "properties":
|
|
165
|
+
record = as_record(value)
|
|
166
|
+
if record is not None:
|
|
167
|
+
translated["properties"] = {
|
|
168
|
+
name: to_gemini_schema(narrowed)
|
|
169
|
+
for name in record
|
|
170
|
+
if (narrowed := as_record(record.get(name))) is not None
|
|
171
|
+
}
|
|
172
|
+
elif key == "items":
|
|
173
|
+
child = as_record(value)
|
|
174
|
+
if child is not None:
|
|
175
|
+
translated["items"] = to_gemini_schema(child)
|
|
176
|
+
else:
|
|
177
|
+
translated[key] = value
|
|
178
|
+
return translated
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _detail(response: httpx.Response) -> str:
|
|
182
|
+
record = as_record(_safe_json(response))
|
|
183
|
+
error = as_record(record.get("error")) if record is not None else None
|
|
184
|
+
message = as_str(error.get("message")) if error is not None else None
|
|
185
|
+
return message or response.text[:200]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _safe_json(response: httpx.Response) -> object:
|
|
189
|
+
try:
|
|
190
|
+
return response.json()
|
|
191
|
+
except ValueError:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _host(base_url: str) -> str:
|
|
196
|
+
return httpx.URL(base_url).host or base_url
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared HTTP plumbing for provider adapters.
|
|
2
|
+
|
|
3
|
+
httpx stays a function-local import: this module sits on the ``container`` path,
|
|
4
|
+
and ``--help`` must never pay for the HTTP stack (tests/test_startup_imports.py
|
|
5
|
+
is the gate).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
__all__ = ["default_timeout", "is_retryable_http", "make_client", "retry_after_seconds"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_timeout() -> httpx.Timeout:
|
|
22
|
+
"""Generous read timeout — local models can take a while per item (plan/architecture.md)."""
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
return httpx.Timeout(connect=5.0, read=120.0, write=30.0, pool=5.0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_client() -> httpx.AsyncClient:
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
return httpx.AsyncClient(timeout=default_timeout())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def retry_after_seconds(exc: Exception, *, now: Callable[[], float] = time.time) -> float | None:
|
|
35
|
+
"""Seconds the server asked us to wait, from a ``Retry-After`` header.
|
|
36
|
+
|
|
37
|
+
Accepts both wire forms (integer seconds and HTTP-date); anything absent,
|
|
38
|
+
unparseable, or in the past is ``None`` — the caller falls back to its own
|
|
39
|
+
backoff. Clamping hostile values is the retry loop's job, not the parser's.
|
|
40
|
+
"""
|
|
41
|
+
import httpx
|
|
42
|
+
|
|
43
|
+
if not isinstance(exc, httpx.HTTPStatusError):
|
|
44
|
+
return None
|
|
45
|
+
header = exc.response.headers.get("retry-after")
|
|
46
|
+
if header is None:
|
|
47
|
+
return None
|
|
48
|
+
text = header.strip()
|
|
49
|
+
if text.isdigit(): # RFC 7231 delay-seconds is non-negative; "-5" falls through
|
|
50
|
+
return float(text)
|
|
51
|
+
from email.utils import parsedate_to_datetime
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
target = parsedate_to_datetime(text)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
return None
|
|
57
|
+
if target.tzinfo is None: # RFC 7231 dates are GMT; a bare date reads as UTC
|
|
58
|
+
from datetime import UTC
|
|
59
|
+
|
|
60
|
+
target = target.replace(tzinfo=UTC)
|
|
61
|
+
delta = target.timestamp() - now()
|
|
62
|
+
return delta if delta >= 0 else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_retryable_http(exc: Exception) -> bool:
|
|
66
|
+
import httpx
|
|
67
|
+
|
|
68
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
69
|
+
status = exc.response.status_code
|
|
70
|
+
return status == 429 or status >= 500
|
|
71
|
+
# TimeoutException is the shared base of ConnectTimeout/ReadTimeout/
|
|
72
|
+
# WriteTimeout/PoolTimeout — keying off it catches all four (the connect and
|
|
73
|
+
# write variants are NOT subclasses of ConnectError/WriteError in httpx).
|
|
74
|
+
return isinstance(
|
|
75
|
+
exc,
|
|
76
|
+
httpx.TimeoutException | httpx.ConnectError | httpx.RemoteProtocolError | httpx.WriteError,
|
|
77
|
+
)
|
smartpipe/models/jina.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The Jina embeddings wire (D39/04): text AND images in one space.
|
|
2
|
+
|
|
3
|
+
jina-clip-v2 is the first media-native embedder — mentioning it
|
|
4
|
+
(``--embed-model jina/jina-clip-v2``) switches image items away from the
|
|
5
|
+
caption pivot. Same endpoint shape as the compat wire, plus image entries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
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.core.jsontools import as_float_vector, as_items, as_record
|
|
18
|
+
from smartpipe.io import metering
|
|
19
|
+
from smartpipe.models.base import ImageData
|
|
20
|
+
from smartpipe.models.http_support import is_retryable_http, retry_after_seconds
|
|
21
|
+
from smartpipe.models.retry import RetryPolicy, with_retries
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
|
|
26
|
+
from smartpipe.models.base import ModelRef
|
|
27
|
+
|
|
28
|
+
__all__ = ["JINA_BASE_URL", "JinaClipEmbeddingModel"]
|
|
29
|
+
|
|
30
|
+
JINA_BASE_URL = "https://api.jina.ai"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class JinaClipEmbeddingModel:
|
|
35
|
+
ref: ModelRef
|
|
36
|
+
client: httpx.AsyncClient
|
|
37
|
+
api_key: str
|
|
38
|
+
base_url: str = JINA_BASE_URL
|
|
39
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
40
|
+
|
|
41
|
+
async def embed(self, texts: Sequence[str]) -> tuple[tuple[float, ...], ...]:
|
|
42
|
+
return await self.embed_parts(list(texts))
|
|
43
|
+
|
|
44
|
+
async def embed_parts(self, parts: Sequence[str | ImageData]) -> tuple[tuple[float, ...], ...]:
|
|
45
|
+
entries: list[dict[str, str]] = []
|
|
46
|
+
for part in parts:
|
|
47
|
+
if isinstance(part, ImageData):
|
|
48
|
+
entries.append({"image": base64.b64encode(part.data).decode("ascii")})
|
|
49
|
+
else:
|
|
50
|
+
entries.append({"text": part})
|
|
51
|
+
payload: dict[str, object] = {"model": self.ref.name, "input": entries}
|
|
52
|
+
metering.add_request_media(tuple(part for part in parts if not isinstance(part, str)))
|
|
53
|
+
data = await self._post("/v1/embeddings", payload)
|
|
54
|
+
record = as_record(data)
|
|
55
|
+
rows = as_items(record.get("data")) if record is not None else None
|
|
56
|
+
if rows is None:
|
|
57
|
+
raise ItemError("jina embedding endpoint returned an unexpected shape")
|
|
58
|
+
usage = as_record(record.get("usage")) if record is not None else None
|
|
59
|
+
if usage is not None:
|
|
60
|
+
total = usage.get("total_tokens")
|
|
61
|
+
metering.add_tokens(tokens_in=total if isinstance(total, int) else 0)
|
|
62
|
+
indexed: list[tuple[int, tuple[float, ...]]] = []
|
|
63
|
+
for position, row in enumerate(rows):
|
|
64
|
+
entry = as_record(row)
|
|
65
|
+
vector = as_float_vector(entry.get("embedding")) if entry is not None else None
|
|
66
|
+
if entry is None or vector is None:
|
|
67
|
+
raise ItemError("jina embedding endpoint returned an unexpected shape")
|
|
68
|
+
index = entry.get("index")
|
|
69
|
+
indexed.append((index if isinstance(index, int) else position, vector))
|
|
70
|
+
return tuple(vector for _index, vector in sorted(indexed))
|
|
71
|
+
|
|
72
|
+
async def _post(self, path: str, payload: dict[str, object]) -> object:
|
|
73
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
74
|
+
|
|
75
|
+
async def attempt() -> object:
|
|
76
|
+
response = await self.client.post(
|
|
77
|
+
f"{self.base_url}{path}", json=payload, headers=headers
|
|
78
|
+
)
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
return response.json()
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
return await with_retries(
|
|
84
|
+
self.retry,
|
|
85
|
+
attempt,
|
|
86
|
+
is_retryable=is_retryable_http,
|
|
87
|
+
delay_hint=retry_after_seconds,
|
|
88
|
+
)
|
|
89
|
+
except httpx.HTTPStatusError as exc:
|
|
90
|
+
status = exc.response.status_code
|
|
91
|
+
if status in (401, 403):
|
|
92
|
+
raise SetupFault(
|
|
93
|
+
"error: Jina rejected the API key\n"
|
|
94
|
+
" Set JINA_API_KEY (https://jina.ai) and retry."
|
|
95
|
+
) from exc
|
|
96
|
+
raise ItemError(f"jina error {status}: {exc.response.text[:200]}") from exc
|
|
97
|
+
except httpx.HTTPError as exc:
|
|
98
|
+
raise ItemError(f"jina request failed ({exc})") from exc
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""The local embedding wire (D44): pure Python, no server, no key.
|
|
2
|
+
|
|
3
|
+
fastembed runs nomic-embed-text v1.5 as ONNX on CPU. This is the DEFAULT
|
|
4
|
+
embedder: `smartpipe embed` works on a fresh install with nothing running.
|
|
5
|
+
The model (~130 MB) downloads once on first use, disclosed. Imports stay
|
|
6
|
+
inside methods — the startup budget never pays for onnxruntime. ONE engine
|
|
7
|
+
per model instance, loaded lazily and reused for every batch; the container
|
|
8
|
+
hands verbs a single instance per run, and weights cache on disk across runs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from smartpipe.core.errors import ItemError
|
|
18
|
+
from smartpipe.io import diagnostics
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
|
|
23
|
+
from smartpipe.models.base import ModelRef
|
|
24
|
+
|
|
25
|
+
__all__ = ["LOCAL_EMBED_MODEL", "LocalEmbeddingModel"]
|
|
26
|
+
|
|
27
|
+
LOCAL_EMBED_MODEL = "nomic-ai/nomic-embed-text-v1.5"
|
|
28
|
+
|
|
29
|
+
_announced = False # the one-time download note, once per process
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _plain(vector: object) -> object:
|
|
33
|
+
"""numpy arrays expose tolist(); plain sequences pass through untouched."""
|
|
34
|
+
lister = getattr(vector, "tolist", None)
|
|
35
|
+
return lister() if callable(lister) else vector
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class LocalEmbeddingModel:
|
|
40
|
+
ref: ModelRef
|
|
41
|
+
engine: object | None = field(default=None, repr=False) # injected in tests; lazy-loaded live
|
|
42
|
+
|
|
43
|
+
async def embed(self, texts: Sequence[str]) -> tuple[tuple[float, ...], ...]:
|
|
44
|
+
return await asyncio.to_thread(self._embed_blocking, list(texts))
|
|
45
|
+
|
|
46
|
+
def _embed_blocking(self, texts: list[str]) -> tuple[tuple[float, ...], ...]:
|
|
47
|
+
from smartpipe.core.jsontools import as_float_vector
|
|
48
|
+
|
|
49
|
+
engine = self._load()
|
|
50
|
+
try:
|
|
51
|
+
produced: list[object] = list(engine.embed(texts)) # type: ignore[attr-defined]
|
|
52
|
+
except Exception as exc: # onnx failures become per-item errors
|
|
53
|
+
raise ItemError(f"local embedding failed ({exc})") from exc
|
|
54
|
+
vectors: list[tuple[float, ...]] = []
|
|
55
|
+
for entry in produced:
|
|
56
|
+
vector = as_float_vector(_plain(entry))
|
|
57
|
+
if vector is None:
|
|
58
|
+
raise ItemError("the local embedder returned an unexpected shape")
|
|
59
|
+
vectors.append(vector)
|
|
60
|
+
return tuple(vectors)
|
|
61
|
+
|
|
62
|
+
def _load(self) -> object:
|
|
63
|
+
if self.engine is not None:
|
|
64
|
+
return self.engine # loaded once, reused for every batch
|
|
65
|
+
global _announced
|
|
66
|
+
if not _announced:
|
|
67
|
+
_announced = True
|
|
68
|
+
diagnostics.note(
|
|
69
|
+
"local embeddings: nomic-embed-text v1.5 on CPU (first use downloads ~130 MB, once)"
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
from fastembed import TextEmbedding
|
|
73
|
+
except ImportError as exc: # pragma: no cover — fastembed is a core dep
|
|
74
|
+
raise ItemError("the local embedder is unavailable — reinstall smartpipe") from exc
|
|
75
|
+
self.engine = TextEmbedding(model_name=LOCAL_EMBED_MODEL)
|
|
76
|
+
return self.engine
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The local-first default provider (plan/architecture.md adapter table).
|
|
2
|
+
|
|
3
|
+
Failure philosophy: a refused connection or a missing model fails *fast* with a
|
|
4
|
+
screen that names the fix (a local daemon being down is a setup problem, not a
|
|
5
|
+
transient blip); 429/5xx/timeouts are retried, then skip just that item.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
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.io import metering
|
|
20
|
+
from smartpipe.models.base import AudioData, ImageData, VideoData
|
|
21
|
+
from smartpipe.models.http_support import is_retryable_http, retry_after_seconds
|
|
22
|
+
from smartpipe.models.retry import RetryPolicy, with_retries
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Mapping, Sequence
|
|
26
|
+
|
|
27
|
+
from smartpipe.models.base import CompletionRequest, ModelRef
|
|
28
|
+
|
|
29
|
+
_NO_VISION = "model can't read images — try a vision model, e.g. --model ollama/qwen3-vl"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DEFAULT_HOST",
|
|
33
|
+
"OllamaChatModel",
|
|
34
|
+
"OllamaEmbeddingModel",
|
|
35
|
+
"ollama_model_names",
|
|
36
|
+
"resolve_host",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
DEFAULT_HOST = "http://localhost:11434"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_host(env: Mapping[str, str]) -> str:
|
|
43
|
+
host = env.get("OLLAMA_HOST", "").strip() or DEFAULT_HOST
|
|
44
|
+
if "://" not in host: # OLLAMA_HOST convention allows bare host:port
|
|
45
|
+
host = f"http://{host}"
|
|
46
|
+
return host.rstrip("/")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class OllamaChatModel:
|
|
51
|
+
ref: ModelRef
|
|
52
|
+
client: httpx.AsyncClient
|
|
53
|
+
host: str
|
|
54
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
55
|
+
|
|
56
|
+
async def complete(self, request: CompletionRequest) -> str:
|
|
57
|
+
messages: list[dict[str, object]] = []
|
|
58
|
+
if request.system is not None:
|
|
59
|
+
messages.append({"role": "system", "content": request.system})
|
|
60
|
+
user: dict[str, object] = {"role": "user", "content": request.user}
|
|
61
|
+
if any(isinstance(part, VideoData) for part in request.media):
|
|
62
|
+
raise ItemError(
|
|
63
|
+
"this model can't watch video — map converts it to frames + audio "
|
|
64
|
+
"automatically (gemini watches natively)"
|
|
65
|
+
)
|
|
66
|
+
if any(isinstance(part, AudioData) for part in request.media):
|
|
67
|
+
# ollama's chat API carries images only — fail before any bytes leave (D20 §2)
|
|
68
|
+
raise ItemError(
|
|
69
|
+
"this model can't hear audio — try an audio model "
|
|
70
|
+
"(voxtral, gemini) — smartpipe transcribes locally otherwise"
|
|
71
|
+
)
|
|
72
|
+
images = [part for part in request.media if isinstance(part, ImageData)]
|
|
73
|
+
if images:
|
|
74
|
+
user["images"] = [base64.b64encode(image.data).decode() for image in images]
|
|
75
|
+
messages.append(user)
|
|
76
|
+
options: dict[str, object] = {
|
|
77
|
+
"num_predict": request.max_tokens,
|
|
78
|
+
"temperature": request.temperature,
|
|
79
|
+
}
|
|
80
|
+
if request.presence_penalty is not None:
|
|
81
|
+
options["presence_penalty"] = request.presence_penalty
|
|
82
|
+
if request.frequency_penalty is not None:
|
|
83
|
+
options["frequency_penalty"] = request.frequency_penalty
|
|
84
|
+
payload: dict[str, object] = {
|
|
85
|
+
"model": self.ref.name,
|
|
86
|
+
"stream": False,
|
|
87
|
+
"messages": messages,
|
|
88
|
+
"options": options, # tiny local models ramble unbounded otherwise (D35)
|
|
89
|
+
}
|
|
90
|
+
if request.json_schema is not None:
|
|
91
|
+
payload["format"] = dict(request.json_schema)
|
|
92
|
+
metering.add_request_media(request.media)
|
|
93
|
+
try:
|
|
94
|
+
data = await _post(self, "/api/chat", payload)
|
|
95
|
+
except ItemError as exc:
|
|
96
|
+
# _post maps a 400 to ItemError; with images in flight that almost
|
|
97
|
+
# always means "this model has no vision" — say so, name a fix.
|
|
98
|
+
if request.media and "error 400" in str(exc):
|
|
99
|
+
raise ItemError(_NO_VISION) from exc
|
|
100
|
+
raise
|
|
101
|
+
message = record_at(data, "message")
|
|
102
|
+
content = as_str(message.get("content")) if message is not None else None
|
|
103
|
+
if content is None:
|
|
104
|
+
raise ItemError("ollama returned an unexpected reply shape")
|
|
105
|
+
_meter_usage(data)
|
|
106
|
+
return content
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class OllamaEmbeddingModel:
|
|
111
|
+
ref: ModelRef
|
|
112
|
+
client: httpx.AsyncClient
|
|
113
|
+
host: str
|
|
114
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
115
|
+
|
|
116
|
+
async def embed(self, texts: Sequence[str]) -> tuple[tuple[float, ...], ...]:
|
|
117
|
+
payload: dict[str, object] = {"model": self.ref.name, "input": list(texts)}
|
|
118
|
+
data = await _post(self, "/api/embed", payload)
|
|
119
|
+
record = as_record(data)
|
|
120
|
+
rows = as_items(record.get("embeddings")) if record is not None else None
|
|
121
|
+
if rows is None:
|
|
122
|
+
raise ItemError("ollama returned an unexpected embeddings shape")
|
|
123
|
+
vectors: list[tuple[float, ...]] = []
|
|
124
|
+
for row in rows:
|
|
125
|
+
vector = as_float_vector(row)
|
|
126
|
+
if vector is None:
|
|
127
|
+
raise ItemError("ollama returned an unexpected embeddings shape")
|
|
128
|
+
vectors.append(vector)
|
|
129
|
+
return tuple(vectors)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def ollama_model_names(client: httpx.AsyncClient, host: str) -> tuple[str, ...] | None:
|
|
133
|
+
"""Installed model names, or None when no Ollama is listening (a probe, never fatal)."""
|
|
134
|
+
try:
|
|
135
|
+
response = await client.get(f"{host}/api/tags", timeout=2.0)
|
|
136
|
+
response.raise_for_status()
|
|
137
|
+
except httpx.HTTPError:
|
|
138
|
+
return None
|
|
139
|
+
models = as_record(response.json())
|
|
140
|
+
entries = as_items(models.get("models")) if models is not None else None
|
|
141
|
+
if entries is None:
|
|
142
|
+
return None
|
|
143
|
+
names: list[str] = []
|
|
144
|
+
for entry in entries:
|
|
145
|
+
record = as_record(entry)
|
|
146
|
+
name = as_str(record.get("name")) if record is not None else None
|
|
147
|
+
if name is not None:
|
|
148
|
+
names.append(name)
|
|
149
|
+
return tuple(names)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def _post(
|
|
153
|
+
model: OllamaChatModel | OllamaEmbeddingModel, path: str, payload: Mapping[str, object]
|
|
154
|
+
) -> object:
|
|
155
|
+
async def attempt() -> object:
|
|
156
|
+
try:
|
|
157
|
+
response = await model.client.post(f"{model.host}{path}", json=payload)
|
|
158
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
|
159
|
+
# A local daemon that's down or wedged is a setup problem: fail fast
|
|
160
|
+
# with the fix screen rather than retrying (ConnectTimeout is a
|
|
161
|
+
# TimeoutException, so it must be named explicitly here).
|
|
162
|
+
timed_out = isinstance(exc, httpx.ConnectTimeout)
|
|
163
|
+
reason = "connection timed out" if timed_out else "connection refused"
|
|
164
|
+
raise SetupFault(
|
|
165
|
+
screens.ollama_unreachable(model.host, str(model.ref), reason)
|
|
166
|
+
) from exc
|
|
167
|
+
if response.status_code == 404:
|
|
168
|
+
detail = _error_detail(response)
|
|
169
|
+
raise SetupFault(screens.ollama_model_missing(model.ref.name, model.host, detail))
|
|
170
|
+
response.raise_for_status()
|
|
171
|
+
return response.json()
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
return await with_retries(
|
|
175
|
+
model.retry, attempt, is_retryable=is_retryable_http, delay_hint=retry_after_seconds
|
|
176
|
+
)
|
|
177
|
+
except httpx.HTTPStatusError as exc:
|
|
178
|
+
detail = _error_detail(exc.response)
|
|
179
|
+
raise ItemError(f"ollama error {exc.response.status_code}: {detail}") from exc
|
|
180
|
+
except httpx.HTTPError as exc:
|
|
181
|
+
raise ItemError(f"ollama request failed: {exc}") from exc
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _error_detail(response: httpx.Response) -> str:
|
|
185
|
+
try:
|
|
186
|
+
record = as_record(response.json())
|
|
187
|
+
except ValueError:
|
|
188
|
+
record = None
|
|
189
|
+
detail = as_str(record.get("error")) if record is not None else None
|
|
190
|
+
return detail if detail is not None else response.text[:200].strip() or "no detail"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _meter_usage(data: object) -> None:
|
|
194
|
+
from smartpipe.core.jsontools import as_record
|
|
195
|
+
|
|
196
|
+
record = as_record(data)
|
|
197
|
+
if record is None:
|
|
198
|
+
return
|
|
199
|
+
tokens_in = record.get("prompt_eval_count")
|
|
200
|
+
tokens_out = record.get("eval_count")
|
|
201
|
+
metering.add_tokens(
|
|
202
|
+
tokens_in=tokens_in if isinstance(tokens_in, int) else 0,
|
|
203
|
+
tokens_out=tokens_out if isinstance(tokens_out, int) else 0,
|
|
204
|
+
)
|