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,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
+