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,537 @@
|
|
|
1
|
+
"""Admin JSON API (SPEC section 16.3, v0)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import orjson
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
15
|
+
|
|
16
|
+
from ctxoptimizer.analysis.break_even import estimate_stale_elision
|
|
17
|
+
from ctxoptimizer.analysis.recommend import recommendations
|
|
18
|
+
from ctxoptimizer.dashboard.service import (
|
|
19
|
+
apply_config_patch,
|
|
20
|
+
config_view,
|
|
21
|
+
findings_from_rows,
|
|
22
|
+
parse_since,
|
|
23
|
+
pricing_payload,
|
|
24
|
+
reload_config,
|
|
25
|
+
rows_to_csv,
|
|
26
|
+
write_pricing,
|
|
27
|
+
)
|
|
28
|
+
from ctxoptimizer.models import WasteFinding
|
|
29
|
+
from ctxoptimizer.telemetry.expand import expand_stats as expand_stats_query
|
|
30
|
+
from ctxoptimizer.telemetry.pricing import load_pricing
|
|
31
|
+
|
|
32
|
+
API_VERSION = 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def summary(request: Request) -> Response:
|
|
36
|
+
store = getattr(request.app.state, "store", None)
|
|
37
|
+
data = (
|
|
38
|
+
store.summary(
|
|
39
|
+
parse_since(request.query_params.get("since")),
|
|
40
|
+
request.query_params.get("project"),
|
|
41
|
+
)
|
|
42
|
+
if store is not None
|
|
43
|
+
else {
|
|
44
|
+
"requests": 0,
|
|
45
|
+
"cost_usd": 0.0,
|
|
46
|
+
"fresh_input": 0,
|
|
47
|
+
"cache_read": 0,
|
|
48
|
+
"output_tokens": 0,
|
|
49
|
+
"tokens_removed": 0,
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
if store is not None:
|
|
53
|
+
data = dict(data)
|
|
54
|
+
data["waste"] = store.waste_summary(parse_since(request.query_params.get("since")))
|
|
55
|
+
return JSONResponse({"api_version": API_VERSION, **data})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def admin_config(request: Request) -> Response:
|
|
59
|
+
return JSONResponse(
|
|
60
|
+
{"api_version": API_VERSION, "config": config_view(request.app.state.config)}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def update_config(request: Request) -> Response:
|
|
65
|
+
try:
|
|
66
|
+
patch = await request.json()
|
|
67
|
+
if not isinstance(patch, dict):
|
|
68
|
+
raise ValueError("config patch must be an object")
|
|
69
|
+
updated = apply_config_patch(request.app, patch)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
72
|
+
return JSONResponse({"api_version": API_VERSION, "config": config_view(updated)})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def reload(request: Request) -> Response:
|
|
76
|
+
try:
|
|
77
|
+
updated = reload_config(request.app)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
80
|
+
return JSONResponse({"api_version": API_VERSION, "config": config_view(updated)})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def set_mode(request: Request) -> Response:
|
|
84
|
+
try:
|
|
85
|
+
body = await request.json()
|
|
86
|
+
except Exception:
|
|
87
|
+
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
|
88
|
+
mode = body.get("mode") if isinstance(body, dict) else None
|
|
89
|
+
if mode not in {"passthrough", "shadow", "active"}:
|
|
90
|
+
return JSONResponse(
|
|
91
|
+
{"error": "mode must be passthrough, shadow or active"}, status_code=400
|
|
92
|
+
)
|
|
93
|
+
try:
|
|
94
|
+
updated = apply_config_patch(request.app, {"mode": {"default": mode}})
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
97
|
+
return JSONResponse({"api_version": API_VERSION, "mode": updated.mode.default})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def purge(request: Request) -> Response:
|
|
101
|
+
store = getattr(request.app.state, "store", None)
|
|
102
|
+
blobs = getattr(request.app.state, "blobs", None)
|
|
103
|
+
analysis = getattr(request.app.state, "analysis", None)
|
|
104
|
+
if store is not None:
|
|
105
|
+
store.purge()
|
|
106
|
+
if blobs is not None:
|
|
107
|
+
blobs.purge()
|
|
108
|
+
if analysis is not None:
|
|
109
|
+
analysis.clear()
|
|
110
|
+
return JSONResponse({"api_version": API_VERSION, "purged": True})
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def export(request: Request) -> Response:
|
|
114
|
+
store = getattr(request.app.state, "store", None)
|
|
115
|
+
try:
|
|
116
|
+
limit = int(request.query_params.get("limit", "1000"))
|
|
117
|
+
except ValueError:
|
|
118
|
+
limit = 1000
|
|
119
|
+
limit = max(1, min(limit, 100_000))
|
|
120
|
+
rows = (
|
|
121
|
+
store.export_rows(parse_since(request.query_params.get("since")), limit)
|
|
122
|
+
if store is not None
|
|
123
|
+
else []
|
|
124
|
+
)
|
|
125
|
+
if request.query_params.get("format") == "csv":
|
|
126
|
+
return Response(
|
|
127
|
+
rows_to_csv(rows),
|
|
128
|
+
media_type="text/csv",
|
|
129
|
+
headers={"content-disposition": "attachment; filename=ctxoptimizer-export.csv"},
|
|
130
|
+
)
|
|
131
|
+
return Response(
|
|
132
|
+
orjson.dumps(rows, default=str, option=orjson.OPT_INDENT_2),
|
|
133
|
+
media_type="application/json",
|
|
134
|
+
headers={"content-disposition": "attachment; filename=ctxoptimizer-export.json"},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def timeseries(request: Request) -> Response:
|
|
139
|
+
store = getattr(request.app.state, "store", None)
|
|
140
|
+
metric = request.query_params.get("metric", "cost")
|
|
141
|
+
bucket = request.query_params.get("bucket", "hour")
|
|
142
|
+
since = parse_since(request.query_params.get("since"))
|
|
143
|
+
points = store.timeseries(metric, bucket, since) if store is not None else []
|
|
144
|
+
return JSONResponse(
|
|
145
|
+
{"api_version": API_VERSION, "metric": metric, "bucket": bucket, "points": points}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def drift(request: Request) -> Response:
|
|
150
|
+
store = getattr(request.app.state, "store", None)
|
|
151
|
+
try:
|
|
152
|
+
limit = int(request.query_params.get("limit", "200"))
|
|
153
|
+
except ValueError:
|
|
154
|
+
limit = 200
|
|
155
|
+
rows = store.fetch_drift(max(1, min(limit, 2000))) if store is not None else []
|
|
156
|
+
return JSONResponse({"api_version": API_VERSION, "events": rows})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def transform_stats(request: Request) -> Response:
|
|
160
|
+
store = getattr(request.app.state, "store", None)
|
|
161
|
+
guard = getattr(request.app.state, "guard", None)
|
|
162
|
+
stats = store.transform_stats() if store is not None else []
|
|
163
|
+
return JSONResponse(
|
|
164
|
+
{
|
|
165
|
+
"api_version": API_VERSION,
|
|
166
|
+
"stats": stats,
|
|
167
|
+
"quarantined": guard.status() if guard is not None else [],
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def request_diff(request: Request) -> Response:
|
|
173
|
+
store = getattr(request.app.state, "store", None)
|
|
174
|
+
blobs = getattr(request.app.state, "blobs", None)
|
|
175
|
+
request_id = request.path_params["request_id"]
|
|
176
|
+
if store is None or blobs is None:
|
|
177
|
+
return JSONResponse({"api_version": API_VERSION, "blocks": []})
|
|
178
|
+
blocks = []
|
|
179
|
+
for capture in store.fetch_captures(request_id):
|
|
180
|
+
before = blobs.get(capture.get("before_hash") or "")
|
|
181
|
+
after = blobs.get(capture.get("after_hash") or "")
|
|
182
|
+
blocks.append(
|
|
183
|
+
{
|
|
184
|
+
"location": capture.get("location"),
|
|
185
|
+
"before": before.decode("utf-8", "replace") if before else None,
|
|
186
|
+
"after": after.decode("utf-8", "replace") if after else None,
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
return JSONResponse({"api_version": API_VERSION, "blocks": blocks})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def live(request: Request) -> Response:
|
|
193
|
+
return StreamingResponse(
|
|
194
|
+
_live_events(request),
|
|
195
|
+
media_type="text/event-stream",
|
|
196
|
+
headers={"cache-control": "no-store"},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def _live_events(request: Request) -> Any:
|
|
201
|
+
while True:
|
|
202
|
+
if await request.is_disconnected():
|
|
203
|
+
return
|
|
204
|
+
yield "data: " + json.dumps(_live_snapshot(request), default=str) + "\n\n"
|
|
205
|
+
await asyncio.sleep(2.0)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _live_snapshot(request: Request) -> dict[str, Any]:
|
|
209
|
+
state = request.app.state
|
|
210
|
+
store = getattr(state, "store", None)
|
|
211
|
+
since = time.time() - 300
|
|
212
|
+
recent = store.fetch_recent(20) if store is not None else []
|
|
213
|
+
burn_rows = [row for row in recent if (row.get("ts") or 0) >= since]
|
|
214
|
+
burn = sum(float(row.get("cost_actual_usd") or 0) for row in burn_rows) / 5.0
|
|
215
|
+
sessions = store.fetch_sessions() if store is not None else []
|
|
216
|
+
active = len([row for row in sessions if (row.get("last_ts") or 0) >= since])
|
|
217
|
+
return {
|
|
218
|
+
"api_version": API_VERSION,
|
|
219
|
+
"ts": time.time(),
|
|
220
|
+
"counters": state.counters,
|
|
221
|
+
"active_sessions": active,
|
|
222
|
+
"burn_per_min_usd": burn,
|
|
223
|
+
"last_request": state.last_request or {},
|
|
224
|
+
"recent": recent,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def waste_status(request: Request) -> Response:
|
|
229
|
+
try:
|
|
230
|
+
body = await request.json()
|
|
231
|
+
except Exception:
|
|
232
|
+
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
|
233
|
+
status = body.get("status") if isinstance(body, dict) else None
|
|
234
|
+
if status not in {"new", "ack", "fixed", "skipped"}:
|
|
235
|
+
return JSONResponse({"error": "invalid status"}, status_code=400)
|
|
236
|
+
finding_id = request.path_params["finding_id"]
|
|
237
|
+
store = getattr(request.app.state, "store", None)
|
|
238
|
+
analysis = getattr(request.app.state, "analysis", None)
|
|
239
|
+
updated = store.set_waste_status(finding_id, status) if store is not None else False
|
|
240
|
+
memory_updated = analysis.set_status(finding_id, status) if analysis is not None else False
|
|
241
|
+
if not updated and not memory_updated:
|
|
242
|
+
return JSONResponse({"error": "finding not found"}, status_code=404)
|
|
243
|
+
return JSONResponse({"api_version": API_VERSION, "finding_id": finding_id, "status": status})
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def dismiss_recommendation(request: Request) -> Response:
|
|
247
|
+
store = getattr(request.app.state, "store", None)
|
|
248
|
+
if store is None:
|
|
249
|
+
return JSONResponse({"error": "store unavailable"}, status_code=503)
|
|
250
|
+
recommendation_id = request.path_params["recommendation_id"]
|
|
251
|
+
try:
|
|
252
|
+
body = await request.json()
|
|
253
|
+
except Exception:
|
|
254
|
+
body = {}
|
|
255
|
+
days = int(body.get("days", 30)) if isinstance(body, dict) else 30
|
|
256
|
+
store.dismiss_recommendation(recommendation_id, days=max(1, min(days, 365)))
|
|
257
|
+
return JSONResponse(
|
|
258
|
+
{
|
|
259
|
+
"api_version": API_VERSION,
|
|
260
|
+
"recommendation_id": recommendation_id,
|
|
261
|
+
"dismissed_days": max(1, min(days, 365)),
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def apply_recommendation(request: Request) -> Response:
|
|
267
|
+
recommendation_id = request.path_params["recommendation_id"]
|
|
268
|
+
patches: dict[str, dict[str, Any]] = {
|
|
269
|
+
"enable_cache_serve": {"transforms": {"cache_serve": {"enabled": True}}},
|
|
270
|
+
"enable_volatile_normalizer": {"transforms": {"volatile_normalizer": {"enabled": True}}},
|
|
271
|
+
"enable_transforms": {"mode": {"default": "active"}},
|
|
272
|
+
"batch_reads": {},
|
|
273
|
+
"investigate_runaway": {},
|
|
274
|
+
"prune_mcp_schemas": {},
|
|
275
|
+
"investigate_churn": {},
|
|
276
|
+
}
|
|
277
|
+
patch = patches.get(recommendation_id)
|
|
278
|
+
if patch is None:
|
|
279
|
+
return JSONResponse({"error": "unknown recommendation"}, status_code=404)
|
|
280
|
+
if not patch:
|
|
281
|
+
return JSONResponse(
|
|
282
|
+
{
|
|
283
|
+
"api_version": API_VERSION,
|
|
284
|
+
"applied": False,
|
|
285
|
+
"reason": "advisory only; no automatic config change",
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
try:
|
|
289
|
+
updated = apply_config_patch(request.app, patch)
|
|
290
|
+
except Exception as exc:
|
|
291
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
292
|
+
return JSONResponse(
|
|
293
|
+
{
|
|
294
|
+
"api_version": API_VERSION,
|
|
295
|
+
"applied": True,
|
|
296
|
+
"config": config_view(updated),
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def put_pricing(request: Request) -> Response:
|
|
302
|
+
try:
|
|
303
|
+
payload = await request.json()
|
|
304
|
+
if not isinstance(payload, dict):
|
|
305
|
+
raise ValueError("pricing payload must be an object")
|
|
306
|
+
config = request.app.state.config
|
|
307
|
+
_updated, path = write_pricing(config, payload)
|
|
308
|
+
apply_config_patch(request.app, {"pricing": {"file": str(path)}})
|
|
309
|
+
except Exception as exc:
|
|
310
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
311
|
+
return JSONResponse(
|
|
312
|
+
{
|
|
313
|
+
"api_version": API_VERSION,
|
|
314
|
+
"pricing": pricing_payload(request.app.state.config),
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def recent_requests(request: Request) -> Response:
|
|
320
|
+
store = getattr(request.app.state, "store", None)
|
|
321
|
+
try:
|
|
322
|
+
limit = int(request.query_params.get("limit", "50"))
|
|
323
|
+
except ValueError:
|
|
324
|
+
limit = 50
|
|
325
|
+
limit = max(1, min(limit, 500))
|
|
326
|
+
rows = store.fetch_recent(limit) if store is not None else []
|
|
327
|
+
for key in ("session_id", "project", "provider", "model", "outcome", "mode"):
|
|
328
|
+
value = request.query_params.get(key)
|
|
329
|
+
if value:
|
|
330
|
+
rows = [row for row in rows if str(row.get(key) or "") == value]
|
|
331
|
+
return JSONResponse({"api_version": API_VERSION, "requests": rows})
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def waste(request: Request) -> Response:
|
|
335
|
+
store = getattr(request.app.state, "store", None)
|
|
336
|
+
analysis = getattr(request.app.state, "analysis", None)
|
|
337
|
+
findings = analysis.findings() if analysis is not None else []
|
|
338
|
+
if not findings and store is not None:
|
|
339
|
+
findings = findings_from_rows(store.fetch_waste())
|
|
340
|
+
return JSONResponse(
|
|
341
|
+
{
|
|
342
|
+
"api_version": API_VERSION,
|
|
343
|
+
"findings": [_finding_dict(finding) for finding in findings],
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def recommendations_api(request: Request) -> Response:
|
|
349
|
+
store = getattr(request.app.state, "store", None)
|
|
350
|
+
analysis = getattr(request.app.state, "analysis", None)
|
|
351
|
+
findings = analysis.findings() if analysis is not None else []
|
|
352
|
+
if not findings and store is not None:
|
|
353
|
+
findings = findings_from_rows(store.fetch_waste())
|
|
354
|
+
config = request.app.state.config
|
|
355
|
+
items = recommendations(
|
|
356
|
+
findings,
|
|
357
|
+
cache_serve_enabled=config.transforms.cache_serve.enabled,
|
|
358
|
+
volatile_normalizer_enabled=config.transforms.volatile_normalizer.enabled,
|
|
359
|
+
transforms_enabled=True,
|
|
360
|
+
)
|
|
361
|
+
suppressed = store.suppressed_recommendations() if store is not None else set()
|
|
362
|
+
items = [item for item in items if item["id"] not in suppressed]
|
|
363
|
+
return JSONResponse({"api_version": API_VERSION, "recommendations": items})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def sessions(request: Request) -> Response:
|
|
367
|
+
store = getattr(request.app.state, "store", None)
|
|
368
|
+
rows = store.fetch_sessions() if store is not None else []
|
|
369
|
+
return JSONResponse({"api_version": API_VERSION, "sessions": rows})
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def session_detail(request: Request) -> Response:
|
|
373
|
+
store = getattr(request.app.state, "store", None)
|
|
374
|
+
session_id = request.path_params["session_id"]
|
|
375
|
+
rows = store.fetch_session_requests(session_id) if store is not None else []
|
|
376
|
+
return JSONResponse({"api_version": API_VERSION, "session_id": session_id, "requests": rows})
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
async def request_detail(request: Request) -> Response:
|
|
380
|
+
store = getattr(request.app.state, "store", None)
|
|
381
|
+
request_id = request.path_params["request_id"]
|
|
382
|
+
row = store.fetch_request(request_id) if store is not None else None
|
|
383
|
+
if row is None:
|
|
384
|
+
return JSONResponse({"error": "request not found"}, status_code=404)
|
|
385
|
+
return JSONResponse({"api_version": API_VERSION, "request": row})
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def transforms(request: Request) -> Response:
|
|
389
|
+
config = request.app.state.config
|
|
390
|
+
guard = getattr(request.app.state, "guard", None)
|
|
391
|
+
rows = [
|
|
392
|
+
{"transform": name, **value}
|
|
393
|
+
for name, value in config.transforms.model_dump().items()
|
|
394
|
+
if isinstance(value, dict)
|
|
395
|
+
]
|
|
396
|
+
return JSONResponse(
|
|
397
|
+
{
|
|
398
|
+
"api_version": API_VERSION,
|
|
399
|
+
"transforms": rows,
|
|
400
|
+
"quarantined": guard.status() if guard is not None else [],
|
|
401
|
+
}
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def pricing(request: Request) -> Response:
|
|
406
|
+
config = request.app.state.config
|
|
407
|
+
table = load_pricing(Path(config.pricing.file) if config.pricing.file else None)
|
|
408
|
+
return JSONResponse(
|
|
409
|
+
{
|
|
410
|
+
"api_version": API_VERSION,
|
|
411
|
+
"version": table.version,
|
|
412
|
+
"currency": table.currency,
|
|
413
|
+
"models": [asdict(model) for model in table.models],
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
async def guard_status(request: Request) -> Response:
|
|
419
|
+
guard = getattr(request.app.state, "guard", None)
|
|
420
|
+
return JSONResponse(
|
|
421
|
+
{
|
|
422
|
+
"api_version": API_VERSION,
|
|
423
|
+
"quarantined": guard.status() if guard is not None else [],
|
|
424
|
+
}
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
async def guard_action(request: Request) -> Response:
|
|
429
|
+
guard = getattr(request.app.state, "guard", None)
|
|
430
|
+
if guard is None:
|
|
431
|
+
return JSONResponse({"error": "guard unavailable"}, status_code=503)
|
|
432
|
+
try:
|
|
433
|
+
body = await request.json()
|
|
434
|
+
except Exception:
|
|
435
|
+
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
|
436
|
+
action = body.get("action") if isinstance(body, dict) else None
|
|
437
|
+
transform_id = body.get("transform_id") if isinstance(body, dict) else None
|
|
438
|
+
session_id = body.get("session_id") if isinstance(body, dict) else None
|
|
439
|
+
if not isinstance(transform_id, str) or not transform_id:
|
|
440
|
+
return JSONResponse({"error": "transform_id required"}, status_code=400)
|
|
441
|
+
targets = (
|
|
442
|
+
[session_id]
|
|
443
|
+
if isinstance(session_id, str) and session_id
|
|
444
|
+
else [item["session_id"] for item in guard.status() if item["transform_id"] == transform_id]
|
|
445
|
+
)
|
|
446
|
+
for target in targets:
|
|
447
|
+
if action == "enable":
|
|
448
|
+
guard.enable(target, transform_id)
|
|
449
|
+
elif action == "disable":
|
|
450
|
+
guard.disable(target, transform_id)
|
|
451
|
+
else:
|
|
452
|
+
return JSONResponse({"error": "action must be enable or disable"}, status_code=400)
|
|
453
|
+
return JSONResponse(
|
|
454
|
+
{
|
|
455
|
+
"api_version": API_VERSION,
|
|
456
|
+
"action": action,
|
|
457
|
+
"transform_id": transform_id,
|
|
458
|
+
"sessions": targets,
|
|
459
|
+
"quarantined": guard.status(),
|
|
460
|
+
}
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
async def stale_estimate(request: Request) -> Response:
|
|
465
|
+
try:
|
|
466
|
+
elided = int(request.query_params.get("elided_tokens", "0"))
|
|
467
|
+
batch = int(request.query_params.get("batch_turns", "8"))
|
|
468
|
+
input_rate = float(request.query_params.get("input_per_mtok", "0"))
|
|
469
|
+
read_rate = float(request.query_params.get("cache_read_per_mtok", "0"))
|
|
470
|
+
write_rate = float(request.query_params.get("cache_write_per_mtok", "0"))
|
|
471
|
+
except ValueError:
|
|
472
|
+
return JSONResponse({"error": "numeric query parameters required"}, status_code=400)
|
|
473
|
+
estimate = estimate_stale_elision(
|
|
474
|
+
elided_tokens=elided,
|
|
475
|
+
batch_turns=batch,
|
|
476
|
+
input_per_mtok=input_rate,
|
|
477
|
+
cache_read_per_mtok=read_rate,
|
|
478
|
+
cache_write_per_mtok=write_rate,
|
|
479
|
+
)
|
|
480
|
+
return JSONResponse(
|
|
481
|
+
{
|
|
482
|
+
"api_version": API_VERSION,
|
|
483
|
+
"label": "sim.",
|
|
484
|
+
"elided_tokens": estimate.elided_tokens,
|
|
485
|
+
"batch_turns": estimate.batch_turns,
|
|
486
|
+
"savings_per_request_usd": estimate.savings_per_request_usd,
|
|
487
|
+
"boundary_miss_cost_usd": estimate.boundary_miss_cost_usd,
|
|
488
|
+
"requests_to_recover": estimate.requests_to_recover,
|
|
489
|
+
"break_even_within_batch": estimate.break_even_within_batch,
|
|
490
|
+
"method": estimate.method,
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
async def expand_usage(request: Request) -> Response:
|
|
496
|
+
return JSONResponse(
|
|
497
|
+
{"api_version": API_VERSION, "label": "measured", "expand": expand_stats_query()}
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _finding_dict(finding: WasteFinding) -> dict[str, Any]:
|
|
502
|
+
data = asdict(finding)
|
|
503
|
+
data["key_hash"] = finding.key_hash.hex()
|
|
504
|
+
return data
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
__all__ = [
|
|
508
|
+
"API_VERSION",
|
|
509
|
+
"admin_config",
|
|
510
|
+
"apply_recommendation",
|
|
511
|
+
"dismiss_recommendation",
|
|
512
|
+
"drift",
|
|
513
|
+
"expand_usage",
|
|
514
|
+
"export",
|
|
515
|
+
"guard_action",
|
|
516
|
+
"guard_status",
|
|
517
|
+
"live",
|
|
518
|
+
"pricing",
|
|
519
|
+
"purge",
|
|
520
|
+
"put_pricing",
|
|
521
|
+
"recent_requests",
|
|
522
|
+
"recommendations_api",
|
|
523
|
+
"reload",
|
|
524
|
+
"request_detail",
|
|
525
|
+
"request_diff",
|
|
526
|
+
"session_detail",
|
|
527
|
+
"sessions",
|
|
528
|
+
"set_mode",
|
|
529
|
+
"stale_estimate",
|
|
530
|
+
"summary",
|
|
531
|
+
"timeseries",
|
|
532
|
+
"transform_stats",
|
|
533
|
+
"transforms",
|
|
534
|
+
"update_config",
|
|
535
|
+
"waste",
|
|
536
|
+
"waste_status",
|
|
537
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Admin service helpers: validated config patches, pricing writes, exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from starlette.applications import Starlette
|
|
14
|
+
|
|
15
|
+
from ctxoptimizer.config import Config, data_dir, load_config
|
|
16
|
+
from ctxoptimizer.models import WasteFinding
|
|
17
|
+
from ctxoptimizer.telemetry.pricing import load_pricing
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
merged = dict(base)
|
|
22
|
+
for key, value in patch.items():
|
|
23
|
+
current = merged.get(key)
|
|
24
|
+
if isinstance(current, dict) and isinstance(value, dict):
|
|
25
|
+
merged[key] = deep_merge(current, value)
|
|
26
|
+
else:
|
|
27
|
+
merged[key] = value
|
|
28
|
+
return merged
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply_config_patch(app: Starlette, patch: dict[str, Any]) -> Config:
|
|
32
|
+
"""Validate a partial config update and make it live in this process."""
|
|
33
|
+
current = app.state.config
|
|
34
|
+
merged = deep_merge(current.model_dump(mode="json"), patch)
|
|
35
|
+
updated = Config.model_validate(merged)
|
|
36
|
+
_install(app, updated)
|
|
37
|
+
return updated
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reload_config(app: Starlette) -> Config:
|
|
41
|
+
path = getattr(app.state, "config_path", None)
|
|
42
|
+
updated = load_config(path=Path(path) if path else None)
|
|
43
|
+
_install(app, updated)
|
|
44
|
+
return updated
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _install(app: Starlette, updated: Config) -> None:
|
|
48
|
+
app.state.config = updated
|
|
49
|
+
guard = getattr(app.state, "guard", None)
|
|
50
|
+
if guard is not None:
|
|
51
|
+
guard.config = updated
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def config_view(config: Config) -> dict[str, Any]:
|
|
55
|
+
data = config.model_dump(mode="json")
|
|
56
|
+
server = data.get("server")
|
|
57
|
+
if isinstance(server, dict):
|
|
58
|
+
server["admin_token"] = "***" if server.get("admin_token") else ""
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_since(value: str | None) -> float | None:
|
|
63
|
+
if not value:
|
|
64
|
+
return None
|
|
65
|
+
now = time.time()
|
|
66
|
+
for suffix, seconds in (("m", 60), ("h", 3600), ("d", 86_400)):
|
|
67
|
+
if value.endswith(suffix) and value[:-1].isdigit():
|
|
68
|
+
return now - int(value[:-1]) * seconds
|
|
69
|
+
try:
|
|
70
|
+
return float(value)
|
|
71
|
+
except ValueError:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def rows_to_csv(rows: list[dict[str, Any]]) -> str:
|
|
76
|
+
if not rows:
|
|
77
|
+
return ""
|
|
78
|
+
buffer = io.StringIO()
|
|
79
|
+
writer = csv.DictWriter(buffer, fieldnames=list(rows[0].keys()))
|
|
80
|
+
writer.writeheader()
|
|
81
|
+
writer.writerows(rows)
|
|
82
|
+
return buffer.getvalue()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def write_pricing(config: Config, payload: dict[str, Any]) -> tuple[Config, Path]:
|
|
86
|
+
"""Validate and persist a pricing override, then return the new config."""
|
|
87
|
+
models = payload.get("models")
|
|
88
|
+
if not isinstance(models, list):
|
|
89
|
+
raise ValueError("pricing payload must contain a models list")
|
|
90
|
+
path = Path(config.pricing.file) if config.pricing.file else data_dir() / "pricing.json"
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
path.write_text(json.dumps({"models": models}, indent=2) + "\n", encoding="utf-8")
|
|
93
|
+
try:
|
|
94
|
+
table = load_pricing(path)
|
|
95
|
+
except Exception:
|
|
96
|
+
path.unlink(missing_ok=True)
|
|
97
|
+
raise
|
|
98
|
+
if not table.models:
|
|
99
|
+
raise ValueError("pricing file has no valid models")
|
|
100
|
+
updated = config.model_copy(
|
|
101
|
+
update={"pricing": config.pricing.model_copy(update={"file": str(path)})}
|
|
102
|
+
)
|
|
103
|
+
return updated, path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def findings_from_rows(rows: list[dict[str, Any]]) -> list[WasteFinding]:
|
|
107
|
+
findings: list[WasteFinding] = []
|
|
108
|
+
for row in rows:
|
|
109
|
+
try:
|
|
110
|
+
evidence = json.loads(str(row.get("evidence_json") or "{}"))
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
evidence = {}
|
|
113
|
+
findings.append(
|
|
114
|
+
WasteFinding(
|
|
115
|
+
finding_id=str(row.get("finding_id")),
|
|
116
|
+
ts=float(row.get("ts") or 0.0),
|
|
117
|
+
session_id=str(row.get("session_id") or ""),
|
|
118
|
+
project=row.get("project"),
|
|
119
|
+
detector=str(row.get("detector") or "unknown"),
|
|
120
|
+
key_hash=bytes.fromhex(str(row.get("key_hash") or "")),
|
|
121
|
+
request_id=row.get("request_id"),
|
|
122
|
+
occurrences=int(row.get("occurrences") or 1),
|
|
123
|
+
first_seen_ts=float(row.get("first_seen_ts") or 0.0),
|
|
124
|
+
last_seen_ts=float(row.get("last_seen_ts") or 0.0),
|
|
125
|
+
evidence=evidence,
|
|
126
|
+
est_tokens_wasted=row.get("est_tokens_wasted"),
|
|
127
|
+
est_cost_usd=row.get("est_cost_usd"),
|
|
128
|
+
status=row.get("status") or "new",
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return findings
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def pricing_payload(config: Config) -> dict[str, Any]:
|
|
135
|
+
try:
|
|
136
|
+
table = load_pricing(Path(config.pricing.file) if config.pricing.file else None)
|
|
137
|
+
except (OSError, ValueError, KeyError):
|
|
138
|
+
table = load_pricing(None)
|
|
139
|
+
return {
|
|
140
|
+
"version": table.version,
|
|
141
|
+
"currency": table.currency,
|
|
142
|
+
"per": table.per,
|
|
143
|
+
"models": [asdict(model) for model in table.models],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = [
|
|
148
|
+
"apply_config_patch",
|
|
149
|
+
"config_view",
|
|
150
|
+
"deep_merge",
|
|
151
|
+
"findings_from_rows",
|
|
152
|
+
"parse_since",
|
|
153
|
+
"pricing_payload",
|
|
154
|
+
"reload_config",
|
|
155
|
+
"rows_to_csv",
|
|
156
|
+
"write_pricing",
|
|
157
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|