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/__init__.py +3 -0
- synckey/__main__.py +4 -0
- synckey/bucket.py +140 -0
- synckey/cli.py +1481 -0
- synckey/config.py +150 -0
- synckey/context.py +75 -0
- synckey/crypto.py +41 -0
- synckey/db.py +426 -0
- synckey/deferred.py +41 -0
- synckey/keypool.py +139 -0
- synckey/prefs.py +82 -0
- synckey/providers.py +189 -0
- synckey/router.py +291 -0
- synckey/server.py +672 -0
- synckey/state.py +152 -0
- synckey/tiers.py +157 -0
- synckey/usage.py +99 -0
- synckey-0.1.0.dist-info/METADATA +799 -0
- synckey-0.1.0.dist-info/RECORD +21 -0
- synckey-0.1.0.dist-info/WHEEL +4 -0
- synckey-0.1.0.dist-info/entry_points.txt +2 -0
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")
|