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.
Files changed (83) hide show
  1. ctxoptimizer/__init__.py +5 -0
  2. ctxoptimizer/__main__.py +14 -0
  3. ctxoptimizer/admin_security.py +130 -0
  4. ctxoptimizer/analysis/__init__.py +1 -0
  5. ctxoptimizer/analysis/baseline.py +69 -0
  6. ctxoptimizer/analysis/break_even.py +49 -0
  7. ctxoptimizer/analysis/drift.py +151 -0
  8. ctxoptimizer/analysis/edits.py +148 -0
  9. ctxoptimizer/analysis/markers.py +34 -0
  10. ctxoptimizer/analysis/recommend.py +114 -0
  11. ctxoptimizer/analysis/segments.py +57 -0
  12. ctxoptimizer/analysis/session.py +62 -0
  13. ctxoptimizer/analysis/state.py +239 -0
  14. ctxoptimizer/analysis/tokens.py +62 -0
  15. ctxoptimizer/analysis/waste.py +322 -0
  16. ctxoptimizer/cli.py +873 -0
  17. ctxoptimizer/config.py +512 -0
  18. ctxoptimizer/dashboard/__init__.py +1 -0
  19. ctxoptimizer/dashboard/api.py +537 -0
  20. ctxoptimizer/dashboard/service.py +157 -0
  21. ctxoptimizer/dashboard/static/.gitkeep +1 -0
  22. ctxoptimizer/dashboard/templates/.gitkeep +1 -0
  23. ctxoptimizer/dashboard/templates/doctor.html +46 -0
  24. ctxoptimizer/dashboard/templates/live.html +91 -0
  25. ctxoptimizer/dashboard/templates/overview.html +172 -0
  26. ctxoptimizer/dashboard/templates/pricing.html +115 -0
  27. ctxoptimizer/dashboard/templates/request.html +111 -0
  28. ctxoptimizer/dashboard/templates/session.html +67 -0
  29. ctxoptimizer/dashboard/templates/settings.html +79 -0
  30. ctxoptimizer/dashboard/templates/table.html +56 -0
  31. ctxoptimizer/dashboard/templates/transforms.html +118 -0
  32. ctxoptimizer/dashboard/templates/waste.html +120 -0
  33. ctxoptimizer/dashboard/views.py +429 -0
  34. ctxoptimizer/data/cache_limits.json +23 -0
  35. ctxoptimizer/data/default_config.toml +185 -0
  36. ctxoptimizer/data/pricing.json +421 -0
  37. ctxoptimizer/doctor.py +129 -0
  38. ctxoptimizer/guard.py +139 -0
  39. ctxoptimizer/ids.py +73 -0
  40. ctxoptimizer/log.py +73 -0
  41. ctxoptimizer/mcp.py +124 -0
  42. ctxoptimizer/models.py +145 -0
  43. ctxoptimizer/pipeline.py +126 -0
  44. ctxoptimizer/profiles.py +152 -0
  45. ctxoptimizer/providers/__init__.py +37 -0
  46. ctxoptimizer/providers/anthropic.py +105 -0
  47. ctxoptimizer/providers/base.py +191 -0
  48. ctxoptimizer/providers/gemini.py +143 -0
  49. ctxoptimizer/providers/ollama.py +133 -0
  50. ctxoptimizer/providers/openai_chat.py +135 -0
  51. ctxoptimizer/providers/openai_responses.py +121 -0
  52. ctxoptimizer/proxy.py +665 -0
  53. ctxoptimizer/routing.py +259 -0
  54. ctxoptimizer/server.py +319 -0
  55. ctxoptimizer/service.py +150 -0
  56. ctxoptimizer/telemetry/__init__.py +1 -0
  57. ctxoptimizer/telemetry/blobs.py +57 -0
  58. ctxoptimizer/telemetry/expand.py +62 -0
  59. ctxoptimizer/telemetry/pricing.py +168 -0
  60. ctxoptimizer/telemetry/redact.py +53 -0
  61. ctxoptimizer/telemetry/sse.py +145 -0
  62. ctxoptimizer/telemetry/store.py +829 -0
  63. ctxoptimizer/telemetry/usage.py +435 -0
  64. ctxoptimizer/transforms/__init__.py +87 -0
  65. ctxoptimizer/transforms/ansi.py +21 -0
  66. ctxoptimizer/transforms/anthropic_cache.py +100 -0
  67. ctxoptimizer/transforms/backref.py +74 -0
  68. ctxoptimizer/transforms/base.py +93 -0
  69. ctxoptimizer/transforms/cache_serve.py +146 -0
  70. ctxoptimizer/transforms/dedupe_lines.py +69 -0
  71. ctxoptimizer/transforms/eligibility.py +24 -0
  72. ctxoptimizer/transforms/openai_hints.py +84 -0
  73. ctxoptimizer/transforms/progress.py +52 -0
  74. ctxoptimizer/transforms/stale_result_elision.py +109 -0
  75. ctxoptimizer/transforms/truncate.py +100 -0
  76. ctxoptimizer/transforms/volatile.py +177 -0
  77. ctxoptimizer/transforms/whitespace.py +25 -0
  78. ctxoptimizer-0.4.1.dist-info/METADATA +196 -0
  79. ctxoptimizer-0.4.1.dist-info/RECORD +83 -0
  80. ctxoptimizer-0.4.1.dist-info/WHEEL +4 -0
  81. ctxoptimizer-0.4.1.dist-info/entry_points.txt +3 -0
  82. ctxoptimizer-0.4.1.dist-info/licenses/LICENSE +21 -0
  83. ctxoptimizer-0.4.1.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,5 @@
1
+ """ctxoptimizer: a local, deterministic cost optimizer for AI coding agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.4.1"
@@ -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"]