synckey 0.1.0__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.
synckey/server.py ADDED
@@ -0,0 +1,672 @@
1
+ """The unified gateway.
2
+
3
+ Endpoints:
4
+ POST /v1/chat/completions streaming and non-streaming
5
+ POST /v1/embeddings
6
+ POST /v1/completions
7
+ GET /v1/models aggregated across configured providers
8
+ GET /v1/usage live token totals
9
+ GET /v1/events recent routing events
10
+ GET /healthz
11
+
12
+ Request header X-Quality-Floor: frontier|high|mid|low overrides the automatic
13
+ tier floor for a single request. Without it, the floor equals the requested
14
+ model's own tier (Opus stays Opus-class; gpt-4o-mini can fall to other mid
15
+ models if needed).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import time
23
+ from contextlib import asynccontextmanager
24
+ from dataclasses import dataclass
25
+ from typing import AsyncIterator
26
+
27
+ import httpx
28
+ import orjson
29
+ from fastapi import FastAPI, Request, Response
30
+ from fastapi.responses import JSONResponse, StreamingResponse
31
+
32
+ from .context import Context
33
+ from .db import sha256
34
+ from .deferred import estimate_eta, new_request_id
35
+ from .keypool import parse_retry_after, Candidate
36
+ from .providers import Provider
37
+ from .router import Resolution
38
+ from .tiers import TIER_BY_NAME, cost_usd
39
+ from .usage import UsageWriter
40
+
41
+
42
+ @dataclass
43
+ class Attempt:
44
+ provider_id: str
45
+ provider: Provider
46
+ key_id: int
47
+ secret: str
48
+ candidate: Candidate
49
+
50
+
51
+ def plan_attempts(ctx: Context, resolution: Resolution, max_retries: int) -> list[Attempt]:
52
+ attempts: list[Attempt] = []
53
+ for pid in resolution.providers:
54
+ prov = ctx.providers.get(pid)
55
+ if not prov:
56
+ continue
57
+ for cand in ctx.pool.candidates(pid):
58
+ attempts.append(Attempt(pid, prov, cand.key_id, cand.secret, cand))
59
+ # ready (bucket has space) before throttled before cooling
60
+ attempts.sort(key=lambda a: (a.candidate.cooling, not a.candidate.ready))
61
+ return attempts[: max_retries + 1]
62
+
63
+
64
+ def all_deep_cooling(attempts: list[Attempt], threshold: float) -> bool:
65
+ """True when every candidate is cooling and won't free up for a while."""
66
+ return bool(attempts) and all(
67
+ a.candidate.cooling and a.candidate.cooldown_remaining >= threshold
68
+ for a in attempts
69
+ )
70
+
71
+
72
+ def build_tier_alt_attempts(ctx: Context, resolution: Resolution, body: dict) -> list[Attempt]:
73
+ """Find ready candidates on same-tier alternative models."""
74
+ if not (resolution.tier and resolution.floor):
75
+ return []
76
+ alts = ctx.router.tier_alternatives(resolution.tier, resolution.floor, resolution.bare_model)
77
+ for alt_model, alt_pid in alts:
78
+ prov = ctx.providers.get(alt_pid)
79
+ if not prov:
80
+ continue
81
+ # Only take candidates that are not themselves cooling.
82
+ cands = [c for c in ctx.pool.candidates(alt_pid) if not c.cooling]
83
+ if not cands:
84
+ continue
85
+ ctx.db.record_event(
86
+ type="tier_fallback",
87
+ provider=alt_pid,
88
+ model=resolution.bare_model,
89
+ fallback_to=alt_model,
90
+ tier_from=resolution.tier,
91
+ tier_to=resolution.tier,
92
+ message=f"Primary blocked; routing to {alt_model} on {alt_pid}",
93
+ )
94
+ body["model"] = alt_model
95
+ return [Attempt(alt_pid, prov, c.key_id, c.secret, c) for c in cands]
96
+ return []
97
+
98
+
99
+ def _record_routing_chain(ctx: Context, resolution: Resolution, attempt: "Attempt", model: str) -> None:
100
+ """Record the routing chain for TUI view 4."""
101
+ chain_parts = []
102
+ for pid in resolution.providers:
103
+ prov = ctx.providers.get(pid)
104
+ if not prov:
105
+ continue
106
+ for cand in ctx.pool.candidates(pid):
107
+ state = "ready" if cand.ready and not cand.cooling else ("cooling" if cand.cooling else "throttled")
108
+ chain_parts.append(f"{pid}/key#{cand.key_id}({state})")
109
+ chain_str = " > ".join(chain_parts)
110
+ ctx.db.record_routing_chain(
111
+ model=model,
112
+ provider=attempt.provider_id,
113
+ key_id=attempt.key_id,
114
+ chain=chain_str,
115
+ )
116
+
117
+
118
+ def authorized(ctx: Context, request: Request) -> bool:
119
+ expected = ctx.unified_key_hash()
120
+ if not expected:
121
+ return False
122
+ header = request.headers.get("authorization", "")
123
+ token = header[7:].strip() if header[:7].lower() == "bearer " else header.strip()
124
+ return bool(token) and sha256(token) == expected
125
+
126
+
127
+ def unauthorized() -> JSONResponse:
128
+ return JSONResponse(
129
+ {"error": {"message": "Invalid unified key.", "type": "authentication_error"}},
130
+ status_code=401,
131
+ )
132
+
133
+
134
+ def err(message: str, etype: str, status: int, **extra) -> JSONResponse:
135
+ return JSONResponse({"error": {"message": message, "type": etype, **extra}}, status_code=status)
136
+
137
+
138
+ @asynccontextmanager
139
+ async def lifespan(app: FastAPI):
140
+ ctx: Context = app.state.ctx
141
+ s = ctx.settings
142
+ app.state.client = httpx.AsyncClient(
143
+ timeout=s.request_timeout,
144
+ limits=httpx.Limits(
145
+ max_connections=s.max_connections,
146
+ max_keepalive_connections=s.max_keepalive,
147
+ ),
148
+ )
149
+ app.state.writer = UsageWriter(ctx.db.path)
150
+ app.state.writer.start()
151
+ app.state.worker_task = None
152
+ if s.deferred_enabled:
153
+ ctx.db.reset_running_deferred() # recover jobs interrupted by a crash
154
+ app.state.worker_task = asyncio.create_task(deferred_worker(app))
155
+ try:
156
+ yield
157
+ finally:
158
+ if app.state.worker_task is not None:
159
+ app.state.worker_task.cancel()
160
+ try:
161
+ await app.state.worker_task
162
+ except (asyncio.CancelledError, Exception):
163
+ pass
164
+ await app.state.client.aclose()
165
+ app.state.writer.stop()
166
+ if app.state.owns_ctx:
167
+ ctx.close()
168
+
169
+
170
+ def create_app(ctx: Context | None = None) -> FastAPI:
171
+ owns_ctx = ctx is None
172
+ ctx = ctx or Context()
173
+ app = FastAPI(title="synckey gateway", version="0.1.0", lifespan=lifespan)
174
+ app.state.ctx = ctx
175
+ app.state.owns_ctx = owns_ctx
176
+
177
+ @app.get("/healthz")
178
+ async def healthz():
179
+ states = ctx.states.all()
180
+ from .state import Health
181
+ dead = sum(1 for s in states.values() if s.health == Health.DEAD)
182
+ cooling = sum(1 for s in states.values() if s.health == Health.COOLING and s.cooldown_remaining() > 0)
183
+ live = len(states) - dead - cooling
184
+ return {
185
+ "status": "ok",
186
+ "providers": ctx.db.providers_with_keys(),
187
+ "models_indexed": len(ctx.router.index),
188
+ "keys": {"live": live, "cooling": cooling, "dead": dead},
189
+ }
190
+
191
+ @app.get("/v1/models")
192
+ async def list_models(request: Request):
193
+ if not authorized(ctx, request):
194
+ return unauthorized()
195
+ from .tiers import model_tier, TIER_NAMES
196
+ data = [
197
+ {
198
+ "id": model,
199
+ "object": "model",
200
+ "owned_by": pid,
201
+ "synckey_provider": pid,
202
+ "synckey_tier": TIER_NAMES.get(model_tier(model) or 0, "unknown"),
203
+ }
204
+ for model, providers in sorted(ctx.router.index.items())
205
+ for pid in sorted(providers)
206
+ ]
207
+ return Response(content=orjson.dumps({"object": "list", "data": data}), media_type="application/json")
208
+
209
+ @app.get("/v1/usage")
210
+ async def usage_summary(request: Request):
211
+ if not authorized(ctx, request):
212
+ return unauthorized()
213
+ totals = ctx.db.usage_totals()
214
+ return {
215
+ "requests": totals["requests"] or 0,
216
+ "total_tokens": totals["total_tokens"] or 0,
217
+ "prompt_tokens": totals["prompt_tokens"] or 0,
218
+ "completion_tokens": totals["completion_tokens"] or 0,
219
+ "cost_usd": round(totals["cost_usd"] or 0.0, 6),
220
+ "errors": totals["errors"] or 0,
221
+ }
222
+
223
+ @app.get("/v1/events")
224
+ async def events(request: Request, limit: int = 50):
225
+ if not authorized(ctx, request):
226
+ return unauthorized()
227
+ rows = ctx.db.recent_events(limit)
228
+ return {
229
+ "events": [
230
+ {
231
+ "ts": r["ts"],
232
+ "type": r["type"],
233
+ "provider": r["provider"],
234
+ "model": r["model"],
235
+ "fallback_to": r["fallback_to"],
236
+ "tier_from": r["tier_from"],
237
+ "tier_to": r["tier_to"],
238
+ "message": r["message"],
239
+ }
240
+ for r in rows
241
+ ]
242
+ }
243
+
244
+ @app.get("/v1/requests/{request_id}")
245
+ async def get_deferred(request: Request, request_id: str):
246
+ if not authorized(ctx, request):
247
+ return unauthorized()
248
+ row = ctx.db.get_deferred(request_id)
249
+ if row is None:
250
+ return err(
251
+ "Unknown or expired request id. Deferred results are purged after their TTL.",
252
+ "not_found_error",
253
+ 404,
254
+ )
255
+ status = row["status"]
256
+ if status == "done":
257
+ return Response(
258
+ content=row["response"] or b"",
259
+ media_type="application/json",
260
+ status_code=row["status_code"] or 200,
261
+ )
262
+ if status == "error":
263
+ return err(
264
+ row["error"] or "Deferred request failed.",
265
+ "deferred_error",
266
+ row["status_code"] or 502,
267
+ request_id=request_id,
268
+ )
269
+ # queued or running: tell the caller when to check back.
270
+ eta = row["eta"] or 0
271
+ remaining = int(eta - time.time()) if eta else int(ctx.settings.default_cooldown)
272
+ retry_after = max(1, remaining) + 1
273
+ return JSONResponse(
274
+ {
275
+ "id": request_id,
276
+ "object": "deferred",
277
+ "status": status,
278
+ "retry_after": retry_after,
279
+ "result_url": f"/v1/requests/{request_id}",
280
+ },
281
+ status_code=202,
282
+ headers={"Retry-After": str(retry_after)},
283
+ )
284
+
285
+ @app.post("/v1/chat/completions")
286
+ async def chat_completions(request: Request):
287
+ return await forward(ctx, request, "/chat/completions")
288
+
289
+ @app.post("/v1/embeddings")
290
+ async def embeddings(request: Request):
291
+ return await forward(ctx, request, "/embeddings", allow_stream=False)
292
+
293
+ @app.post("/v1/completions")
294
+ async def completions(request: Request):
295
+ return await forward(ctx, request, "/completions")
296
+
297
+ return app
298
+
299
+
300
+ async def forward(
301
+ ctx: Context, request: Request, path: str, allow_stream: bool = True
302
+ ) -> Response:
303
+ if not authorized(ctx, request):
304
+ return unauthorized()
305
+
306
+ try:
307
+ body = await request.json()
308
+ except Exception:
309
+ return err("Body must be valid JSON.", "invalid_request_error", 400)
310
+
311
+ # synckey-specific routing hints; strip from the body before forwarding upstream.
312
+ provider_hint = body.pop("provider", None) or request.headers.get("x-provider")
313
+ model = body.get("model")
314
+
315
+ # Parse optional quality floor from caller.
316
+ floor_header = request.headers.get("x-quality-floor")
317
+ floor = TIER_BY_NAME.get(floor_header.lower()) if floor_header else None
318
+
319
+ resolution = ctx.router.resolve_full(
320
+ str(model) if model else None, provider_hint, floor
321
+ )
322
+ if resolution is None:
323
+ return err(
324
+ "No 'model' given and no default is configured. Send a model, name a "
325
+ "provider, or set one with `synckey config set-default <model>`.",
326
+ "invalid_request_error",
327
+ 400,
328
+ )
329
+ if not resolution.providers:
330
+ return err(
331
+ f"Could not route '{model or resolution.bare_model}'. No provider advertises it. "
332
+ "Use a 'provider/model' prefix, name a provider, or run `synckey models --refresh`.",
333
+ "not_found_error",
334
+ 404,
335
+ )
336
+
337
+ client: httpx.AsyncClient = request.app.state.client
338
+ writer: UsageWriter = request.app.state.writer
339
+
340
+ result = await run_request(
341
+ ctx, client, writer, path, body, resolution, allow_stream,
342
+ defer_when_no_capacity=ctx.settings.deferred_enabled,
343
+ )
344
+ if result is not None:
345
+ return result
346
+
347
+ # No key has spare capacity and deferral is on: queue it, hand back a poll id.
348
+ return defer_request(ctx, path, body, model, provider_hint, floor, resolution)
349
+
350
+
351
+ async def run_request(
352
+ ctx: Context,
353
+ client: httpx.AsyncClient,
354
+ writer: UsageWriter,
355
+ path: str,
356
+ body: dict,
357
+ resolution: Resolution,
358
+ allow_stream: bool,
359
+ defer_when_no_capacity: bool = True,
360
+ ) -> Response | None:
361
+ """Run a resolved request through the key pool.
362
+
363
+ Returns a Response on success, upstream error, or exhaustion. Returns None
364
+ only when no key has spare capacity right now i.e. every usable key is
365
+ either provider-cooling (429'd) or locally bucket-throttled and
366
+ `defer_when_no_capacity` is set. That None is the caller's signal to queue
367
+ the request for later. When there are no usable keys at all (none configured
368
+ or all dead), a 503 Response is returned instead, since deferring could never
369
+ resolve.
370
+ """
371
+ body["model"] = resolution.bare_model
372
+ stream = bool(body.get("stream")) and allow_stream
373
+ if stream:
374
+ opts = body.get("stream_options") or {}
375
+ opts["include_usage"] = True
376
+ body["stream_options"] = opts
377
+
378
+ attempts = plan_attempts(ctx, resolution, ctx.settings.max_retries)
379
+
380
+ # Jump to same-tier alternatives when primary is gone or all keys deep-cooling.
381
+ if ctx.settings.tier_fallback_enabled:
382
+ threshold = ctx.settings.deep_cooling_threshold
383
+ if not attempts or all_deep_cooling(attempts, threshold):
384
+ alt_attempts = build_tier_alt_attempts(ctx, resolution, body)
385
+ if alt_attempts:
386
+ attempts = alt_attempts
387
+ # If no tier alts found, fall through to the cooling primaries (beats 503).
388
+
389
+ if not attempts:
390
+ return err(
391
+ "No available keys for this route: none configured, or all are dead. "
392
+ "Run `synckey key list` to check.",
393
+ "no_available_keys",
394
+ 503,
395
+ )
396
+ if defer_when_no_capacity and not any(a.candidate.ready for a in attempts):
397
+ # No key is ready: all are cooling (429'd) and/or locally bucket-throttled.
398
+ return None # caller defers rather than firing into a likely 429
399
+
400
+ last_err = "unknown"
401
+ last_status = 502
402
+ for attempt in attempts:
403
+ url = attempt.provider.base_url.rstrip("/") + path
404
+ headers = {
405
+ "Content-Type": "application/json",
406
+ **attempt.provider.auth_headers(attempt.secret),
407
+ }
408
+ params = attempt.provider.auth_params(attempt.secret)
409
+ ctx.pool.consume(attempt.key_id)
410
+ started = time.perf_counter()
411
+
412
+ if stream:
413
+ result = await try_stream(ctx, writer, client, url, headers, params, body, attempt, resolution, started)
414
+ if result is not None:
415
+ return result
416
+ continue
417
+
418
+ try:
419
+ resp = await client.post(url, json=body, headers=headers, params=params)
420
+ except httpx.HTTPError as exc:
421
+ ctx.pool.on_failure(attempt.key_id)
422
+ last_err, last_status = str(exc)[:200], 502
423
+ continue
424
+
425
+ latency = int((time.perf_counter() - started) * 1000)
426
+
427
+ if resp.status_code == 200:
428
+ ctx.pool.on_success(attempt.key_id)
429
+ tokens = extract_usage(resp.content)
430
+ writer.record(
431
+ provider=attempt.provider_id,
432
+ model=body["model"],
433
+ key_id=attempt.key_id,
434
+ prompt_tokens=tokens[0],
435
+ completion_tokens=tokens[1],
436
+ total_tokens=tokens[2],
437
+ cost_usd=cost_usd(body["model"], tokens[0], tokens[1]),
438
+ status_code=200,
439
+ latency_ms=latency,
440
+ tier=resolution.tier,
441
+ )
442
+ _record_routing_chain(ctx, resolution, attempt, body["model"])
443
+ return Response(content=resp.content, media_type="application/json")
444
+
445
+ retriable, last_err, last_status = classify_failure(
446
+ ctx, writer, attempt, resp, resolution, body["model"], latency
447
+ )
448
+ if not retriable:
449
+ return Response(content=resp.content, media_type="application/json", status_code=resp.status_code)
450
+
451
+ return err(
452
+ f"All {len(attempts)} attempts failed for '{resolution.bare_model}'. Last: {last_err}",
453
+ "upstream_exhausted",
454
+ last_status if last_status >= 400 else 502,
455
+ providers_tried=resolution.providers,
456
+ )
457
+
458
+
459
+ def defer_request(
460
+ ctx: Context,
461
+ path: str,
462
+ body: dict,
463
+ model: str | None,
464
+ provider_hint: str | None,
465
+ floor: int | None,
466
+ resolution: Resolution,
467
+ ) -> JSONResponse:
468
+ """Persist a request whose keys are all cooling and return a 202 + poll info."""
469
+ if ctx.db.count_deferred("queued") >= ctx.settings.deferred_max_queue:
470
+ return err(
471
+ "Deferred queue is full; try again shortly.", "queue_full", 503
472
+ )
473
+
474
+ body.pop("stream", None)
475
+ body.pop("stream_options", None)
476
+ rid = new_request_id()
477
+ eta_seconds = estimate_eta(ctx, resolution.providers)
478
+ ctx.db.create_deferred(
479
+ id=rid,
480
+ path=path,
481
+ body=json.dumps(body),
482
+ model=str(model) if model else resolution.bare_model,
483
+ provider_hint=provider_hint,
484
+ floor=floor,
485
+ eta=time.time() + eta_seconds,
486
+ )
487
+ retry_after = int(eta_seconds) + 1
488
+ ctx.db.record_event(
489
+ type="deferred",
490
+ model=resolution.bare_model,
491
+ message=f"All keys cooling; queued {rid}, ETA ~{retry_after}s",
492
+ )
493
+ return JSONResponse(
494
+ {
495
+ "id": rid,
496
+ "object": "deferred",
497
+ "status": "queued",
498
+ "model": resolution.bare_model,
499
+ "retry_after": retry_after,
500
+ "result_url": f"/v1/requests/{rid}",
501
+ "message": (
502
+ "All provider keys are rate-limited. Your request is queued; poll "
503
+ "result_url with your unified key after retry_after seconds."
504
+ ),
505
+ },
506
+ status_code=202,
507
+ headers={"Retry-After": str(retry_after)},
508
+ )
509
+
510
+
511
+ async def deferred_worker(app: FastAPI) -> None:
512
+ """Background loop: replay queued requests once a key frees, store the result."""
513
+ ctx: Context = app.state.ctx
514
+ s = ctx.settings
515
+ client: httpx.AsyncClient = app.state.client
516
+ writer: UsageWriter = app.state.writer
517
+ while True:
518
+ try:
519
+ await asyncio.sleep(s.deferred_poll)
520
+ ctx.db.purge_deferred(s.deferred_ttl, s.deferred_max_queue_age)
521
+ for row in ctx.db.queued_deferred(limit=20):
522
+ await _run_deferred_job(ctx, client, writer, row)
523
+ except asyncio.CancelledError:
524
+ break
525
+ except Exception:
526
+ continue
527
+
528
+
529
+ async def _run_deferred_job(ctx: Context, client, writer, row) -> None:
530
+ rid = row["id"]
531
+ try:
532
+ body = json.loads(row["body"])
533
+ except ValueError:
534
+ ctx.db.fail_deferred(rid, 400, "Stored body was not valid JSON.")
535
+ return
536
+ resolution = ctx.router.resolve_full(row["model"], row["provider_hint"], row["floor"])
537
+ if resolution is None or not resolution.providers:
538
+ ctx.db.fail_deferred(rid, 404, "Request is no longer routable.")
539
+ return
540
+
541
+ ctx.db.set_deferred_running(rid)
542
+ resp = await run_request(ctx, client, writer, row["path"], body, resolution, allow_stream=False)
543
+ if resp is None:
544
+ # Still no capacity; put it back with a fresh ETA for the next cycle.
545
+ ctx.db.requeue_deferred(rid, time.time() + estimate_eta(ctx, resolution.providers))
546
+ return
547
+ ctx.db.complete_deferred(rid, resp.status_code, bytes(resp.body))
548
+
549
+
550
+ def extract_usage(content: bytes) -> tuple[int, int, int]:
551
+ try:
552
+ u = orjson.loads(content).get("usage") or {}
553
+ return u.get("prompt_tokens", 0), u.get("completion_tokens", 0), u.get("total_tokens", 0)
554
+ except Exception:
555
+ return 0, 0, 0
556
+
557
+
558
+ def classify_failure(
559
+ ctx: Context,
560
+ writer: UsageWriter,
561
+ attempt: Attempt,
562
+ resp: httpx.Response,
563
+ resolution: Resolution,
564
+ model: str,
565
+ latency: int,
566
+ ) -> tuple[bool, str, int]:
567
+ text = resp.text[:300]
568
+ writer.record(
569
+ provider=attempt.provider_id,
570
+ model=model,
571
+ key_id=attempt.key_id,
572
+ status_code=resp.status_code,
573
+ latency_ms=latency,
574
+ tier=resolution.tier,
575
+ error=text,
576
+ )
577
+
578
+ if resp.status_code == 429:
579
+ retry_after = parse_retry_after(resp.headers.get("retry-after"))
580
+ rpm_at_429 = attempt.candidate.bucket.emission_rpm()
581
+ ctx.pool.on_rate_limit(attempt.key_id, retry_after, rpm_at_429)
582
+ ctx.db.record_event(
583
+ type="rate_limited",
584
+ key_id=attempt.key_id,
585
+ provider=attempt.provider_id,
586
+ model=model,
587
+ message=f"429 from {attempt.provider_id}; cooldown {retry_after or 'default'}s",
588
+ )
589
+ return True, text, 429
590
+
591
+ if resp.status_code in (401, 403):
592
+ reason = f"HTTP {resp.status_code} from {attempt.provider_id}: key likely expired or invalid"
593
+ ctx.pool.on_dead(attempt.key_id, reason)
594
+ ctx.db.record_event(
595
+ type="key_dead",
596
+ key_id=attempt.key_id,
597
+ provider=attempt.provider_id,
598
+ model=model,
599
+ message=reason,
600
+ )
601
+ return True, text, resp.status_code
602
+
603
+ if resp.status_code >= 500:
604
+ ctx.pool.on_failure(attempt.key_id)
605
+ return True, text, resp.status_code
606
+
607
+ # 4xx other than 401/403/429: the request itself is wrong; don't burn more keys.
608
+ return False, text, resp.status_code
609
+
610
+
611
+ async def try_stream(
612
+ ctx: Context,
613
+ writer: UsageWriter,
614
+ client: httpx.AsyncClient,
615
+ url: str,
616
+ headers: dict,
617
+ params: dict,
618
+ body: dict,
619
+ attempt: Attempt,
620
+ resolution: Resolution,
621
+ started: float,
622
+ ) -> StreamingResponse | None:
623
+ req = client.build_request("POST", url, json=body, headers=headers, params=params)
624
+ try:
625
+ resp = await client.send(req, stream=True)
626
+ except httpx.HTTPError:
627
+ ctx.pool.on_failure(attempt.key_id)
628
+ return None
629
+
630
+ if resp.status_code != 200:
631
+ latency = int((time.perf_counter() - started) * 1000)
632
+ await resp.aread()
633
+ classify_failure(ctx, writer, attempt, resp, resolution, body["model"], latency)
634
+ await resp.aclose()
635
+ return None
636
+
637
+ ctx.pool.on_success(attempt.key_id)
638
+ _record_routing_chain(ctx, resolution, attempt, body["model"])
639
+
640
+ async def stream_body() -> AsyncIterator[bytes]:
641
+ prompt = completion = total = 0
642
+ try:
643
+ async for line in resp.aiter_lines():
644
+ if line.startswith("data: "):
645
+ chunk = line[6:].strip()
646
+ if chunk and chunk != "[DONE]":
647
+ try:
648
+ u = orjson.loads(chunk).get("usage")
649
+ if isinstance(u, dict):
650
+ prompt = u.get("prompt_tokens", prompt)
651
+ completion = u.get("completion_tokens", completion)
652
+ total = u.get("total_tokens", total)
653
+ except Exception:
654
+ pass
655
+ yield (line + "\n").encode()
656
+ finally:
657
+ await resp.aclose()
658
+ writer.record(
659
+ provider=attempt.provider_id,
660
+ model=body["model"],
661
+ key_id=attempt.key_id,
662
+ prompt_tokens=prompt,
663
+ completion_tokens=completion,
664
+ total_tokens=total,
665
+ cost_usd=cost_usd(body["model"], prompt, completion),
666
+ status_code=200,
667
+ latency_ms=int((time.perf_counter() - started) * 1000),
668
+ stream=True,
669
+ tier=resolution.tier,
670
+ )
671
+
672
+ return StreamingResponse(stream_body(), media_type="text/event-stream")