ctxoptimizer 0.4.1__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.
- ctxoptimizer/__init__.py +5 -0
- ctxoptimizer/__main__.py +14 -0
- ctxoptimizer/admin_security.py +130 -0
- ctxoptimizer/analysis/__init__.py +1 -0
- ctxoptimizer/analysis/baseline.py +69 -0
- ctxoptimizer/analysis/break_even.py +49 -0
- ctxoptimizer/analysis/drift.py +151 -0
- ctxoptimizer/analysis/edits.py +148 -0
- ctxoptimizer/analysis/markers.py +34 -0
- ctxoptimizer/analysis/recommend.py +114 -0
- ctxoptimizer/analysis/segments.py +57 -0
- ctxoptimizer/analysis/session.py +62 -0
- ctxoptimizer/analysis/state.py +239 -0
- ctxoptimizer/analysis/tokens.py +62 -0
- ctxoptimizer/analysis/waste.py +322 -0
- ctxoptimizer/cli.py +873 -0
- ctxoptimizer/config.py +512 -0
- ctxoptimizer/dashboard/__init__.py +1 -0
- ctxoptimizer/dashboard/api.py +537 -0
- ctxoptimizer/dashboard/service.py +157 -0
- ctxoptimizer/dashboard/static/.gitkeep +1 -0
- ctxoptimizer/dashboard/templates/.gitkeep +1 -0
- ctxoptimizer/dashboard/templates/doctor.html +46 -0
- ctxoptimizer/dashboard/templates/live.html +91 -0
- ctxoptimizer/dashboard/templates/overview.html +172 -0
- ctxoptimizer/dashboard/templates/pricing.html +115 -0
- ctxoptimizer/dashboard/templates/request.html +111 -0
- ctxoptimizer/dashboard/templates/session.html +67 -0
- ctxoptimizer/dashboard/templates/settings.html +79 -0
- ctxoptimizer/dashboard/templates/table.html +56 -0
- ctxoptimizer/dashboard/templates/transforms.html +118 -0
- ctxoptimizer/dashboard/templates/waste.html +120 -0
- ctxoptimizer/dashboard/views.py +429 -0
- ctxoptimizer/data/cache_limits.json +23 -0
- ctxoptimizer/data/default_config.toml +185 -0
- ctxoptimizer/data/pricing.json +421 -0
- ctxoptimizer/doctor.py +129 -0
- ctxoptimizer/guard.py +139 -0
- ctxoptimizer/ids.py +73 -0
- ctxoptimizer/log.py +73 -0
- ctxoptimizer/mcp.py +124 -0
- ctxoptimizer/models.py +145 -0
- ctxoptimizer/pipeline.py +126 -0
- ctxoptimizer/profiles.py +152 -0
- ctxoptimizer/providers/__init__.py +37 -0
- ctxoptimizer/providers/anthropic.py +105 -0
- ctxoptimizer/providers/base.py +191 -0
- ctxoptimizer/providers/gemini.py +143 -0
- ctxoptimizer/providers/ollama.py +133 -0
- ctxoptimizer/providers/openai_chat.py +135 -0
- ctxoptimizer/providers/openai_responses.py +121 -0
- ctxoptimizer/proxy.py +665 -0
- ctxoptimizer/routing.py +259 -0
- ctxoptimizer/server.py +319 -0
- ctxoptimizer/service.py +150 -0
- ctxoptimizer/telemetry/__init__.py +1 -0
- ctxoptimizer/telemetry/blobs.py +57 -0
- ctxoptimizer/telemetry/expand.py +62 -0
- ctxoptimizer/telemetry/pricing.py +168 -0
- ctxoptimizer/telemetry/redact.py +53 -0
- ctxoptimizer/telemetry/sse.py +145 -0
- ctxoptimizer/telemetry/store.py +829 -0
- ctxoptimizer/telemetry/usage.py +435 -0
- ctxoptimizer/transforms/__init__.py +87 -0
- ctxoptimizer/transforms/ansi.py +21 -0
- ctxoptimizer/transforms/anthropic_cache.py +100 -0
- ctxoptimizer/transforms/backref.py +74 -0
- ctxoptimizer/transforms/base.py +93 -0
- ctxoptimizer/transforms/cache_serve.py +146 -0
- ctxoptimizer/transforms/dedupe_lines.py +69 -0
- ctxoptimizer/transforms/eligibility.py +24 -0
- ctxoptimizer/transforms/openai_hints.py +84 -0
- ctxoptimizer/transforms/progress.py +52 -0
- ctxoptimizer/transforms/stale_result_elision.py +109 -0
- ctxoptimizer/transforms/truncate.py +100 -0
- ctxoptimizer/transforms/volatile.py +177 -0
- ctxoptimizer/transforms/whitespace.py +25 -0
- ctxoptimizer-0.4.1.dist-info/METADATA +196 -0
- ctxoptimizer-0.4.1.dist-info/RECORD +83 -0
- ctxoptimizer-0.4.1.dist-info/WHEEL +4 -0
- ctxoptimizer-0.4.1.dist-info/entry_points.txt +3 -0
- ctxoptimizer-0.4.1.dist-info/licenses/LICENSE +21 -0
- ctxoptimizer-0.4.1.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Ranked recommendations from measured evidence (SPEC section 13.7).
|
|
2
|
+
|
|
3
|
+
P2 scope: a deterministic, advisory list. Nothing is auto-applied.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ctxoptimizer.models import WasteFinding
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def recommendations(
|
|
14
|
+
findings: list[WasteFinding],
|
|
15
|
+
*,
|
|
16
|
+
cache_serve_enabled: bool = False,
|
|
17
|
+
volatile_normalizer_enabled: bool = False,
|
|
18
|
+
transforms_enabled: bool = True,
|
|
19
|
+
) -> list[dict[str, Any]]:
|
|
20
|
+
grouped: dict[str, list[WasteFinding]] = {}
|
|
21
|
+
for finding in findings:
|
|
22
|
+
grouped.setdefault(finding.detector, []).append(finding)
|
|
23
|
+
|
|
24
|
+
result: list[dict[str, Any]] = []
|
|
25
|
+
rereads = grouped.get("redundant_reread", [])
|
|
26
|
+
if rereads and not cache_serve_enabled:
|
|
27
|
+
tokens = sum(item.est_tokens_wasted or 0 for item in rereads)
|
|
28
|
+
result.append(
|
|
29
|
+
{
|
|
30
|
+
"id": "enable_cache_serve",
|
|
31
|
+
"title": "Enable cache_serve for repeated file reads",
|
|
32
|
+
"evidence": f"{len(rereads)} redundant re-read finding(s)",
|
|
33
|
+
"est_tokens": tokens,
|
|
34
|
+
"label": "sim.",
|
|
35
|
+
"action": "set transforms.cache_serve.enabled = true",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
drift = grouped.get("recurring_drift", [])
|
|
39
|
+
if drift and not volatile_normalizer_enabled:
|
|
40
|
+
tokens = sum(item.est_tokens_wasted or 0 for item in drift)
|
|
41
|
+
result.append(
|
|
42
|
+
{
|
|
43
|
+
"id": "enable_volatile_normalizer",
|
|
44
|
+
"title": "Stabilize volatile system-prompt tokens",
|
|
45
|
+
"evidence": f"{len(drift)} recurring drift finding(s)",
|
|
46
|
+
"est_tokens": tokens,
|
|
47
|
+
"label": "sim.",
|
|
48
|
+
"action": "investigate timestamps/UUIDs in the system prompt",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
if grouped.get("unbatched_fanout"):
|
|
52
|
+
result.append(
|
|
53
|
+
{
|
|
54
|
+
"id": "batch_reads",
|
|
55
|
+
"title": "Batch consecutive read calls",
|
|
56
|
+
"evidence": f"{len(grouped['unbatched_fanout'])} fan-out finding(s)",
|
|
57
|
+
"est_tokens": 0,
|
|
58
|
+
"label": "est.",
|
|
59
|
+
"action": "advisory: issue independent reads together",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
if grouped.get("runaway_session"):
|
|
63
|
+
result.append(
|
|
64
|
+
{
|
|
65
|
+
"id": "investigate_runaway",
|
|
66
|
+
"title": "Investigate long-running unattended sessions",
|
|
67
|
+
"evidence": f"{len(grouped['runaway_session'])} runaway finding(s)",
|
|
68
|
+
"est_tokens": sum(
|
|
69
|
+
item.est_tokens_wasted or 0 for item in grouped["runaway_session"]
|
|
70
|
+
),
|
|
71
|
+
"label": "est.",
|
|
72
|
+
"action": "advisory: stop idle agents before resuming",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
if grouped.get("schema_bloat"):
|
|
76
|
+
result.append(
|
|
77
|
+
{
|
|
78
|
+
"id": "prune_mcp_schemas",
|
|
79
|
+
"title": "Prune unused tool schemas",
|
|
80
|
+
"evidence": f"{len(grouped['schema_bloat'])} unused schema finding(s)",
|
|
81
|
+
"est_tokens": sum(item.est_tokens_wasted or 0 for item in grouped["schema_bloat"]),
|
|
82
|
+
"label": "sim.",
|
|
83
|
+
"action": "advisory: remove MCP tools the agent never calls",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
if grouped.get("churn_detector"):
|
|
87
|
+
result.append(
|
|
88
|
+
{
|
|
89
|
+
"id": "investigate_churn",
|
|
90
|
+
"title": "Investigate revert/whitespace edit churn",
|
|
91
|
+
"evidence": f"{len(grouped['churn_detector'])} churn finding(s)",
|
|
92
|
+
"est_tokens": sum(
|
|
93
|
+
item.est_tokens_wasted or 0 for item in grouped["churn_detector"]
|
|
94
|
+
),
|
|
95
|
+
"label": "est.",
|
|
96
|
+
"action": "advisory: fix instructions or formatter loops",
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
if not transforms_enabled:
|
|
100
|
+
result.append(
|
|
101
|
+
{
|
|
102
|
+
"id": "enable_transforms",
|
|
103
|
+
"title": "Enable the deterministic transform pipeline",
|
|
104
|
+
"evidence": "pipeline disabled in config",
|
|
105
|
+
"est_tokens": 0,
|
|
106
|
+
"label": "sim.",
|
|
107
|
+
"action": "set mode = active after a shadow run",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
result.sort(key=lambda item: int(item["est_tokens"]), reverse=True)
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["recommendations"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Canonical segments and content hashing (SPEC section 13.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from hashlib import blake2b
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import orjson
|
|
11
|
+
|
|
12
|
+
from ctxoptimizer.analysis.tokens import estimate_tokens
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Segment:
|
|
17
|
+
kind: Literal["tool_def", "system", "message"]
|
|
18
|
+
msg_index: int | None
|
|
19
|
+
block_index: int | None
|
|
20
|
+
hash: bytes
|
|
21
|
+
est_tokens: int
|
|
22
|
+
has_marker: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def block_hash(block: dict[str, Any]) -> bytes:
|
|
26
|
+
"""Stable 8-byte hash of a block, ignoring cache markers."""
|
|
27
|
+
clean = {key: value for key, value in block.items() if key != "cache_control"}
|
|
28
|
+
encoded = orjson.dumps(clean, option=orjson.OPT_SORT_KEYS)
|
|
29
|
+
return blake2b(encoded, digest_size=8).digest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def block_tokens(block: dict[str, Any]) -> int:
|
|
33
|
+
for key in ("text", "content"):
|
|
34
|
+
value = block.get(key)
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
return estimate_tokens(value)
|
|
37
|
+
return max(1, math.ceil(len(orjson.dumps(block)) / 3.5))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_segment(
|
|
41
|
+
kind: Literal["tool_def", "system", "message"],
|
|
42
|
+
block: dict[str, Any],
|
|
43
|
+
*,
|
|
44
|
+
msg_index: int | None = None,
|
|
45
|
+
block_index: int | None = None,
|
|
46
|
+
) -> Segment:
|
|
47
|
+
return Segment(
|
|
48
|
+
kind=kind,
|
|
49
|
+
msg_index=msg_index,
|
|
50
|
+
block_index=block_index,
|
|
51
|
+
hash=block_hash(block),
|
|
52
|
+
est_tokens=block_tokens(block),
|
|
53
|
+
has_marker="cache_control" in block,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["Segment", "block_hash", "block_tokens", "make_segment"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Session and project identification (SPEC section 13.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from hashlib import blake2b, sha256
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ctxoptimizer.models import Provider
|
|
11
|
+
from ctxoptimizer.providers.base import ProviderAdapter
|
|
12
|
+
|
|
13
|
+
_WORKDIR_PATTERNS = (
|
|
14
|
+
re.compile(r"Working directory:\s*(\S+)"),
|
|
15
|
+
re.compile(r"<cwd>(.*?)</cwd>"),
|
|
16
|
+
re.compile(r"Primary working directory:\s*(\S+)"),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def key_fingerprint(headers: Mapping[str, str]) -> str:
|
|
21
|
+
"""Stable fingerprint of the client credential; the raw key is never kept."""
|
|
22
|
+
value = headers.get("x-api-key") or headers.get("authorization") or ""
|
|
23
|
+
if not value:
|
|
24
|
+
return "anonymous"
|
|
25
|
+
return sha256(value.encode("utf-8")).hexdigest()[:12]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def derive_session_id(
|
|
29
|
+
provider: Provider,
|
|
30
|
+
headers: Mapping[str, str],
|
|
31
|
+
payload: dict[str, Any],
|
|
32
|
+
adapter: ProviderAdapter,
|
|
33
|
+
) -> str:
|
|
34
|
+
system = adapter.system_text(payload)[:4096]
|
|
35
|
+
tool_names = "|".join(adapter.tool_names(payload))
|
|
36
|
+
first_user = adapter.first_user_text(payload)[:2048]
|
|
37
|
+
system_hash = blake2b((system + tool_names).encode("utf-8"), digest_size=8).hexdigest()
|
|
38
|
+
user_hash = blake2b(first_user.encode("utf-8"), digest_size=8).hexdigest()
|
|
39
|
+
material = "|".join((provider.value, key_fingerprint(headers), system_hash, user_hash))
|
|
40
|
+
return sha256(material.encode("utf-8")).hexdigest()[:16]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def project_from_text(text: str) -> str | None:
|
|
44
|
+
for pattern in _WORKDIR_PATTERNS:
|
|
45
|
+
match = pattern.search(text)
|
|
46
|
+
if match:
|
|
47
|
+
value = match.group(1).strip().rstrip("/\\")
|
|
48
|
+
if value:
|
|
49
|
+
return value.replace("\\", "/").split("/")[-1]
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def project_from_payload(payload: dict[str, Any], adapter: ProviderAdapter) -> str | None:
|
|
54
|
+
return project_from_text(f"{adapter.system_text(payload)}\n{adapter.first_user_text(payload)}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"derive_session_id",
|
|
59
|
+
"key_fingerprint",
|
|
60
|
+
"project_from_payload",
|
|
61
|
+
"project_from_text",
|
|
62
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Per-session analysis state: drift + waste findings (P2 wiring)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
|
|
8
|
+
from ctxoptimizer.analysis.baseline import CacheSim, simulate
|
|
9
|
+
from ctxoptimizer.analysis.drift import DriftEvent, detect_drift
|
|
10
|
+
from ctxoptimizer.analysis.markers import MarkerQuality, marker_quality
|
|
11
|
+
from ctxoptimizer.analysis.segments import Segment
|
|
12
|
+
from ctxoptimizer.analysis.tokens import estimate_tokens
|
|
13
|
+
from ctxoptimizer.analysis.waste import (
|
|
14
|
+
EditObservation,
|
|
15
|
+
HashObservation,
|
|
16
|
+
churn_edits,
|
|
17
|
+
identical_outputs,
|
|
18
|
+
recurring_drift,
|
|
19
|
+
redundant_rereads,
|
|
20
|
+
runaway_session,
|
|
21
|
+
schema_bloat,
|
|
22
|
+
)
|
|
23
|
+
from ctxoptimizer.models import WasteFinding
|
|
24
|
+
from ctxoptimizer.transforms.ansi import clean as ansi_clean
|
|
25
|
+
from ctxoptimizer.transforms.progress import clean as progress_clean
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SessionState:
|
|
30
|
+
hashes: dict[bytes, HashObservation] = field(default_factory=dict)
|
|
31
|
+
observations: list[HashObservation] = field(default_factory=list)
|
|
32
|
+
segments: list[Segment] | None = None
|
|
33
|
+
last_ts: float | None = None
|
|
34
|
+
turns: int = 0
|
|
35
|
+
drift_events: list[DriftEvent] = field(default_factory=list)
|
|
36
|
+
used_tools: set[str] = field(default_factory=set)
|
|
37
|
+
last_edits: dict[str, EditObservation] = field(default_factory=dict)
|
|
38
|
+
schema_reported: set[str] = field(default_factory=set)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class AnalysisResult:
|
|
43
|
+
drift: DriftEvent | None
|
|
44
|
+
findings: list[WasteFinding]
|
|
45
|
+
marker_quality: MarkerQuality
|
|
46
|
+
baseline: CacheSim
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AnalysisState:
|
|
50
|
+
"""Bounded in-memory analysis for the dashboard and CLI."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, max_sessions: int = 256, max_findings: int = 500) -> None:
|
|
53
|
+
self.max_sessions = max_sessions
|
|
54
|
+
self.max_findings = max_findings
|
|
55
|
+
self._sessions: dict[str, SessionState] = {}
|
|
56
|
+
self._findings: list[WasteFinding] = []
|
|
57
|
+
|
|
58
|
+
def observe(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
session_id: str,
|
|
62
|
+
project: str | None,
|
|
63
|
+
ts: float,
|
|
64
|
+
segments: list[Segment],
|
|
65
|
+
observations: list[HashObservation],
|
|
66
|
+
model: str,
|
|
67
|
+
config_hash: str,
|
|
68
|
+
ttl_s: float = 300.0,
|
|
69
|
+
tool_schemas: list[tuple[str, int]] | None = None,
|
|
70
|
+
used_tools: list[str] | None = None,
|
|
71
|
+
edits: list[EditObservation] | None = None,
|
|
72
|
+
churn_enabled: bool = False,
|
|
73
|
+
schema_bloat_enabled: bool = True,
|
|
74
|
+
schema_min_requests: int = 10,
|
|
75
|
+
cache_policy: str = "none",
|
|
76
|
+
) -> AnalysisResult:
|
|
77
|
+
state = self._sessions.get(session_id)
|
|
78
|
+
if state is None:
|
|
79
|
+
if len(self._sessions) >= self.max_sessions:
|
|
80
|
+
self._sessions.pop(next(iter(self._sessions)))
|
|
81
|
+
state = SessionState()
|
|
82
|
+
self._sessions[session_id] = state
|
|
83
|
+
|
|
84
|
+
drift = detect_drift(
|
|
85
|
+
state.segments,
|
|
86
|
+
segments,
|
|
87
|
+
previous_ts=state.last_ts,
|
|
88
|
+
current_ts=ts,
|
|
89
|
+
ttl_s=ttl_s,
|
|
90
|
+
previous_params=model,
|
|
91
|
+
current_params=model,
|
|
92
|
+
previous_config_hash=config_hash,
|
|
93
|
+
current_config_hash=config_hash,
|
|
94
|
+
previous_model=model,
|
|
95
|
+
current_model=model,
|
|
96
|
+
)
|
|
97
|
+
baseline = simulate(
|
|
98
|
+
cache_policy,
|
|
99
|
+
state.segments,
|
|
100
|
+
state.last_ts,
|
|
101
|
+
segments,
|
|
102
|
+
ts,
|
|
103
|
+
ttl_s=ttl_s,
|
|
104
|
+
)
|
|
105
|
+
if drift is not None:
|
|
106
|
+
state.drift_events.append(drift)
|
|
107
|
+
|
|
108
|
+
findings: list[WasteFinding] = []
|
|
109
|
+
findings.extend(
|
|
110
|
+
redundant_rereads(
|
|
111
|
+
observations, state.hashes, session_id=session_id, project=project, ts=ts
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
findings.extend(
|
|
115
|
+
identical_outputs(
|
|
116
|
+
[*state.observations, *observations],
|
|
117
|
+
session_id=session_id,
|
|
118
|
+
project=project,
|
|
119
|
+
ts=ts,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
runaway = runaway_session(
|
|
123
|
+
state.last_ts,
|
|
124
|
+
ts,
|
|
125
|
+
state.turns,
|
|
126
|
+
session_id=session_id,
|
|
127
|
+
project=project,
|
|
128
|
+
tokens=sum(item.tokens for item in observations),
|
|
129
|
+
)
|
|
130
|
+
if runaway is not None:
|
|
131
|
+
findings.append(runaway)
|
|
132
|
+
if drift is not None:
|
|
133
|
+
findings.extend(
|
|
134
|
+
recurring_drift(state.drift_events, session_id=session_id, project=project, ts=ts)
|
|
135
|
+
)
|
|
136
|
+
if used_tools:
|
|
137
|
+
state.used_tools.update(used_tools)
|
|
138
|
+
if schema_bloat_enabled and tool_schemas:
|
|
139
|
+
request_count = state.turns + 1
|
|
140
|
+
for name, tokens in tool_schemas:
|
|
141
|
+
if name in state.used_tools or name in state.schema_reported:
|
|
142
|
+
continue
|
|
143
|
+
bloat = schema_bloat(
|
|
144
|
+
name,
|
|
145
|
+
tokens,
|
|
146
|
+
request_count,
|
|
147
|
+
session_id=session_id,
|
|
148
|
+
project=project,
|
|
149
|
+
ts=ts,
|
|
150
|
+
min_requests=schema_min_requests,
|
|
151
|
+
)
|
|
152
|
+
if bloat is not None:
|
|
153
|
+
findings.append(bloat)
|
|
154
|
+
state.schema_reported.add(name)
|
|
155
|
+
if churn_enabled and edits:
|
|
156
|
+
findings.extend(
|
|
157
|
+
churn_edits(
|
|
158
|
+
edits,
|
|
159
|
+
state.last_edits,
|
|
160
|
+
session_id=session_id,
|
|
161
|
+
project=project,
|
|
162
|
+
ts=ts,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
for observation in observations:
|
|
167
|
+
state.hashes[observation.hash] = observation
|
|
168
|
+
state.observations.extend(observations)
|
|
169
|
+
state.observations = state.observations[-2000:]
|
|
170
|
+
state.segments = segments
|
|
171
|
+
state.last_ts = ts
|
|
172
|
+
state.turns += 1
|
|
173
|
+
self._findings.extend(findings)
|
|
174
|
+
self._findings = self._dedupe(self._findings)[-self.max_findings :]
|
|
175
|
+
return AnalysisResult(
|
|
176
|
+
drift=drift,
|
|
177
|
+
findings=findings,
|
|
178
|
+
marker_quality=marker_quality(segments),
|
|
179
|
+
baseline=baseline,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def findings(self) -> list[WasteFinding]:
|
|
183
|
+
return list(self._findings)
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _dedupe(findings: list[WasteFinding]) -> list[WasteFinding]:
|
|
187
|
+
by_key: dict[tuple[str, bytes], WasteFinding] = {}
|
|
188
|
+
for finding in findings:
|
|
189
|
+
key = (finding.detector, finding.key_hash)
|
|
190
|
+
existing = by_key.get(key)
|
|
191
|
+
if existing is None or existing.status not in {"new", "ack"}:
|
|
192
|
+
by_key[key] = finding
|
|
193
|
+
else:
|
|
194
|
+
existing.occurrences += finding.occurrences
|
|
195
|
+
existing.last_seen_ts = max(existing.last_seen_ts, finding.last_seen_ts)
|
|
196
|
+
existing.est_tokens_wasted = max(
|
|
197
|
+
existing.est_tokens_wasted or 0,
|
|
198
|
+
finding.est_tokens_wasted or 0,
|
|
199
|
+
)
|
|
200
|
+
existing.evidence = finding.evidence
|
|
201
|
+
return list(by_key.values())
|
|
202
|
+
|
|
203
|
+
def set_status(self, finding_id: str, status: str) -> bool:
|
|
204
|
+
allowed = {"new", "ack", "fixed", "skipped"}
|
|
205
|
+
if status not in allowed:
|
|
206
|
+
return False
|
|
207
|
+
for finding in self._findings:
|
|
208
|
+
if finding.finding_id == finding_id:
|
|
209
|
+
finding.status = status # type: ignore[assignment]
|
|
210
|
+
return True
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
def clear(self) -> None:
|
|
214
|
+
self._sessions.clear()
|
|
215
|
+
self._findings.clear()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def observations_from_texts(
|
|
219
|
+
texts: list[tuple[str | None, str | None, str]],
|
|
220
|
+
) -> list[HashObservation]:
|
|
221
|
+
observations: list[HashObservation] = []
|
|
222
|
+
for _tool_use_id, tool_name, text in texts:
|
|
223
|
+
sigma = progress_clean(ansi_clean(text))
|
|
224
|
+
observations.append(
|
|
225
|
+
HashObservation(
|
|
226
|
+
hash=sha256(sigma.encode("utf-8")).digest(),
|
|
227
|
+
tokens=estimate_tokens(text),
|
|
228
|
+
tool_name=tool_name,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
return observations
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
__all__ = [
|
|
235
|
+
"AnalysisResult",
|
|
236
|
+
"AnalysisState",
|
|
237
|
+
"SessionState",
|
|
238
|
+
"observations_from_texts",
|
|
239
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Token estimation and calibration (SPEC section 12.0.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import math
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _load_tiktoken() -> Any | None:
|
|
13
|
+
if importlib.util.find_spec("tiktoken") is None:
|
|
14
|
+
return None
|
|
15
|
+
try:
|
|
16
|
+
return importlib.import_module("tiktoken")
|
|
17
|
+
except Exception:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_tiktoken = _load_tiktoken()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=2)
|
|
25
|
+
def _encoding(name: str) -> Any:
|
|
26
|
+
if _tiktoken is None:
|
|
27
|
+
raise RuntimeError("tiktoken is not available")
|
|
28
|
+
return _tiktoken.get_encoding(name)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def estimate_tokens(text: str, family: str = "generic") -> int:
|
|
32
|
+
"""Estimate tokens with tiktoken when available, else a stable heuristic."""
|
|
33
|
+
if not text:
|
|
34
|
+
return 0
|
|
35
|
+
if _tiktoken is not None:
|
|
36
|
+
encoding_name = "o200k_base" if family == "openai" else "cl100k_base"
|
|
37
|
+
try:
|
|
38
|
+
return max(1, len(_encoding(encoding_name).encode(text)))
|
|
39
|
+
except Exception:
|
|
40
|
+
return max(1, math.ceil(len(text) / 3.5))
|
|
41
|
+
return max(1, math.ceil(len(text) / 3.5))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Calibrator:
|
|
45
|
+
"""Exponential moving average of actual/estimated tokens per family."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, alpha: float = 0.1) -> None:
|
|
48
|
+
self.alpha = alpha
|
|
49
|
+
self.ratios: dict[str, float] = {}
|
|
50
|
+
|
|
51
|
+
def update(self, family: str, actual: int, estimated: int) -> None:
|
|
52
|
+
if estimated <= 0 or actual <= 0:
|
|
53
|
+
return
|
|
54
|
+
ratio = actual / estimated
|
|
55
|
+
previous = self.ratios.get(family, 1.0)
|
|
56
|
+
self.ratios[family] = (1 - self.alpha) * previous + self.alpha * ratio
|
|
57
|
+
|
|
58
|
+
def apply(self, family: str, estimate: int) -> int:
|
|
59
|
+
return max(0, round(estimate * self.ratios.get(family, 1.0)))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = ["Calibrator", "estimate_tokens"]
|