sift-cli 1.0.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.
- sift/__init__.py +18 -0
- sift/answers.py +175 -0
- sift/background.py +444 -0
- sift/capture.py +240 -0
- sift/cli.py +820 -0
- sift/digest.py +101 -0
- sift/distill.py +670 -0
- sift/fallback.py +98 -0
- sift/hook.py +275 -0
- sift/lines.py +37 -0
- sift/many.py +51 -0
- sift/memory.py +94 -0
- sift/model.py +433 -0
- sift/outline.py +117 -0
- sift/peek.py +161 -0
- sift/privacy.py +145 -0
- sift/records.py +95 -0
- sift/server.py +552 -0
- sift/store.py +499 -0
- sift/tools.py +76 -0
- sift/view.py +317 -0
- sift/watch.py +166 -0
- sift_cli-1.0.0.dist-info/METADATA +326 -0
- sift_cli-1.0.0.dist-info/RECORD +27 -0
- sift_cli-1.0.0.dist-info/WHEEL +4 -0
- sift_cli-1.0.0.dist-info/entry_points.txt +3 -0
- sift_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
sift/model.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Reaching a model, and never depending on having reached one.
|
|
2
|
+
|
|
3
|
+
`sift` asks a model which lines of a capture are worth showing. That question is
|
|
4
|
+
worth asking well, so the ladder starts at the largest model available and only
|
|
5
|
+
steps down when a rung cannot be reached. Stepping down is about availability,
|
|
6
|
+
never about spending less: every rung here is free, and the top one is where the
|
|
7
|
+
judgement is best.
|
|
8
|
+
|
|
9
|
+
Three things are deliberate.
|
|
10
|
+
|
|
11
|
+
**Nothing is raised.** `ask` returns an answer or nothing at all. A missing key,
|
|
12
|
+
an unplugged network, a model that is busy, a reply that makes no sense -- none
|
|
13
|
+
of these are the user's problem, and none of them may stop a tool whose job is to
|
|
14
|
+
run their command. Everything that goes wrong is recorded in `last_error` for
|
|
15
|
+
anyone who wants to look, and then the caller carries on without a model.
|
|
16
|
+
|
|
17
|
+
**No client library.** This talks to an OpenAI-shaped endpoint over `urllib`,
|
|
18
|
+
which is in the standard library. A tool that gets installed into other people's
|
|
19
|
+
environments should not drag a dependency tree in behind it, and the request here
|
|
20
|
+
is a single POST -- there is nothing to abstract.
|
|
21
|
+
|
|
22
|
+
**Only what the caller hands over leaves this machine.** No identity, no
|
|
23
|
+
environment, no paths, nothing gathered on the side. What gets sent is the
|
|
24
|
+
prompt, and deciding what is safe to put in that prompt is the caller's job, not
|
|
25
|
+
a hidden one taken on here.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import socket
|
|
33
|
+
import time
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.request
|
|
36
|
+
from collections.abc import Callable, Sequence
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from sift.privacy import sending_on
|
|
41
|
+
|
|
42
|
+
DEFAULT_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
|
43
|
+
|
|
44
|
+
DEFAULT_LADDER = (
|
|
45
|
+
# Ordered by measurement, not by parameter count, and the flagship is not on
|
|
46
|
+
# it. Measured on 2026-09-08 against the free tier: the 550b holds a request
|
|
47
|
+
# in a queue for 107-124 seconds before it answers, on two independent
|
|
48
|
+
# providers -- so the wait is the model's, not one endpoint's mood. The same
|
|
49
|
+
# question costs `super` about six seconds and `lightning` about fourteen.
|
|
50
|
+
#
|
|
51
|
+
# Keeping it first cost every distillation 181.5 seconds it could not use:
|
|
52
|
+
# two timeouts at 90s, then the fall to the rung that was going to answer
|
|
53
|
+
# anyway. Measured end to end, one 404-line build took 500 seconds. Without
|
|
54
|
+
# it, the same work is 6-15.
|
|
55
|
+
#
|
|
56
|
+
# It is left out rather than moved last because last is where a ladder goes
|
|
57
|
+
# when everything above it has failed -- which is exactly when nobody can
|
|
58
|
+
# afford to wait two minutes. `SIFT_MODELS` puts it back for anyone who
|
|
59
|
+
# wants it.
|
|
60
|
+
"nvidia/nemotron-3-super-120b-a12b",
|
|
61
|
+
"nvidia/nemotron-3.5-lightning-30b-a3b",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
KEY_FILE = Path("~/.config/nvidia/api_key")
|
|
65
|
+
KEY_VARIABLES = ("SIFT_API_KEY", "NVIDIA_API_KEY")
|
|
66
|
+
|
|
67
|
+
# Conditions that say "not now" rather than "not ever". Anything else is a fact
|
|
68
|
+
# about the request, and a fact about the request does not improve on retry.
|
|
69
|
+
TRANSIENT = frozenset({0, 408, 409, 425, 429, 500, 502, 503, 504})
|
|
70
|
+
|
|
71
|
+
_TRIES_PER_RUNG = 2
|
|
72
|
+
_BACKOFF_SECONDS = 1.5
|
|
73
|
+
# How long one ask may take before the rung is given up on.
|
|
74
|
+
#
|
|
75
|
+
# Measured. A rung that is going to answer answers in about six seconds; a rung
|
|
76
|
+
# that is queued takes over a hundred. Ninety was the worst number available:
|
|
77
|
+
# far past the first, far short of the second, so it paid a minute and a half
|
|
78
|
+
# and learned nothing. Twenty-five is comfortably past a working rung and
|
|
79
|
+
# comfortably short of a queued one. `SIFT_TIMEOUT` moves it.
|
|
80
|
+
_DEFAULT_TIMEOUT = 25.0
|
|
81
|
+
|
|
82
|
+
# What the second pass waits, when the first one ran out of time everywhere.
|
|
83
|
+
#
|
|
84
|
+
# The first pass is short on purpose, and short is right nearly always: a rung
|
|
85
|
+
# that is going to answer answers in about six seconds. But "nobody answered in
|
|
86
|
+
# twenty-five seconds" is not the same statement as "nobody was going to" --
|
|
87
|
+
# measured, a queued rung clears at 107-124 seconds, which is where the flagship
|
|
88
|
+
# sits when the free tier is busy.
|
|
89
|
+
#
|
|
90
|
+
# So the choice between fast and patient is not made. Both are, in that order,
|
|
91
|
+
# and the patience is only ever spent when the quick way came back with nothing.
|
|
92
|
+
# On an ordinary day it costs nothing at all: the first pass answers and the
|
|
93
|
+
# second never begins. `SIFT_PATIENCE=0` turns it off for a caller who would
|
|
94
|
+
# rather have a quick no.
|
|
95
|
+
_PATIENT_TIMEOUT = 150.0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Reply:
|
|
100
|
+
"""One HTTP answer, reduced to the two things that matter here.
|
|
101
|
+
|
|
102
|
+
A status of 0 means the endpoint was never reached at all -- no network, no
|
|
103
|
+
DNS, a refused connection. It is grouped with the busy statuses because it
|
|
104
|
+
says the same thing: try again, or try elsewhere.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
status: int
|
|
108
|
+
body: bytes
|
|
109
|
+
# Whether the endpoint took longer than it was given, as opposed to
|
|
110
|
+
# refusing quickly. Both are "try elsewhere", and only one of them is worth
|
|
111
|
+
# asking twice: a refusal costs a moment, a wait costs the whole timeout.
|
|
112
|
+
timed_out: bool = False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Answer:
|
|
117
|
+
"""What the model said, and what it took to get it."""
|
|
118
|
+
|
|
119
|
+
text: str
|
|
120
|
+
model: str
|
|
121
|
+
tries: int
|
|
122
|
+
# What the endpoint says this exchange cost, in its own tokens. Read from
|
|
123
|
+
# the reply rather than worked out here: a token count computed by dividing
|
|
124
|
+
# bytes by four is a guess wearing the clothes of a measurement, and it is
|
|
125
|
+
# wrong by different amounts in every language this tool is pointed at.
|
|
126
|
+
# Zero when the reply did not say, which is the honest answer to "how many"
|
|
127
|
+
# when nobody counted.
|
|
128
|
+
tokens: int = 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
Transport = Callable[..., Reply]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def find_key() -> str | None:
|
|
135
|
+
"""The API key, from the environment or from where `nemotron` keeps it.
|
|
136
|
+
|
|
137
|
+
The file is checked last so that a shell can override it for one command
|
|
138
|
+
without editing anything, which is what a person debugging expects.
|
|
139
|
+
"""
|
|
140
|
+
for name in KEY_VARIABLES:
|
|
141
|
+
value = (os.environ.get(name) or "").strip()
|
|
142
|
+
if value:
|
|
143
|
+
return value
|
|
144
|
+
try:
|
|
145
|
+
value = KEY_FILE.expanduser().read_text(encoding="utf-8").strip()
|
|
146
|
+
except OSError:
|
|
147
|
+
return None
|
|
148
|
+
return value or None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def effort() -> str | None:
|
|
152
|
+
"""How hard the model should think before answering, or None to leave it alone.
|
|
153
|
+
|
|
154
|
+
Measured, and the measurement is the reason this exists. Asked which lines
|
|
155
|
+
matter in a 404-line build, the model wrote 924 tokens of reasoning to
|
|
156
|
+
produce a twelve-token answer, and the caller waited 15.3 seconds for it.
|
|
157
|
+
The same question at `reasoning_effort=low` took 2.4 seconds and named the
|
|
158
|
+
same lines.
|
|
159
|
+
|
|
160
|
+
Deliberately a setting rather than a constant: it is an OpenAI-shaped field
|
|
161
|
+
that not every endpoint accepts, and an endpoint that rejects it would
|
|
162
|
+
otherwise turn a speed setting into no answer at all.
|
|
163
|
+
"""
|
|
164
|
+
written = os.environ.get("SIFT_EFFORT", "").strip().lower()
|
|
165
|
+
return written or None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def default_ladder() -> tuple[str, ...]:
|
|
169
|
+
"""The rungs to try, in order, with `SIFT_MODELS` overriding the built-in list."""
|
|
170
|
+
written = os.environ.get("SIFT_MODELS", "")
|
|
171
|
+
chosen = tuple(part.strip() for part in written.split(",") if part.strip())
|
|
172
|
+
return chosen or DEFAULT_LADDER
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Bridge:
|
|
176
|
+
"""A way to ask a model something, or to find out that you cannot."""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
ladder: Sequence[str] | None = None,
|
|
182
|
+
base_url: str | None = None,
|
|
183
|
+
api_key: str | None = None,
|
|
184
|
+
timeout: float | None = None,
|
|
185
|
+
patience: float | None = None,
|
|
186
|
+
transport: Transport | None = None,
|
|
187
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
188
|
+
) -> None:
|
|
189
|
+
self.ladder = tuple(ladder) if ladder is not None else default_ladder()
|
|
190
|
+
written_url = base_url or os.environ.get("SIFT_BASE_URL") or DEFAULT_BASE_URL
|
|
191
|
+
self.base_url = written_url.rstrip("/")
|
|
192
|
+
self.api_key = find_key() if api_key is None else api_key
|
|
193
|
+
self.timeout = _as_float(os.environ.get("SIFT_TIMEOUT"), _DEFAULT_TIMEOUT, timeout)
|
|
194
|
+
self.patience = _as_float(
|
|
195
|
+
os.environ.get("SIFT_PATIENCE"), _PATIENT_TIMEOUT, patience
|
|
196
|
+
)
|
|
197
|
+
self.last_error: str | None = None
|
|
198
|
+
self._post = transport or post
|
|
199
|
+
self._sleep = sleep
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def available(self) -> bool:
|
|
203
|
+
"""Whether there is a key to ask with.
|
|
204
|
+
|
|
205
|
+
Callers use this to choose a path before spending effort building a
|
|
206
|
+
prompt, not to decide whether they are allowed to fail: `ask` is safe to
|
|
207
|
+
call either way.
|
|
208
|
+
"""
|
|
209
|
+
return bool(self.api_key)
|
|
210
|
+
|
|
211
|
+
def ask(self, system: str, user: str, *, max_tokens: int = 1024) -> Answer | None:
|
|
212
|
+
"""Put a question to the best model that will take it, or return nothing.
|
|
213
|
+
|
|
214
|
+
The ladder is walked twice at most, and the second walk is the whole of
|
|
215
|
+
why the first one may be impatient.
|
|
216
|
+
|
|
217
|
+
**The quick pass.** Every rung, with the short timeout. A rung that is
|
|
218
|
+
going to answer answers in about six seconds, so this is the pass that
|
|
219
|
+
does the work nearly every time.
|
|
220
|
+
|
|
221
|
+
**The patient pass.** Only when the quick one ran out of time, and only
|
|
222
|
+
for that reason. "Nobody answered in twenty-five seconds" is not the same
|
|
223
|
+
statement as "nobody was going to": measured, a queued rung clears at
|
|
224
|
+
107-124 seconds. A rejected key or a refused request is not a queue and
|
|
225
|
+
waiting cannot help it, so neither buys a second pass.
|
|
226
|
+
|
|
227
|
+
Between them these give what one timeout could not. A single short value
|
|
228
|
+
is fast and gives up on a busy hour; a single long one waits two minutes
|
|
229
|
+
for every distillation to be sure. Two passes are fast when it is fast
|
|
230
|
+
and patient when patience is the only thing left.
|
|
231
|
+
"""
|
|
232
|
+
if not sending_on():
|
|
233
|
+
# Asked before the key, because the reason a caller is given should
|
|
234
|
+
# be the one they can act on: a switch they set is not a key they
|
|
235
|
+
# forgot.
|
|
236
|
+
self.last_error = "sending is switched off (SIFT_NO_MODEL)"
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
if not self.available:
|
|
240
|
+
self.last_error = "no api key"
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
answer, queued = self._walk(system, user, max_tokens, self.timeout)
|
|
244
|
+
if answer is not None or not queued:
|
|
245
|
+
return answer
|
|
246
|
+
|
|
247
|
+
if self.patience <= self.timeout:
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
quick = self.last_error
|
|
251
|
+
answer, _ = self._walk(system, user, max_tokens, self.patience)
|
|
252
|
+
if answer is None and self.last_error:
|
|
253
|
+
self.last_error = f"{quick}; then {self.patience:g}s: {self.last_error}"
|
|
254
|
+
return answer
|
|
255
|
+
|
|
256
|
+
def _walk(
|
|
257
|
+
self, system: str, user: str, max_tokens: int, timeout: float
|
|
258
|
+
) -> tuple[Answer | None, bool]:
|
|
259
|
+
"""One pass down the ladder. Returns the answer, and whether to be patient.
|
|
260
|
+
|
|
261
|
+
The second half of that pair is the only thing this knows that `ask`
|
|
262
|
+
does not: a walk that ended in timeouts may be worth repeating slowly,
|
|
263
|
+
and a walk that ended in refusals is not. A rejected key returns False
|
|
264
|
+
with it -- waiting will not mint a new key, and a second pass would put
|
|
265
|
+
a dead one in front of every rung again.
|
|
266
|
+
"""
|
|
267
|
+
url = f"{self.base_url}/chat/completions"
|
|
268
|
+
headers = {
|
|
269
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
270
|
+
"Content-Type": "application/json",
|
|
271
|
+
"Accept": "application/json",
|
|
272
|
+
}
|
|
273
|
+
tries = 0
|
|
274
|
+
queued = False
|
|
275
|
+
|
|
276
|
+
for model in self.ladder:
|
|
277
|
+
wanted = effort()
|
|
278
|
+
body = _body(model, system, user, max_tokens, wanted)
|
|
279
|
+
|
|
280
|
+
for attempt in range(_TRIES_PER_RUNG):
|
|
281
|
+
tries += 1
|
|
282
|
+
reply = self._reach(url, headers, body, timeout)
|
|
283
|
+
|
|
284
|
+
if reply.status == 200:
|
|
285
|
+
text = _said(reply.body)
|
|
286
|
+
if text is not None:
|
|
287
|
+
self.last_error = None
|
|
288
|
+
return (
|
|
289
|
+
Answer(
|
|
290
|
+
text=text,
|
|
291
|
+
model=model,
|
|
292
|
+
tries=tries,
|
|
293
|
+
tokens=_spent(reply.body),
|
|
294
|
+
),
|
|
295
|
+
False,
|
|
296
|
+
)
|
|
297
|
+
self.last_error = f"{model}: reply could not be read"
|
|
298
|
+
break # the same model will phrase it the same way again
|
|
299
|
+
|
|
300
|
+
if reply.status in (401, 403):
|
|
301
|
+
self.last_error = f"key rejected ({reply.status})"
|
|
302
|
+
return None, False
|
|
303
|
+
|
|
304
|
+
if reply.status in TRANSIENT:
|
|
305
|
+
self.last_error = f"{model}: busy or unreachable ({reply.status})"
|
|
306
|
+
if reply.timed_out:
|
|
307
|
+
# Measured, and this is the whole of it. A rung on a free
|
|
308
|
+
# tier can hold a request in a queue for 107-124 seconds
|
|
309
|
+
# before answering or refusing. A timeout means that queue
|
|
310
|
+
# is longer than this pass is willing to wait, and asking
|
|
311
|
+
# the same rung again inside the same pass is joining the
|
|
312
|
+
# same queue again -- a second full timeout for the same
|
|
313
|
+
# answer. Waiting longer is the patient pass's job, once,
|
|
314
|
+
# after every rung has had its quick chance.
|
|
315
|
+
#
|
|
316
|
+
# A fast refusal is a different thing: it cost a moment,
|
|
317
|
+
# and the moment after may go through.
|
|
318
|
+
self.last_error = f"{model}: no answer in {timeout:g}s"
|
|
319
|
+
queued = True
|
|
320
|
+
break
|
|
321
|
+
if attempt + 1 < _TRIES_PER_RUNG:
|
|
322
|
+
self._sleep(_BACKOFF_SECONDS * (attempt + 1))
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
# 404 and the rest of the 4xx family are statements about this
|
|
326
|
+
# request. A different model may still accept it, so the walk goes
|
|
327
|
+
# on, but repeating it word for word to the same one will not.
|
|
328
|
+
if wanted:
|
|
329
|
+
# Unless the only thing wrong with it was ours. `SIFT_EFFORT`
|
|
330
|
+
# is a speed setting this tool adds; an endpoint that will
|
|
331
|
+
# not take the field should cost the caller a second
|
|
332
|
+
# request, never an answer. Asked again without it, and only
|
|
333
|
+
# once -- if it is refused again the reason was not this.
|
|
334
|
+
self.last_error = f"{model}: refused ({reply.status}) with effort"
|
|
335
|
+
wanted = None
|
|
336
|
+
body = _body(model, system, user, max_tokens, None)
|
|
337
|
+
continue
|
|
338
|
+
self.last_error = f"{model}: refused ({reply.status})"
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
return None, queued
|
|
342
|
+
|
|
343
|
+
def _reach(
|
|
344
|
+
self, url: str, headers: dict[str, str], body: bytes, timeout: float
|
|
345
|
+
) -> Reply:
|
|
346
|
+
"""Call the transport, turning any way it can fail into an unreachable reply."""
|
|
347
|
+
try:
|
|
348
|
+
return self._post(url=url, headers=headers, body=body, timeout=timeout)
|
|
349
|
+
except TimeoutError as exc:
|
|
350
|
+
return Reply(0, str(exc).encode("utf-8", "replace"), timed_out=True)
|
|
351
|
+
except OSError as exc: # a transport of one's own is allowed to be less careful
|
|
352
|
+
waited = isinstance(exc, socket.timeout) or "timed out" in str(exc).lower()
|
|
353
|
+
return Reply(0, str(exc).encode("utf-8", "replace"), timed_out=waited)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def post(*, url: str, headers: dict[str, str], body: bytes, timeout: float) -> Reply:
|
|
357
|
+
"""One POST, with every failure reported as a status rather than an exception."""
|
|
358
|
+
request = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
359
|
+
try:
|
|
360
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
361
|
+
return Reply(response.status, response.read())
|
|
362
|
+
except urllib.error.HTTPError as exc:
|
|
363
|
+
return Reply(exc.code, exc.read() or b"")
|
|
364
|
+
except (OSError, ValueError) as exc:
|
|
365
|
+
return Reply(0, str(exc).encode("utf-8", "replace"))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _body(
|
|
369
|
+
model: str, system: str, user: str, max_tokens: int, effort_now: str | None
|
|
370
|
+
) -> bytes:
|
|
371
|
+
"""One request, as bytes. Built here so it can be built twice.
|
|
372
|
+
|
|
373
|
+
The second time is without `reasoning_effort`, for an endpoint that does not
|
|
374
|
+
know the field -- see the refusal branch in `ask`.
|
|
375
|
+
"""
|
|
376
|
+
asked: dict = {
|
|
377
|
+
"model": model,
|
|
378
|
+
"messages": [
|
|
379
|
+
{"role": "system", "content": system},
|
|
380
|
+
{"role": "user", "content": user},
|
|
381
|
+
],
|
|
382
|
+
"temperature": 0,
|
|
383
|
+
"max_tokens": max_tokens,
|
|
384
|
+
"stream": False,
|
|
385
|
+
}
|
|
386
|
+
if effort_now:
|
|
387
|
+
asked["reasoning_effort"] = effort_now
|
|
388
|
+
return json.dumps(asked, ensure_ascii=False).encode("utf-8")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _said(body: bytes) -> str | None:
|
|
392
|
+
"""The message out of an OpenAI-shaped reply, or None if there is not one.
|
|
393
|
+
|
|
394
|
+
An empty answer counts as no answer. A caller that acted on it would be
|
|
395
|
+
acting on silence dressed up as a decision.
|
|
396
|
+
"""
|
|
397
|
+
try:
|
|
398
|
+
content = json.loads(body)["choices"][0]["message"]["content"]
|
|
399
|
+
except (json.JSONDecodeError, UnicodeDecodeError, KeyError, IndexError, TypeError):
|
|
400
|
+
return None
|
|
401
|
+
if not isinstance(content, str) or not content.strip():
|
|
402
|
+
return None
|
|
403
|
+
return content
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _spent(body: bytes) -> int:
|
|
407
|
+
"""What the endpoint says the exchange cost, or zero if it did not say.
|
|
408
|
+
|
|
409
|
+
Measured, and that is the whole point of reading it rather than estimating
|
|
410
|
+
it. The alternative on offer is bytes divided by four, which is a rule of
|
|
411
|
+
thumb about English prose being sold as a count -- and this tool is pointed
|
|
412
|
+
at Japanese, at Turkish, at base64 and at stack traces, where it is wrong by
|
|
413
|
+
a factor rather than a margin.
|
|
414
|
+
|
|
415
|
+
Zero is not a failure and is not treated as one. It means nobody counted,
|
|
416
|
+
and a report that filled that in with arithmetic would be publishing its own
|
|
417
|
+
guess as the endpoint's number.
|
|
418
|
+
"""
|
|
419
|
+
try:
|
|
420
|
+
usage = json.loads(body)["usage"]
|
|
421
|
+
return max(0, int(usage["total_tokens"]))
|
|
422
|
+
except (json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError, ValueError):
|
|
423
|
+
return 0
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _as_float(written: str | None, fallback: float, given: float | None) -> float:
|
|
427
|
+
"""A number the caller gave, else one from the environment, else the default."""
|
|
428
|
+
if given is not None:
|
|
429
|
+
return float(given)
|
|
430
|
+
try:
|
|
431
|
+
return float(written) if written else fallback
|
|
432
|
+
except ValueError:
|
|
433
|
+
return fallback
|
sift/outline.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""What a file declares, without its bodies.
|
|
2
|
+
|
|
3
|
+
A log is read for what went wrong; a source file is read for what is in it and
|
|
4
|
+
where. Those are two questions. `distill` answers the first. This asks the
|
|
5
|
+
second -- of the same model, through the same machinery, under the same
|
|
6
|
+
guarantee: the answer is a set of line numbers, and every line shown is printed
|
|
7
|
+
from the file byte for byte.
|
|
8
|
+
|
|
9
|
+
The design this replaces was a table. One entry per language, each holding
|
|
10
|
+
regular expressions for what a declaration looks like there, plus the suffixes
|
|
11
|
+
and the bare filenames that language claims. It ran to a thousand lines, covered
|
|
12
|
+
eighty-odd languages, and was still missing the next one. Its own comment
|
|
13
|
+
admitted the shape of the problem: *the rules are shapes, not grammars.* A
|
|
14
|
+
signature is only usually the line a language spends its keywords on, and
|
|
15
|
+
usually is a word that fails in front of a user.
|
|
16
|
+
|
|
17
|
+
There is no table here, and nothing in this file knows one language from
|
|
18
|
+
another. A model that can read Rust can also read the Zig that shipped last
|
|
19
|
+
week, the internal DSL nobody outside one company has seen, and the Makefile
|
|
20
|
+
with no extension at all. Being wrong is still possible -- the wrong lines can
|
|
21
|
+
be chosen -- but the cost of that mistake is a line missing from an outline,
|
|
22
|
+
never a line that says something the file does not say. And a missing line is
|
|
23
|
+
one `sift peek` away, because the file was never touched.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from sift import lines as text_lines
|
|
31
|
+
from sift.distill import View, select
|
|
32
|
+
from sift.fallback import from_lines
|
|
33
|
+
from sift.model import Bridge
|
|
34
|
+
|
|
35
|
+
# An outline is a table of contents, and a table of contents that runs past a
|
|
36
|
+
# screen has stopped being one.
|
|
37
|
+
#
|
|
38
|
+
# The number is stated here and spent by `select`, which divides it across
|
|
39
|
+
# however many asks the file takes and hands an over-long answer back rather than
|
|
40
|
+
# cutting it. Cutting here is what this module cannot do: deciding which
|
|
41
|
+
# declarations matter least means knowing what "nested" looks like, which is
|
|
42
|
+
# indentation in one language, braces in the next, and neither in the one after
|
|
43
|
+
# -- the guess this file exists to avoid. Asked again, the model does the ranking
|
|
44
|
+
# it is the only one here able to do; and an outline that comes back long anyway
|
|
45
|
+
# is visible, because the view says how many lines it kept out of how many.
|
|
46
|
+
BUDGET = 120
|
|
47
|
+
|
|
48
|
+
QUESTION = (
|
|
49
|
+
"You are given a file, numbered by line.\n"
|
|
50
|
+
"Choose the lines where the file declares something: where it names a thing "
|
|
51
|
+
"it contains or offers -- a function, a type, a class, a constant, a target, "
|
|
52
|
+
"a rule, a section, a setting -- together with any line that must be read "
|
|
53
|
+
"with it to know what that thing is, such as a line that decorates or "
|
|
54
|
+
"annotates the declaration, or that continues its signature.\n"
|
|
55
|
+
"Leave out the bodies: the statements inside a definition, and anything that "
|
|
56
|
+
"says how a thing works rather than that it exists.\n"
|
|
57
|
+
"If more declarations qualify than the answer has room for, keep the "
|
|
58
|
+
"outermost ones and leave the nested ones out.\n"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read(path: str | Path) -> list[str]:
|
|
63
|
+
"""The lines of a file, counted the one way `sift` counts lines.
|
|
64
|
+
|
|
65
|
+
Read as bytes and decoded here rather than through `Path.read_text`, which
|
|
66
|
+
turns on universal newlines and would rewrite every carriage return into a
|
|
67
|
+
line of its own. A file `sift` numbers has to be the file the editor beside
|
|
68
|
+
it numbers, or every number in the outline points at different text.
|
|
69
|
+
"""
|
|
70
|
+
return text_lines.of(text_of(path))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def text_of(path: str | Path) -> str:
|
|
74
|
+
"""The whole file, decoded the one way `sift` decodes one.
|
|
75
|
+
|
|
76
|
+
Split out because not everything that reads a file wants it in lines: whether
|
|
77
|
+
a line is even the right unit is a question about the text, and it cannot be
|
|
78
|
+
asked of something already cut into lines.
|
|
79
|
+
"""
|
|
80
|
+
return Path(path).read_bytes().decode("utf-8", errors="replace")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def outline(
|
|
84
|
+
path: str | Path,
|
|
85
|
+
bridge: Bridge | None = None,
|
|
86
|
+
budget: int | None = None,
|
|
87
|
+
keep: str | None = None,
|
|
88
|
+
) -> View | None:
|
|
89
|
+
"""The declarations in `path`, chosen by a model and printed from the file.
|
|
90
|
+
|
|
91
|
+
The view is handled by the path itself, so the gap marker names the way back
|
|
92
|
+
and `sift peek` will take it. Returns nothing when there is no usable
|
|
93
|
+
judgement, exactly as `distill` does, and for the same reason: a view that
|
|
94
|
+
pretended to have been chosen would be worse than one that admits it wasn't.
|
|
95
|
+
|
|
96
|
+
The budget is passed rather than left to default, because a table of contents
|
|
97
|
+
and a failing build are not the same length for the same reasons even when
|
|
98
|
+
the number happens to match.
|
|
99
|
+
"""
|
|
100
|
+
return select(
|
|
101
|
+
read(path),
|
|
102
|
+
QUESTION,
|
|
103
|
+
str(path),
|
|
104
|
+
bridge,
|
|
105
|
+
budget=BUDGET if budget is None else budget,
|
|
106
|
+
keep=keep,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def ends_of(path: str | Path) -> View:
|
|
111
|
+
"""The outline to show when nobody could be asked.
|
|
112
|
+
|
|
113
|
+
The first lines of a file and the last are a poor table of contents -- the
|
|
114
|
+
top is usually a licence and the imports. They are not a wrong one, and
|
|
115
|
+
every line they skip is counted and named.
|
|
116
|
+
"""
|
|
117
|
+
return from_lines(read(path), str(path))
|