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