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
ctxoptimizer/__init__.py
ADDED
ctxoptimizer/__main__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Module entry point for ``python -m ctxoptimizer``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ctxoptimizer.cli import app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
"""Run the command line interface."""
|
|
10
|
+
app()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Admin endpoint security: Host allowlist, token and CSRF (SPEC 16.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import JSONResponse, Response
|
|
14
|
+
from starlette.types import ASGIApp
|
|
15
|
+
|
|
16
|
+
from ctxoptimizer.config import LOOPBACK_HOSTS, Config, data_dir
|
|
17
|
+
|
|
18
|
+
_MUTATING = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
19
|
+
_HEALTH = "/_ctxoptimizer/health"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AdminSecurityMiddleware(BaseHTTPMiddleware):
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
app: ASGIApp,
|
|
26
|
+
*,
|
|
27
|
+
config: Config,
|
|
28
|
+
admin_token: str | None = None,
|
|
29
|
+
csrf_token: str | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
super().__init__(app)
|
|
32
|
+
self.config = config
|
|
33
|
+
self.admin_token = admin_token or config.server.admin_token or secrets.token_urlsafe(24)
|
|
34
|
+
self.csrf_token = csrf_token or secrets.token_urlsafe(24)
|
|
35
|
+
|
|
36
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
37
|
+
path = request.url.path
|
|
38
|
+
if not path.startswith("/_ctxoptimizer") or path == _HEALTH:
|
|
39
|
+
return await call_next(request)
|
|
40
|
+
|
|
41
|
+
host = (request.url.hostname or "").lower()
|
|
42
|
+
allowed = set(LOOPBACK_HOSTS)
|
|
43
|
+
if self.config.server.allow_remote:
|
|
44
|
+
allowed.add(self.config.server.host.lower())
|
|
45
|
+
if host not in allowed:
|
|
46
|
+
return JSONResponse({"error": "forbidden admin host"}, status_code=403)
|
|
47
|
+
|
|
48
|
+
supplied = (
|
|
49
|
+
request.query_params.get("token")
|
|
50
|
+
or request.headers.get("x-ctxoptimizer-admin-token")
|
|
51
|
+
or request.cookies.get("ctxoptimizer_admin")
|
|
52
|
+
)
|
|
53
|
+
if not _same(supplied, self.admin_token):
|
|
54
|
+
return JSONResponse({"error": "admin token required"}, status_code=401)
|
|
55
|
+
|
|
56
|
+
if request.method in _MUTATING:
|
|
57
|
+
csrf = request.headers.get("x-ctxoptimizer-csrf") or request.query_params.get("csrf")
|
|
58
|
+
if not _same(csrf, self.csrf_token):
|
|
59
|
+
return JSONResponse({"error": "csrf token required"}, status_code=403)
|
|
60
|
+
|
|
61
|
+
response = await call_next(request)
|
|
62
|
+
if request.query_params.get("token"):
|
|
63
|
+
response.set_cookie(
|
|
64
|
+
"ctxoptimizer_admin",
|
|
65
|
+
self.admin_token,
|
|
66
|
+
httponly=True,
|
|
67
|
+
samesite="strict",
|
|
68
|
+
path="/_ctxoptimizer",
|
|
69
|
+
)
|
|
70
|
+
response.set_cookie(
|
|
71
|
+
"ctxoptimizer_csrf",
|
|
72
|
+
self.csrf_token,
|
|
73
|
+
httponly=False,
|
|
74
|
+
samesite="strict",
|
|
75
|
+
path="/_ctxoptimizer",
|
|
76
|
+
)
|
|
77
|
+
# No CORS support on admin endpoints (SPEC 16.1).
|
|
78
|
+
if "access-control-allow-origin" in response.headers:
|
|
79
|
+
del response.headers["access-control-allow-origin"]
|
|
80
|
+
return response
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def admin_credentials() -> tuple[str, str]:
|
|
84
|
+
"""Create a fresh per-run admin token and CSRF token."""
|
|
85
|
+
return secrets.token_urlsafe(24), secrets.token_urlsafe(24)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def token_file() -> Path:
|
|
89
|
+
return data_dir() / "admin-token.json"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def save_tokens(admin_token: str, csrf_token: str) -> Path:
|
|
93
|
+
"""Persist the per-run tokens for local CLI commands (0600 on POSIX)."""
|
|
94
|
+
path = token_file()
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
path.write_text(
|
|
97
|
+
json.dumps({"admin_token": admin_token, "csrf_token": csrf_token}),
|
|
98
|
+
encoding="utf-8",
|
|
99
|
+
)
|
|
100
|
+
if os.name == "posix":
|
|
101
|
+
os.chmod(path, 0o600)
|
|
102
|
+
return path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_tokens() -> tuple[str | None, str | None]:
|
|
106
|
+
try:
|
|
107
|
+
payload = json.loads(token_file().read_text(encoding="utf-8"))
|
|
108
|
+
except (OSError, json.JSONDecodeError):
|
|
109
|
+
return None, None
|
|
110
|
+
admin = payload.get("admin_token")
|
|
111
|
+
csrf = payload.get("csrf_token")
|
|
112
|
+
return (
|
|
113
|
+
admin if isinstance(admin, str) else None,
|
|
114
|
+
csrf if isinstance(csrf, str) else None,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _same(left: str | None, right: str) -> bool:
|
|
119
|
+
if not left:
|
|
120
|
+
return False
|
|
121
|
+
return hmac.compare_digest(left, right)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"AdminSecurityMiddleware",
|
|
126
|
+
"admin_credentials",
|
|
127
|
+
"load_tokens",
|
|
128
|
+
"save_tokens",
|
|
129
|
+
"token_file",
|
|
130
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Deterministic analysis: segments, drift, waste and baseline (SPEC section 13)."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Cache baseline simulator (SPEC section 13.5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ctxoptimizer.analysis.segments import Segment
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class CacheSim:
|
|
12
|
+
fresh_tokens: int
|
|
13
|
+
read_tokens: int
|
|
14
|
+
write_5m_tokens: int
|
|
15
|
+
write_1h_tokens: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def common_prefix_tokens(previous: list[Segment], current: list[Segment]) -> int:
|
|
19
|
+
total = 0
|
|
20
|
+
for old, new in zip(previous, current, strict=False):
|
|
21
|
+
if old.hash != new.hash:
|
|
22
|
+
break
|
|
23
|
+
total += new.est_tokens
|
|
24
|
+
return total
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def simulate(
|
|
28
|
+
cache_policy: str,
|
|
29
|
+
previous: list[Segment] | None,
|
|
30
|
+
previous_ts: float | None,
|
|
31
|
+
current: list[Segment],
|
|
32
|
+
current_ts: float,
|
|
33
|
+
*,
|
|
34
|
+
ttl_s: float = 300.0,
|
|
35
|
+
min_prefix: int = 1024,
|
|
36
|
+
) -> CacheSim:
|
|
37
|
+
total = sum(segment.est_tokens for segment in current)
|
|
38
|
+
if previous is None or previous_ts is None or current_ts - previous_ts > ttl_s:
|
|
39
|
+
return CacheSim(total, 0, 0, 0)
|
|
40
|
+
if cache_policy == "explicit-anthropic":
|
|
41
|
+
return _explicit(previous, current, total)
|
|
42
|
+
if cache_policy in {"implicit-openai", "gemini-implicit", "automatic-deepseek"}:
|
|
43
|
+
prefix = common_prefix_tokens(previous, current)
|
|
44
|
+
read = (prefix // 128) * 128 if prefix >= min_prefix else 0
|
|
45
|
+
return CacheSim(max(0, total - read), min(total, read), 0, 0)
|
|
46
|
+
return CacheSim(total, 0, 0, 0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _explicit(previous: list[Segment], current: list[Segment], total: int) -> CacheSim:
|
|
50
|
+
previous_marker = _tokens_to_last_marker(previous)
|
|
51
|
+
current_marker = _tokens_to_last_marker(current)
|
|
52
|
+
if previous_marker == 0 or current_marker == 0:
|
|
53
|
+
return CacheSim(total, 0, 0, 0)
|
|
54
|
+
read = min(common_prefix_tokens(previous, current), previous_marker)
|
|
55
|
+
write = max(0, current_marker - read)
|
|
56
|
+
return CacheSim(max(0, total - read - write), read, write, 0)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _tokens_to_last_marker(segments: list[Segment]) -> int:
|
|
60
|
+
last = -1
|
|
61
|
+
for index, segment in enumerate(segments):
|
|
62
|
+
if segment.has_marker:
|
|
63
|
+
last = index
|
|
64
|
+
if last < 0:
|
|
65
|
+
return 0
|
|
66
|
+
return sum(segment.est_tokens for segment in segments[: last + 1])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
__all__ = ["CacheSim", "common_prefix_tokens", "simulate"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Conservative break-even estimate for ``stale_result_elision`` (P6-4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class StaleElisionEstimate:
|
|
10
|
+
elided_tokens: int
|
|
11
|
+
batch_turns: int
|
|
12
|
+
savings_per_request_usd: float
|
|
13
|
+
boundary_miss_cost_usd: float
|
|
14
|
+
requests_to_recover: float | None
|
|
15
|
+
break_even_within_batch: bool
|
|
16
|
+
method: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def estimate_stale_elision(
|
|
20
|
+
*,
|
|
21
|
+
elided_tokens: int,
|
|
22
|
+
batch_turns: int,
|
|
23
|
+
input_per_mtok: float,
|
|
24
|
+
cache_read_per_mtok: float,
|
|
25
|
+
cache_write_per_mtok: float = 0.0,
|
|
26
|
+
) -> StaleElisionEstimate:
|
|
27
|
+
"""Estimate the trade-off. All money is simulated, never billed."""
|
|
28
|
+
tokens = max(int(elided_tokens), 0)
|
|
29
|
+
batch = max(int(batch_turns), 1)
|
|
30
|
+
savings = tokens * max(input_per_mtok, 0.0) / 1_000_000
|
|
31
|
+
miss = (
|
|
32
|
+
tokens * max(input_per_mtok + cache_write_per_mtok - cache_read_per_mtok, 0.0) / 1_000_000
|
|
33
|
+
)
|
|
34
|
+
recover = (miss / savings) if savings > 0 else None
|
|
35
|
+
return StaleElisionEstimate(
|
|
36
|
+
elided_tokens=tokens,
|
|
37
|
+
batch_turns=batch,
|
|
38
|
+
savings_per_request_usd=savings,
|
|
39
|
+
boundary_miss_cost_usd=miss,
|
|
40
|
+
requests_to_recover=recover,
|
|
41
|
+
break_even_within_batch=(recover is not None and recover <= batch),
|
|
42
|
+
method=(
|
|
43
|
+
"sim.: assumes elided tokens would otherwise be fresh input; one "
|
|
44
|
+
"cache-miss per boundary advance at input + write - read rates."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["StaleElisionEstimate", "estimate_stale_elision"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Prefix-drift detector (SPEC section 13.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from ctxoptimizer.analysis.segments import Segment
|
|
9
|
+
|
|
10
|
+
DriftCause = Literal[
|
|
11
|
+
"tools_changed",
|
|
12
|
+
"system_changed",
|
|
13
|
+
"params_changed",
|
|
14
|
+
"history_mutated",
|
|
15
|
+
"ttl_expired",
|
|
16
|
+
"config_changed",
|
|
17
|
+
"model_switched",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
_CAUSE_BY_KIND: dict[str, DriftCause] = {
|
|
21
|
+
"tool_def": "tools_changed",
|
|
22
|
+
"system": "system_changed",
|
|
23
|
+
"message": "history_mutated",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class DriftEvent:
|
|
29
|
+
cause: DriftCause
|
|
30
|
+
at_segment: int | None
|
|
31
|
+
est_tokens_lost: int
|
|
32
|
+
detail: dict[str, object]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def detect_drift(
|
|
36
|
+
previous: list[Segment] | None,
|
|
37
|
+
current: list[Segment],
|
|
38
|
+
*,
|
|
39
|
+
previous_ts: float | None = None,
|
|
40
|
+
current_ts: float | None = None,
|
|
41
|
+
ttl_s: float = 300.0,
|
|
42
|
+
previous_params: str | None = None,
|
|
43
|
+
current_params: str | None = None,
|
|
44
|
+
previous_config_hash: str | None = None,
|
|
45
|
+
current_config_hash: str | None = None,
|
|
46
|
+
previous_model: str | None = None,
|
|
47
|
+
current_model: str | None = None,
|
|
48
|
+
) -> DriftEvent | None:
|
|
49
|
+
"""Return the first divergence that would break prefix cache reuse."""
|
|
50
|
+
if previous is None:
|
|
51
|
+
return None
|
|
52
|
+
if previous_model and current_model and previous_model != current_model:
|
|
53
|
+
return DriftEvent(
|
|
54
|
+
"model_switched",
|
|
55
|
+
0,
|
|
56
|
+
_lost(previous, current, 0),
|
|
57
|
+
{"previous": previous_model, "current": current_model},
|
|
58
|
+
)
|
|
59
|
+
if (
|
|
60
|
+
previous_params is not None
|
|
61
|
+
and current_params is not None
|
|
62
|
+
and previous_params != current_params
|
|
63
|
+
):
|
|
64
|
+
return DriftEvent(
|
|
65
|
+
"params_changed", 0, _lost(previous, current, 0), {"params": current_params}
|
|
66
|
+
)
|
|
67
|
+
if previous_config_hash and current_config_hash and previous_config_hash != current_config_hash:
|
|
68
|
+
return DriftEvent(
|
|
69
|
+
"config_changed",
|
|
70
|
+
0,
|
|
71
|
+
_lost(previous, current, 0),
|
|
72
|
+
{"config_hash": current_config_hash},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
limit = min(len(previous), len(current))
|
|
76
|
+
first = next(
|
|
77
|
+
(index for index in range(limit) if previous[index].hash != current[index].hash),
|
|
78
|
+
None,
|
|
79
|
+
)
|
|
80
|
+
if first is None and len(current) < len(previous):
|
|
81
|
+
first = len(current)
|
|
82
|
+
if first is not None:
|
|
83
|
+
kind = current[first].kind if first < len(current) else previous[first].kind
|
|
84
|
+
return DriftEvent(
|
|
85
|
+
_CAUSE_BY_KIND.get(kind, "history_mutated"),
|
|
86
|
+
first,
|
|
87
|
+
_lost(previous, current, first),
|
|
88
|
+
{"kind": kind},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if previous_ts is not None and current_ts is not None:
|
|
92
|
+
delta = current_ts - previous_ts
|
|
93
|
+
if delta > ttl_s:
|
|
94
|
+
return DriftEvent(
|
|
95
|
+
"ttl_expired",
|
|
96
|
+
None,
|
|
97
|
+
sum(segment.est_tokens for segment in current),
|
|
98
|
+
{"delta_s": round(delta, 3), "ttl_s": ttl_s},
|
|
99
|
+
)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DriftTracker:
|
|
104
|
+
"""Keep the last segment list and fingerprint per session."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, max_sessions: int = 256) -> None:
|
|
107
|
+
self.max_sessions = max_sessions
|
|
108
|
+
self._state: dict[str, tuple[list[Segment], float, str, str, str]] = {}
|
|
109
|
+
|
|
110
|
+
def observe(
|
|
111
|
+
self,
|
|
112
|
+
session_id: str,
|
|
113
|
+
segments: list[Segment],
|
|
114
|
+
ts: float,
|
|
115
|
+
*,
|
|
116
|
+
params: str = "",
|
|
117
|
+
config_hash: str = "",
|
|
118
|
+
model: str = "",
|
|
119
|
+
ttl_s: float = 300.0,
|
|
120
|
+
) -> DriftEvent | None:
|
|
121
|
+
previous = self._state.get(session_id)
|
|
122
|
+
event = detect_drift(
|
|
123
|
+
previous[0] if previous else None,
|
|
124
|
+
segments,
|
|
125
|
+
previous_ts=previous[1] if previous else None,
|
|
126
|
+
current_ts=ts,
|
|
127
|
+
ttl_s=ttl_s,
|
|
128
|
+
previous_params=previous[2] if previous else None,
|
|
129
|
+
current_params=params,
|
|
130
|
+
previous_config_hash=previous[3] if previous else None,
|
|
131
|
+
current_config_hash=config_hash,
|
|
132
|
+
previous_model=previous[4] if previous else None,
|
|
133
|
+
current_model=model,
|
|
134
|
+
)
|
|
135
|
+
if len(self._state) >= self.max_sessions and session_id not in self._state:
|
|
136
|
+
self._state.pop(next(iter(self._state)))
|
|
137
|
+
self._state[session_id] = (segments, ts, params, config_hash, model)
|
|
138
|
+
return event
|
|
139
|
+
|
|
140
|
+
def clear(self) -> None:
|
|
141
|
+
self._state.clear()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _lost(previous: list[Segment], current: list[Segment], first: int) -> int:
|
|
145
|
+
end = min(len(previous), len(current))
|
|
146
|
+
if first >= end:
|
|
147
|
+
return 0
|
|
148
|
+
return sum(segment.est_tokens for segment in current[first:end])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["DriftCause", "DriftEvent", "DriftTracker", "detect_drift"]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Conservative tool-edit extraction for the opt-in churn detector (W7).
|
|
2
|
+
|
|
3
|
+
Only argument dictionaries with a known old/new string pair are inspected. Raw
|
|
4
|
+
edit text stays in memory for the current request and is never persisted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import orjson
|
|
12
|
+
|
|
13
|
+
from ctxoptimizer.analysis.tokens import estimate_tokens
|
|
14
|
+
from ctxoptimizer.analysis.waste import EditObservation
|
|
15
|
+
from ctxoptimizer.models import Provider
|
|
16
|
+
from ctxoptimizer.providers.base import blocks_of
|
|
17
|
+
|
|
18
|
+
_PAIRS: tuple[tuple[str, str], ...] = (
|
|
19
|
+
("old_string", "new_string"),
|
|
20
|
+
("oldText", "newText"),
|
|
21
|
+
("old_str", "new_str"),
|
|
22
|
+
("old_content", "new_content"),
|
|
23
|
+
("search", "replace"),
|
|
24
|
+
("old", "new"),
|
|
25
|
+
)
|
|
26
|
+
_PATH_KEYS = ("file_path", "path", "filename", "file", "target_file")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def edit_observations(payload: dict[str, Any], provider: Provider) -> list[EditObservation]:
|
|
30
|
+
observations: list[EditObservation] = []
|
|
31
|
+
for tool_name, arguments in _tool_inputs(payload, provider):
|
|
32
|
+
if not isinstance(arguments, dict):
|
|
33
|
+
continue
|
|
34
|
+
path = _path_of(arguments) or tool_name or "<unknown>"
|
|
35
|
+
for old_key, new_key in _PAIRS:
|
|
36
|
+
old = arguments.get(old_key)
|
|
37
|
+
new = arguments.get(new_key)
|
|
38
|
+
if not isinstance(old, str) or not isinstance(new, str):
|
|
39
|
+
continue
|
|
40
|
+
observations.append(
|
|
41
|
+
EditObservation(
|
|
42
|
+
path=path,
|
|
43
|
+
old=old,
|
|
44
|
+
new=new,
|
|
45
|
+
tool_name=tool_name,
|
|
46
|
+
tokens=max(estimate_tokens(old), estimate_tokens(new)),
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
break
|
|
50
|
+
return observations
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tool_inputs(payload: dict[str, Any], provider: Provider) -> list[tuple[str | None, object]]:
|
|
54
|
+
if provider is Provider.ANTHROPIC:
|
|
55
|
+
return _anthropic_inputs(payload)
|
|
56
|
+
if provider is Provider.GEMINI:
|
|
57
|
+
return _gemini_inputs(payload)
|
|
58
|
+
if provider is Provider.OLLAMA:
|
|
59
|
+
return _chat_inputs(payload)
|
|
60
|
+
return _openai_inputs(payload)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _anthropic_inputs(payload: dict[str, Any]) -> list[tuple[str | None, object]]:
|
|
64
|
+
result: list[tuple[str | None, object]] = []
|
|
65
|
+
result.extend(
|
|
66
|
+
(
|
|
67
|
+
block.get("name") if isinstance(block.get("name"), str) else None,
|
|
68
|
+
block.get("input"),
|
|
69
|
+
)
|
|
70
|
+
for message in payload.get("messages") or []
|
|
71
|
+
if isinstance(message, dict)
|
|
72
|
+
for block in blocks_of(message.get("content"))
|
|
73
|
+
if block.get("type") == "tool_use"
|
|
74
|
+
)
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _openai_inputs(payload: dict[str, Any]) -> list[tuple[str | None, object]]:
|
|
79
|
+
result: list[tuple[str | None, object]] = []
|
|
80
|
+
result.extend(
|
|
81
|
+
(
|
|
82
|
+
item.get("name") if isinstance(item.get("name"), str) else None,
|
|
83
|
+
_arguments(item.get("arguments")),
|
|
84
|
+
)
|
|
85
|
+
for item in payload.get("input") or []
|
|
86
|
+
if isinstance(item, dict) and item.get("type") == "function_call"
|
|
87
|
+
)
|
|
88
|
+
result.extend(_chat_inputs(payload))
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _chat_inputs(payload: dict[str, Any]) -> list[tuple[str | None, object]]:
|
|
93
|
+
result: list[tuple[str | None, object]] = []
|
|
94
|
+
for message in payload.get("messages") or []:
|
|
95
|
+
if not isinstance(message, dict):
|
|
96
|
+
continue
|
|
97
|
+
for call in message.get("tool_calls") or []:
|
|
98
|
+
if not isinstance(call, dict):
|
|
99
|
+
continue
|
|
100
|
+
function = call.get("function")
|
|
101
|
+
if not isinstance(function, dict):
|
|
102
|
+
continue
|
|
103
|
+
result.append(
|
|
104
|
+
(
|
|
105
|
+
function.get("name") if isinstance(function.get("name"), str) else None,
|
|
106
|
+
_arguments(function.get("arguments")),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _gemini_inputs(payload: dict[str, Any]) -> list[tuple[str | None, object]]:
|
|
113
|
+
result: list[tuple[str | None, object]] = []
|
|
114
|
+
for content in payload.get("contents") or []:
|
|
115
|
+
if not isinstance(content, dict):
|
|
116
|
+
continue
|
|
117
|
+
for part in content.get("parts") or []:
|
|
118
|
+
if not isinstance(part, dict):
|
|
119
|
+
continue
|
|
120
|
+
call = part.get("functionCall")
|
|
121
|
+
if isinstance(call, dict):
|
|
122
|
+
result.append(
|
|
123
|
+
(
|
|
124
|
+
call.get("name") if isinstance(call.get("name"), str) else None,
|
|
125
|
+
call.get("args"),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _arguments(value: object) -> object:
|
|
132
|
+
if isinstance(value, str):
|
|
133
|
+
try:
|
|
134
|
+
return orjson.loads(value)
|
|
135
|
+
except (orjson.JSONDecodeError, TypeError):
|
|
136
|
+
return None
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _path_of(arguments: dict[str, Any]) -> str | None:
|
|
141
|
+
for key in _PATH_KEYS:
|
|
142
|
+
value = arguments.get(key)
|
|
143
|
+
if isinstance(value, str) and value:
|
|
144
|
+
return value
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
__all__ = ["edit_observations"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Client cache-marker quality (SPEC section 12.9 / P4-3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ctxoptimizer.analysis.segments import Segment
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class MarkerQuality:
|
|
12
|
+
markers: int
|
|
13
|
+
cacheable_tokens: int
|
|
14
|
+
total_tokens: int
|
|
15
|
+
coverage: float
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def marker_quality(segments: list[Segment]) -> MarkerQuality:
|
|
19
|
+
markers = sum(1 for segment in segments if segment.has_marker)
|
|
20
|
+
total = sum(segment.est_tokens for segment in segments)
|
|
21
|
+
last = -1
|
|
22
|
+
for index, segment in enumerate(segments):
|
|
23
|
+
if segment.has_marker:
|
|
24
|
+
last = index
|
|
25
|
+
cacheable = sum(segment.est_tokens for segment in segments[: last + 1]) if last >= 0 else 0
|
|
26
|
+
return MarkerQuality(
|
|
27
|
+
markers=markers,
|
|
28
|
+
cacheable_tokens=cacheable,
|
|
29
|
+
total_tokens=total,
|
|
30
|
+
coverage=(cacheable / total) if total else 0.0,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["MarkerQuality", "marker_quality"]
|