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.
- stacktrace/__init__.py +1 -1
- stacktrace/cli.py +0 -2
- stacktrace/correlate/observed.py +49 -5
- stacktrace/cost/baseline.py +16 -1
- stacktrace/cost/copies.py +90 -0
- stacktrace/cost/ledger.py +8 -2
- stacktrace/cost/measures.py +3 -2
- stacktrace/cost/models.py +29 -3
- stacktrace/cost/output.py +123 -0
- stacktrace/cost/price_class.py +48 -0
- stacktrace/cost/reasons.py +3 -2
- stacktrace/cost/rules.py +2 -1
- stacktrace/cost/usage.py +169 -16
- stacktrace/cost/weights.py +125 -0
- stacktrace/daemon/costs.py +6 -1
- stacktrace/daemon/db.py +123 -15
- stacktrace/daemon/presentation.py +26 -2
- stacktrace/daemon/store.py +140 -25
- stacktrace/daemon/usage.py +72 -18
- stacktrace/detector/blocked.py +102 -0
- stacktrace/detector/deterministic.py +138 -35
- stacktrace/detector/prompts/__init__.py +3 -2
- stacktrace/detector/prompts/jev-v3/stacktrace-deceptive-completion.json +16 -0
- stacktrace/detector/prompts/jev-v3/stacktrace-infra-unsafe-action.json +10 -0
- stacktrace/detector/reasoning.py +5 -19
- stacktrace/detector/run.py +3 -0
- stacktrace/gateway/formats/_json.py +1 -5
- stacktrace/gateway/session.py +20 -2
- stacktrace/monitor/costs.py +113 -12
- stacktrace/monitor/render.py +23 -2
- stacktrace/monitor/site/app.js +14 -9
- stacktrace/monitor/site/cost.js +152 -16
- stacktrace/monitor/site/index.html +1 -1
- stacktrace/monitor/site/styles.css +13 -2
- stacktrace/remote/upload_contract.py +4 -0
- stacktrace/sessions/entrypoint.py +43 -0
- stacktrace/sessions/protocols.py +5 -39
- {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.12.0.dist-info}/METADATA +6 -3
- {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.12.0.dist-info}/RECORD +41 -34
- {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.12.0.dist-info}/WHEEL +0 -0
- {stacktrace_cli-0.11.0.dist-info → stacktrace_cli-0.12.0.dist-info}/entry_points.txt +0 -0
stacktrace/__init__.py
CHANGED
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
|
stacktrace/correlate/observed.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
|
stacktrace/cost/baseline.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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]:
|
stacktrace/cost/measures.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#:
|
|
15
|
-
#: `claude-haiku-4-5-20251001`; the model is
|
|
16
|
-
|
|
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]
|
stacktrace/cost/reasons.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|