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/proxy.py
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""Forwarding core: headers, streaming, transforms and error shaping (SPEC 9)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncIterator, Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import orjson
|
|
13
|
+
from starlette.requests import Request
|
|
14
|
+
from starlette.responses import JSONResponse, Response, StreamingResponse
|
|
15
|
+
|
|
16
|
+
from ctxoptimizer.analysis.edits import edit_observations
|
|
17
|
+
from ctxoptimizer.analysis.session import derive_session_id, project_from_payload
|
|
18
|
+
from ctxoptimizer.analysis.state import AnalysisResult, AnalysisState, observations_from_texts
|
|
19
|
+
from ctxoptimizer.config import Config
|
|
20
|
+
from ctxoptimizer.guard import Guard
|
|
21
|
+
from ctxoptimizer.ids import new_ulid
|
|
22
|
+
from ctxoptimizer.models import Endpoint, Mode, Provider, RequestContext, Usage
|
|
23
|
+
from ctxoptimizer.pipeline import PipelineResult, run_pipeline
|
|
24
|
+
from ctxoptimizer.providers import adapter_for
|
|
25
|
+
from ctxoptimizer.routing import Route
|
|
26
|
+
from ctxoptimizer.telemetry.pricing import (
|
|
27
|
+
PriceResult,
|
|
28
|
+
PricingTable,
|
|
29
|
+
load_pricing,
|
|
30
|
+
price_usage,
|
|
31
|
+
)
|
|
32
|
+
from ctxoptimizer.telemetry.redact import redact_bytes
|
|
33
|
+
from ctxoptimizer.telemetry.usage import new_usage_parser, parse_usage_body
|
|
34
|
+
from ctxoptimizer.transforms import build_transforms
|
|
35
|
+
from ctxoptimizer.transforms.cache_serve import CacheServeState
|
|
36
|
+
|
|
37
|
+
HOP_BY_HOP = frozenset(
|
|
38
|
+
{
|
|
39
|
+
"connection",
|
|
40
|
+
"keep-alive",
|
|
41
|
+
"proxy-authenticate",
|
|
42
|
+
"proxy-authorization",
|
|
43
|
+
"proxy-connection",
|
|
44
|
+
"te",
|
|
45
|
+
"trailer",
|
|
46
|
+
"trailers",
|
|
47
|
+
"transfer-encoding",
|
|
48
|
+
"upgrade",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_REQUEST_STRIP = frozenset({"host", "content-length", "accept-encoding"})
|
|
53
|
+
|
|
54
|
+
_TABLES: dict[str, PricingTable] = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def forward(request: Request, route: Route, config: Config) -> Response:
|
|
58
|
+
"""Forward one request upstream, optionally applying deterministic transforms."""
|
|
59
|
+
started = time.perf_counter()
|
|
60
|
+
counters = request.app.state.counters
|
|
61
|
+
counters["requests"] += 1
|
|
62
|
+
request_id = new_ulid()
|
|
63
|
+
request.state.request_id = request_id
|
|
64
|
+
|
|
65
|
+
raw = await _request_content(request, config)
|
|
66
|
+
content: bytes | AsyncIterator[bytes] = raw
|
|
67
|
+
mutated = False
|
|
68
|
+
pipeline_result: PipelineResult | None = None
|
|
69
|
+
ctx: RequestContext | None = None
|
|
70
|
+
analysis: AnalysisResult | None = None
|
|
71
|
+
if (
|
|
72
|
+
isinstance(raw, bytes)
|
|
73
|
+
and route.endpoint is not Endpoint.PASSTHROUGH
|
|
74
|
+
and route.mode is not Mode.PASSTHROUGH
|
|
75
|
+
):
|
|
76
|
+
content, mutated, pipeline_result, ctx, analysis = _apply_transforms(
|
|
77
|
+
request, route, config, request_id, raw
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
model = (ctx.model if ctx else None) or _extract_model(raw)
|
|
81
|
+
session_id = ctx.session_id if ctx else route.session_id
|
|
82
|
+
project = ctx.project if ctx else route.project
|
|
83
|
+
effective_mode = ctx.mode if ctx else route.mode
|
|
84
|
+
captures = _capture_blobs(request, config, raw, pipeline_result, content)
|
|
85
|
+
|
|
86
|
+
headers = _clean_request_headers(
|
|
87
|
+
request.headers, force_identity=config.http.force_identity_encoding
|
|
88
|
+
)
|
|
89
|
+
client: httpx.AsyncClient = request.app.state.client
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
upstream = await client.send(
|
|
93
|
+
client.build_request(
|
|
94
|
+
request.method,
|
|
95
|
+
route.upstream_url(),
|
|
96
|
+
headers=headers,
|
|
97
|
+
content=content,
|
|
98
|
+
),
|
|
99
|
+
stream=True,
|
|
100
|
+
)
|
|
101
|
+
except httpx.TimeoutException:
|
|
102
|
+
counters["upstream_errors"] += 1
|
|
103
|
+
return error_response(route.provider, 504, "ctxoptimizer: upstream timeout")
|
|
104
|
+
except httpx.HTTPError:
|
|
105
|
+
counters["upstream_errors"] += 1
|
|
106
|
+
return error_response(route.provider, 502, "ctxoptimizer: upstream unreachable")
|
|
107
|
+
|
|
108
|
+
fallback_used = False
|
|
109
|
+
fallback_error: str | None = None
|
|
110
|
+
if (
|
|
111
|
+
mutated
|
|
112
|
+
and isinstance(raw, bytes)
|
|
113
|
+
and upstream.status_code in config.pipeline.retry_original_on_status
|
|
114
|
+
):
|
|
115
|
+
try:
|
|
116
|
+
fallback_error = redact_bytes((await upstream.aread())[:2048]).decode(
|
|
117
|
+
"utf-8", "replace"
|
|
118
|
+
)
|
|
119
|
+
except Exception:
|
|
120
|
+
fallback_error = None
|
|
121
|
+
await upstream.aclose()
|
|
122
|
+
fallback_used = True
|
|
123
|
+
try:
|
|
124
|
+
upstream = await client.send(
|
|
125
|
+
client.build_request(
|
|
126
|
+
request.method,
|
|
127
|
+
route.upstream_url(),
|
|
128
|
+
headers=headers,
|
|
129
|
+
content=raw,
|
|
130
|
+
),
|
|
131
|
+
stream=True,
|
|
132
|
+
)
|
|
133
|
+
except httpx.TimeoutException:
|
|
134
|
+
counters["upstream_errors"] += 1
|
|
135
|
+
return error_response(route.provider, 504, "ctxoptimizer: upstream timeout")
|
|
136
|
+
except httpx.HTTPError:
|
|
137
|
+
counters["upstream_errors"] += 1
|
|
138
|
+
return error_response(route.provider, 502, "ctxoptimizer: upstream unreachable")
|
|
139
|
+
|
|
140
|
+
ttfb_ms = (time.perf_counter() - started) * 1000
|
|
141
|
+
parser = new_usage_parser(route.provider, route.endpoint, route.profile)
|
|
142
|
+
content_type = upstream.headers.get("content-type", "").lower()
|
|
143
|
+
incrementally_parsed = (
|
|
144
|
+
"text/event-stream" in content_type or "ndjson" in content_type or "jsonl" in content_type
|
|
145
|
+
)
|
|
146
|
+
body_buffer = bytearray()
|
|
147
|
+
response_buffer = bytearray()
|
|
148
|
+
|
|
149
|
+
async def body() -> AsyncIterator[bytes]:
|
|
150
|
+
outcome = "client_disconnect"
|
|
151
|
+
try:
|
|
152
|
+
async for chunk in upstream.aiter_raw():
|
|
153
|
+
try:
|
|
154
|
+
parser.feed(chunk)
|
|
155
|
+
except Exception:
|
|
156
|
+
parser.disable()
|
|
157
|
+
if not incrementally_parsed and len(body_buffer) < 4 * 1024 * 1024:
|
|
158
|
+
body_buffer.extend(chunk)
|
|
159
|
+
if (
|
|
160
|
+
config.telemetry.capture_payloads == "full"
|
|
161
|
+
and len(response_buffer) < 8 * 1024 * 1024
|
|
162
|
+
):
|
|
163
|
+
response_buffer.extend(chunk)
|
|
164
|
+
yield chunk
|
|
165
|
+
outcome = "completed"
|
|
166
|
+
except Exception:
|
|
167
|
+
outcome = "upstream_error"
|
|
168
|
+
raise
|
|
169
|
+
finally:
|
|
170
|
+
await upstream.aclose()
|
|
171
|
+
total_ms = (time.perf_counter() - started) * 1000
|
|
172
|
+
counters[outcome] += 1
|
|
173
|
+
usage = parser.result()
|
|
174
|
+
if usage.source == "none" and body_buffer:
|
|
175
|
+
usage = parse_usage_body(
|
|
176
|
+
route.provider, route.endpoint, route.profile, bytes(body_buffer)
|
|
177
|
+
)
|
|
178
|
+
if response_buffer:
|
|
179
|
+
blobs = getattr(request.app.state, "blobs", None)
|
|
180
|
+
if blobs is not None:
|
|
181
|
+
captures.append(
|
|
182
|
+
{
|
|
183
|
+
"transform_id": None,
|
|
184
|
+
"location": "response",
|
|
185
|
+
"before_hash": blobs.put(redact_bytes(bytes(response_buffer))),
|
|
186
|
+
"after_hash": None,
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
priced = _price(usage, route, config, model)
|
|
190
|
+
_observe_guard_savings(request, session_id, pipeline_result, priced, effective_mode)
|
|
191
|
+
baseline_cost = _baseline_price(analysis, route, config, model)
|
|
192
|
+
request.app.state.last_request = {
|
|
193
|
+
"request_id": request_id,
|
|
194
|
+
"upstream": route.upstream_name,
|
|
195
|
+
"provider": route.provider.value,
|
|
196
|
+
"endpoint": route.endpoint.value,
|
|
197
|
+
"model": model,
|
|
198
|
+
"status_code": upstream.status_code,
|
|
199
|
+
"outcome": outcome,
|
|
200
|
+
"mutated": mutated,
|
|
201
|
+
"fallback_used": fallback_used,
|
|
202
|
+
"ttfb_ms": round(ttfb_ms, 3),
|
|
203
|
+
"total_ms": round(total_ms, 3),
|
|
204
|
+
"usage": usage,
|
|
205
|
+
"cost_usd": priced.cost_usd,
|
|
206
|
+
"priced": priced.priced,
|
|
207
|
+
"price_source": priced.source,
|
|
208
|
+
"transform_ids": (
|
|
209
|
+
[
|
|
210
|
+
report.transform_id
|
|
211
|
+
for report in pipeline_result.reports
|
|
212
|
+
if report.changed and report.discarded_reason is None
|
|
213
|
+
]
|
|
214
|
+
if pipeline_result
|
|
215
|
+
else []
|
|
216
|
+
),
|
|
217
|
+
"drift": analysis.drift.cause if analysis and analysis.drift else None,
|
|
218
|
+
"findings": len(analysis.findings) if analysis else 0,
|
|
219
|
+
"marker_quality": (
|
|
220
|
+
{
|
|
221
|
+
"markers": analysis.marker_quality.markers,
|
|
222
|
+
"coverage": round(analysis.marker_quality.coverage, 4),
|
|
223
|
+
}
|
|
224
|
+
if analysis
|
|
225
|
+
else None
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
store = getattr(request.app.state, "store", None)
|
|
229
|
+
if store is not None:
|
|
230
|
+
store.record_request(
|
|
231
|
+
_store_record(
|
|
232
|
+
request_id=request_id,
|
|
233
|
+
route=route,
|
|
234
|
+
config=config,
|
|
235
|
+
model=model,
|
|
236
|
+
raw=raw,
|
|
237
|
+
content=content,
|
|
238
|
+
session_id=session_id,
|
|
239
|
+
project=project,
|
|
240
|
+
mode=effective_mode,
|
|
241
|
+
mutated=mutated,
|
|
242
|
+
fallback_used=fallback_used,
|
|
243
|
+
status_code=upstream.status_code,
|
|
244
|
+
outcome=outcome,
|
|
245
|
+
ttfb_ms=ttfb_ms,
|
|
246
|
+
total_ms=total_ms,
|
|
247
|
+
usage=usage,
|
|
248
|
+
priced=priced,
|
|
249
|
+
pipeline_result=pipeline_result,
|
|
250
|
+
analysis=analysis,
|
|
251
|
+
captures=captures,
|
|
252
|
+
baseline_cost=baseline_cost,
|
|
253
|
+
error=fallback_error,
|
|
254
|
+
headers=_safe_headers(request.headers),
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
if fallback_used and pipeline_result is not None:
|
|
258
|
+
guard = getattr(request.app.state, "guard", None)
|
|
259
|
+
if isinstance(guard, Guard):
|
|
260
|
+
guard.observe_fallback(
|
|
261
|
+
session_id,
|
|
262
|
+
[
|
|
263
|
+
report.transform_id
|
|
264
|
+
for report in pipeline_result.reports
|
|
265
|
+
if report.changed and report.discarded_reason is None
|
|
266
|
+
],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return StreamingResponse(
|
|
270
|
+
body(),
|
|
271
|
+
status_code=upstream.status_code,
|
|
272
|
+
headers=_clean_response_headers(upstream.headers, request_id),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _apply_transforms(
|
|
277
|
+
request: Request,
|
|
278
|
+
route: Route,
|
|
279
|
+
config: Config,
|
|
280
|
+
request_id: str,
|
|
281
|
+
raw: bytes,
|
|
282
|
+
) -> tuple[bytes, bool, PipelineResult | None, RequestContext | None, AnalysisResult | None]:
|
|
283
|
+
if request.headers.get("content-encoding"):
|
|
284
|
+
return raw, False, None, None, None
|
|
285
|
+
try:
|
|
286
|
+
payload = orjson.loads(raw)
|
|
287
|
+
except (orjson.JSONDecodeError, TypeError):
|
|
288
|
+
return raw, False, None, None, None
|
|
289
|
+
if not isinstance(payload, dict):
|
|
290
|
+
return raw, False, None, None, None
|
|
291
|
+
adapter = adapter_for(route.provider, route.endpoint)
|
|
292
|
+
if not adapter.is_transformable(payload):
|
|
293
|
+
return raw, False, None, None, None
|
|
294
|
+
mode = route.mode
|
|
295
|
+
model = adapter.model_of(payload) or _extract_model(raw)
|
|
296
|
+
ctx = RequestContext(
|
|
297
|
+
request_id=request_id,
|
|
298
|
+
provider=route.provider,
|
|
299
|
+
endpoint=route.endpoint,
|
|
300
|
+
upstream_name=route.upstream_name,
|
|
301
|
+
model=model,
|
|
302
|
+
stream=adapter.is_stream(payload),
|
|
303
|
+
mode=mode,
|
|
304
|
+
session_id=(
|
|
305
|
+
route.session_id or derive_session_id(route.provider, request.headers, payload, adapter)
|
|
306
|
+
),
|
|
307
|
+
project=route.project or project_from_payload(payload, adapter),
|
|
308
|
+
client_name=route.client_name,
|
|
309
|
+
config=config,
|
|
310
|
+
config_hash=config.config_hash,
|
|
311
|
+
started_monotonic=time.monotonic(),
|
|
312
|
+
)
|
|
313
|
+
try:
|
|
314
|
+
guard = getattr(request.app.state, "guard", None)
|
|
315
|
+
if isinstance(guard, Guard):
|
|
316
|
+
guard.observe_request(ctx.session_id)
|
|
317
|
+
guard.observe_expand_health(ctx.session_id)
|
|
318
|
+
disabled = guard.quarantined_for(ctx.session_id)
|
|
319
|
+
else:
|
|
320
|
+
disabled = set()
|
|
321
|
+
cache_state = getattr(request.app.state, "cache_serve", None)
|
|
322
|
+
transforms = build_transforms(
|
|
323
|
+
config,
|
|
324
|
+
disabled=disabled,
|
|
325
|
+
cache_serve_state=(cache_state if isinstance(cache_state, CacheServeState) else None),
|
|
326
|
+
)
|
|
327
|
+
result = run_pipeline(ctx, payload, adapter, transforms, config)
|
|
328
|
+
except Exception:
|
|
329
|
+
return raw, False, None, ctx, None
|
|
330
|
+
analysis = getattr(request.app.state, "analysis", None)
|
|
331
|
+
analysis_result: AnalysisResult | None = None
|
|
332
|
+
if isinstance(analysis, AnalysisState):
|
|
333
|
+
tool_schemas = list(
|
|
334
|
+
zip(
|
|
335
|
+
adapter.tool_names(payload),
|
|
336
|
+
[
|
|
337
|
+
segment.est_tokens
|
|
338
|
+
for segment in result.original_segments
|
|
339
|
+
if segment.kind == "tool_def"
|
|
340
|
+
],
|
|
341
|
+
strict=False,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
analysis_result = analysis.observe(
|
|
345
|
+
session_id=ctx.session_id,
|
|
346
|
+
project=ctx.project,
|
|
347
|
+
ts=time.time(),
|
|
348
|
+
segments=result.original_segments,
|
|
349
|
+
observations=observations_from_texts(result.original_texts),
|
|
350
|
+
model=model or "",
|
|
351
|
+
config_hash=ctx.config_hash,
|
|
352
|
+
tool_schemas=tool_schemas,
|
|
353
|
+
used_tools=adapter.used_tool_names(payload),
|
|
354
|
+
edits=(
|
|
355
|
+
edit_observations(payload, route.provider)
|
|
356
|
+
if config.waste.churn_detector.enabled
|
|
357
|
+
else []
|
|
358
|
+
),
|
|
359
|
+
churn_enabled=config.waste.churn_detector.enabled,
|
|
360
|
+
schema_bloat_enabled=config.waste.schema_bloat.enabled,
|
|
361
|
+
schema_min_requests=config.waste.schema_bloat.min_session_requests,
|
|
362
|
+
cache_policy=route.profile.cache_policy,
|
|
363
|
+
)
|
|
364
|
+
if mode is Mode.ACTIVE and result.changed:
|
|
365
|
+
return orjson.dumps(payload), True, result, ctx, analysis_result
|
|
366
|
+
return raw, False, result, ctx, analysis_result
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def _request_content(request: Request, config: Config) -> bytes | AsyncIterator[bytes]:
|
|
370
|
+
raw_length = request.headers.get("content-length")
|
|
371
|
+
if raw_length and raw_length.isdigit():
|
|
372
|
+
if int(raw_length) > config.server.max_body_bytes:
|
|
373
|
+
return request.stream()
|
|
374
|
+
return await request.body()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _capture_blobs(
|
|
378
|
+
request: Request,
|
|
379
|
+
config: Config,
|
|
380
|
+
raw: bytes | AsyncIterator[bytes],
|
|
381
|
+
result: PipelineResult | None,
|
|
382
|
+
final: bytes | AsyncIterator[bytes] | None = None,
|
|
383
|
+
) -> list[dict[str, Any]]:
|
|
384
|
+
capture = config.telemetry.capture_payloads
|
|
385
|
+
captures: list[dict[str, Any]] = []
|
|
386
|
+
if capture == "off" or not isinstance(raw, bytes):
|
|
387
|
+
return captures
|
|
388
|
+
blobs = getattr(request.app.state, "blobs", None)
|
|
389
|
+
if blobs is None:
|
|
390
|
+
return captures
|
|
391
|
+
if capture == "full":
|
|
392
|
+
request_hash = blobs.put(redact_bytes(raw))
|
|
393
|
+
captures.append(
|
|
394
|
+
{
|
|
395
|
+
"transform_id": None,
|
|
396
|
+
"location": "request",
|
|
397
|
+
"before_hash": request_hash,
|
|
398
|
+
"after_hash": None,
|
|
399
|
+
}
|
|
400
|
+
)
|
|
401
|
+
if isinstance(final, bytes):
|
|
402
|
+
final_hash = blobs.put(redact_bytes(final))
|
|
403
|
+
captures.append(
|
|
404
|
+
{
|
|
405
|
+
"transform_id": None,
|
|
406
|
+
"location": "final_request",
|
|
407
|
+
"before_hash": final_hash,
|
|
408
|
+
"after_hash": None,
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
if result is not None:
|
|
412
|
+
for index, (before, after) in enumerate(result.changed_blocks):
|
|
413
|
+
before_hash = blobs.put(redact_bytes(before.encode("utf-8")))
|
|
414
|
+
after_hash = blobs.put(redact_bytes(after.encode("utf-8")))
|
|
415
|
+
captures.append(
|
|
416
|
+
{
|
|
417
|
+
"transform_id": None,
|
|
418
|
+
"location": f"block-{index}",
|
|
419
|
+
"before_hash": before_hash,
|
|
420
|
+
"after_hash": after_hash,
|
|
421
|
+
}
|
|
422
|
+
)
|
|
423
|
+
return captures
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
_DROPPED_HEADERS = frozenset(
|
|
427
|
+
{
|
|
428
|
+
"authorization",
|
|
429
|
+
"x-api-key",
|
|
430
|
+
"x-goog-api-key",
|
|
431
|
+
"cookie",
|
|
432
|
+
"set-cookie",
|
|
433
|
+
"proxy-authorization",
|
|
434
|
+
}
|
|
435
|
+
)
|
|
436
|
+
_SECRET_HEADER_PARTS = ("key", "token", "secret", "password")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _safe_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
|
440
|
+
"""Redacted request headers for the local dashboard (INV-14)."""
|
|
441
|
+
safe: dict[str, str] = {}
|
|
442
|
+
for name, value in headers.items():
|
|
443
|
+
lower = name.lower()
|
|
444
|
+
if lower in _DROPPED_HEADERS or any(part in lower for part in _SECRET_HEADER_PARTS):
|
|
445
|
+
safe[name] = "[redacted]"
|
|
446
|
+
else:
|
|
447
|
+
safe[name] = value
|
|
448
|
+
return safe
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _clean_request_headers(headers: Mapping[str, str], *, force_identity: bool) -> dict[str, str]:
|
|
452
|
+
connection = _connection_tokens(headers.get("connection", ""))
|
|
453
|
+
blocked = HOP_BY_HOP | connection | _REQUEST_STRIP
|
|
454
|
+
cleaned: dict[str, str] = {}
|
|
455
|
+
for name, value in headers.items():
|
|
456
|
+
lower = name.lower()
|
|
457
|
+
if lower in blocked or lower.startswith("x-ctxoptimizer-"):
|
|
458
|
+
continue
|
|
459
|
+
cleaned[name] = value
|
|
460
|
+
if force_identity:
|
|
461
|
+
cleaned["accept-encoding"] = "identity"
|
|
462
|
+
return cleaned
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _clean_response_headers(headers: httpx.Headers, request_id: str) -> dict[str, str]:
|
|
466
|
+
connection = _connection_tokens(headers.get("connection", ""))
|
|
467
|
+
blocked = HOP_BY_HOP | connection | {"content-length", "transfer-encoding"}
|
|
468
|
+
cleaned = {name: value for name, value in headers.items() if name.lower() not in blocked}
|
|
469
|
+
cleaned["x-ctxoptimizer-request-id"] = request_id
|
|
470
|
+
return cleaned
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _connection_tokens(value: str) -> frozenset[str]:
|
|
474
|
+
return frozenset(token.strip().lower() for token in value.split(",") if token.strip())
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _extract_model(content: bytes | AsyncIterator[bytes]) -> str | None:
|
|
478
|
+
if not isinstance(content, bytes):
|
|
479
|
+
return None
|
|
480
|
+
try:
|
|
481
|
+
payload = orjson.loads(content)
|
|
482
|
+
except (orjson.JSONDecodeError, TypeError):
|
|
483
|
+
return None
|
|
484
|
+
if not isinstance(payload, dict):
|
|
485
|
+
return None
|
|
486
|
+
model = payload.get("model")
|
|
487
|
+
return model if isinstance(model, str) else None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _is_stream(content: bytes | AsyncIterator[bytes]) -> int | None:
|
|
491
|
+
if not isinstance(content, bytes):
|
|
492
|
+
return None
|
|
493
|
+
try:
|
|
494
|
+
payload = orjson.loads(content)
|
|
495
|
+
except (orjson.JSONDecodeError, TypeError):
|
|
496
|
+
return None
|
|
497
|
+
if isinstance(payload, dict):
|
|
498
|
+
return 1 if payload.get("stream") is True else 0
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _table_for(config: Config) -> PricingTable:
|
|
503
|
+
key = config.pricing.file
|
|
504
|
+
table = _TABLES.get(key)
|
|
505
|
+
if table is None:
|
|
506
|
+
try:
|
|
507
|
+
table = load_pricing(Path(key) if key else None)
|
|
508
|
+
except Exception:
|
|
509
|
+
table = PricingTable([])
|
|
510
|
+
_TABLES[key] = table
|
|
511
|
+
return table
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _price(usage: Usage, route: Route, config: Config, model: str | None) -> PriceResult:
|
|
515
|
+
return price_usage(usage, _table_for(config), route.profile.pricing_provider, model)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _observe_guard_savings(
|
|
519
|
+
request: Request,
|
|
520
|
+
session_id: str,
|
|
521
|
+
pipeline_result: PipelineResult | None,
|
|
522
|
+
priced: PriceResult,
|
|
523
|
+
mode: Mode,
|
|
524
|
+
) -> None:
|
|
525
|
+
if mode is not Mode.ACTIVE or pipeline_result is None:
|
|
526
|
+
return
|
|
527
|
+
guard = getattr(request.app.state, "guard", None)
|
|
528
|
+
if not isinstance(guard, Guard) or priced.price is None:
|
|
529
|
+
return
|
|
530
|
+
input_rate = priced.price.input
|
|
531
|
+
for report in pipeline_result.reports:
|
|
532
|
+
if not report.ran or report.discarded_reason is not None:
|
|
533
|
+
continue
|
|
534
|
+
saved_tokens = max(report.est_tokens_before - report.est_tokens_after, 0)
|
|
535
|
+
guard.observe_savings(
|
|
536
|
+
session_id,
|
|
537
|
+
report.transform_id,
|
|
538
|
+
saved_tokens * input_rate / 1_000_000,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _baseline_price(
|
|
543
|
+
analysis: AnalysisResult | None,
|
|
544
|
+
route: Route,
|
|
545
|
+
config: Config,
|
|
546
|
+
model: str | None,
|
|
547
|
+
) -> float | None:
|
|
548
|
+
if analysis is None:
|
|
549
|
+
return None
|
|
550
|
+
sim = analysis.baseline
|
|
551
|
+
usage = Usage(
|
|
552
|
+
fresh_input=sim.fresh_tokens,
|
|
553
|
+
cache_read=sim.read_tokens,
|
|
554
|
+
cache_write_5m=sim.write_5m_tokens,
|
|
555
|
+
cache_write_1h=sim.write_1h_tokens,
|
|
556
|
+
source="estimated",
|
|
557
|
+
)
|
|
558
|
+
priced = _price(usage, route, config, model)
|
|
559
|
+
return priced.cost_usd if priced.priced else None
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _store_record(
|
|
563
|
+
*,
|
|
564
|
+
request_id: str,
|
|
565
|
+
route: Route,
|
|
566
|
+
config: Config,
|
|
567
|
+
model: str | None,
|
|
568
|
+
raw: bytes | AsyncIterator[bytes],
|
|
569
|
+
content: bytes | AsyncIterator[bytes],
|
|
570
|
+
session_id: str,
|
|
571
|
+
project: str | None,
|
|
572
|
+
mode: Mode,
|
|
573
|
+
mutated: bool,
|
|
574
|
+
fallback_used: bool,
|
|
575
|
+
status_code: int,
|
|
576
|
+
outcome: str,
|
|
577
|
+
ttfb_ms: float,
|
|
578
|
+
total_ms: float,
|
|
579
|
+
usage: Usage,
|
|
580
|
+
priced: PriceResult,
|
|
581
|
+
pipeline_result: PipelineResult | None = None,
|
|
582
|
+
analysis: AnalysisResult | None = None,
|
|
583
|
+
captures: list[dict[str, Any]] | None = None,
|
|
584
|
+
baseline_cost: float | None = None,
|
|
585
|
+
error: str | None = None,
|
|
586
|
+
headers: dict[str, str] | None = None,
|
|
587
|
+
) -> dict[str, Any]:
|
|
588
|
+
raw_size = len(raw) if isinstance(raw, bytes) else None
|
|
589
|
+
final_size = len(content) if isinstance(content, bytes) else raw_size
|
|
590
|
+
return {
|
|
591
|
+
"request_id": request_id,
|
|
592
|
+
"ts": time.time(),
|
|
593
|
+
"session_id": session_id or "unknown",
|
|
594
|
+
"project": project,
|
|
595
|
+
"client": route.client_name,
|
|
596
|
+
"provider": route.provider.value,
|
|
597
|
+
"endpoint": route.endpoint.value,
|
|
598
|
+
"upstream": route.upstream_name,
|
|
599
|
+
"model": model,
|
|
600
|
+
"stream": _is_stream(raw),
|
|
601
|
+
"mode": mode.value,
|
|
602
|
+
"mutated": 1 if mutated else 0,
|
|
603
|
+
"fallback_used": 1 if fallback_used else 0,
|
|
604
|
+
"config_hash": config.config_hash,
|
|
605
|
+
"status_code": status_code,
|
|
606
|
+
"outcome": outcome,
|
|
607
|
+
"ttfb_ms": round(ttfb_ms, 3),
|
|
608
|
+
"total_ms": round(total_ms, 3),
|
|
609
|
+
"proxy_overhead_ms": round(max(total_ms - ttfb_ms, 0.0), 3),
|
|
610
|
+
"orig_bytes": raw_size,
|
|
611
|
+
"final_bytes": final_size,
|
|
612
|
+
"orig_est_tokens": pipeline_result.orig_est_tokens if pipeline_result else None,
|
|
613
|
+
"final_est_tokens": pipeline_result.final_est_tokens if pipeline_result else None,
|
|
614
|
+
"request_headers_json": json.dumps(headers or {}, sort_keys=True),
|
|
615
|
+
"usage_source": usage.source,
|
|
616
|
+
"fresh_input": usage.fresh_input,
|
|
617
|
+
"cache_read": usage.cache_read,
|
|
618
|
+
"cache_write_5m": usage.cache_write_5m,
|
|
619
|
+
"cache_write_1h": usage.cache_write_1h,
|
|
620
|
+
"cache_write_30m": usage.cache_write_30m,
|
|
621
|
+
"cache_write_12h": usage.cache_write_12h,
|
|
622
|
+
"output_tokens": usage.output,
|
|
623
|
+
"reasoning_tokens": usage.reasoning,
|
|
624
|
+
"provider_cost_usd": usage.provider_cost_usd,
|
|
625
|
+
"priced": 1 if priced.priced else 0,
|
|
626
|
+
"price_source": priced.source,
|
|
627
|
+
"cost_actual_usd": priced.cost_usd,
|
|
628
|
+
"_transform_reports": pipeline_result.reports if pipeline_result else [],
|
|
629
|
+
"_drift": analysis.drift if analysis else None,
|
|
630
|
+
"_findings": analysis.findings if analysis else [],
|
|
631
|
+
"_captures": captures or [],
|
|
632
|
+
"cost_baseline_sim_usd": baseline_cost,
|
|
633
|
+
"sim_read_baseline": analysis.baseline.read_tokens if analysis else None,
|
|
634
|
+
"sim_read_optimized": usage.cache_read,
|
|
635
|
+
"error": error,
|
|
636
|
+
"_fallback_transform_ids": (
|
|
637
|
+
[
|
|
638
|
+
report.transform_id
|
|
639
|
+
for report in pipeline_result.reports
|
|
640
|
+
if report.changed and report.discarded_reason is None
|
|
641
|
+
]
|
|
642
|
+
if fallback_used and pipeline_result
|
|
643
|
+
else []
|
|
644
|
+
),
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def error_response(provider: Provider, status: int, message: str) -> JSONResponse:
|
|
649
|
+
"""Build a provider-shaped proxy-originated error (SPEC section 9.6)."""
|
|
650
|
+
if provider is Provider.ANTHROPIC:
|
|
651
|
+
payload: dict[str, object] = {
|
|
652
|
+
"type": "error",
|
|
653
|
+
"error": {"type": "api_error", "message": message},
|
|
654
|
+
}
|
|
655
|
+
elif provider is Provider.GEMINI:
|
|
656
|
+
payload = {"error": {"code": status, "message": message, "status": "UNAVAILABLE"}}
|
|
657
|
+
elif provider is Provider.OLLAMA:
|
|
658
|
+
payload = {"error": message}
|
|
659
|
+
else:
|
|
660
|
+
code = "upstream_timeout" if status == 504 else "upstream_unreachable"
|
|
661
|
+
payload = {"error": {"message": message, "type": "server_error", "code": code}}
|
|
662
|
+
return JSONResponse(payload, status_code=status)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
__all__ = ["error_response", "forward"]
|