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,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)
|
smartpipe/models/base.py
ADDED
|
@@ -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
|