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,421 @@
1
+ {
2
+ "version": "2026-10-25",
3
+ "currency": "USD",
4
+ "per": 1000000,
5
+ "models": [
6
+ {
7
+ "provider": "anthropic",
8
+ "match": "claude-opus-5-5*",
9
+ "input": 4.0,
10
+ "output": 20.0,
11
+ "cache_read": 0.2,
12
+ "cache_write_5m": 5.0,
13
+ "cache_write_1h": 8.0,
14
+ "verified": true,
15
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
16
+ },
17
+ {
18
+ "provider": "anthropic",
19
+ "match": "claude-sonnet-5-5*",
20
+ "input": 2.0,
21
+ "output": 10.0,
22
+ "cache_read": 0.1,
23
+ "cache_write_5m": 2.5,
24
+ "cache_write_1h": 4.0,
25
+ "verified": true,
26
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
27
+ },
28
+ {
29
+ "provider": "anthropic",
30
+ "match": "claude-haiku-5-5*",
31
+ "input": 0.1,
32
+ "output": 0.5,
33
+ "cache_read": 0.01,
34
+ "cache_write_5m": 0.125,
35
+ "cache_write_1h": 0.2,
36
+ "verified": true,
37
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching",
38
+ "notes": "Applies to prompts up to 100k tokens; long-context tier is unsupported in v1."
39
+ },
40
+ {
41
+ "provider": "anthropic",
42
+ "match": "claude-opus-5*",
43
+ "input": 5.0,
44
+ "output": 25.0,
45
+ "cache_read": 0.5,
46
+ "cache_write_5m": 6.25,
47
+ "cache_write_1h": 10.0,
48
+ "verified": true,
49
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
50
+ },
51
+ {
52
+ "provider": "anthropic",
53
+ "match": "claude-sonnet-5*",
54
+ "input": 2.0,
55
+ "output": 10.0,
56
+ "cache_read": 0.2,
57
+ "cache_write_5m": 2.5,
58
+ "cache_write_1h": 4.0,
59
+ "verified": true,
60
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
61
+ },
62
+ {
63
+ "provider": "anthropic",
64
+ "match": "claude-sonnet-4-6*",
65
+ "input": 3.0,
66
+ "output": 15.0,
67
+ "cache_read": 0.3,
68
+ "cache_write_5m": 3.75,
69
+ "cache_write_1h": 6.0,
70
+ "verified": true,
71
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
72
+ },
73
+ {
74
+ "provider": "anthropic",
75
+ "match": "claude-sonnet-4-5*",
76
+ "input": 3.0,
77
+ "output": 15.0,
78
+ "cache_read": 0.3,
79
+ "cache_write_5m": 3.75,
80
+ "cache_write_1h": 6.0,
81
+ "verified": true,
82
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
83
+ },
84
+ {
85
+ "provider": "anthropic",
86
+ "match": "claude-opus-4-8*",
87
+ "input": 5.0,
88
+ "output": 25.0,
89
+ "cache_read": 0.5,
90
+ "cache_write_5m": 6.25,
91
+ "cache_write_1h": 10.0,
92
+ "verified": true,
93
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
94
+ },
95
+ {
96
+ "provider": "anthropic",
97
+ "match": "claude-opus-4-7*",
98
+ "input": 5.0,
99
+ "output": 25.0,
100
+ "cache_read": 0.5,
101
+ "cache_write_5m": 6.25,
102
+ "cache_write_1h": 10.0,
103
+ "verified": true,
104
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
105
+ },
106
+ {
107
+ "provider": "anthropic",
108
+ "match": "claude-opus-4-6*",
109
+ "input": 5.0,
110
+ "output": 25.0,
111
+ "cache_read": 0.5,
112
+ "cache_write_5m": 6.25,
113
+ "cache_write_1h": 10.0,
114
+ "verified": true,
115
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
116
+ },
117
+ {
118
+ "provider": "anthropic",
119
+ "match": "claude-opus-4-5*",
120
+ "input": 5.0,
121
+ "output": 25.0,
122
+ "cache_read": 0.5,
123
+ "cache_write_5m": 6.25,
124
+ "cache_write_1h": 10.0,
125
+ "verified": true,
126
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
127
+ },
128
+ {
129
+ "provider": "anthropic",
130
+ "match": "claude-haiku-4-5*",
131
+ "input": 1.0,
132
+ "output": 5.0,
133
+ "cache_read": 0.1,
134
+ "cache_write_5m": 1.25,
135
+ "cache_write_1h": 2.0,
136
+ "verified": true,
137
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
138
+ },
139
+ {
140
+ "provider": "anthropic",
141
+ "match": "claude-fable-5-1*",
142
+ "input": 10.0,
143
+ "output": 50.0,
144
+ "cache_read": 0.25,
145
+ "cache_write_5m": 12.5,
146
+ "cache_write_1h": 20.0,
147
+ "verified": true,
148
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
149
+ },
150
+ {
151
+ "provider": "anthropic",
152
+ "match": "claude-mythos-5-1*",
153
+ "input": 10.0,
154
+ "output": 50.0,
155
+ "cache_read": 0.25,
156
+ "cache_write_5m": 12.5,
157
+ "cache_write_1h": 20.0,
158
+ "verified": true,
159
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
160
+ },
161
+ {
162
+ "provider": "anthropic",
163
+ "match": "claude-fable-5*",
164
+ "input": 10.0,
165
+ "output": 50.0,
166
+ "cache_read": 1.0,
167
+ "cache_write_5m": 12.5,
168
+ "cache_write_1h": 20.0,
169
+ "verified": true,
170
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
171
+ },
172
+ {
173
+ "provider": "anthropic",
174
+ "match": "claude-mythos-5*",
175
+ "input": 10.0,
176
+ "output": 50.0,
177
+ "cache_read": 1.0,
178
+ "cache_write_5m": 12.5,
179
+ "cache_write_1h": 20.0,
180
+ "verified": true,
181
+ "source": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching"
182
+ },
183
+ {
184
+ "provider": "openai",
185
+ "match": "gpt-6-astra*",
186
+ "input": 10.0,
187
+ "output": 50.0,
188
+ "cache_read": 1.0,
189
+ "cache_write_30m": 12.5,
190
+ "cache_write_12h": 25.0,
191
+ "verified": true,
192
+ "source": "https://developers.openai.com/api/docs/pricing",
193
+ "notes": "Short context (<=272K); long-context tier unsupported in v1."
194
+ },
195
+ {
196
+ "provider": "openai",
197
+ "match": "gpt-6.1-sol*",
198
+ "input": 2.0,
199
+ "output": 10.0,
200
+ "cache_read": 0.1,
201
+ "cache_write_30m": 2.5,
202
+ "cache_write_12h": 5.0,
203
+ "verified": true,
204
+ "source": "https://developers.openai.com/api/docs/pricing"
205
+ },
206
+ {
207
+ "provider": "openai",
208
+ "match": "gpt-6-luna*",
209
+ "input": 0.1,
210
+ "output": 0.5,
211
+ "cache_read": 0.01,
212
+ "cache_write_30m": 0.125,
213
+ "cache_write_12h": 0.25,
214
+ "verified": true,
215
+ "source": "https://developers.openai.com/api/docs/pricing"
216
+ },
217
+ {
218
+ "provider": "openai",
219
+ "match": "gpt-6-sol*",
220
+ "input": 2.0,
221
+ "output": 10.0,
222
+ "cache_read": 0.2,
223
+ "cache_write_30m": 2.5,
224
+ "cache_write_12h": 5.0,
225
+ "verified": true,
226
+ "source": "https://developers.openai.com/api/docs/pricing"
227
+ },
228
+ {
229
+ "provider": "openai",
230
+ "match": "gpt-5.6-sol*",
231
+ "input": 4.0,
232
+ "output": 20.0,
233
+ "cache_read": 0.4,
234
+ "cache_write_30m": 5.0,
235
+ "cache_write_12h": 10.0,
236
+ "verified": true,
237
+ "source": "https://developers.openai.com/api/docs/pricing"
238
+ },
239
+ {
240
+ "provider": "openai",
241
+ "match": "gpt-5.6-terra*",
242
+ "input": 2.0,
243
+ "output": 12.0,
244
+ "cache_read": 0.2,
245
+ "cache_write_30m": 2.5,
246
+ "cache_write_12h": 5.0,
247
+ "verified": true,
248
+ "source": "https://developers.openai.com/api/docs/pricing"
249
+ },
250
+ {
251
+ "provider": "openai",
252
+ "match": "gpt-5.6-luna*",
253
+ "input": 0.2,
254
+ "output": 1.2,
255
+ "cache_read": 0.02,
256
+ "cache_write_30m": 0.25,
257
+ "cache_write_12h": 0.5,
258
+ "verified": true,
259
+ "source": "https://developers.openai.com/api/docs/pricing"
260
+ },
261
+ {
262
+ "provider": "openai",
263
+ "match": "gpt-5.5*",
264
+ "input": 5.0,
265
+ "output": 30.0,
266
+ "cache_read": 0.5,
267
+ "verified": true,
268
+ "source": "https://developers.openai.com/api/docs/pricing",
269
+ "notes": "Short context; no separate cache-write charge on this model."
270
+ },
271
+ {
272
+ "provider": "openai",
273
+ "match": "gpt-5.4*",
274
+ "input": 2.5,
275
+ "output": 15.0,
276
+ "cache_read": 0.25,
277
+ "verified": true,
278
+ "source": "https://developers.openai.com/api/docs/pricing"
279
+ },
280
+ {
281
+ "provider": "openai",
282
+ "match": "gpt-5.3-codex*",
283
+ "input": 1.75,
284
+ "output": 14.0,
285
+ "cache_read": 0.175,
286
+ "verified": true,
287
+ "source": "https://developers.openai.com/api/docs/pricing"
288
+ },
289
+ {
290
+ "provider": "openai",
291
+ "match": "gpt-5.2*",
292
+ "input": 1.75,
293
+ "output": 14.0,
294
+ "cache_read": 0.175,
295
+ "verified": true,
296
+ "source": "https://developers.openai.com/api/docs/pricing"
297
+ },
298
+ {
299
+ "provider": "openai",
300
+ "match": "gpt-5.1*",
301
+ "input": 1.25,
302
+ "output": 10.0,
303
+ "cache_read": 0.125,
304
+ "verified": true,
305
+ "source": "https://developers.openai.com/api/docs/pricing"
306
+ },
307
+ {
308
+ "provider": "openai",
309
+ "match": "gpt-5*",
310
+ "input": 1.25,
311
+ "output": 10.0,
312
+ "cache_read": 0.125,
313
+ "verified": true,
314
+ "source": "https://developers.openai.com/api/docs/pricing"
315
+ },
316
+ {
317
+ "provider": "openai",
318
+ "match": "gpt-4.1*",
319
+ "input": 2.0,
320
+ "output": 8.0,
321
+ "cache_read": 0.5,
322
+ "verified": true,
323
+ "source": "https://developers.openai.com/api/docs/pricing"
324
+ },
325
+ {
326
+ "provider": "openai",
327
+ "match": "gpt-4o-mini*",
328
+ "input": 0.15,
329
+ "output": 0.6,
330
+ "cache_read": 0.075,
331
+ "verified": true,
332
+ "source": "https://developers.openai.com/api/docs/pricing"
333
+ },
334
+ {
335
+ "provider": "openai",
336
+ "match": "gpt-4o*",
337
+ "input": 2.5,
338
+ "output": 10.0,
339
+ "cache_read": 1.25,
340
+ "verified": true,
341
+ "source": "https://developers.openai.com/api/docs/pricing"
342
+ },
343
+ {
344
+ "provider": "deepseek",
345
+ "match": "deepseek-flash*",
346
+ "input": 0.3,
347
+ "output": 1.2,
348
+ "cache_read": 0.006,
349
+ "verified": true,
350
+ "source": "https://api-docs.deepseek.com/quick_start/pricing",
351
+ "notes": "Peak rates; off-peak is half. Cache writes have no fee."
352
+ },
353
+ {
354
+ "provider": "deepseek",
355
+ "match": "deepseek-v4-pro*",
356
+ "input": 1.32,
357
+ "output": 3.96,
358
+ "cache_read": 0.044,
359
+ "verified": true,
360
+ "source": "https://api-docs.deepseek.com/quick_start/pricing",
361
+ "notes": "Peak rates; off-peak is half. Cache writes have no fee."
362
+ },
363
+ {
364
+ "provider": "gemini",
365
+ "match": "gemini-4-argon*",
366
+ "input": 2.0,
367
+ "output": 10.0,
368
+ "cache_read": 0.1,
369
+ "verified": true,
370
+ "source": "https://ai.google.dev/gemini-api/docs/pricing",
371
+ "notes": "Standard tier through 2026-11-30; prices rise on 2026-12-01."
372
+ },
373
+ {
374
+ "provider": "gemini",
375
+ "match": "gemini-3.8-flash*",
376
+ "input": 0.75,
377
+ "output": 3.75,
378
+ "cache_read": 0.075,
379
+ "verified": true,
380
+ "source": "https://ai.google.dev/gemini-api/docs/pricing",
381
+ "notes": "Standard tier through 2026-12-31; prices rise on 2027-01-01."
382
+ },
383
+ {
384
+ "provider": "gemini",
385
+ "match": "gemini-3.6-flash*",
386
+ "input": 0.75,
387
+ "output": 3.75,
388
+ "cache_read": 0.075,
389
+ "verified": true,
390
+ "source": "https://ai.google.dev/gemini-api/docs/pricing"
391
+ },
392
+ {
393
+ "provider": "gemini",
394
+ "match": "gemini-3.5-flash-lite*",
395
+ "input": 0.3,
396
+ "output": 2.5,
397
+ "cache_read": 0.03,
398
+ "verified": true,
399
+ "source": "https://ai.google.dev/gemini-api/docs/pricing"
400
+ },
401
+ {
402
+ "provider": "gemini",
403
+ "match": "gemini-3.1-flash-lite*",
404
+ "input": 0.25,
405
+ "output": 1.5,
406
+ "cache_read": 0.025,
407
+ "verified": true,
408
+ "source": "https://ai.google.dev/gemini-api/docs/pricing"
409
+ },
410
+ {
411
+ "provider": "gemini",
412
+ "match": "gemini-3.1-pro-preview*",
413
+ "input": 2.0,
414
+ "output": 12.0,
415
+ "cache_read": 0.2,
416
+ "verified": true,
417
+ "source": "https://ai.google.dev/gemini-api/docs/pricing",
418
+ "notes": "Standard tier for prompts <=200k tokens."
419
+ }
420
+ ]
421
+ }
ctxoptimizer/doctor.py ADDED
@@ -0,0 +1,129 @@
1
+ """Shared diagnostic checks for the CLI and dashboard Doctor page (P5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import socket
7
+ import sqlite3
8
+ from dataclasses import dataclass
9
+ from datetime import date
10
+ from pathlib import Path
11
+
12
+ from ctxoptimizer.config import LOOPBACK_HOSTS, Config, data_dir
13
+ from ctxoptimizer.telemetry.pricing import load_pricing
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class Check:
18
+ name: str
19
+ ok: bool
20
+ detail: str
21
+ warning: bool = False
22
+
23
+
24
+ def run_checks(config: Config, *, offline: bool = True) -> list[Check]:
25
+ checks: list[Check] = []
26
+ checks.append(
27
+ Check(
28
+ "bind host",
29
+ config.server.host in LOOPBACK_HOSTS or config.server.allow_remote,
30
+ config.server.host,
31
+ )
32
+ )
33
+ db_path = (
34
+ Path(config.telemetry.db_path)
35
+ if config.telemetry.db_path
36
+ else data_dir() / "ctxoptimizer.db"
37
+ )
38
+ try:
39
+ with sqlite3.connect(db_path) as conn:
40
+ conn.execute("PRAGMA user_version").fetchone()
41
+ checks.append(Check("database writable", True, str(db_path)))
42
+ except Exception as exc:
43
+ checks.append(Check("database writable", False, str(exc)))
44
+
45
+ try:
46
+ table = load_pricing(Path(config.pricing.file) if config.pricing.file else None)
47
+ verified = sum(1 for price in table.models if price.verified)
48
+ checks.append(
49
+ Check(
50
+ "pricing",
51
+ True,
52
+ f"version {table.version}, {verified} verified entries",
53
+ )
54
+ )
55
+ try:
56
+ age = (date.today() - date.fromisoformat(table.version)).days
57
+ if age > 60:
58
+ checks.append(
59
+ Check(
60
+ "pricing age",
61
+ False,
62
+ f"{age} days; check provider pages",
63
+ warning=True,
64
+ )
65
+ )
66
+ except ValueError:
67
+ checks.append(
68
+ Check(
69
+ "pricing age",
70
+ False,
71
+ f"unparseable version {table.version!r}",
72
+ warning=True,
73
+ )
74
+ )
75
+ except Exception as exc:
76
+ checks.append(Check("pricing", False, str(exc)))
77
+
78
+ try:
79
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
80
+ sock.bind((config.server.host, config.server.port))
81
+ checks.append(Check("port free", True, str(config.server.port)))
82
+ except OSError:
83
+ checks.append(
84
+ Check(
85
+ "port free",
86
+ False,
87
+ f"{config.server.port} already in use (is ctxoptimizer running?)",
88
+ warning=True,
89
+ )
90
+ )
91
+
92
+ checks.append(
93
+ Check(
94
+ "tiktoken",
95
+ importlib.util.find_spec("tiktoken") is not None,
96
+ "available" if importlib.util.find_spec("tiktoken") else "fallback estimator",
97
+ warning=True,
98
+ )
99
+ )
100
+ if config.transforms.cache_serve.enabled and config.telemetry.capture_payloads == "off":
101
+ checks.append(
102
+ Check(
103
+ "cache_serve capture",
104
+ False,
105
+ "cache_serve requires capture_payloads != off",
106
+ )
107
+ )
108
+ if not offline:
109
+ import httpx
110
+
111
+ for name, entry in sorted(config.upstreams.items()):
112
+ base = entry if isinstance(entry, str) else entry.url
113
+ try:
114
+ response = httpx.head(base, timeout=2.0, follow_redirects=False)
115
+ checks.append(
116
+ Check(
117
+ f"upstream {name}",
118
+ response.status_code < 500,
119
+ f"HTTP {response.status_code}",
120
+ warning=True,
121
+ )
122
+ )
123
+ except httpx.HTTPError as exc:
124
+ checks.append(Check(f"upstream {name}", False, str(exc), warning=True))
125
+
126
+ return checks
127
+
128
+
129
+ __all__ = ["Check", "run_checks"]
ctxoptimizer/guard.py ADDED
@@ -0,0 +1,139 @@
1
+ """Auto-quarantine guard (SPEC section 12.14). Session-scoped and reversible."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections import defaultdict, deque
7
+
8
+ from ctxoptimizer.config import Config
9
+
10
+
11
+ class Guard:
12
+ def __init__(self, config: Config) -> None:
13
+ self.config = config
14
+ self._fallbacks: dict[tuple[str, str], deque[float]] = defaultdict(
15
+ lambda: deque(
16
+ maxlen=max(
17
+ 1,
18
+ config.guard.fallback_window,
19
+ config.guard.fallback_limit + 1,
20
+ )
21
+ )
22
+ )
23
+ self._drift: dict[tuple[str, str], int] = defaultdict(int)
24
+ self._quarantined: dict[tuple[str, str], str] = {}
25
+ self._recovery: dict[tuple[str, str], int] = defaultdict(int)
26
+ self._savings: dict[tuple[str, str], deque[float]] = defaultdict(
27
+ lambda: deque(maxlen=max(1, config.guard.fallback_window))
28
+ )
29
+ self._store: object | None = None
30
+
31
+ def bind_store(self, store: object) -> None:
32
+ """Attach the telemetry store so quarantine events are persisted."""
33
+ self._store = store
34
+
35
+ def _record(self, session_id: str, transform_id: str, reason: str) -> None:
36
+ record = getattr(self._store, "record_guard_event", None)
37
+ if callable(record):
38
+ record(session_id, transform_id, reason, {"limit": self.config.guard.fallback_limit})
39
+
40
+ def quarantined_for(self, session_id: str) -> set[str]:
41
+ return {transform_id for (sid, transform_id) in self._quarantined if sid == session_id}
42
+
43
+ def is_quarantined(self, session_id: str, transform_id: str) -> bool:
44
+ return (session_id, transform_id) in self._quarantined
45
+
46
+ def observe_fallback(self, session_id: str, transform_ids: list[str]) -> None:
47
+ if not self.config.guard.enabled:
48
+ return
49
+ for transform_id in transform_ids:
50
+ key = (session_id, transform_id)
51
+ self._fallbacks[key].append(time.time())
52
+ if len(self._fallbacks[key]) > self.config.guard.fallback_limit:
53
+ self._quarantined[key] = "fallbacks"
54
+ self._record(session_id, transform_id, "fallbacks")
55
+
56
+ def observe_final_drift(
57
+ self, session_id: str, cause: str | None, transform_ids: list[str]
58
+ ) -> None:
59
+ if not self.config.guard.enabled or not cause:
60
+ return
61
+ for transform_id in transform_ids:
62
+ key = (session_id, transform_id)
63
+ self._drift[key] += 1
64
+ if self._drift[key] >= self.config.guard.our_fault_drift_limit:
65
+ self._quarantined[key] = "our_fault_drift"
66
+ self._record(session_id, transform_id, "our_fault_drift")
67
+
68
+ def observe_request(self, session_id: str) -> None:
69
+ """Count successful requests and optionally auto-re-enable."""
70
+ if not self.config.guard.enabled or self.config.guard.auto_reenable <= 0:
71
+ return
72
+ for key in list(self._quarantined):
73
+ if key[0] != session_id:
74
+ continue
75
+ self._recovery[key] += 1
76
+ if self._recovery[key] >= self.config.guard.auto_reenable:
77
+ self.enable(*key)
78
+
79
+ def observe_savings(self, session_id: str, transform_id: str, usd: float) -> None:
80
+ """Quarantine a reduction that costs more than it saves over a window."""
81
+ if not self.config.guard.enabled:
82
+ return
83
+ if transform_id in {"anthropic_cache", "openai_hints"}:
84
+ return
85
+ key = (session_id, transform_id)
86
+ window = self._savings[key]
87
+ window.append(max(usd, 0.0))
88
+ if (
89
+ len(window) >= self.config.guard.fallback_window
90
+ and sum(window) < self.config.guard.min_savings_to_stay
91
+ ):
92
+ self._quarantined[key] = "low_savings"
93
+ self._record(session_id, transform_id, "low_savings")
94
+
95
+ def observe_expand_health(self, session_id: str) -> None:
96
+ """Quarantine omission transforms when expand errors are too high."""
97
+ if not self.config.guard.enabled or self._store is None:
98
+ return
99
+ from ctxoptimizer.telemetry.expand import expand_stats
100
+
101
+ path = getattr(self._store, "path", None)
102
+ stats = expand_stats(db_path=path) if path is not None else expand_stats()
103
+ if stats["total"] < 10:
104
+ return
105
+ errors = int(stats["errors"])
106
+ if errors / max(int(stats["total"]), 1) <= self.config.guard.expand_error_rate:
107
+ return
108
+ for transform_id in ("cache_serve", "stale_result_elision"):
109
+ key = (session_id, transform_id)
110
+ if key not in self._quarantined:
111
+ self._quarantined[key] = "expand_errors"
112
+ self._record(session_id, transform_id, "expand_errors")
113
+
114
+ def enable(self, session_id: str, transform_id: str) -> None:
115
+ self._quarantined.pop((session_id, transform_id), None)
116
+ self._fallbacks.pop((session_id, transform_id), None)
117
+ self._drift.pop((session_id, transform_id), None)
118
+ self._recovery.pop((session_id, transform_id), None)
119
+ self._savings.pop((session_id, transform_id), None)
120
+ record = getattr(self._store, "record_guard_event", None)
121
+ if callable(record):
122
+ record(session_id, transform_id, "enabled", {"cleared": True}, cleared_ts=time.time())
123
+
124
+ def disable(self, session_id: str, transform_id: str, reason: str = "manual") -> None:
125
+ self._quarantined[(session_id, transform_id)] = reason
126
+ self._record(session_id, transform_id, reason)
127
+
128
+ def status(self) -> list[dict[str, str]]:
129
+ return [
130
+ {
131
+ "session_id": session_id,
132
+ "transform_id": transform_id,
133
+ "reason": reason,
134
+ }
135
+ for (session_id, transform_id), reason in self._quarantined.items()
136
+ ]
137
+
138
+
139
+ __all__ = ["Guard"]