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
ctxoptimizer/routing.py
ADDED
|
@@ -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"]
|