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/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))