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,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
@@ -0,0 +1,5 @@
1
+ """Shared value types, errors, and exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []