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
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"]