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,149 @@
1
+ """Anthropic (Claude) adapter via the official SDK — an optional extra.
2
+
3
+ Project policy (plan/decisions.md D04): Claude calls go through the ``anthropic``
4
+ SDK, not raw HTTP. The SDK is lazy-imported so the core install stays SDK-free; a
5
+ ``claude-*`` model without the extra produces the actionable missing-extra screen.
6
+ The SDK owns its own retries and credential resolution (``ANTHROPIC_API_KEY``,
7
+ ``ANTHROPIC_AUTH_TOKEN``, ``ant`` profiles) — we never pre-check the env var, and a
8
+ rejected key surfaces as the key screen at request time.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from smartpipe.cli import screens
17
+ from smartpipe.core.errors import ItemError, SetupFault
18
+ from smartpipe.core.jsontools import as_str, record_at
19
+ from smartpipe.io import metering
20
+ from smartpipe.models.base import AudioData, ImageData
21
+
22
+ if TYPE_CHECKING:
23
+ from smartpipe.models.base import CompletionRequest, ModelRef
24
+
25
+ __all__ = [
26
+ "AnthropicChatModel",
27
+ "build_anthropic_chat_model",
28
+ "build_kwargs",
29
+ "load_anthropic_client",
30
+ ]
31
+
32
+ _TIMEOUT_SECONDS = 120.0
33
+ # The SDK's built-in retries already honor Retry-After on 429 (unlike our raw-httpx
34
+ # adapters, which route the header through retry.with_retries' delay_hint).
35
+ _MAX_RETRIES = 3
36
+
37
+
38
+ def load_anthropic_client(model: str) -> Any:
39
+ try:
40
+ import anthropic
41
+ except ImportError as exc:
42
+ raise SetupFault(screens.missing_anthropic_extra(model)) from exc
43
+ try:
44
+ return anthropic.AsyncAnthropic(timeout=_TIMEOUT_SECONDS, max_retries=_MAX_RETRIES)
45
+ except anthropic.AnthropicError as exc: # e.g. no credentials resolvable at all
46
+ raise SetupFault(_key_screen(model)) from exc
47
+
48
+
49
+ def _key_screen(model: str) -> str:
50
+ return screens.missing_api_key(model, "Anthropic", "ANTHROPIC_API_KEY", "sk-ant-...")
51
+
52
+
53
+ def build_anthropic_chat_model(ref: ModelRef) -> AnthropicChatModel:
54
+ return AnthropicChatModel(ref=ref, client=load_anthropic_client(ref.name))
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class AnthropicChatModel:
59
+ ref: ModelRef
60
+ client: Any # anthropic.AsyncAnthropic — untyped here to keep the SDK a soft dependency
61
+
62
+ async def complete(self, request: CompletionRequest) -> str:
63
+ import anthropic
64
+
65
+ kwargs = build_kwargs(self.ref.name, request)
66
+ try:
67
+ metering.add_request_media(request.media)
68
+ message = await self.client.messages.create(**kwargs)
69
+ except (anthropic.AuthenticationError, anthropic.PermissionDeniedError) as exc:
70
+ raise SetupFault(_key_screen(self.ref.name)) from exc
71
+ except anthropic.APIConnectionError as exc:
72
+ raise SetupFault(
73
+ f"error: can't reach the Anthropic API ({exc})\n"
74
+ f" The model '{self.ref}' needs api.anthropic.com.\n"
75
+ " Check your network connection and try again."
76
+ ) from exc
77
+ except anthropic.APIStatusError as exc:
78
+ raise ItemError(f"anthropic error {exc.status_code}: {_status_detail(exc)}") from exc
79
+ return _reply_text(self.ref.name, message)
80
+
81
+
82
+ def build_kwargs(name: str, request: CompletionRequest) -> dict[str, object]:
83
+ kwargs: dict[str, object] = {
84
+ "model": name,
85
+ "max_tokens": request.max_tokens,
86
+ "temperature": request.temperature, # reproducible by default (D36)
87
+ "messages": [{"role": "user", "content": _user_content(request)}],
88
+ }
89
+ if request.system is not None:
90
+ kwargs["system"] = request.system
91
+ if request.json_schema is not None:
92
+ kwargs["output_config"] = {
93
+ "format": {"type": "json_schema", "schema": dict(request.json_schema)}
94
+ }
95
+ return kwargs
96
+
97
+
98
+ def _user_content(request: CompletionRequest) -> str | list[dict[str, object]]:
99
+ """Plain string normally; image blocks (image first) when media rides along."""
100
+ if not request.media:
101
+ return request.user
102
+ if any(isinstance(part, AudioData) for part in request.media):
103
+ # the messages API has no audio input — fail before any bytes leave (D20 §2)
104
+ raise ItemError(
105
+ "this model can't hear audio — try an audio model "
106
+ "(voxtral, gemini) — smartpipe transcribes locally otherwise"
107
+ )
108
+ import base64
109
+
110
+ blocks: list[dict[str, object]] = [
111
+ {
112
+ "type": "image",
113
+ "source": {
114
+ "type": "base64",
115
+ "media_type": part.mime,
116
+ "data": base64.b64encode(part.data).decode(),
117
+ },
118
+ }
119
+ for part in request.media
120
+ if isinstance(part, ImageData)
121
+ ]
122
+ blocks.append({"type": "text", "text": request.user})
123
+ return blocks
124
+
125
+
126
+ def _reply_text(name: str, message: Any) -> str:
127
+ if getattr(message, "stop_reason", None) == "refusal":
128
+ raise ItemError(f"the model '{name}' declined this item")
129
+ blocks = getattr(message, "content", []) or []
130
+ text = "".join(block.text for block in blocks if getattr(block, "type", None) == "text")
131
+ if not text:
132
+ raise ItemError(f"the model '{name}' returned an empty reply")
133
+ usage = getattr(message, "usage", None)
134
+ tokens_in = getattr(usage, "input_tokens", None)
135
+ tokens_out = getattr(usage, "output_tokens", None)
136
+ metering.add_tokens(
137
+ tokens_in=tokens_in if isinstance(tokens_in, int) else 0,
138
+ tokens_out=tokens_out if isinstance(tokens_out, int) else 0,
139
+ )
140
+ return text
141
+
142
+
143
+ def _status_detail(exc: Any) -> str:
144
+ error = record_at(getattr(exc, "body", None), "error")
145
+ message = as_str(error.get("message")) if error is not None else None
146
+ if message is not None:
147
+ return message
148
+ fallback = getattr(exc, "message", None)
149
+ return fallback if isinstance(fallback, str) else str(exc)
@@ -0,0 +1,170 @@
1
+ """Provider-neutral model contracts (plan/architecture.md "Provider abstraction").
2
+
3
+ Routing rule (plan/decisions.md D04, refined): ``provider/name`` is explicit for
4
+ the four known providers; bare names route by shape — ``claude-*`` → anthropic,
5
+ ``gpt-*``/o-series/``text-embedding-*`` → openai, the Mistral family prefixes
6
+ (``mistral-``, ``codestral-``, ``pixtral-`` …) → mistral, everything else → ollama.
7
+ Unknown ``x/y`` prefixes are NOT errors: Ollama supports namespaced model names
8
+ (``someuser/model:tag``, ``hf.co/org/model``), so they route to ollama whole —
9
+ which is also why ``hf.co/mistralai/...`` must never be hijacked by the bare-name
10
+ prefixes (the slash form wins first).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Literal, Protocol, TypeGuard, runtime_checkable
18
+
19
+ from smartpipe.core.errors import UsageFault
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Mapping, Sequence
23
+
24
+ __all__ = [
25
+ "AudioData",
26
+ "ChatModel",
27
+ "CompletionRequest",
28
+ "EmbeddingModel",
29
+ "ImageData",
30
+ "MediaData",
31
+ "MediaEmbeddingModel",
32
+ "ModelRef",
33
+ "Provider",
34
+ "VideoData",
35
+ "parse_model_ref",
36
+ "supports_media_embedding",
37
+ ]
38
+
39
+ Provider = Literal[
40
+ "local", "ollama", "openai", "anthropic", "mistral", "gemini", "openrouter", "jina"
41
+ ]
42
+
43
+ _OPENAI_PREFIXES = ("gpt-", "chatgpt-", "text-embedding-")
44
+ _OPENAI_O_SERIES = re.compile(r"o\d")
45
+ _MISTRAL_PREFIXES = (
46
+ "mistral-", # also covers mistral-embed
47
+ "ministral-",
48
+ "codestral-",
49
+ "magistral-",
50
+ "devstral-",
51
+ "pixtral-",
52
+ "voxtral-", # the audio family — found missing by the live smoke
53
+ "open-mistral-",
54
+ "open-mixtral-",
55
+ )
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class ImageData:
60
+ data: bytes
61
+ mime: str
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class VideoData:
66
+ """Raw video bytes + mime — converted to frames + track before any wire (D27)."""
67
+
68
+ data: bytes
69
+ mime: str
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class AudioData:
74
+ data: bytes
75
+ mime: str # audio/mpeg · audio/wav · audio/mp4 · audio/ogg · audio/flac
76
+
77
+
78
+ # The D20 union: ONE optional media field on Item/CompletionRequest, dispatched
79
+ # with match + assert_never. VideoData is reserved, not added — no wired provider
80
+ # carries video on our wires (capability follows wire).
81
+ MediaData = ImageData | AudioData | VideoData
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class ModelRef:
86
+ provider: Provider
87
+ name: str # passed through to the backend verbatim — smartpipe keeps no registry
88
+
89
+ def __str__(self) -> str:
90
+ return f"{self.provider}/{self.name}"
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class CompletionRequest:
95
+ system: str | None
96
+ user: str
97
+ json_schema: Mapping[str, object] | None = None # provider-native structured output
98
+ max_tokens: int = 8192
99
+ temperature: float = 0.0 # a pipe is a data tool — reproducible by default (D36)
100
+ presence_penalty: float | None = None # anti-rambling; prose-only (D35)
101
+ frequency_penalty: float | None = None # never on schema calls — penalties corrupt JSON
102
+ media: tuple[MediaData, ...] = () # vision/audio (bytes + mime; D20 union)
103
+
104
+
105
+ class ChatModel(Protocol):
106
+ # A read-only property (not a bare attribute) so frozen-dataclass adapters,
107
+ # whose fields pyright treats as read-only, structurally satisfy the Protocol.
108
+ @property
109
+ def ref(self) -> ModelRef: ...
110
+
111
+ async def complete(self, request: CompletionRequest) -> str: ...
112
+
113
+
114
+ @runtime_checkable
115
+ class MediaEmbeddingModel(Protocol):
116
+ """An embedder that takes text AND images in one space (D39/04) — the
117
+ mention of such a model is the switch away from the caption pivot."""
118
+
119
+ @property
120
+ def ref(self) -> ModelRef: ...
121
+
122
+ async def embed_parts(
123
+ self, parts: Sequence[str | ImageData]
124
+ ) -> tuple[tuple[float, ...], ...]: ...
125
+
126
+
127
+ def supports_media_embedding(model: object) -> TypeGuard[MediaEmbeddingModel]:
128
+ return isinstance(model, MediaEmbeddingModel)
129
+
130
+
131
+ class EmbeddingModel(Protocol):
132
+ @property
133
+ def ref(self) -> ModelRef: ...
134
+
135
+ async def embed(self, texts: Sequence[str]) -> tuple[tuple[float, ...], ...]: ...
136
+
137
+
138
+ def parse_model_ref(text: str) -> ModelRef:
139
+ cleaned = text.strip()
140
+ if not cleaned:
141
+ raise UsageFault("no model given — try: --model ollama/qwen3:8b")
142
+ prefix, slash, rest = cleaned.partition("/")
143
+ if slash:
144
+ match prefix:
145
+ case (
146
+ "local"
147
+ | "ollama"
148
+ | "openai"
149
+ | "anthropic"
150
+ | "mistral"
151
+ | "gemini"
152
+ | "openrouter"
153
+ | "jina"
154
+ ):
155
+ if not rest:
156
+ raise UsageFault(f"model '{cleaned}' is missing a name after '{prefix}/'")
157
+ return ModelRef(provider=prefix, name=rest)
158
+ case _:
159
+ pass # a namespaced ollama model name — fall through to shape routing
160
+ if cleaned.startswith("claude"):
161
+ return ModelRef(provider="anthropic", name=cleaned)
162
+ if cleaned.startswith(_OPENAI_PREFIXES) or _OPENAI_O_SERIES.match(cleaned):
163
+ return ModelRef(provider="openai", name=cleaned)
164
+ if not slash and cleaned.startswith(_MISTRAL_PREFIXES):
165
+ return ModelRef(provider="mistral", name=cleaned)
166
+ if not slash and cleaned.startswith("gemini-"):
167
+ return ModelRef(provider="gemini", name=cleaned)
168
+ # openrouter is explicit-only: its names ARE other vendors' names — a bare
169
+ # prefix would hijack every routing rule above (D24 commit)
170
+ return ModelRef(provider="ollama", name=cleaned)
@@ -0,0 +1,94 @@
1
+ """``--max-calls`` (D18): a hard ceiling on model calls.
2
+
3
+ The budget wraps models at the composition root, so verbs stay ignorant. One
4
+ charge = one model call (a repair re-ask charges again; wire retries of the same
5
+ call, inside ``with_retries``, do not — the wrapper sits outside them).
6
+
7
+ Two exhaustion behaviors, pinned in ux.md: with a ``stop`` event (the per-item
8
+ verbs' drain machinery) the limit call still runs, intake stops, and any racing
9
+ in-flight worker skips its item; without one (whole-set verbs — a partial
10
+ collection is nothing usable) exhaustion is fatal with the fix screen.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING
17
+
18
+ from smartpipe.core.errors import ItemError, SetupFault
19
+
20
+ if TYPE_CHECKING:
21
+ import asyncio
22
+ from collections.abc import Sequence
23
+
24
+ from smartpipe.models.base import ChatModel, CompletionRequest, EmbeddingModel, ModelRef
25
+
26
+ __all__ = ["CallBudget", "budgeted_chat", "budgeted_embed"]
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class CallBudget:
31
+ """Mutable by design (documented Spinner-style exception): the counter is
32
+ per-run state shared by every model the container hands out."""
33
+
34
+ limit: int
35
+ stop: asyncio.Event | None # per-item verbs trip intake; None = whole-set (fatal)
36
+ calls: int = 0
37
+ exhausted: bool = False
38
+
39
+ def __post_init__(self) -> None:
40
+ if self.limit < 1:
41
+ raise ValueError(f"--max-calls must be >= 1, got {self.limit}")
42
+
43
+ def charge(self) -> None:
44
+ if self.calls >= self.limit:
45
+ self.exhausted = True
46
+ if self.stop is None:
47
+ raise SetupFault(
48
+ f"error: call budget reached mid-collection (--max-calls {self.limit})\n"
49
+ " This verb needs every item before it can produce a result — a cap\n"
50
+ " that stops early leaves nothing usable.\n"
51
+ " Raise --max-calls, shrink the input, or drop the cap."
52
+ )
53
+ raise ItemError(f"call budget reached (--max-calls {self.limit})")
54
+ self.calls += 1
55
+ if self.calls >= self.limit:
56
+ self.exhausted = True
57
+ if self.stop is not None:
58
+ self.stop.set() # the limit call runs; nothing new starts
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class _BudgetedChat:
63
+ inner: ChatModel
64
+ budget: CallBudget
65
+
66
+ @property
67
+ def ref(self) -> ModelRef:
68
+ return self.inner.ref
69
+
70
+ async def complete(self, request: CompletionRequest) -> str:
71
+ self.budget.charge()
72
+ return await self.inner.complete(request)
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class _BudgetedEmbed:
77
+ inner: EmbeddingModel
78
+ budget: CallBudget
79
+
80
+ @property
81
+ def ref(self) -> ModelRef:
82
+ return self.inner.ref
83
+
84
+ async def embed(self, texts: Sequence[str]) -> tuple[tuple[float, ...], ...]:
85
+ self.budget.charge()
86
+ return await self.inner.embed(texts)
87
+
88
+
89
+ def budgeted_chat(inner: ChatModel, budget: CallBudget) -> ChatModel:
90
+ return _BudgetedChat(inner, budget)
91
+
92
+
93
+ def budgeted_embed(inner: EmbeddingModel, budget: CallBudget) -> EmbeddingModel:
94
+ return _BudgetedEmbed(inner, budget)
@@ -0,0 +1,132 @@
1
+ """Result caching for chat completions (D38/15, KQL ``materialize``).
2
+
3
+ Iteration stops re-paying unchanged work. Sound because of D36: temperature
4
+ is 0.0 everywhere, so identical request → identical reply is the contract.
5
+ The cache wraps OUTSIDE the call budget — a hit costs nothing and must not
6
+ count against ``--max-calls`` (the belt caps spend, not answers).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from pathlib import Path
18
+
19
+ from smartpipe.models.base import ChatModel, CompletionRequest, ModelRef
20
+
21
+ __all__ = ["CachingChatModel", "cache_key", "sweep"]
22
+
23
+
24
+ def cache_key(ref: ModelRef, request: CompletionRequest) -> str:
25
+ """Anything that changes the reply changes the key."""
26
+ payload: dict[str, object] = {
27
+ "provider": ref.provider,
28
+ "model": ref.name,
29
+ "system": request.system,
30
+ "user": request.user,
31
+ "schema": request.json_schema,
32
+ "temperature": request.temperature,
33
+ "presence": request.presence_penalty,
34
+ "frequency": request.frequency_penalty,
35
+ "max_tokens": request.max_tokens,
36
+ "media": [(part.mime, hashlib.sha256(part.data).hexdigest()) for part in request.media],
37
+ }
38
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
39
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
40
+
41
+
42
+ class CachingChatModel:
43
+ """ChatModel-shaped wrapper: hit → stored reply, miss → inner + store."""
44
+
45
+ def __init__(self, inner: ChatModel, directory: Path) -> None:
46
+ self.inner = inner
47
+ self.directory = directory
48
+ self.hits = 0
49
+ self.misses = 0
50
+
51
+ @property
52
+ def ref(self) -> ModelRef:
53
+ return self.inner.ref
54
+
55
+ async def complete(self, request: CompletionRequest) -> str:
56
+ key = cache_key(self.inner.ref, request)
57
+ path = self.directory / key[:2] / f"{key}.json"
58
+ stored = _read(path)
59
+ if stored is not None:
60
+ self.hits += 1
61
+ return stored
62
+ reply = await self.inner.complete(request)
63
+ self.misses += 1
64
+ _write(path, reply)
65
+ return reply
66
+
67
+
68
+ def _read(path: Path) -> str | None:
69
+ try:
70
+ parsed: object = json.loads(path.read_text(encoding="utf-8"))
71
+ os.utime(path) # a hit refreshes recency — the LRU truth (D39/02)
72
+ except (OSError, json.JSONDecodeError):
73
+ return None # missing or corrupt — a miss, never a crash
74
+ from smartpipe.core.jsontools import as_record
75
+
76
+ record = as_record(parsed)
77
+ if record is not None:
78
+ reply = record.get("reply")
79
+ if isinstance(reply, str):
80
+ return reply
81
+ return None
82
+
83
+
84
+ def _write(path: Path, reply: str) -> None:
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ scratch = path.with_suffix(".tmp")
87
+ scratch.write_text(json.dumps({"reply": reply}, ensure_ascii=False), encoding="utf-8")
88
+ os.replace(scratch, path) # atomic on POSIX — never a half-written entry
89
+
90
+
91
+ _DAY_SECONDS = 86_400
92
+
93
+
94
+ def sweep(directory: Path, *, ttl_days: int, max_mb: int, now: float) -> tuple[int, int]:
95
+ """Expire entries past the TTL, then LRU-evict (oldest mtime first) until
96
+ under the size cap. Returns (entries removed, bytes removed). Pure walk —
97
+ the caller owns the once-a-day gating and error tolerance."""
98
+ entries: list[tuple[float, int, Path]] = []
99
+ for path in directory.rglob("*.json"):
100
+ try:
101
+ stat = path.stat()
102
+ except OSError:
103
+ continue
104
+ entries.append((stat.st_mtime, stat.st_size, path))
105
+ removed = 0
106
+ freed = 0
107
+ survivors: list[tuple[float, int, Path]] = []
108
+ horizon = now - ttl_days * _DAY_SECONDS
109
+ for mtime, size, path in entries:
110
+ if mtime < horizon:
111
+ try:
112
+ path.unlink()
113
+ removed += 1
114
+ freed += size
115
+ except OSError:
116
+ survivors.append((mtime, size, path))
117
+ else:
118
+ survivors.append((mtime, size, path))
119
+ survivors.sort() # oldest first
120
+ total = sum(size for _mtime, size, _path in survivors)
121
+ cap = max_mb * 1_048_576
122
+ for _mtime, size, path in survivors:
123
+ if total <= cap:
124
+ break
125
+ try:
126
+ path.unlink()
127
+ removed += 1
128
+ freed += size
129
+ total -= size
130
+ except OSError:
131
+ continue
132
+ return removed, freed