stacktrace-cli 0.11.0__py3-none-any.whl → 0.12.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 (41) hide show
  1. stacktrace/__init__.py +1 -1
  2. stacktrace/cli.py +0 -2
  3. stacktrace/correlate/observed.py +49 -5
  4. stacktrace/cost/baseline.py +16 -1
  5. stacktrace/cost/copies.py +90 -0
  6. stacktrace/cost/ledger.py +8 -2
  7. stacktrace/cost/measures.py +3 -2
  8. stacktrace/cost/models.py +29 -3
  9. stacktrace/cost/output.py +123 -0
  10. stacktrace/cost/price_class.py +48 -0
  11. stacktrace/cost/reasons.py +3 -2
  12. stacktrace/cost/rules.py +2 -1
  13. stacktrace/cost/usage.py +169 -16
  14. stacktrace/cost/weights.py +125 -0
  15. stacktrace/daemon/costs.py +6 -1
  16. stacktrace/daemon/db.py +123 -15
  17. stacktrace/daemon/presentation.py +26 -2
  18. stacktrace/daemon/store.py +140 -25
  19. stacktrace/daemon/usage.py +72 -18
  20. stacktrace/detector/blocked.py +102 -0
  21. stacktrace/detector/deterministic.py +138 -35
  22. stacktrace/detector/prompts/__init__.py +3 -2
  23. stacktrace/detector/prompts/jev-v3/stacktrace-deceptive-completion.json +16 -0
  24. stacktrace/detector/prompts/jev-v3/stacktrace-infra-unsafe-action.json +10 -0
  25. stacktrace/detector/reasoning.py +5 -19
  26. stacktrace/detector/run.py +3 -0
  27. stacktrace/gateway/formats/_json.py +1 -5
  28. stacktrace/gateway/session.py +20 -2
  29. stacktrace/monitor/costs.py +113 -12
  30. stacktrace/monitor/render.py +23 -2
  31. stacktrace/monitor/site/app.js +14 -9
  32. stacktrace/monitor/site/cost.js +152 -16
  33. stacktrace/monitor/site/index.html +1 -1
  34. stacktrace/monitor/site/styles.css +13 -2
  35. stacktrace/remote/upload_contract.py +4 -0
  36. stacktrace/sessions/entrypoint.py +43 -0
  37. stacktrace/sessions/protocols.py +5 -39
  38. {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.12.0.dist-info}/METADATA +6 -3
  39. {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.12.0.dist-info}/RECORD +41 -34
  40. {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.12.0.dist-info}/WHEEL +0 -0
  41. {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.12.0.dist-info}/entry_points.txt +0 -0
stacktrace/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import logging as _logging
4
4
 
5
- __version__ = "0.11.0"
5
+ __version__ = "0.12.0"
6
6
 
7
7
  # Only the daemon attaches a handler (ADR-0048). Without this one, a WARNING
8
8
  # logged in any other process would reach Python's last-resort handler and
stacktrace/cli.py CHANGED
@@ -52,8 +52,6 @@ PASSTHROUGH: Final = ("scan", "bom", "policy")
52
52
  #: and `telemetry on` already says what it did with `installed`.
53
53
  UNCOUNTED: Final = frozenset({"telemetry"})
54
54
 
55
- HOMEPAGE = "https://stacktrace.ai"
56
-
57
55
  #: The unit is required here and optional in `analysis.parse_since`. That is
58
56
  #: the whole of the difference between the two spellings, and it is deliberate:
59
57
  #: the forgiving parser also answers the upload path, whose value comes from a
@@ -204,6 +204,12 @@ _NOT_A_BINARY = frozenset(
204
204
  #: transport is *unknown*, never local.
205
205
  _REMOTE_TRANSPORTS = frozenset({"claudeai-proxy", "http", "https", "sse", "streamable-http"})
206
206
 
207
+
208
+ def declares_remote_transport(transport: str | None) -> bool:
209
+ """Whether a transport, observed or declared, leaves the machine."""
210
+ return transport in _REMOTE_TRANSPORTS
211
+
212
+
207
213
  #: Redirect destinations that are not files. `/dev/null` is the overwhelming
208
214
  #: one -- 3,493 of the corpus's 13,514 `>` characters -- and a command sending
209
215
  #: output there wrote nothing at all. The rest name an already-open stream, so
@@ -1692,15 +1698,53 @@ def redirect_targets(command: object) -> tuple[str, ...]:
1692
1698
  return tuple(found)
1693
1699
 
1694
1700
 
1701
+ #: The shell options that consume the next word, and the ones that answer
1702
+ #: themselves and run nothing. The same grammar as `_SHELL_SPEC` in
1703
+ #: `detector/deterministic.py`, which a test holds this reader to.
1704
+ _SHELL_VALUE_OPTIONS = frozenset({"-O", "+O", "-o", "+o", "--rcfile", "--init-file"})
1705
+ _SHELL_TERMINAL_OPTIONS = frozenset({"--help", "--version"})
1706
+
1707
+
1695
1708
  def _shell_command_body(arguments: Sequence[str]) -> str | None:
1696
- """The command a shell was handed with `-c`, if it was handed one.
1709
+ """The command a shell was handed with `-c`, if it ran it.
1697
1710
 
1698
1711
  `shlex` has already removed the quoting, so the body arrives as one token.
1712
+
1713
+ `-c` may share a word with other short flags: `bash -lc "curl ..."` is a
1714
+ login shell running curl, and reading only a literal `-c` hid it. A value
1715
+ option in the same cluster (`-Oc extglob body`) takes its word first.
1716
+
1717
+ Only the options are walked. The first operand, or `--`, ends them, and
1718
+ what follows is a script's arguments rather than a body. A noexec shell
1719
+ (`-n`, `-o noexec`) and one answering `--help` or `--version` run nothing,
1720
+ so neither has a body.
1699
1721
  """
1700
- for index, token in enumerate(arguments):
1701
- if token == "-c" and index + 1 < len(arguments):
1702
- body = arguments[index + 1]
1703
- return body if body else None
1722
+ noexec = False
1723
+ index = 0
1724
+ while index < len(arguments):
1725
+ token = arguments[index]
1726
+ if token in ("-n", "+n"):
1727
+ noexec = token == "-n"
1728
+ index += 1
1729
+ elif token in _SHELL_TERMINAL_OPTIONS or token == "--":
1730
+ return None
1731
+ elif token in _SHELL_VALUE_OPTIONS:
1732
+ if token in ("-o", "+o") and "noexec" in arguments[index + 1 : index + 2]:
1733
+ noexec = token == "-o"
1734
+ index += 2
1735
+ elif token.startswith("-") and not token.startswith("--") and len(token) > 1:
1736
+ letters = token[1:]
1737
+ noexec = noexec or "n" in letters
1738
+ if "c" in letters:
1739
+ at = index + 1 + sum(letter in "Oo" for letter in letters)
1740
+ if noexec or at >= len(arguments):
1741
+ return None
1742
+ return arguments[at] or None
1743
+ index += 1
1744
+ elif token.startswith(("-", "+")):
1745
+ index += 1
1746
+ else:
1747
+ return None
1704
1748
  return None
1705
1749
 
1706
1750
 
@@ -14,7 +14,8 @@ Two halves (ADR-0093):
14
14
  never depends on when it was read.
15
15
 
16
16
  Tokens, never dollars, and recorded tokens only: nothing else ever
17
- enters a baseline.
17
+ enters a baseline. A response that is not final counts at least the output
18
+ it shows (`cost.output`, ADR-0129).
18
19
  """
19
20
 
20
21
  from __future__ import annotations
@@ -525,6 +526,20 @@ def tokens_by_working_day(summaries: Iterable[Summary], *, before: date) -> dict
525
526
  return days
526
527
 
527
528
 
529
+ def tokens_by_active_slot(
530
+ summaries: Iterable[Summary], *, before: date
531
+ ) -> dict[tuple[date, int], int]:
532
+ """Recorded tokens in each quarter hour with use, of the 30 days before
533
+ `before`, on every model: the slots of the working days
534
+ `tokens_by_working_day` reads."""
535
+ slots: dict[tuple[date, int], int] = {}
536
+ for summary in summaries:
537
+ for on, _model, slot, tokens in dated_slots(summary):
538
+ if _in_window(on, before):
539
+ slots[(on, slot)] = slots.get((on, slot), 0) + tokens
540
+ return slots
541
+
542
+
528
543
  def _quantile(values: Sequence[float], share: float) -> float:
529
544
  if not values:
530
545
  return 0.0
@@ -0,0 +1,90 @@
1
+ """Each response once, whichever sessions carry it.
2
+
3
+ Claude Code's `/branch` and `--fork-session` write a new session holding the
4
+ conversation so far: each record whole, its message id, usage and time
5
+ included, under the new session id. OpenAIDR reads each file as its own
6
+ session and keys a response by its session (its ADR-0014), so without this a
7
+ conversation's tokens would count again in every branch of it.
8
+
9
+ A response two sessions carry counts in the session that began first, since a
10
+ branch's copies are never older than the conversation they copy: a branch
11
+ holds a subset of its original's records, plus its own later ones. Equal starts
12
+ go by session id, so every reader picks the same session. The usage upload
13
+ files responses by the same rank (`ResponseUse.rank`), and remembers where it
14
+ filed each (`FindingStore.put_usage`), since what it sent cannot move.
15
+
16
+ A session that lost a response loses that response's turn too: a consumer
17
+ reading tool calls off `turns` (component usage, cost's reason analyzers)
18
+ would otherwise still see the copied conversation's calls, counted again
19
+ under the branch.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Iterable
25
+ from datetime import UTC, datetime
26
+ from typing import Any, TypeVar, cast
27
+
28
+ from stacktrace.cost.runs import UsageSession
29
+ from stacktrace.sessions.protocols import ModelResponseLike
30
+
31
+ S = TypeVar("S", bound=UsageSession)
32
+
33
+ _EARLIEST = datetime.min.replace(tzinfo=UTC)
34
+
35
+
36
+ def rank(started_at: datetime | None, session_id: str) -> tuple[bool, datetime, str]:
37
+ """Which of the sessions carrying one response counts it: the one that
38
+ began first, one with no start last, then by session id."""
39
+ return started_at is None, started_at or _EARLIEST, session_id
40
+
41
+
42
+ def distinct(sessions: Iterable[S]) -> tuple[S, ...]:
43
+ """The sessions in their order, each response in only the session ranked
44
+ first of those carrying it. A session that lost none is returned as it is."""
45
+ held = list(sessions)
46
+ # Per kind, then by id: no key object per response, on a month's backfill.
47
+ owner: dict[str | None, dict[str, str]] = {}
48
+ for session in sorted(held, key=lambda s: rank(_started(s), s.session_id)):
49
+ owners = owner.setdefault(session.agent_kind, {})
50
+ for response in session.responses:
51
+ owners.setdefault(response.response_id, session.session_id)
52
+ found: list[S] = []
53
+ for session in held:
54
+ owners = owner[session.agent_kind]
55
+ if all(owners[r.response_id] == session.session_id for r in session.responses):
56
+ found.append(session)
57
+ continue
58
+ own = tuple(r for r in session.responses if owners[r.response_id] == session.session_id)
59
+ found.append(_without(session, own))
60
+ return tuple(found)
61
+
62
+
63
+ def _started(session: UsageSession) -> datetime | None:
64
+ return getattr(session, "started_at", None)
65
+
66
+
67
+ def _without(session: S, responses: tuple[ModelResponseLike, ...]) -> S:
68
+ return cast(S, _Without(session, responses))
69
+
70
+
71
+ class _Without:
72
+ """A session less the responses another session counts, and the turns
73
+ that record them: everything else is the session's own.
74
+
75
+ A turn with no response id (a user turn, never carrying tool calls) is
76
+ always kept: it names no response to own or lose."""
77
+
78
+ def __init__(self, session: UsageSession, responses: tuple[ModelResponseLike, ...]) -> None:
79
+ self._session = session
80
+ self.responses = responses
81
+ kept = {r.response_id for r in responses}
82
+ self.turns = tuple(
83
+ turn
84
+ for turn in getattr(session, "turns", ()) or ()
85
+ if getattr(turn, "response_id", None) is None
86
+ or getattr(turn, "response_id", None) in kept
87
+ )
88
+
89
+ def __getattr__(self, name: str) -> Any:
90
+ return getattr(self._session, name)
stacktrace/cost/ledger.py CHANGED
@@ -16,6 +16,8 @@ from collections.abc import Iterable
16
16
  from datetime import datetime, timedelta
17
17
  from typing import Generic, TypeVar
18
18
 
19
+ from stacktrace.cost.copies import distinct
20
+ from stacktrace.cost.output import floored
19
21
  from stacktrace.cost.runs import UsageSession
20
22
  from stacktrace.sessions.lineage import parent_session_id
21
23
 
@@ -31,7 +33,9 @@ class Ledger(Generic[S]):
31
33
  self._sessions: dict[tuple[str | None, str], S] = {}
32
34
 
33
35
  def update(self, sessions: Iterable[S]) -> None:
34
- for session in sessions:
36
+ # Each response not final counts what it shows (`cost.output`), worked
37
+ # out once per change rather than on every read.
38
+ for session in floored(sessions):
35
39
  self._sessions[(session.agent_kind, session.session_id)] = session
36
40
 
37
41
  def sessions(self, *, now: datetime) -> tuple[S, ...]:
@@ -51,7 +55,9 @@ class Ledger(Generic[S]):
51
55
  for key, session in self._sessions.items()
52
56
  if (at := latest[_run_of(session)]) is None or at >= cutoff
53
57
  }
54
- return tuple(self._sessions.values())
58
+ # A branch's copies of its conversation count in the conversation
59
+ # (`cost.copies`): a budget or a run counts each response once.
60
+ return distinct(self._sessions.values())
55
61
 
56
62
 
57
63
  def _run_of(session: UsageSession) -> tuple[str | None, str]:
@@ -222,8 +222,9 @@ def _afresh(
222
222
 
223
223
  def window_share(response: ModelResponseLike) -> float | None:
224
224
  """The share of the model's context window this response was sent, or None
225
- for a model whose window is not known."""
226
- window = context_window(response.model)
225
+ for a model whose window is not known. The window the client recorded
226
+ comes first: it is the one the model ran against."""
227
+ window = response.context_window or context_window(response.model)
227
228
  return None if window is None else context_tokens(response) / window
228
229
 
229
230
 
stacktrace/cost/models.py CHANGED
@@ -11,9 +11,10 @@ import re
11
11
 
12
12
  _MILLION = 1_000_000
13
13
 
14
- #: Claude Code records some models with their snapshot date, such as
15
- #: `claude-haiku-4-5-20251001`; the model is the name without it.
16
- _SNAPSHOT = re.compile(r"-\d{8}$")
14
+ #: Clients record some models with their snapshot date, such as
15
+ #: `claude-haiku-4-5-20251001` or OpenAI's `gpt-5.1-2025-11-13`; the model is
16
+ #: the name without it.
17
+ _SNAPSHOT = re.compile(r"-(?:\d{8}|\d{4}-\d{2}-\d{2})$")
17
18
 
18
19
  CONTEXT_WINDOWS: dict[str, int] = {
19
20
  "claude-fable-5-1": _MILLION,
@@ -30,6 +31,31 @@ CONTEXT_WINDOWS: dict[str, int] = {
30
31
  }
31
32
 
32
33
 
34
+ #: Models a finding may name although no window is listed for them here: the
35
+ #: OpenAI models Codex runs on, whose window Codex records itself. A model name
36
+ #: is recorded text, so one outside this set and `CONTEXT_WINDOWS` is never
37
+ #: printed (`cost/reasons.py`'s `_model_name`).
38
+ OTHER_NAMED_MODELS: frozenset[str] = frozenset(
39
+ {
40
+ "gpt-5",
41
+ "gpt-5-codex",
42
+ "gpt-5-mini",
43
+ "gpt-5.1",
44
+ "gpt-5.1-codex",
45
+ "gpt-5.1-codex-max",
46
+ "gpt-5.1-codex-mini",
47
+ "gpt-5.2",
48
+ "gpt-5.2-codex",
49
+ "gpt-5.3-codex",
50
+ "gpt-5.3-codex-spark",
51
+ "gpt-5.4",
52
+ "gpt-5.4-mini",
53
+ "gpt-5.5",
54
+ "gpt-5.6",
55
+ }
56
+ )
57
+
58
+
33
59
  def canonical_model(model: str) -> str:
34
60
  return _SNAPSHOT.sub("", model)
35
61
 
@@ -0,0 +1,123 @@
1
+ """Output a response shows, where its record states less (ADR-0129).
2
+
3
+ Claude Code writes a response one content block per record, each repeating its
4
+ usage. Only the last record, the one with a stop reason, states the whole
5
+ output; the ones before it state a part. OpenAIDR marks a response `final` only
6
+ when it read that record, and calls output on any other a lower bound (its
7
+ ADR-0014). Most sub-agent responses never get that record: on one machine 874
8
+ of them recorded 9,907 output tokens and showed 105,802 in text and tool calls.
9
+
10
+ So a response that is not final counts the greater of its recorded output and
11
+ what its turns show: the characters of their text and of their tool calls'
12
+ arguments as JSON, four to a token. On 425 final responses, whose output is
13
+ exact, that count was never above the recorded output. A final response counts
14
+ as recorded.
15
+
16
+ A response's turns are its own session's, so this gives the same count before
17
+ or after `cost.copies.distinct`. Only a count per response is kept, never text.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from collections.abc import Iterable
24
+ from typing import Any, TypeVar, cast
25
+
26
+ from stacktrace.cost.runs import UsageSession
27
+ from stacktrace.sessions.protocols import ModelResponseLike
28
+
29
+ S = TypeVar("S", bound=UsageSession)
30
+
31
+ #: Characters a token, at least: calibrated on final responses, whose output
32
+ #: is exact. Three was above the output on one of 425.
33
+ CHARS_PER_TOKEN = 4
34
+
35
+
36
+ def floored(sessions: Iterable[S]) -> tuple[S, ...]:
37
+ """The sessions in their order, each response that is not final counting
38
+ at least the output its turns show. A session none of whose responses
39
+ rises is returned as it is."""
40
+ return tuple(_floored(session) for session in sessions)
41
+
42
+
43
+ def _floored(session: S) -> S:
44
+ unfinished = {r.response_id for r in session.responses if not r.final}
45
+ if not unfinished:
46
+ return session
47
+ shown = _shown(session, unfinished)
48
+ risen: dict[str, int] = {}
49
+ for response in session.responses:
50
+ floor = shown.get(response.response_id, 0) // CHARS_PER_TOKEN
51
+ if not response.final and floor > (response.usage.output_tokens or 0):
52
+ risen[response.response_id] = floor
53
+ if not risen:
54
+ return session
55
+ responses = tuple(
56
+ cast(ModelResponseLike, _Floored(r, risen[r.response_id])) if r.response_id in risen else r
57
+ for r in session.responses
58
+ )
59
+ return cast(S, _WithResponses(session, responses))
60
+
61
+
62
+ def _shown(session: UsageSession, response_ids: set[str]) -> dict[str, int]:
63
+ """Characters each of these responses shows across its assistant turns.
64
+
65
+ A turn that issued tool calls shows their arguments: a reader stands a
66
+ placeholder in for the text of a record that holds only calls. Arguments
67
+ count as the model writes them, so a character outside ASCII is one, not
68
+ its escape."""
69
+ shown: dict[str, int] = {}
70
+ for turn in getattr(session, "turns", ()) or ():
71
+ response_id = getattr(turn, "response_id", None)
72
+ if response_id not in response_ids or getattr(turn, "role", None) != "assistant":
73
+ continue
74
+ calls = getattr(turn, "tool_calls", ()) or ()
75
+ if calls:
76
+ chars = sum(
77
+ len(json.dumps(call.arguments, ensure_ascii=False, default=str)) for call in calls
78
+ )
79
+ else:
80
+ chars = len(getattr(turn, "text", "") or "")
81
+ shown[response_id] = shown.get(response_id, 0) + chars
82
+ return shown
83
+
84
+
85
+ class _WithResponses:
86
+ """A session whose responses count what they show: everything else is the
87
+ session's own."""
88
+
89
+ __slots__ = ("_session", "responses")
90
+
91
+ def __init__(self, session: UsageSession, responses: tuple[ModelResponseLike, ...]) -> None:
92
+ self._session = session
93
+ self.responses = responses
94
+
95
+ def __getattr__(self, name: str) -> Any:
96
+ return getattr(self._session, name)
97
+
98
+
99
+ class _Floored:
100
+ """A response whose output is `output`: everything else is the response's
101
+ own, and it is still not final."""
102
+
103
+ __slots__ = ("_response", "usage")
104
+
105
+ def __init__(self, response: ModelResponseLike, output: int) -> None:
106
+ self._response = response
107
+ self.usage = _Output(response.usage, output)
108
+
109
+ def __getattr__(self, name: str) -> Any:
110
+ return getattr(self._response, name)
111
+
112
+
113
+ class _Output:
114
+ """Usage with output replaced: every other kind as recorded."""
115
+
116
+ __slots__ = ("_usage", "output_tokens")
117
+
118
+ def __init__(self, usage: Any, output: int) -> None:
119
+ self._usage = usage
120
+ self.output_tokens = output
121
+
122
+ def __getattr__(self, name: str) -> Any:
123
+ return getattr(self._usage, name)
@@ -0,0 +1,48 @@
1
+ """A response's price class, named on its bucket's model (ADR-0130).
2
+
3
+ Anthropic charges some responses more than their model's standard rate, and
4
+ Fleet sees only bucket sums, so the client names the class:
5
+
6
+ - `fast`: the response's `usage.speed` is `"fast"`.
7
+ - `long`: its prompt is over its model's threshold. A prompt is input, cache
8
+ reads and cache writes.
9
+ - `us`: its `usage.inference_geo` is `"us"`, US-only inference.
10
+
11
+ `claude-haiku-5-5+long+us` is Haiku 5.5's long prompts run in the US. Each
12
+ class is its own bucket, and Fleet prices it (Fleet ADR-0090). This module
13
+ holds tokens only, never a price (ADR-0099).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from stacktrace.cost.tokens import Tokens
19
+ from stacktrace.cost.weights import model_key
20
+ from stacktrace.sessions.protocols import ModelResponseLike
21
+
22
+ #: Joins a model and its qualifiers.
23
+ SEPARATOR = "+"
24
+
25
+ #: The prompt, in tokens, over which a model's responses cost more, keyed as
26
+ #: `weights.model_key` keys a model. Anthropic's pricing page (read 2026-10-16)
27
+ #: gives one: Claude Haiku 5.5, "for prompts over 100,000 tokens".
28
+ LONG_PROMPT: dict[str, int] = {"claude-haiku-5-5": 100_000}
29
+
30
+
31
+ def qualified(model: str, response: ModelResponseLike) -> str:
32
+ """`model` with its response's qualifiers, in a fixed order:
33
+ `claude-opus-5-5+fast`, `claude-haiku-5-5+long+us`. A value the API does
34
+ not document names no class, so Fleet prices it at the model's rate."""
35
+ found = [model]
36
+ if getattr(response, "speed", None) == "fast":
37
+ found.append("fast")
38
+ threshold = LONG_PROMPT.get(model_key(model))
39
+ if threshold is not None and Tokens.of(response.usage).context > threshold:
40
+ found.append("long")
41
+ if getattr(response, "inference_region", None) == "us":
42
+ found.append("us")
43
+ return SEPARATOR.join(found)
44
+
45
+
46
+ def base_model(model: str) -> str:
47
+ """A model's name without its qualifiers."""
48
+ return model.split(SEPARATOR, 1)[0]
@@ -50,7 +50,7 @@ from stacktrace.cost.measures import (
50
50
  repeats,
51
51
  window_share,
52
52
  )
53
- from stacktrace.cost.models import CONTEXT_WINDOWS, canonical_model
53
+ from stacktrace.cost.models import CONTEXT_WINDOWS, OTHER_NAMED_MODELS, canonical_model
54
54
  from stacktrace.cost.runs import Run
55
55
  from stacktrace.cost.tokens import Tokens
56
56
  from stacktrace.sessions.protocols import ModelResponseLike, SessionLike, ToolCallLike
@@ -554,7 +554,8 @@ def _model_name(model_id: str) -> str:
554
554
  """A model as a finding may name it: one this release knows, by its own
555
555
  name, and anything else by a label of ours. The transcript's model field
556
556
  is recorded text (`test_untrusted_content_never_travels.py`)."""
557
- return model_id if model_id in CONTEXT_WINDOWS else "a model this release does not list"
557
+ known = model_id in CONTEXT_WINDOWS or model_id in OTHER_NAMED_MODELS
558
+ return model_id if known else "a model this release does not list"
558
559
 
559
560
 
560
561
  def sustained_pace(judged: Judged) -> Reason:
stacktrace/cost/rules.py CHANGED
@@ -47,6 +47,7 @@ from stacktrace.cost.reasons import lines as reason_lines
47
47
  from stacktrace.cost.runs import Run, as_of
48
48
  from stacktrace.detector.finding import NO_AGENT_KIND, Detection, Evidence, SessionRef, Severity
49
49
  from stacktrace.detector.rules import COST_RULES
50
+ from stacktrace.sessions.entrypoint import programmatic
50
51
  from stacktrace.sessions.protocols import ModelResponseLike, SessionLike, TurnLike
51
52
 
52
53
  RUNAWAY_SESSION = "stacktrace-cost-runaway-session"
@@ -568,7 +569,7 @@ def runaway_session(
568
569
  ),
569
570
  *reason_lines(found, above[0]),
570
571
  ]
571
- if main.entrypoint == "sdk-cli":
572
+ if programmatic(main.entrypoint):
572
573
  lines.append(("unattended", "started by a program, with nobody at the keyboard"))
573
574
  firings.append(_firing(RUNAWAY_SESSION, main, turn, above, lines, context.placements))
574
575
  return tuple(firings), tuple(misses)