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,252 @@
|
|
|
1
|
+
"""Read and write ``config.toml``.
|
|
2
|
+
|
|
3
|
+
Rules (plan/decisions.md D09 + DEFER-1): the file is optional; unknown keys are
|
|
4
|
+
*ignored* on read (forward compatibility) but *preserved* on write — an older
|
|
5
|
+
smartpipe never strips a newer one's settings; wrong-typed values fail loudly
|
|
6
|
+
with the key named; API keys are never stored here. Writes are atomic
|
|
7
|
+
(same-directory temp file + ``os.replace``), so a concurrent reader can never
|
|
8
|
+
see a torn file. Comments do not survive a rewrite: tomli-w cannot round-trip
|
|
9
|
+
them and tomlkit stays outside the dependency budget — docs/reference/cli.md
|
|
10
|
+
says so out loud.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import tempfile
|
|
19
|
+
import tomllib
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
import tomli_w
|
|
24
|
+
|
|
25
|
+
from smartpipe.config.paths import human_path
|
|
26
|
+
from smartpipe.core.errors import SetupFault
|
|
27
|
+
from smartpipe.core.jsontools import as_record
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Mapping
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BUILTIN_PROFILES",
|
|
35
|
+
"Config",
|
|
36
|
+
"load_config",
|
|
37
|
+
"profile_names",
|
|
38
|
+
"save_config",
|
|
39
|
+
"set_active_profile",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class Config:
|
|
45
|
+
model: str | None = None
|
|
46
|
+
embed_model: str | None = None
|
|
47
|
+
concurrency: int | None = None
|
|
48
|
+
output: str | None = None
|
|
49
|
+
profile: str | None = None # the active profile's name (D30)
|
|
50
|
+
allow_captions: bool | None = None # cloud conversions consent (D35; flag wins)
|
|
51
|
+
stt_model: str | None = None # remote transcription role (D39/05); unset = the ladder
|
|
52
|
+
cache: bool | None = None # result caching (D38/15) — account-level posture
|
|
53
|
+
cache_days: int | None = None # sweep TTL (D39/02); default 30
|
|
54
|
+
cache_max_mb: int | None = None # LRU size cap (D39/02); default 500
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_EMPTY_PROFILE: Mapping[str, object] = {}
|
|
58
|
+
|
|
59
|
+
# D30: shipped presets, generated here so they can't rot in user files. A
|
|
60
|
+
# profile is ONLY a bundle of existing config keys — that's the fence.
|
|
61
|
+
BUILTIN_PROFILES: Mapping[str, Mapping[str, object]] = {
|
|
62
|
+
# picking a cloud preset IS the consent for paid image/audio-to-text
|
|
63
|
+
# conversions (D35) — the wizard says so; per-row disclosures continue
|
|
64
|
+
# gpt-5.4-mini works on BOTH the key wire and the ChatGPT-login (Codex)
|
|
65
|
+
# wire — 4o-mini is rejected by Codex accounts (owner-hit). Audio input is
|
|
66
|
+
# assumed UNSUPPORTED on OpenAI: the ladder falls to whisper automatically.
|
|
67
|
+
"openai": {
|
|
68
|
+
"model": "gpt-5.4-mini",
|
|
69
|
+
"embed-model": "text-embedding-3-small",
|
|
70
|
+
"allow-captions": True,
|
|
71
|
+
},
|
|
72
|
+
"gemini": {
|
|
73
|
+
"model": "gemini-3.1-flash-lite", # GA, fully multimodal, probe-verified (D45)
|
|
74
|
+
"embed-model": "gemini/gemini-embedding-001",
|
|
75
|
+
"allow-captions": True,
|
|
76
|
+
},
|
|
77
|
+
"local": {
|
|
78
|
+
"model": "ollama/gemma-4-e2b", # multimodal 2.3B-effective, 128k
|
|
79
|
+
"embed-model": "ollama/embeddinggemma", # the pivot anchor: 308M, multilingual
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_config(path: Path, environ: Mapping[str, str] | None = None) -> Config:
|
|
85
|
+
"""Flat keys win over the active profile (a direct set is the most recent
|
|
86
|
+
intent); the active profile is SMARTPIPE_PROFILE > the file's `profile` key."""
|
|
87
|
+
data = _read_raw(path)
|
|
88
|
+
active = _active_profile(data, environ or {}, path)
|
|
89
|
+
base: Mapping[str, object] = _profile_values(data, active, path) if active is not None else {}
|
|
90
|
+
merged = {**base, **{k: v for k, v in data.items() if k != "profiles"}}
|
|
91
|
+
return Config(
|
|
92
|
+
model=_string(merged, "model", path),
|
|
93
|
+
embed_model=_string(merged, "embed-model", path),
|
|
94
|
+
concurrency=_positive_int(merged, "concurrency", path),
|
|
95
|
+
output=_string(merged, "output", path),
|
|
96
|
+
profile=active,
|
|
97
|
+
allow_captions=_boolean(merged, "allow-captions", path),
|
|
98
|
+
stt_model=_string(merged, "stt-model", path),
|
|
99
|
+
cache=_boolean(merged, "cache", path),
|
|
100
|
+
cache_days=_positive_int(merged, "cache-days", path),
|
|
101
|
+
cache_max_mb=_positive_int(merged, "cache-max-mb", path),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def set_active_profile(path: Path, name: str | None) -> None:
|
|
106
|
+
"""Flip ONLY the profile key — never the flat keys, so resolved profile
|
|
107
|
+
values are not materialized into the file (they'd shadow every later
|
|
108
|
+
profile switch)."""
|
|
109
|
+
merged = dict(_read_raw(path))
|
|
110
|
+
if name is None:
|
|
111
|
+
merged.pop("profile", None)
|
|
112
|
+
else:
|
|
113
|
+
merged["profile"] = name
|
|
114
|
+
_write_raw(path, merged)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def profile_names(path: Path) -> tuple[str, ...]:
|
|
118
|
+
"""Every selectable profile: user-defined tables plus the shipped presets."""
|
|
119
|
+
defined = as_record(_read_raw(path).get("profiles"))
|
|
120
|
+
names = set(BUILTIN_PROFILES)
|
|
121
|
+
if defined is not None:
|
|
122
|
+
names.update(defined)
|
|
123
|
+
return tuple(sorted(names))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _active_profile(
|
|
127
|
+
data: Mapping[str, object], environ: Mapping[str, str], path: Path
|
|
128
|
+
) -> str | None:
|
|
129
|
+
from_env = environ.get("SMARTPIPE_PROFILE", "").strip()
|
|
130
|
+
name = from_env or _string(data, "profile", path)
|
|
131
|
+
if not name:
|
|
132
|
+
return None
|
|
133
|
+
defined = as_record(data.get("profiles"))
|
|
134
|
+
known: set[str] = set(BUILTIN_PROFILES)
|
|
135
|
+
if defined is not None:
|
|
136
|
+
known |= set(defined)
|
|
137
|
+
if name not in known:
|
|
138
|
+
raise SetupFault(
|
|
139
|
+
f"error: profile {name!r} doesn't exist\n"
|
|
140
|
+
f" Known profiles: {', '.join(sorted(known))}\n"
|
|
141
|
+
" Pick one: smartpipe config profile NAME — or define [profiles."
|
|
142
|
+
f"{name}] in {human_path(path)}"
|
|
143
|
+
)
|
|
144
|
+
return name
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _profile_values(data: Mapping[str, object], name: str, path: Path) -> Mapping[str, object]:
|
|
148
|
+
del path # errors here don't need the file location — the key names suffice
|
|
149
|
+
defined = as_record(data.get("profiles"))
|
|
150
|
+
table = as_record(defined.get(name)) if defined is not None else None
|
|
151
|
+
if table is not None:
|
|
152
|
+
allowed = {"model", "embed-model", "concurrency", "output", "allow-captions"}
|
|
153
|
+
unknown = set(table) - allowed
|
|
154
|
+
if unknown:
|
|
155
|
+
raise SetupFault(
|
|
156
|
+
f"error: profile {name!r} has unknown key {sorted(unknown)[0]!r}\n"
|
|
157
|
+
f" A profile bundles: {', '.join(sorted(allowed))}"
|
|
158
|
+
)
|
|
159
|
+
return table
|
|
160
|
+
return BUILTIN_PROFILES.get(name, _EMPTY_PROFILE)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def save_config(path: Path, config: Config) -> None:
|
|
164
|
+
merged = dict(_read_raw(path)) # a corrupt file fails loudly before we overwrite evidence
|
|
165
|
+
ours: dict[str, str | int | None] = {
|
|
166
|
+
"model": config.model,
|
|
167
|
+
"embed-model": config.embed_model,
|
|
168
|
+
"concurrency": config.concurrency,
|
|
169
|
+
"output": config.output,
|
|
170
|
+
"profile": config.profile,
|
|
171
|
+
"allow-captions": config.allow_captions,
|
|
172
|
+
"stt-model": config.stt_model,
|
|
173
|
+
"cache": config.cache,
|
|
174
|
+
"cache-days": config.cache_days,
|
|
175
|
+
"cache-max-mb": config.cache_max_mb,
|
|
176
|
+
}
|
|
177
|
+
for key, value in ours.items():
|
|
178
|
+
if value is None:
|
|
179
|
+
merged.pop(key, None) # None = unset (pinned semantics)
|
|
180
|
+
else:
|
|
181
|
+
merged[key] = value
|
|
182
|
+
_write_raw(path, merged)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _write_raw(path: Path, merged: dict[str, object]) -> None:
|
|
186
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
188
|
+
try:
|
|
189
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
190
|
+
handle.write(tomli_w.dumps(merged))
|
|
191
|
+
os.replace(tmp, path) # atomic on POSIX & Windows (same volume by construction)
|
|
192
|
+
except BaseException:
|
|
193
|
+
with contextlib.suppress(OSError):
|
|
194
|
+
os.unlink(tmp)
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _read_raw(path: Path) -> Mapping[str, object]:
|
|
199
|
+
"""The file as parsed TOML — ``{}`` if missing, the broken-file screen if corrupt."""
|
|
200
|
+
if not path.exists():
|
|
201
|
+
return {}
|
|
202
|
+
try:
|
|
203
|
+
return tomllib.loads(path.read_text(encoding="utf-8"))
|
|
204
|
+
except tomllib.TOMLDecodeError as exc:
|
|
205
|
+
raise SetupFault(_broken_screen(path, exc)) from exc
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _string(data: Mapping[str, object], key: str, path: Path) -> str | None:
|
|
209
|
+
value = data.get(key)
|
|
210
|
+
if value is None:
|
|
211
|
+
return None
|
|
212
|
+
if not isinstance(value, str):
|
|
213
|
+
raise SetupFault(_wrong_type_screen(path, key, "a string", value))
|
|
214
|
+
return value
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _boolean(data: Mapping[str, object], key: str, path: Path) -> bool | None:
|
|
218
|
+
value = data.get(key)
|
|
219
|
+
if value is None:
|
|
220
|
+
return None
|
|
221
|
+
if not isinstance(value, bool):
|
|
222
|
+
raise SetupFault(_wrong_type_screen(path, key, "true or false", value))
|
|
223
|
+
return value
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _positive_int(data: Mapping[str, object], key: str, path: Path) -> int | None:
|
|
227
|
+
value = data.get(key)
|
|
228
|
+
if value is None:
|
|
229
|
+
return None
|
|
230
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
231
|
+
raise SetupFault(_wrong_type_screen(path, key, "a whole number ≥ 1", value))
|
|
232
|
+
return value
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _broken_screen(path: Path, exc: tomllib.TOMLDecodeError) -> str:
|
|
236
|
+
detail = str(exc)
|
|
237
|
+
located = re.search(r"at line (\d+)", detail)
|
|
238
|
+
location = f", line {located.group(1)}" if located else ""
|
|
239
|
+
detail = re.sub(r"\s*\(at line [^)]*\)$", "", detail)
|
|
240
|
+
return (
|
|
241
|
+
"error: config file has a syntax error\n"
|
|
242
|
+
f" {human_path(path)}{location}: {detail}\n"
|
|
243
|
+
" Fix the line, or start fresh: smartpipe config"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _wrong_type_screen(path: Path, key: str, expected: str, value: object) -> str:
|
|
248
|
+
return (
|
|
249
|
+
f"error: config value '{key}' should be {expected}\n"
|
|
250
|
+
f" {human_path(path)} has: {key} = {value!r}\n"
|
|
251
|
+
f" Fix the line, or reset it: smartpipe config"
|
|
252
|
+
)
|
smartpipe/container.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""The composition root (design template: intent-finder's ``src/containers.py``).
|
|
2
|
+
|
|
3
|
+
One place wires every dependency for an invocation: the env snapshot, the loaded
|
|
4
|
+
config, a shared ``httpx.AsyncClient``, the retry policy, and the color mode. Verbs
|
|
5
|
+
receive a built ``AppContainer`` and ask it for ``Protocol``-typed collaborators
|
|
6
|
+
(``ChatModel``, ``EmbeddingModel``, ``ResultWriter``) — they never construct
|
|
7
|
+
adapters or read the environment themselves.
|
|
8
|
+
|
|
9
|
+
Unlike the template, the wiring is hand-rolled rather than using ``dependency-
|
|
10
|
+
injector``: the core dependency budget is frozen (plan/decisions.md D10), and a
|
|
11
|
+
CLI's composition root is small enough that a frozen dataclass of factory methods
|
|
12
|
+
is clearer than a container framework.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from contextlib import asynccontextmanager
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, assert_never
|
|
22
|
+
|
|
23
|
+
from smartpipe.cli import screens
|
|
24
|
+
from smartpipe.config.paths import config_path
|
|
25
|
+
from smartpipe.config.store import Config, load_config
|
|
26
|
+
from smartpipe.core.errors import SetupFault, UsageFault
|
|
27
|
+
from smartpipe.io import diagnostics, tty
|
|
28
|
+
from smartpipe.io.tty import ColorMode
|
|
29
|
+
from smartpipe.io.writers import OutputFormat, WriterConfig, make_writer, resolve_format
|
|
30
|
+
from smartpipe.models.anthropic_adapter import build_anthropic_chat_model
|
|
31
|
+
from smartpipe.models.base import ModelRef, parse_model_ref
|
|
32
|
+
from smartpipe.models.budget import CallBudget, budgeted_chat, budgeted_embed
|
|
33
|
+
from smartpipe.models.http_support import make_client
|
|
34
|
+
from smartpipe.models.ollama import (
|
|
35
|
+
OllamaChatModel,
|
|
36
|
+
OllamaEmbeddingModel,
|
|
37
|
+
ollama_model_names,
|
|
38
|
+
resolve_host,
|
|
39
|
+
)
|
|
40
|
+
from smartpipe.models.openai_compat import (
|
|
41
|
+
GEMINI_WIRE,
|
|
42
|
+
MISTRAL_WIRE,
|
|
43
|
+
OPENROUTER_WIRE,
|
|
44
|
+
OpenAIChatModel,
|
|
45
|
+
OpenAIEmbeddingModel,
|
|
46
|
+
WireConfig,
|
|
47
|
+
require_api_key,
|
|
48
|
+
resolve_base_url,
|
|
49
|
+
)
|
|
50
|
+
from smartpipe.models.resolve import resolve_chat_ref, resolve_embed_ref
|
|
51
|
+
from smartpipe.models.retry import RetryPolicy
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from collections.abc import AsyncGenerator, Mapping
|
|
55
|
+
from typing import TextIO
|
|
56
|
+
|
|
57
|
+
import httpx
|
|
58
|
+
|
|
59
|
+
from smartpipe.io.writers import ResultWriter
|
|
60
|
+
from smartpipe.models.base import ChatModel, EmbeddingModel
|
|
61
|
+
from smartpipe.models.stt import RemoteTranscriber
|
|
62
|
+
|
|
63
|
+
__all__ = ["AppContainer", "build_container"]
|
|
64
|
+
|
|
65
|
+
_DEFAULT_CONCURRENCY = 4
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class AppContainer:
|
|
70
|
+
env: Mapping[str, str]
|
|
71
|
+
config: Config
|
|
72
|
+
http_client: httpx.AsyncClient
|
|
73
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
74
|
+
color_mode: ColorMode = ColorMode.AUTO
|
|
75
|
+
budget: CallBudget | None = None # --max-calls (D18); None = uncapped
|
|
76
|
+
caches: list[object] = field(default_factory=list[object]) # CachingChatModel wrappers (D38/15)
|
|
77
|
+
window_cache: dict[str, int | None] = field(default_factory=dict[str, "int | None"])
|
|
78
|
+
|
|
79
|
+
async def chat_model(self, flag: str | None = None) -> ChatModel:
|
|
80
|
+
resolved = await resolve_chat_ref(flag, self.env, self.config, self.probe_ollama)
|
|
81
|
+
if resolved.notice is not None:
|
|
82
|
+
diagnostics.note(resolved.notice)
|
|
83
|
+
model = self._build_chat(resolved.ref)
|
|
84
|
+
wired = model if self.budget is None else budgeted_chat(model, self.budget)
|
|
85
|
+
if not _cache_enabled(self.env, self.config):
|
|
86
|
+
return wired
|
|
87
|
+
# cache OUTERMOST: a hit short-circuits before the budget counts it —
|
|
88
|
+
# the belt caps SPEND, not answers (D38/15)
|
|
89
|
+
from smartpipe.models.cache import CachingChatModel
|
|
90
|
+
|
|
91
|
+
wrapper = CachingChatModel(wired, _cache_dir(self.env))
|
|
92
|
+
self.caches.append(wrapper)
|
|
93
|
+
return wrapper
|
|
94
|
+
|
|
95
|
+
async def context_window(self, ref: ModelRef) -> int | None:
|
|
96
|
+
"""The model's context window: env override > one cached live probe > None
|
|
97
|
+
(D26 layer 1). Called lazily — only when chunking math needs it."""
|
|
98
|
+
override = self.env.get("SMARTPIPE_CONTEXT_TOKENS", "").strip()
|
|
99
|
+
if override:
|
|
100
|
+
if not override.isdigit():
|
|
101
|
+
raise SetupFault(
|
|
102
|
+
f"error: SMARTPIPE_CONTEXT_TOKENS must be a token count, got {override!r}\n"
|
|
103
|
+
" Example: SMARTPIPE_CONTEXT_TOKENS=32000"
|
|
104
|
+
)
|
|
105
|
+
return int(override)
|
|
106
|
+
key = f"{ref.provider}/{ref.name}"
|
|
107
|
+
if key not in self.window_cache:
|
|
108
|
+
from smartpipe.models.windows import probe_context_window
|
|
109
|
+
|
|
110
|
+
self.window_cache[key] = await probe_context_window(
|
|
111
|
+
ref, client=self.http_client, env=self.env
|
|
112
|
+
)
|
|
113
|
+
return self.window_cache[key]
|
|
114
|
+
|
|
115
|
+
async def embedding_model(self, flag: str | None = None) -> EmbeddingModel:
|
|
116
|
+
model = self._build_embed(resolve_embed_ref(flag, self.env, self.config))
|
|
117
|
+
return model if self.budget is None else budgeted_embed(model, self.budget)
|
|
118
|
+
|
|
119
|
+
def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None:
|
|
120
|
+
"""The stt-model role (D39/05): explicit env/config wins; otherwise the
|
|
121
|
+
owner's auto-matrix — an openai KEY means whisper-1 (the API supports
|
|
122
|
+
it; ChatGPT-login does not, so OAuth-only stays local); gemini hears
|
|
123
|
+
natively (no preemption); ollama has no STT (local whisper)."""
|
|
124
|
+
raw = self.env.get("SMARTPIPE_STT_MODEL", "").strip() or (self.config.stt_model or "")
|
|
125
|
+
if not raw:
|
|
126
|
+
if (
|
|
127
|
+
chat_ref is not None
|
|
128
|
+
and chat_ref.provider == "openai"
|
|
129
|
+
and self.env.get("OPENAI_API_KEY", "").strip()
|
|
130
|
+
):
|
|
131
|
+
raw = "openai/whisper-1" # the key wire supports transcriptions
|
|
132
|
+
else:
|
|
133
|
+
return None
|
|
134
|
+
ref = parse_model_ref(raw)
|
|
135
|
+
if ref.provider != "openai":
|
|
136
|
+
raise SetupFault(
|
|
137
|
+
f"error: no STT wire for {ref.provider!r} yet\n"
|
|
138
|
+
" Remote transcription supports openai models: "
|
|
139
|
+
"smartpipe config stt-model openai/whisper-1"
|
|
140
|
+
)
|
|
141
|
+
key = self.env.get("OPENAI_API_KEY", "").strip()
|
|
142
|
+
if not key:
|
|
143
|
+
raise SetupFault(
|
|
144
|
+
"error: remote transcription needs OPENAI_API_KEY\n"
|
|
145
|
+
" export OPENAI_API_KEY=sk-… (or unset stt-model to use the ladder)"
|
|
146
|
+
)
|
|
147
|
+
from smartpipe.models.stt import RemoteTranscriber
|
|
148
|
+
|
|
149
|
+
return RemoteTranscriber(ref=ref, client=self.http_client, api_key=key, retry=self.retry)
|
|
150
|
+
|
|
151
|
+
def concurrency(self, flag: int | None = None) -> int:
|
|
152
|
+
"""Max parallel model calls: flag > SMARTPIPE_CONCURRENCY > config > default 4."""
|
|
153
|
+
if flag is not None:
|
|
154
|
+
if flag < 1:
|
|
155
|
+
raise UsageFault(f"--concurrency must be >= 1, got {flag}")
|
|
156
|
+
return flag
|
|
157
|
+
env_value = self.env.get("SMARTPIPE_CONCURRENCY", "").strip()
|
|
158
|
+
if env_value:
|
|
159
|
+
if not (env_value.isdigit() and int(env_value) >= 1):
|
|
160
|
+
raise UsageFault(
|
|
161
|
+
f"SMARTPIPE_CONCURRENCY must be a whole number >= 1, got {env_value!r}"
|
|
162
|
+
)
|
|
163
|
+
return int(env_value)
|
|
164
|
+
if self.config.concurrency is not None:
|
|
165
|
+
return self.config.concurrency
|
|
166
|
+
return _DEFAULT_CONCURRENCY
|
|
167
|
+
|
|
168
|
+
def writer(
|
|
169
|
+
self,
|
|
170
|
+
output_flag: OutputFormat,
|
|
171
|
+
*,
|
|
172
|
+
structured: bool,
|
|
173
|
+
stdout: TextIO,
|
|
174
|
+
fields: tuple[str, ...] | None = None,
|
|
175
|
+
) -> ResultWriter:
|
|
176
|
+
mode = resolve_format(
|
|
177
|
+
output_flag,
|
|
178
|
+
self.env,
|
|
179
|
+
stdout_tty=tty.stdout_is_tty(),
|
|
180
|
+
structured=structured,
|
|
181
|
+
fields=fields,
|
|
182
|
+
)
|
|
183
|
+
config = WriterConfig(
|
|
184
|
+
mode=mode,
|
|
185
|
+
color=tty.stdout_supports_color(self.color_mode),
|
|
186
|
+
width=tty.terminal_width(),
|
|
187
|
+
fields=fields,
|
|
188
|
+
)
|
|
189
|
+
return make_writer(config, stdout)
|
|
190
|
+
|
|
191
|
+
def _build_openai_chat(self, ref: ModelRef) -> ChatModel:
|
|
192
|
+
"""D19 precedence: an explicit API key (billable, deliberate) always wins;
|
|
193
|
+
else a stored ChatGPT login rides the codex wire; else the dual-fix screen."""
|
|
194
|
+
if self.env.get("OPENAI_API_KEY", "").strip():
|
|
195
|
+
return OpenAIChatModel(
|
|
196
|
+
ref=ref,
|
|
197
|
+
client=self.http_client,
|
|
198
|
+
base_url=resolve_base_url(self.env),
|
|
199
|
+
api_key=require_api_key(self.env, ref.name),
|
|
200
|
+
retry=self.retry,
|
|
201
|
+
)
|
|
202
|
+
from smartpipe.config.credentials import credentials_path, load_oauth
|
|
203
|
+
|
|
204
|
+
store = credentials_path(self.env)
|
|
205
|
+
credential = load_oauth(store, "openai")
|
|
206
|
+
if credential is not None:
|
|
207
|
+
from smartpipe.models.openai_codex import CodexChatModel
|
|
208
|
+
|
|
209
|
+
return CodexChatModel(
|
|
210
|
+
ref=ref, client=self.http_client, store_path=store, credential=credential
|
|
211
|
+
)
|
|
212
|
+
raise SetupFault(screens.openai_needs_key_or_login(ref.name))
|
|
213
|
+
|
|
214
|
+
def _build_openai_embed(self, ref: ModelRef) -> EmbeddingModel:
|
|
215
|
+
if not self.env.get("OPENAI_API_KEY", "").strip():
|
|
216
|
+
from smartpipe.config.credentials import credentials_path, load_oauth
|
|
217
|
+
|
|
218
|
+
if load_oauth(credentials_path(self.env), "openai") is not None:
|
|
219
|
+
raise SetupFault(screens.EMBEDDINGS_NEED_KEY) # the login wire has no embeddings
|
|
220
|
+
return OpenAIEmbeddingModel(
|
|
221
|
+
ref=ref,
|
|
222
|
+
client=self.http_client,
|
|
223
|
+
base_url=resolve_base_url(self.env),
|
|
224
|
+
api_key=require_api_key(self.env, ref.name),
|
|
225
|
+
retry=self.retry,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _build_chat(self, ref: ModelRef) -> ChatModel:
|
|
229
|
+
match ref.provider:
|
|
230
|
+
case "ollama":
|
|
231
|
+
return OllamaChatModel(
|
|
232
|
+
ref=ref,
|
|
233
|
+
client=self.http_client,
|
|
234
|
+
host=resolve_host(self.env),
|
|
235
|
+
retry=self.retry,
|
|
236
|
+
)
|
|
237
|
+
case "openai":
|
|
238
|
+
return self._build_openai_chat(ref)
|
|
239
|
+
case "anthropic":
|
|
240
|
+
return build_anthropic_chat_model(ref)
|
|
241
|
+
case "mistral": # the parametrized OpenAI wire (workstream 10)
|
|
242
|
+
return self._wire_chat(ref, MISTRAL_WIRE)
|
|
243
|
+
case "jina" | "local":
|
|
244
|
+
raise SetupFault(
|
|
245
|
+
f"error: '{ref.name}' is an embedding model, not a chat model\n"
|
|
246
|
+
" Pick a chat model instead: smartpipe config model …"
|
|
247
|
+
)
|
|
248
|
+
case "gemini": # D34: chat rides the NATIVE wire — the one that watches video
|
|
249
|
+
from smartpipe.models.gemini_native import GeminiNativeChatModel, native_base_url
|
|
250
|
+
from smartpipe.models.openai_compat import require_api_key
|
|
251
|
+
|
|
252
|
+
return GeminiNativeChatModel(
|
|
253
|
+
ref=ref,
|
|
254
|
+
client=self.http_client,
|
|
255
|
+
base_url=native_base_url(self.env),
|
|
256
|
+
api_key=require_api_key(self.env, ref.name, GEMINI_WIRE),
|
|
257
|
+
retry=self.retry,
|
|
258
|
+
)
|
|
259
|
+
case "openrouter":
|
|
260
|
+
return self._wire_chat(ref, OPENROUTER_WIRE)
|
|
261
|
+
case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
|
|
262
|
+
assert_never(unreachable)
|
|
263
|
+
|
|
264
|
+
def _wire_chat(self, ref: ModelRef, wire: WireConfig) -> ChatModel:
|
|
265
|
+
return OpenAIChatModel(
|
|
266
|
+
ref=ref,
|
|
267
|
+
client=self.http_client,
|
|
268
|
+
base_url=resolve_base_url(self.env, wire),
|
|
269
|
+
api_key=require_api_key(self.env, ref.name, wire),
|
|
270
|
+
retry=self.retry,
|
|
271
|
+
wire=wire,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _wire_embed(self, ref: ModelRef, wire: WireConfig) -> EmbeddingModel:
|
|
275
|
+
return OpenAIEmbeddingModel(
|
|
276
|
+
ref=ref,
|
|
277
|
+
client=self.http_client,
|
|
278
|
+
base_url=resolve_base_url(self.env, wire),
|
|
279
|
+
api_key=require_api_key(self.env, ref.name, wire),
|
|
280
|
+
retry=self.retry,
|
|
281
|
+
wire=wire,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def _build_embed(self, ref: ModelRef) -> EmbeddingModel:
|
|
285
|
+
match ref.provider:
|
|
286
|
+
case "local": # D44: the on-device default — no server, no key
|
|
287
|
+
from smartpipe.models.local_embed import LocalEmbeddingModel
|
|
288
|
+
|
|
289
|
+
return LocalEmbeddingModel(ref=ref)
|
|
290
|
+
case "ollama":
|
|
291
|
+
return OllamaEmbeddingModel(
|
|
292
|
+
ref=ref,
|
|
293
|
+
client=self.http_client,
|
|
294
|
+
host=resolve_host(self.env),
|
|
295
|
+
retry=self.retry,
|
|
296
|
+
)
|
|
297
|
+
case "openai":
|
|
298
|
+
return self._build_openai_embed(ref)
|
|
299
|
+
case "anthropic":
|
|
300
|
+
raise SetupFault(
|
|
301
|
+
f"error: '{ref.name}' is a chat model, not an embedding model\n"
|
|
302
|
+
" Claude models don't provide embeddings. Use a local one:\n"
|
|
303
|
+
" smartpipe config embed-model nomic-embed-text"
|
|
304
|
+
)
|
|
305
|
+
case "mistral": # mistral-embed rides the same /v1/embeddings wire
|
|
306
|
+
return self._wire_embed(ref, MISTRAL_WIRE)
|
|
307
|
+
case "gemini":
|
|
308
|
+
return self._wire_embed(ref, GEMINI_WIRE)
|
|
309
|
+
case "openrouter":
|
|
310
|
+
return self._wire_embed(ref, OPENROUTER_WIRE)
|
|
311
|
+
case "jina": # D39/04: the media-native space (text + images)
|
|
312
|
+
key = self.env.get("JINA_API_KEY", "").strip()
|
|
313
|
+
if not key:
|
|
314
|
+
raise SetupFault(
|
|
315
|
+
"error: Jina needs an API key\n export JINA_API_KEY=… (https://jina.ai)"
|
|
316
|
+
)
|
|
317
|
+
from smartpipe.models.jina import JINA_BASE_URL, JinaClipEmbeddingModel
|
|
318
|
+
|
|
319
|
+
base = self.env.get("SMARTPIPE_JINA_BASE_URL", "").strip().rstrip("/")
|
|
320
|
+
return JinaClipEmbeddingModel(
|
|
321
|
+
ref=ref,
|
|
322
|
+
client=self.http_client,
|
|
323
|
+
api_key=key,
|
|
324
|
+
base_url=base or JINA_BASE_URL,
|
|
325
|
+
retry=self.retry,
|
|
326
|
+
)
|
|
327
|
+
case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
|
|
328
|
+
assert_never(unreachable)
|
|
329
|
+
|
|
330
|
+
async def probe_ollama(self) -> tuple[str, ...] | None:
|
|
331
|
+
"""Installed ollama model names, or None if nothing is listening."""
|
|
332
|
+
return await ollama_model_names(self.http_client, resolve_host(self.env))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _resolve_max_calls(environ: Mapping[str, str], flag: int | None) -> int | None:
|
|
336
|
+
"""--max-calls > SMARTPIPE_MAX_CALLS > uncapped; bad values are loud (D18)."""
|
|
337
|
+
if flag is not None:
|
|
338
|
+
if flag < 1:
|
|
339
|
+
raise UsageFault(f"--max-calls must be >= 1, got {flag}")
|
|
340
|
+
return flag
|
|
341
|
+
env_value = environ.get("SMARTPIPE_MAX_CALLS", "").strip()
|
|
342
|
+
if not env_value:
|
|
343
|
+
return None
|
|
344
|
+
if not (env_value.isdigit() and int(env_value) >= 1):
|
|
345
|
+
raise UsageFault(f"SMARTPIPE_MAX_CALLS must be a whole number >= 1, got {env_value!r}")
|
|
346
|
+
return int(env_value)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _cache_enabled(env: Mapping[str, str], config: Config) -> bool:
|
|
350
|
+
flag = env.get("SMARTPIPE_CACHE", "").strip().lower()
|
|
351
|
+
if flag in ("1", "true", "on", "yes"):
|
|
352
|
+
return True
|
|
353
|
+
if flag in ("0", "false", "off", "no"):
|
|
354
|
+
return False
|
|
355
|
+
return bool(config.cache)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _cache_dir(env: Mapping[str, str]) -> Path:
|
|
359
|
+
base = env.get("XDG_CACHE_HOME", "").strip()
|
|
360
|
+
root = Path(base) if base else Path.home() / ".cache"
|
|
361
|
+
return root / "smartpipe" / "results"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@asynccontextmanager
|
|
365
|
+
async def build_container(
|
|
366
|
+
environ: Mapping[str, str],
|
|
367
|
+
*,
|
|
368
|
+
color_mode: ColorMode = ColorMode.AUTO,
|
|
369
|
+
max_calls: int | None = None,
|
|
370
|
+
stop: asyncio.Event | None = None,
|
|
371
|
+
) -> AsyncGenerator[AppContainer]:
|
|
372
|
+
"""Build the container for one invocation and own the HTTP client's lifecycle.
|
|
373
|
+
|
|
374
|
+
``stop`` is the drain event of the per-item verbs: a tripped call budget stops
|
|
375
|
+
intake through it. Whole-set verbs pass no stop — exhaustion there is fatal.
|
|
376
|
+
"""
|
|
377
|
+
limit = _resolve_max_calls(environ, max_calls)
|
|
378
|
+
config = load_config(config_path(environ), environ)
|
|
379
|
+
client = make_client()
|
|
380
|
+
from smartpipe.io import metering
|
|
381
|
+
|
|
382
|
+
metering.reset() # a fresh run's meter (D40)
|
|
383
|
+
container = AppContainer(
|
|
384
|
+
env=dict(environ),
|
|
385
|
+
config=config,
|
|
386
|
+
http_client=client,
|
|
387
|
+
color_mode=color_mode,
|
|
388
|
+
budget=None if limit is None else CallBudget(limit=limit, stop=stop),
|
|
389
|
+
)
|
|
390
|
+
try:
|
|
391
|
+
yield container
|
|
392
|
+
finally:
|
|
393
|
+
await client.aclose()
|
|
394
|
+
totals = metering.receipt()
|
|
395
|
+
if totals is not None:
|
|
396
|
+
diagnostics.note(totals) # D40: the number that goes in the report
|
|
397
|
+
from smartpipe.io import usage
|
|
398
|
+
|
|
399
|
+
usage.record_run(metering.snapshot(), container.env) # D41: the ledger
|
|
400
|
+
_cache_receipt(container)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _cache_receipt(container: AppContainer) -> None:
|
|
404
|
+
from smartpipe.models.cache import CachingChatModel
|
|
405
|
+
|
|
406
|
+
hits = sum(w.hits for w in container.caches if isinstance(w, CachingChatModel))
|
|
407
|
+
misses = sum(w.misses for w in container.caches if isinstance(w, CachingChatModel))
|
|
408
|
+
if hits or misses:
|
|
409
|
+
diagnostics.note(f"cache: {hits:,} hits · {misses:,} calls")
|
|
410
|
+
if container.caches:
|
|
411
|
+
_maybe_sweep(container)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _maybe_sweep(container: AppContainer) -> None:
|
|
415
|
+
"""TTL + LRU sweep at exit, at most daily — the cache is never the user's
|
|
416
|
+
problem (D39/02). Any filesystem trouble is swallowed: a broken cache dir
|
|
417
|
+
must never fail a run."""
|
|
418
|
+
import time
|
|
419
|
+
|
|
420
|
+
from smartpipe.models.cache import sweep
|
|
421
|
+
|
|
422
|
+
directory = _cache_dir(container.env)
|
|
423
|
+
marker = directory / "last-sweep"
|
|
424
|
+
try:
|
|
425
|
+
now = time.time()
|
|
426
|
+
if marker.exists() and now - marker.stat().st_mtime < 86_400:
|
|
427
|
+
return
|
|
428
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
429
|
+
marker.write_text("", encoding="utf-8")
|
|
430
|
+
removed, freed = sweep(
|
|
431
|
+
directory,
|
|
432
|
+
ttl_days=container.config.cache_days or 30,
|
|
433
|
+
max_mb=container.config.cache_max_mb or 500,
|
|
434
|
+
now=now,
|
|
435
|
+
)
|
|
436
|
+
if removed:
|
|
437
|
+
diagnostics.note(f"cache: swept {removed:,} entries ({freed / 1_048_576:.1f} MB)")
|
|
438
|
+
except OSError:
|
|
439
|
+
return
|