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,259 @@
1
+ """Request classification and upstream selection (SPEC section 8).
2
+
3
+ Pure functions: no Starlette, no I/O. Given method/path/query/headers and the
4
+ config, produce a :class:`Route` describing where the request goes and how it
5
+ should be treated. The server layer decides what to do with it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+
13
+ from ctxoptimizer.config import Config
14
+ from ctxoptimizer.models import Endpoint, Mode, Provider
15
+ from ctxoptimizer.profiles import (
16
+ ResolvedUpstream,
17
+ UnknownUpstream,
18
+ UpstreamProfile,
19
+ resolve_upstream,
20
+ )
21
+
22
+ ADMIN_PREFIX = "/_ctxoptimizer"
23
+ PROJECT_PREFIX = "/p/"
24
+ UPSTREAM_PREFIX = "/u/"
25
+
26
+ _MODE_HEADERS = ("x-ctxoptimizer-mode", "x-ctxoptimizer-bypass")
27
+ _LOOPBACK_MODES = frozenset({"passthrough", "shadow", "active"})
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Route:
32
+ """Where one request is forwarded and how it is classified."""
33
+
34
+ provider: Provider
35
+ endpoint: Endpoint
36
+ upstream_name: str
37
+ upstream_base: str
38
+ profile: UpstreamProfile
39
+ path: str
40
+ query: str
41
+ project: str | None
42
+ mode: Mode
43
+ session_id: str
44
+ client_name: str | None
45
+
46
+ def upstream_url(self) -> str:
47
+ """Build the absolute upstream URL, preserving path and raw query."""
48
+ base = self.upstream_base.rstrip("/")
49
+ path = self.path if self.path.startswith("/") else f"/{self.path}"
50
+ if self.query:
51
+ return f"{base}{path}?{self.query}"
52
+ return f"{base}{path}"
53
+
54
+
55
+ def is_absolute_target(raw_target: str) -> bool:
56
+ """Return True for absolute-form request targets (SEC-11)."""
57
+ lowered = raw_target.lower()
58
+ return lowered.startswith("http://") or lowered.startswith("https://")
59
+
60
+
61
+ def classify(
62
+ method: str,
63
+ path: str,
64
+ query: str,
65
+ headers: Mapping[str, str],
66
+ config: Config,
67
+ ) -> Route:
68
+ """Classify one request. Raises UnknownUpstream for unknown ``/u/`` names."""
69
+ lowered = {name.lower(): value for name, value in headers.items()}
70
+ project = lowered.get("x-ctxoptimizer-project") or None
71
+ remaining = path
72
+
73
+ if remaining.startswith(PROJECT_PREFIX):
74
+ project_part, _, tail = remaining[len(PROJECT_PREFIX) :].partition("/")
75
+ if project_part:
76
+ project = project or project_part
77
+ remaining = f"/{tail}"
78
+
79
+ if remaining.startswith(UPSTREAM_PREFIX):
80
+ name, _, tail = remaining[len(UPSTREAM_PREFIX) :].partition("/")
81
+ if not name:
82
+ raise UnknownUpstream("missing upstream name after /u/")
83
+ upstream = resolve_upstream(name, config)
84
+ remaining = _normalise_path(tail)
85
+ provider, endpoint = _detect_for_kind(upstream.profile.kind, method, remaining, lowered)
86
+ return _route(
87
+ provider=provider,
88
+ endpoint=endpoint,
89
+ upstream=upstream,
90
+ path=remaining,
91
+ query=query,
92
+ project=project,
93
+ headers=lowered,
94
+ config=config,
95
+ )
96
+
97
+ provider, endpoint, upstream_name = detect(method, remaining, lowered)
98
+ upstream = resolve_upstream(upstream_name, config)
99
+ return _route(
100
+ provider=provider,
101
+ endpoint=endpoint,
102
+ upstream=upstream,
103
+ path=remaining,
104
+ query=query,
105
+ project=project,
106
+ headers=lowered,
107
+ config=config,
108
+ )
109
+
110
+
111
+ def detect(method: str, path: str, headers: Mapping[str, str]) -> tuple[Provider, Endpoint, str]:
112
+ """Auto-detect provider/endpoint from the path (SPEC section 8.2)."""
113
+ if _is_anthropic_messages(path, method):
114
+ return Provider.ANTHROPIC, Endpoint.MESSAGES, Provider.ANTHROPIC.value
115
+ if _is_anthropic_passthrough(path):
116
+ return Provider.ANTHROPIC, Endpoint.PASSTHROUGH, Provider.ANTHROPIC.value
117
+ if _is_openai_chat(path, method):
118
+ return Provider.OPENAI, Endpoint.CHAT, Provider.OPENAI.value
119
+ if _is_openai_responses(path, method):
120
+ return Provider.OPENAI, Endpoint.RESPONSES, Provider.OPENAI.value
121
+ if _is_openai_passthrough(path, method):
122
+ return Provider.OPENAI, Endpoint.PASSTHROUGH, Provider.OPENAI.value
123
+ if _is_gemini(path, method):
124
+ return Provider.GEMINI, Endpoint.GENERATE, Provider.GEMINI.value
125
+ if _is_ollama(path, method):
126
+ endpoint = Endpoint.CHAT if path.endswith("/chat") else Endpoint.GENERATE
127
+ return Provider.OLLAMA, endpoint, Provider.OLLAMA.value
128
+ if path.startswith("/v1/models"):
129
+ anthropic = "anthropic-version" in headers or "x-api-key" in headers
130
+ provider = Provider.ANTHROPIC if anthropic else Provider.OPENAI
131
+ return provider, Endpoint.PASSTHROUGH, provider.value
132
+ return Provider.OPENAI, Endpoint.PASSTHROUGH, Provider.OPENAI.value
133
+
134
+
135
+ def _detect_for_kind(
136
+ kind: str | None, method: str, path: str, headers: Mapping[str, str]
137
+ ) -> tuple[Provider, Endpoint]:
138
+ if kind == "anthropic":
139
+ provider = Provider.ANTHROPIC
140
+ if _is_anthropic_messages(path, method):
141
+ return provider, Endpoint.MESSAGES
142
+ return provider, Endpoint.PASSTHROUGH
143
+ if kind == "openai":
144
+ provider = Provider.OPENAI
145
+ if _is_openai_chat(path, method):
146
+ return provider, Endpoint.CHAT
147
+ if _is_openai_responses(path, method):
148
+ return provider, Endpoint.RESPONSES
149
+ return provider, Endpoint.PASSTHROUGH
150
+ if kind == "gemini":
151
+ return Provider.GEMINI, Endpoint.GENERATE
152
+ if kind == "ollama":
153
+ endpoint = Endpoint.CHAT if path.endswith("/chat") else Endpoint.GENERATE
154
+ return Provider.OLLAMA, endpoint
155
+ provider, endpoint, _ = detect(method, path, headers)
156
+ return provider, endpoint
157
+
158
+
159
+ def _route(
160
+ *,
161
+ provider: Provider,
162
+ endpoint: Endpoint,
163
+ upstream: ResolvedUpstream,
164
+ path: str,
165
+ query: str,
166
+ project: str | None,
167
+ headers: Mapping[str, str],
168
+ config: Config,
169
+ ) -> Route:
170
+ return Route(
171
+ provider=provider,
172
+ endpoint=endpoint,
173
+ upstream_name=upstream.name,
174
+ upstream_base=upstream.url,
175
+ profile=upstream.profile,
176
+ path=path,
177
+ query=query,
178
+ project=project,
179
+ mode=_mode(headers, config),
180
+ session_id=headers.get("x-ctxoptimizer-session", ""),
181
+ client_name=_client_name(headers.get("user-agent", "")),
182
+ )
183
+
184
+
185
+ def _mode(headers: Mapping[str, str], config: Config) -> Mode:
186
+ bypass = headers.get("x-ctxoptimizer-bypass", "").lower()
187
+ if bypass in {"1", "true", "yes"}:
188
+ return Mode.PASSTHROUGH
189
+ requested = headers.get("x-ctxoptimizer-mode", "").lower()
190
+ if requested in _LOOPBACK_MODES:
191
+ return Mode(requested)
192
+ return Mode(config.mode.default)
193
+
194
+
195
+ def _client_name(user_agent: str) -> str | None:
196
+ lowered = user_agent.lower()
197
+ if "claude" in lowered:
198
+ return "claude-code"
199
+ if "aider" in lowered:
200
+ return "aider"
201
+ if "opencode" in lowered:
202
+ return "opencode"
203
+ if "codex" in lowered:
204
+ return "codex"
205
+ if "openai" in lowered:
206
+ return "openai-sdk"
207
+ if "anthropic" in lowered:
208
+ return "anthropic-sdk"
209
+ if "httpx" in lowered:
210
+ return "python-httpx"
211
+ return None
212
+
213
+
214
+ def _normalise_path(tail: str) -> str:
215
+ if not tail:
216
+ return "/"
217
+ return tail if tail.startswith("/") else f"/{tail}"
218
+
219
+
220
+ def _is_anthropic_messages(path: str, method: str) -> bool:
221
+ return method == "POST" and path.rstrip("/").endswith("/messages")
222
+
223
+
224
+ def _is_anthropic_passthrough(path: str) -> bool:
225
+ return (
226
+ "/messages/count_tokens" in path
227
+ or "/messages/batches" in path
228
+ or "/files" in path
229
+ or "/skills" in path
230
+ )
231
+
232
+
233
+ def _is_openai_chat(path: str, method: str) -> bool:
234
+ return method == "POST" and path.rstrip("/").endswith("/chat/completions")
235
+
236
+
237
+ def _is_openai_responses(path: str, method: str) -> bool:
238
+ return method == "POST" and path.rstrip("/").endswith("/responses")
239
+
240
+
241
+ def _is_openai_passthrough(path: str, method: str) -> bool:
242
+ if method != "POST":
243
+ return False
244
+ return any(
245
+ path.rstrip("/").endswith(suffix) for suffix in ("/embeddings", "/moderations")
246
+ ) or any(segment in path for segment in ("/audio/", "/images/"))
247
+
248
+
249
+ def _is_gemini(path: str, method: str) -> bool:
250
+ return method == "POST" and (":generateContent" in path or ":streamGenerateContent" in path)
251
+
252
+
253
+ def _is_ollama(path: str, method: str) -> bool:
254
+ if method != "POST":
255
+ return False
256
+ return path.rstrip("/").endswith("/api/chat") or path.rstrip("/").endswith("/api/generate")
257
+
258
+
259
+ __all__ = ["Route", "UnknownUpstream", "classify", "detect", "is_absolute_target"]
ctxoptimizer/server.py ADDED
@@ -0,0 +1,319 @@
1
+ """Starlette application, ingress and health endpoint (SPEC section 4.2).
2
+
3
+ P1-1 wires classification and health. The forwarding core is injected, so the
4
+ server can be tested without a live upstream and P1-2 can supply the real one.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from collections.abc import AsyncIterator, Awaitable, Callable
11
+ from contextlib import asynccontextmanager
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+ from starlette.applications import Starlette
16
+ from starlette.requests import Request
17
+ from starlette.responses import JSONResponse, Response
18
+ from starlette.routing import Route as StarletteRoute
19
+
20
+ from ctxoptimizer import __version__
21
+ from ctxoptimizer.admin_security import AdminSecurityMiddleware, admin_credentials
22
+ from ctxoptimizer.analysis.state import AnalysisState
23
+ from ctxoptimizer.config import Config, data_dir
24
+ from ctxoptimizer.dashboard.api import (
25
+ admin_config,
26
+ apply_recommendation,
27
+ dismiss_recommendation,
28
+ drift,
29
+ expand_usage,
30
+ export,
31
+ guard_action,
32
+ guard_status,
33
+ live,
34
+ purge,
35
+ put_pricing,
36
+ recent_requests,
37
+ recommendations_api,
38
+ reload,
39
+ request_detail,
40
+ request_diff,
41
+ session_detail,
42
+ set_mode,
43
+ stale_estimate,
44
+ summary,
45
+ timeseries,
46
+ transform_stats,
47
+ update_config,
48
+ waste,
49
+ waste_status,
50
+ )
51
+ from ctxoptimizer.dashboard.api import (
52
+ pricing as pricing_api,
53
+ )
54
+ from ctxoptimizer.dashboard.api import (
55
+ sessions as sessions_api,
56
+ )
57
+ from ctxoptimizer.dashboard.api import (
58
+ transforms as transforms_api,
59
+ )
60
+ from ctxoptimizer.dashboard.views import (
61
+ doctor_view,
62
+ live_view,
63
+ overview,
64
+ pricing_view,
65
+ request_view,
66
+ requests_view,
67
+ session_view,
68
+ sessions_view,
69
+ settings_view,
70
+ transforms_view,
71
+ waste_view,
72
+ )
73
+ from ctxoptimizer.guard import Guard
74
+ from ctxoptimizer.routing import Route, UnknownUpstream, classify, is_absolute_target
75
+ from ctxoptimizer.telemetry.blobs import BlobStore
76
+ from ctxoptimizer.telemetry.store import Store, default_db_path
77
+ from ctxoptimizer.transforms.cache_serve import CacheServeState
78
+
79
+ Forwarder = Callable[[Request, Route, Config], Awaitable[Response]]
80
+
81
+ _METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
82
+
83
+
84
+ def create_app(
85
+ config: Config,
86
+ forwarder: Forwarder | None = None,
87
+ *,
88
+ config_path: Path | None = None,
89
+ ) -> Starlette:
90
+ """Build the ASGI app. ``forwarder`` overrides the real proxy core in tests."""
91
+
92
+ @asynccontextmanager
93
+ async def lifespan(app: Starlette) -> AsyncIterator[None]:
94
+ app.state.client = _make_client(config)
95
+ app.state.store = _make_store(config)
96
+ app.state.blobs = _make_blobs(config)
97
+ if app.state.store is not None:
98
+ app.state.guard.bind_store(app.state.store)
99
+ try:
100
+ yield
101
+ finally:
102
+ await app.state.client.aclose()
103
+ if app.state.store is not None:
104
+ app.state.store.close()
105
+
106
+ async def health(request: Request) -> Response:
107
+ state = request.app.state
108
+ current = state.config
109
+ counters = state.counters
110
+ last = state.last_request or {}
111
+ usage = last.get("usage")
112
+ store = getattr(state, "store", None)
113
+ return JSONResponse(
114
+ {
115
+ "status": "ok",
116
+ "version": __version__,
117
+ "mode": current.mode.default,
118
+ "uptime_s": round(time.monotonic() - state.started, 3),
119
+ "upstreams": sorted(current.upstreams),
120
+ "capture_payloads": current.telemetry.capture_payloads,
121
+ "requests": counters["requests"],
122
+ "completed": counters["completed"],
123
+ "client_disconnect": counters["client_disconnect"],
124
+ "upstream_errors": counters["upstream_errors"],
125
+ "dropped_telemetry": (
126
+ store.stats()["dropped_events"]
127
+ if store is not None
128
+ else counters["dropped_telemetry"]
129
+ ),
130
+ "stored_requests": (store.count_requests() if store is not None else 0),
131
+ "last_model": last.get("model"),
132
+ "last_cost_usd": last.get("cost_usd"),
133
+ "last_priced": last.get("priced", False),
134
+ "last_usage_source": getattr(usage, "source", None),
135
+ "last_mutated": last.get("mutated", False),
136
+ "last_fallback": last.get("fallback_used", False),
137
+ "last_transforms": last.get("transform_ids", []),
138
+ "last_drift": last.get("drift"),
139
+ "last_marker_coverage": (last.get("marker_quality") or {}).get("coverage"),
140
+ "findings": (
141
+ len(state.analysis.findings())
142
+ if getattr(state, "analysis", None) is not None
143
+ else 0
144
+ ),
145
+ "blob_count": (
146
+ state.blobs.count() if getattr(state, "blobs", None) is not None else 0
147
+ ),
148
+ }
149
+ )
150
+
151
+ async def handler(request: Request) -> Response:
152
+ raw_target = request.scope.get("raw_path", b"").decode("latin-1")
153
+ if is_absolute_target(raw_target):
154
+ return JSONResponse(
155
+ {"error": "absolute-form request targets are not supported"},
156
+ status_code=400,
157
+ )
158
+ path = request.url.path
159
+ if path.startswith("/_ctxoptimizer"):
160
+ return JSONResponse({"error": "not found"}, status_code=404)
161
+ current = request.app.state.config
162
+ try:
163
+ route = classify(request.method, path, request.url.query, request.headers, current)
164
+ except UnknownUpstream as exc:
165
+ return JSONResponse({"error": str(exc)}, status_code=404)
166
+ except ValueError as exc:
167
+ return JSONResponse({"error": str(exc)}, status_code=400)
168
+ active = forwarder or _default_forwarder
169
+ return await active(request, route, current)
170
+
171
+ app = Starlette(
172
+ routes=[
173
+ StarletteRoute("/_ctxoptimizer/health", health, methods=["GET"]),
174
+ StarletteRoute("/_ctxoptimizer/api/health", health, methods=["GET"]),
175
+ StarletteRoute("/_ctxoptimizer", overview, methods=["GET"]),
176
+ StarletteRoute("/_ctxoptimizer/", overview, methods=["GET"]),
177
+ StarletteRoute("/_ctxoptimizer/api/summary", summary, methods=["GET"]),
178
+ StarletteRoute("/_ctxoptimizer/api/requests", recent_requests, methods=["GET"]),
179
+ StarletteRoute("/_ctxoptimizer/api/waste", waste, methods=["GET"]),
180
+ StarletteRoute("/_ctxoptimizer/api/recommend", recommendations_api, methods=["GET"]),
181
+ StarletteRoute("/_ctxoptimizer/api/sessions", sessions_api, methods=["GET"]),
182
+ StarletteRoute(
183
+ "/_ctxoptimizer/api/sessions/{session_id}", session_detail, methods=["GET"]
184
+ ),
185
+ StarletteRoute(
186
+ "/_ctxoptimizer/api/requests/{request_id}", request_detail, methods=["GET"]
187
+ ),
188
+ StarletteRoute("/_ctxoptimizer/api/transforms", transforms_api, methods=["GET"]),
189
+ StarletteRoute("/_ctxoptimizer/api/pricing", pricing_api, methods=["GET"]),
190
+ StarletteRoute("/_ctxoptimizer/api/pricing", put_pricing, methods=["PUT"]),
191
+ StarletteRoute("/_ctxoptimizer/api/guard", guard_status, methods=["GET"]),
192
+ StarletteRoute("/_ctxoptimizer/api/guard", guard_action, methods=["POST"]),
193
+ StarletteRoute("/_ctxoptimizer/api/config", admin_config, methods=["GET"]),
194
+ StarletteRoute("/_ctxoptimizer/api/config", update_config, methods=["POST"]),
195
+ StarletteRoute("/_ctxoptimizer/api/reload", reload, methods=["POST"]),
196
+ StarletteRoute("/_ctxoptimizer/api/mode", set_mode, methods=["POST"]),
197
+ StarletteRoute("/_ctxoptimizer/api/purge", purge, methods=["POST"]),
198
+ StarletteRoute("/_ctxoptimizer/api/export", export, methods=["GET"]),
199
+ StarletteRoute("/_ctxoptimizer/api/timeseries", timeseries, methods=["GET"]),
200
+ StarletteRoute(
201
+ "/_ctxoptimizer/api/stale-elision/estimate",
202
+ stale_estimate,
203
+ methods=["GET"],
204
+ ),
205
+ StarletteRoute("/_ctxoptimizer/api/drift", drift, methods=["GET"]),
206
+ StarletteRoute("/_ctxoptimizer/api/expand/stats", expand_usage, methods=["GET"]),
207
+ StarletteRoute("/_ctxoptimizer/api/transforms/stats", transform_stats, methods=["GET"]),
208
+ StarletteRoute(
209
+ "/_ctxoptimizer/api/requests/{request_id}/diff",
210
+ request_diff,
211
+ methods=["GET"],
212
+ ),
213
+ StarletteRoute("/_ctxoptimizer/api/live", live, methods=["GET"]),
214
+ StarletteRoute(
215
+ "/_ctxoptimizer/api/waste/{finding_id}/status",
216
+ waste_status,
217
+ methods=["POST"],
218
+ ),
219
+ StarletteRoute(
220
+ "/_ctxoptimizer/api/recommend/{recommendation_id}/apply",
221
+ apply_recommendation,
222
+ methods=["POST"],
223
+ ),
224
+ StarletteRoute(
225
+ "/_ctxoptimizer/api/recommend/{recommendation_id}/dismiss",
226
+ dismiss_recommendation,
227
+ methods=["POST"],
228
+ ),
229
+ StarletteRoute("/_ctxoptimizer/waste", waste_view, methods=["GET"]),
230
+ StarletteRoute("/_ctxoptimizer/live", live_view, methods=["GET"]),
231
+ StarletteRoute("/_ctxoptimizer/sessions", sessions_view, methods=["GET"]),
232
+ StarletteRoute("/_ctxoptimizer/sessions/{session_id}", session_view, methods=["GET"]),
233
+ StarletteRoute("/_ctxoptimizer/requests", requests_view, methods=["GET"]),
234
+ StarletteRoute("/_ctxoptimizer/requests/{request_id}", request_view, methods=["GET"]),
235
+ StarletteRoute("/_ctxoptimizer/transforms", transforms_view, methods=["GET"]),
236
+ StarletteRoute("/_ctxoptimizer/pricing", pricing_view, methods=["GET"]),
237
+ StarletteRoute("/_ctxoptimizer/settings", settings_view, methods=["GET"]),
238
+ StarletteRoute("/_ctxoptimizer/doctor", doctor_view, methods=["GET"]),
239
+ StarletteRoute("/{path:path}", handler, methods=_METHODS),
240
+ ],
241
+ lifespan=lifespan,
242
+ )
243
+ app.state.config = config
244
+ app.state.config_path = config_path
245
+ app.state.started = time.monotonic()
246
+ admin_token, csrf_token = admin_credentials()
247
+ if config.server.admin_token:
248
+ admin_token = config.server.admin_token
249
+ app.state.admin_token = admin_token
250
+ app.state.csrf_token = csrf_token
251
+ app.add_middleware(
252
+ AdminSecurityMiddleware,
253
+ config=config,
254
+ admin_token=admin_token,
255
+ csrf_token=csrf_token,
256
+ )
257
+ app.state.analysis = AnalysisState()
258
+ app.state.guard = Guard(config)
259
+ app.state.cache_serve = CacheServeState(
260
+ config.transforms.cache_serve.max_state_sessions,
261
+ config.transforms.cache_serve.max_entries_per_session,
262
+ )
263
+ app.state.counters = {
264
+ "requests": 0,
265
+ "completed": 0,
266
+ "client_disconnect": 0,
267
+ "upstream_error": 0,
268
+ "upstream_errors": 0,
269
+ "dropped_telemetry": 0,
270
+ }
271
+ app.state.last_request = None
272
+ return app
273
+
274
+
275
+ async def _default_forwarder(request: Request, route: Route, config: Config) -> Response:
276
+ from ctxoptimizer.proxy import forward
277
+
278
+ return await forward(request, route, config)
279
+
280
+
281
+ def _make_client(config: Config) -> httpx.AsyncClient:
282
+ http = config.http
283
+ timeout = httpx.Timeout(
284
+ connect=http.connect_timeout_s,
285
+ read=None if http.read_timeout_s == 0 else http.read_timeout_s,
286
+ write=http.write_timeout_s,
287
+ pool=http.pool_timeout_s,
288
+ )
289
+ return httpx.AsyncClient(
290
+ http2=http.http2,
291
+ trust_env=True,
292
+ follow_redirects=False,
293
+ timeout=timeout,
294
+ limits=httpx.Limits(max_connections=http.max_connections),
295
+ )
296
+
297
+
298
+ def _make_store(config: Config) -> Store | None:
299
+ try:
300
+ path = Path(config.telemetry.db_path) if config.telemetry.db_path else default_db_path()
301
+ return Store(
302
+ path,
303
+ queue_max=config.telemetry.writer_queue_max,
304
+ retention_days=config.telemetry.retention_days,
305
+ )
306
+ except Exception:
307
+ return None
308
+
309
+
310
+ def _make_blobs(config: Config) -> BlobStore | None:
311
+ if config.telemetry.capture_payloads == "off":
312
+ return None
313
+ try:
314
+ return BlobStore(data_dir() / "blobs")
315
+ except Exception:
316
+ return None
317
+
318
+
319
+ __all__ = ["Forwarder", "create_app"]