@sakk-dev/sakk 0.0.0-stage → 0.4.0

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.
@@ -0,0 +1,823 @@
1
+ """Sakk spend gate for the LiteLLM proxy.
2
+
3
+ Every chat call through the proxy reserves its worst-case cost with Sakk
4
+ before the provider is called, and settles the real cost afterwards.
5
+ Anything the gate cannot price is refused, never let through.
6
+
7
+ When Sakk itself cannot answer (it will not start, crashed, hung past the
8
+ timeout, or replied garbage) the default is to let the call through,
9
+ unmetered and loudly: a WARNING log line, an `x-sakk-status: unmetered`
10
+ response header, and an outage record in the status file that the console
11
+ and `sakk status` show. Calls let through this way are not in the ledger
12
+ and not capped. SAKK_ON_UNAVAILABLE=deny refuses them with 503 instead.
13
+ A deliberate refusal from Sakk (over the cap, halted, needs
14
+ reconciliation) stays a refusal in both modes.
15
+
16
+ One LiteLLM worker process is assumed: the status file is guarded by an
17
+ in-process lock, so several workers would overwrite each other's counts.
18
+ Status writes take that lock and fsync on the event-loop thread, so a slow
19
+ disk stalls every call in this worker while an outage opens, counts or ends.
20
+
21
+ Load it from the proxy config, with this file next to config.yaml:
22
+
23
+ litellm_settings:
24
+ callbacks: sakk_gateway.handler
25
+
26
+ Required environment:
27
+ SAKK_BIN path to sakk's bin/sakk.mjs
28
+ SAKK_LEDGER absolute path of the ledger file
29
+ SAKK_GATEWAY_CONFIG the proxy's config.yaml (prices are read from it)
30
+ Optional:
31
+ SAKK_NODE node executable (default: node)
32
+ SAKK_DEFAULT_MAX_TOKENS max_tokens injected when a request has none (2048)
33
+ SAKK_TIMEOUT_S how long one ledger call may take (5)
34
+ SAKK_BREAKER_S after a reserve outage, how long reserves skip the ledger (10; 0 = never)
35
+ SAKK_RESERVATION_TTL_S age at which this gateway's unsettled reservations settle at their worst case (3600)
36
+ SAKK_ON_UNAVAILABLE allow (default) or deny, when Sakk cannot answer
37
+ SAKK_GATEWAY_STATUS status file (default: <SAKK_LEDGER>.gateway.json, folder links resolved)
38
+
39
+ A reservation whose settle never arrives (the proxy crashed, a settle failed
40
+ twice, the event never fired) would stay outstanding for ever: its worst case
41
+ counts against the cap either way, but it also holds one of the ledger's
42
+ outstanding slots (LEDGER_FULL) and keeps the books from sealing. Once it is
43
+ older than SAKK_RESERVATION_TTL_S, a background sweep settles it at the worst
44
+ case it reserved, with a WARNING `sakk_reservation_expired` line (F-242). Only
45
+ entries this gateway made (agent litellm-gateway) are swept. With several
46
+ gateways on one ledger, one may sweep another's long call; that call's own
47
+ settle then refuses with SETTLE_MISMATCH and an alert, and the books keep the
48
+ worst case: over-recorded, never under.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import asyncio
54
+ import errno
55
+ import json
56
+ import logging
57
+ import math
58
+ import os
59
+ import threading
60
+ import time
61
+ import uuid
62
+ from datetime import datetime, timezone
63
+ from decimal import ROUND_CEILING, Decimal
64
+ from typing import Any, Callable
65
+
66
+ import yaml
67
+ from fastapi import HTTPException
68
+ from litellm.integrations.custom_logger import CustomLogger
69
+
70
+ log = logging.getLogger("sakk_gateway")
71
+
72
+ AGENT = "litellm-gateway"
73
+ # The proxy's route type for /chat/completions. Every other route
74
+ # (embeddings, images, audio, responses, pass-through) is refused until it
75
+ # has its own cost model: unmetered spend is the failure Sakk exists to stop.
76
+ METERED_CALL_TYPES = frozenset({"acompletion", "completion"})
77
+ # Text sent for chat formatting, roles and tool framing on top of the raw
78
+ # message bytes; output margin for stop sequences and provider rounding.
79
+ INPUT_OVERHEAD_TOKENS = 512
80
+ OUTPUT_MARGIN_TOKENS = 64
81
+ # How long a proxy-side failure waits for the provider-call log event.
82
+ LATE_SETTLE_S = float(os.environ.get("SAKK_LATE_SETTLE_S", "2"))
83
+ # A ledger call that has not answered in this long is an outage. It must be
84
+ # longer than the ledger's own lock wait (about 2 s of spinning in
85
+ # src/ledger.mjs, then LEDGER_LOCK_UNAVAILABLE): at 2 s a busy but healthy
86
+ # ledger read as down (F-243). Queued calls do not each wait it out: the
87
+ # breaker below fails them at once.
88
+ DEFAULT_TIMEOUT_S = 5.0
89
+ # After a reserve outage, reserves skip the ledger this long and take the
90
+ # outage path at once (allow: unmetered and counted; deny: 503), instead of
91
+ # queueing behind one another for a timeout each.
92
+ DEFAULT_BREAKER_S = 10.0
93
+ # A gateway reservation still unsettled after this long settles at its
94
+ # reserved worst case (F-242). The sweep runs at most this often, and settles
95
+ # at most this many entries per run.
96
+ DEFAULT_RESERVATION_TTL_S = 3600.0
97
+ SWEEP_EVERY_S = 60.0
98
+ SWEEP_MAX = 50
99
+ OUTAGE_MODES = ("allow", "deny")
100
+ STATUS_VERSION = 1
101
+ REASON_MAX = 200
102
+ # Request fields that become prompt tokens.
103
+ PROMPT_FIELDS = ("messages", "tools", "functions", "tool_choice", "response_format", "system")
104
+
105
+
106
+ class SakkUnavailable(Exception):
107
+ """The ledger could not be reached or answered something unreadable."""
108
+
109
+
110
+ class _BreakerOpen(SakkUnavailable):
111
+ """A reserve not sent because the ledger failed one moments ago; it takes the outage path."""
112
+
113
+
114
+ class _NotSent(Exception):
115
+ """A request that never fully reached `sakk mcp`; the only failure that is sent again."""
116
+
117
+
118
+ class SakkRefused(Exception):
119
+ def __init__(self, code: str, message: str):
120
+ super().__init__(f"{code}: {message}")
121
+ self.code = code
122
+ self.message = message
123
+
124
+
125
+ def _required_env(name: str) -> str:
126
+ value = os.environ.get(name, "")
127
+ if not value:
128
+ raise RuntimeError(f"sakk_gateway: {name} is not set; the gateway refuses to start without it")
129
+ return value
130
+
131
+
132
+ def _outage_mode() -> str:
133
+ value = os.environ.get("SAKK_ON_UNAVAILABLE")
134
+ if value is None:
135
+ return "allow"
136
+ if value not in OUTAGE_MODES:
137
+ raise RuntimeError(
138
+ f"sakk_gateway: SAKK_ON_UNAVAILABLE must be allow or deny, not {value!r}; the gateway refuses to start"
139
+ )
140
+ return value
141
+
142
+
143
+ def _timeout_s() -> float:
144
+ raw = os.environ.get("SAKK_TIMEOUT_S")
145
+ if raw is None:
146
+ return DEFAULT_TIMEOUT_S
147
+ try:
148
+ value = float(raw)
149
+ except ValueError:
150
+ value = math.nan
151
+ # A zero timeout would turn every call into an outage, and in allow mode
152
+ # into an unmetered call.
153
+ if not math.isfinite(value) or value <= 0:
154
+ raise RuntimeError(
155
+ f"sakk_gateway: SAKK_TIMEOUT_S must be a positive number of seconds, not {raw!r}; "
156
+ "the gateway refuses to start"
157
+ )
158
+ return value
159
+
160
+
161
+ def _seconds_env(name: str, default: float, zero_ok: bool) -> float:
162
+ raw = os.environ.get(name)
163
+ if raw is None:
164
+ return default
165
+ try:
166
+ value = float(raw)
167
+ except ValueError:
168
+ value = math.nan
169
+ if not math.isfinite(value) or value < 0 or (value == 0 and not zero_ok):
170
+ need = "zero or a positive number" if zero_ok else "a positive number"
171
+ raise RuntimeError(f"sakk_gateway: {name} must be {need} of seconds, not {raw!r}; the gateway refuses to start")
172
+ return value
173
+
174
+
175
+ def _breaker_s() -> float:
176
+ # 0 turns the breaker off: every reserve tries the ledger and waits out the timeout.
177
+ return _seconds_env("SAKK_BREAKER_S", DEFAULT_BREAKER_S, zero_ok=True)
178
+
179
+
180
+ def _reservation_ttl_s() -> float:
181
+ # No "never": a reservation nobody settles stays outstanding for ever (F-242).
182
+ return _seconds_env("SAKK_RESERVATION_TTL_S", DEFAULT_RESERVATION_TTL_S, zero_ok=False)
183
+
184
+
185
+ def _expired_reservation(e: Any, cutoff: float) -> tuple[str, float] | None:
186
+ """(callId, worst case) for a reservation this gateway made before `cutoff`, else None.
187
+
188
+ The ledger file is read as data anyone could have written: an entry with a
189
+ field of the wrong type, or an unreadable time, is left alone.
190
+ """
191
+ if not isinstance(e, dict) or e.get("status") != "RESERVED" or e.get("agentId") != AGENT:
192
+ return None
193
+ cid, usd, at = e.get("callId"), e.get("estimatedMaxUsd"), e.get("createdAt")
194
+ if not isinstance(cid, str) or not cid or not isinstance(at, str):
195
+ return None
196
+ if not isinstance(usd, (int, float)) or isinstance(usd, bool) or not math.isfinite(usd) or usd < 0:
197
+ return None
198
+ try:
199
+ created = datetime.fromisoformat(at.replace("Z", "+00:00"))
200
+ except ValueError:
201
+ return None
202
+ if created.tzinfo is None or created.timestamp() > cutoff:
203
+ return None
204
+ return cid, float(usd)
205
+
206
+
207
+ def _read_json(path: str) -> Any:
208
+ with open(path, encoding="utf-8") as f:
209
+ return json.load(f)
210
+
211
+
212
+ def status_path_for(ledger: str) -> str:
213
+ """Where the gateway records outages; `sakk status` and the console derive the same path.
214
+
215
+ The ledger's folder with every link resolved, the ledger's file name, then
216
+ ".gateway.json": the same rule as gatewayStatusPathFor in
217
+ src/gateway-status.mjs, so a relative SAKK_LEDGER or one through a linked
218
+ folder still names the file the readers look at.
219
+ """
220
+ override = os.environ.get("SAKK_GATEWAY_STATUS")
221
+ if override:
222
+ return override
223
+ absolute = os.path.abspath(ledger)
224
+ folder = os.path.realpath(os.path.dirname(absolute))
225
+ return os.path.join(folder, os.path.basename(absolute)) + ".gateway.json"
226
+
227
+
228
+ def _now_iso() -> str:
229
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
230
+
231
+
232
+ def _valid_outage(o: Any) -> bool:
233
+ if not isinstance(o, dict) or not isinstance(o.get("since"), str):
234
+ return False
235
+ n, usd = o.get("allowed_calls"), o.get("est_max_unrecorded_usd")
236
+ return (
237
+ isinstance(n, int)
238
+ and not isinstance(n, bool)
239
+ and n >= 0
240
+ and isinstance(usd, (int, float))
241
+ and not isinstance(usd, bool)
242
+ and math.isfinite(usd)
243
+ and usd >= 0
244
+ )
245
+
246
+
247
+ class GatewayStatus:
248
+ """The gateway's own record of Sakk outages, kept in one small JSON file.
249
+
250
+ Written whole each time (temp file, fsync, rename, mode 0600). A write
251
+ that fails is logged and never fails the call it describes.
252
+ """
253
+
254
+ def __init__(self, path: str, mode: str):
255
+ self.path = path
256
+ self.mode = mode
257
+ self._lock = threading.Lock()
258
+ self.outage: dict[str, Any] | None = None
259
+ self.last_outage: dict[str, Any] | None = None
260
+ self._load()
261
+ with self._lock:
262
+ self._write_locked()
263
+
264
+ def _load(self) -> None:
265
+ try:
266
+ with open(self.path, encoding="utf-8") as f:
267
+ doc = json.load(f)
268
+ except FileNotFoundError:
269
+ return
270
+ except Exception as e:
271
+ log.error("sakk_gateway: status file %s is unreadable, starting a fresh one: %s", self.path, e)
272
+ return
273
+ if not isinstance(doc, dict) or doc.get("version") != STATUS_VERSION:
274
+ log.error("sakk_gateway: status file %s has an unknown shape, starting a fresh one", self.path)
275
+ return
276
+ # An outage still open when the gateway last stopped keeps counting.
277
+ if _valid_outage(doc.get("outage")):
278
+ self.outage = dict(doc["outage"])
279
+ if _valid_outage(doc.get("last_outage")):
280
+ self.last_outage = dict(doc["last_outage"])
281
+
282
+ def _write_locked(self) -> None:
283
+ doc = {
284
+ "version": STATUS_VERSION,
285
+ "updatedAt": _now_iso(),
286
+ "mode": self.mode,
287
+ "outage": self.outage,
288
+ "last_outage": self.last_outage,
289
+ }
290
+ tmp = f"{self.path}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp"
291
+ try:
292
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
293
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
294
+ json.dump(doc, f)
295
+ f.flush()
296
+ os.fsync(f.fileno())
297
+ os.replace(tmp, self.path)
298
+ except Exception as e:
299
+ log.error("sakk_gateway: could not write the status file %s: %s", self.path, e)
300
+ try:
301
+ os.unlink(tmp)
302
+ except OSError:
303
+ pass
304
+
305
+ def outage_seen(self, reason: str, worst: float, allowed: bool) -> dict[str, Any]:
306
+ with self._lock:
307
+ now = _now_iso()
308
+ if self.outage is None:
309
+ self.outage = {
310
+ "since": now,
311
+ "allowed_calls": 0,
312
+ "est_max_unrecorded_usd": 0.0,
313
+ "last_reason": "",
314
+ "last_at": now,
315
+ }
316
+ o = self.outage
317
+ if allowed:
318
+ o["allowed_calls"] += 1
319
+ o["est_max_unrecorded_usd"] = round(o["est_max_unrecorded_usd"] + worst, 6)
320
+ o["last_reason"] = reason[:REASON_MAX]
321
+ o["last_at"] = now
322
+ snap = dict(o)
323
+ self._write_locked()
324
+ return snap
325
+
326
+ def answered(self) -> dict[str, Any] | None:
327
+ """Sakk answered a reserve: close the open outage, if any."""
328
+ if self.outage is None:
329
+ return None
330
+ with self._lock:
331
+ if self.outage is None:
332
+ return None
333
+ ended = {**self.outage, "ended_at": _now_iso()}
334
+ self.last_outage = ended
335
+ self.outage = None
336
+ self._write_locked()
337
+ return ended
338
+
339
+
340
+ def _round_up_usd(x: float) -> float:
341
+ """Round up to a millionth of a dollar, never down, never zero."""
342
+ d = Decimal(repr(x)).quantize(Decimal("0.000001"), rounding=ROUND_CEILING)
343
+ return max(float(d), 0.000001)
344
+
345
+
346
+ def load_prices(config_path: str) -> dict[str, tuple[float, float]]:
347
+ """Per-token (input, output) prices by model name, from the proxy config.
348
+
349
+ A model with several deployments takes the highest price of each kind.
350
+ A model where any deployment lacks a price, or a wildcard model, gets no
351
+ entry, so calls to it are refused.
352
+ """
353
+ with open(config_path, encoding="utf-8") as f:
354
+ doc = yaml.safe_load(f) or {}
355
+ seen: dict[str, list[tuple[float, float] | None]] = {}
356
+ for dep in doc.get("model_list") or []:
357
+ name = str(dep.get("model_name", ""))
358
+ params = dep.get("litellm_params") or {}
359
+ p_in = params.get("input_cost_per_token")
360
+ p_out = params.get("output_cost_per_token")
361
+ ok = (
362
+ isinstance(p_in, (int, float))
363
+ and isinstance(p_out, (int, float))
364
+ and not isinstance(p_in, bool)
365
+ and not isinstance(p_out, bool)
366
+ and math.isfinite(p_in)
367
+ and math.isfinite(p_out)
368
+ and p_in > 0
369
+ and p_out > 0
370
+ )
371
+ seen.setdefault(name, []).append((float(p_in), float(p_out)) if ok else None)
372
+ prices: dict[str, tuple[float, float]] = {}
373
+ for name, deps in seen.items():
374
+ if not name or "*" in name or any(d is None for d in deps):
375
+ continue
376
+ prices[name] = (max(d[0] for d in deps), max(d[1] for d in deps))
377
+ return prices
378
+
379
+
380
+ class SakkClient:
381
+ """One long-lived `sakk mcp` process, one request at a time."""
382
+
383
+ def __init__(self, node: str, bin_path: str, ledger: str, timeout_s: float):
384
+ self._argv = [node, bin_path, "mcp", "--ledger", ledger]
385
+ self._timeout = timeout_s
386
+ self._proc: asyncio.subprocess.Process | None = None
387
+ self._lock = asyncio.Lock()
388
+ self._next_id = 0
389
+
390
+ def _kill(self) -> None:
391
+ if self._proc is not None and self._proc.returncode is None:
392
+ try:
393
+ self._proc.kill()
394
+ except ProcessLookupError:
395
+ pass
396
+ self._proc = None
397
+
398
+ async def _send(self, msg: dict[str, Any]) -> None:
399
+ assert self._proc is not None and self._proc.stdin is not None
400
+ self._proc.stdin.write((json.dumps(msg) + "\n").encode())
401
+ await self._proc.stdin.drain()
402
+
403
+ async def _rpc(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
404
+ self._next_id += 1
405
+ rid = self._next_id
406
+ try:
407
+ await self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
408
+ except OSError as e:
409
+ # The pipe was already closed, so the request's closing newline never
410
+ # reached Sakk and it cannot have acted on it.
411
+ if isinstance(e, (BrokenPipeError, ConnectionResetError)) or e.errno == errno.EPIPE:
412
+ raise _NotSent(f"{type(e).__name__}: {e}") from e
413
+ raise
414
+ assert self._proc is not None and self._proc.stdout is not None
415
+ while True:
416
+ line = await self._proc.stdout.readline()
417
+ if not line:
418
+ raise SakkUnavailable("sakk mcp closed its output")
419
+ msg = json.loads(line)
420
+ if msg.get("id") != rid:
421
+ continue # a notification, not our answer
422
+ if "error" in msg:
423
+ raise SakkUnavailable(f"sakk mcp protocol error: {msg['error']}")
424
+ return msg["result"]
425
+
426
+ async def _start(self) -> None:
427
+ self._proc = await asyncio.create_subprocess_exec(
428
+ *self._argv,
429
+ stdin=asyncio.subprocess.PIPE,
430
+ stdout=asyncio.subprocess.PIPE,
431
+ stderr=asyncio.subprocess.DEVNULL,
432
+ limit=2 * 1024 * 1024,
433
+ )
434
+ await self._rpc(
435
+ "initialize",
436
+ {
437
+ "protocolVersion": "2024-11-05",
438
+ "capabilities": {},
439
+ "clientInfo": {"name": "sakk-litellm-gateway", "version": "0.1.0"},
440
+ },
441
+ )
442
+ await self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
443
+
444
+ async def call(
445
+ self, tool: str, args: dict[str, Any], gate: Callable[[], None] | None = None
446
+ ) -> dict[str, Any]:
447
+ # gate raises to skip the ledger. It is asked again once the lock is
448
+ # held, so a call that queued behind a failing one does not then wait
449
+ # out its own timeout (F-243).
450
+ if gate is not None:
451
+ gate()
452
+ async with self._lock:
453
+ if gate is not None:
454
+ gate()
455
+ for _ in range(2): # the second pass is always a fresh process: _kill clears _proc
456
+ fresh = self._proc is None or self._proc.returncode is not None
457
+ try:
458
+ if fresh:
459
+ await asyncio.wait_for(self._start(), self._timeout)
460
+ result = await asyncio.wait_for(
461
+ self._rpc("tools/call", {"name": tool, "arguments": args}), self._timeout
462
+ )
463
+ body = json.loads(result["content"][0]["text"])
464
+ break
465
+ except Exception as e: # any doubt about the ledger is an outage
466
+ self._kill()
467
+ # Sent again only when the request never reached a process that
468
+ # was already running (it died since the last call). Anything
469
+ # else is an outage at once: a fresh process failing, a hang, or
470
+ # a reply lost after the request was written, because the same
471
+ # callId sent twice is refused as a duplicate when the first one
472
+ # landed (Stripe's rule for calls that are not idempotent).
473
+ if fresh or not isinstance(e, _NotSent):
474
+ raise SakkUnavailable(f"{tool}: {type(e).__name__}: {e}") from e
475
+ if result.get("isError"):
476
+ raise SakkRefused(str(body.get("code", "REFUSED")), str(body.get("message", "")))
477
+ return body
478
+
479
+ def close(self) -> None:
480
+ self._kill()
481
+
482
+
483
+ def worst_case_usd(data: dict[str, Any], price: tuple[float, float], max_out: int) -> float:
484
+ prompt = {k: data[k] for k in PROMPT_FIELDS if k in data}
485
+ in_bytes = len(json.dumps(prompt, ensure_ascii=False, default=str).encode("utf-8"))
486
+ n = data.get("n") or 1
487
+ if not isinstance(n, int) or isinstance(n, bool) or n < 1:
488
+ raise ValueError("n must be a positive integer")
489
+ cost = (in_bytes + INPUT_OVERHEAD_TOKENS) * price[0] + (max_out + OUTPUT_MARGIN_TOKENS) * n * price[1]
490
+ return _round_up_usd(cost)
491
+
492
+
493
+ def _non_text_part(messages: Any) -> bool:
494
+ """True when any message carries a part the byte count cannot price."""
495
+ if not isinstance(messages, list):
496
+ return True
497
+ for m in messages:
498
+ if not isinstance(m, dict):
499
+ return True
500
+ content = m.get("content")
501
+ if content is None or isinstance(content, str):
502
+ continue
503
+ if not isinstance(content, list):
504
+ return True
505
+ for part in content:
506
+ if not isinstance(part, dict) or part.get("type") != "text":
507
+ return True
508
+ return False
509
+
510
+
511
+ def _refuse(status: int, code: str, message: str, **extra: Any) -> HTTPException:
512
+ return HTTPException(status_code=status, detail={"error": {"code": code, "message": message, **extra}})
513
+
514
+
515
+ def _settled_cost(usage: Any, price: tuple[float, float]) -> float | None:
516
+ if not isinstance(usage, dict):
517
+ return None
518
+ p, c = usage.get("prompt_tokens"), usage.get("completion_tokens")
519
+ if not isinstance(p, int) or not isinstance(c, int) or p < 0 or c < 0:
520
+ return None
521
+ return p * price[0] + c * price[1]
522
+
523
+
524
+ def _provider_answered(exc: Any) -> bool:
525
+ """True only when the provider returned an HTTP error, so nothing was billed.
526
+
527
+ Timeouts and lost connections may have been billed: those settle the
528
+ reserved worst case instead.
529
+ """
530
+ if exc is None or isinstance(exc, HTTPException):
531
+ return False
532
+ name = type(exc).__name__
533
+ if name in {"Timeout", "APITimeoutError", "APIConnectionError", "ServiceUnavailableError"}:
534
+ return False
535
+ status = getattr(exc, "status_code", None)
536
+ return isinstance(status, int) and 400 <= status < 600 and status != 408
537
+
538
+
539
+ class SakkGate(CustomLogger):
540
+ def __init__(self) -> None:
541
+ super().__init__()
542
+ self._config_path = _required_env("SAKK_GATEWAY_CONFIG")
543
+ self.prices = load_prices(self._config_path)
544
+ log.warning("sakk_gateway: priced models: %s", ", ".join(sorted(self.prices)) or "(none)")
545
+ self.default_max_tokens = int(os.environ.get("SAKK_DEFAULT_MAX_TOKENS", "2048"))
546
+ self.on_unavailable = _outage_mode()
547
+ self.breaker_s = _breaker_s()
548
+ self.reservation_ttl_s = _reservation_ttl_s()
549
+ self.ledger = _required_env("SAKK_LEDGER")
550
+ self.client = SakkClient(
551
+ os.environ.get("SAKK_NODE", "node"),
552
+ _required_env("SAKK_BIN"),
553
+ self.ledger,
554
+ _timeout_s(),
555
+ )
556
+ self.status = GatewayStatus(status_path_for(self.ledger), self.on_unavailable)
557
+ log.warning(
558
+ "sakk_gateway: when Sakk cannot answer: %s (status file %s)", self.on_unavailable, self.status.path
559
+ )
560
+ # sakk callId -> what settling it needs. Popped exactly once.
561
+ self.pending: dict[str, dict[str, Any]] = {}
562
+ # callIds a settle is in flight for: the sweep leaves them alone.
563
+ self._settling: set[str] = set()
564
+ # event-loop time until which reserves take the outage path unsent
565
+ self._breaker_until = -math.inf
566
+ self._next_sweep = -math.inf
567
+ self._sweep_task: asyncio.Task | None = None
568
+ self._settled_since_read: set[str] | None = None
569
+
570
+ def _breaker_gate(self) -> None:
571
+ if asyncio.get_running_loop().time() < self._breaker_until:
572
+ raise _BreakerOpen(f"breaker open: a reserve failed within the last {self.breaker_s:g}s, not sent")
573
+
574
+ # ---- before the provider ------------------------------------------------
575
+
576
+ async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
577
+ md = data.get("metadata")
578
+ if not isinstance(md, dict):
579
+ md = {}
580
+ data["metadata"] = md
581
+ md.pop("sakk_call_id", None) # only this hook may set these
582
+ md.pop("sakk_unmetered", None)
583
+
584
+ if call_type not in METERED_CALL_TYPES:
585
+ raise _refuse(403, "SAKK_UNMETERED", f"route type {call_type!r} has no cost model in the Sakk gateway")
586
+ model = data.get("model")
587
+ price = self.prices.get(model) if isinstance(model, str) else None
588
+ if price is None:
589
+ raise _refuse(403, "SAKK_NO_PRICE", f"model {model!r} has no price in the gateway config")
590
+ if _non_text_part(data.get("messages")):
591
+ raise _refuse(403, "SAKK_UNMETERED", "only text message parts can be priced before the call")
592
+
593
+ caps = [data[k] for k in ("max_tokens", "max_completion_tokens") if data.get(k) is not None]
594
+ if any(not isinstance(v, int) or isinstance(v, bool) or v < 1 for v in caps):
595
+ raise _refuse(400, "SAKK_BAD_REQUEST", "max_tokens must be a positive integer")
596
+ if not caps:
597
+ data["max_tokens"] = self.default_max_tokens
598
+ caps = [self.default_max_tokens]
599
+ if data.get("stream"):
600
+ opts = data.get("stream_options")
601
+ data["stream_options"] = {**(opts if isinstance(opts, dict) else {}), "include_usage": True}
602
+
603
+ try:
604
+ worst = worst_case_usd(data, price, max(caps))
605
+ except ValueError as e:
606
+ raise _refuse(400, "SAKK_BAD_REQUEST", str(e))
607
+
608
+ self._maybe_sweep()
609
+ cid = "gw-" + uuid.uuid4().hex
610
+ try:
611
+ await self.client.call(
612
+ "sakk_reserve",
613
+ {"callId": cid, "estimatedMaxUsd": worst, "agent": AGENT, "action": f"chat:{model}"},
614
+ gate=self._breaker_gate,
615
+ )
616
+ except SakkRefused as e:
617
+ self._sakk_answered() # a refusal is an answer: Sakk is back
618
+ raise _refuse(402, e.code, e.message, estimatedMaxUsd=worst)
619
+ except SakkUnavailable as e:
620
+ if not isinstance(e, _BreakerOpen) and self.breaker_s > 0:
621
+ self._breaker_until = asyncio.get_running_loop().time() + self.breaker_s
622
+ reason = str(e)[:REASON_MAX]
623
+ if self.on_unavailable == "deny":
624
+ self.status.outage_seen(reason, worst, allowed=False)
625
+ log.error("sakk reserve failed: %s", reason)
626
+ raise _refuse(503, "SAKK_UNAVAILABLE", "the spend ledger is unreachable; no call was made")
627
+ snap = self.status.outage_seen(reason, worst, allowed=True)
628
+ log.warning(
629
+ "%s",
630
+ json.dumps(
631
+ {
632
+ "event": "sakk_unavailable_allowed",
633
+ "model": model,
634
+ "est_max_usd": worst,
635
+ "reason": reason,
636
+ "outage_since": snap["since"],
637
+ "allowed_calls": snap["allowed_calls"],
638
+ }
639
+ ),
640
+ )
641
+ # No call id and no pending entry: the settle hooks skip this call.
642
+ md["sakk_unmetered"] = True
643
+ return data
644
+ self._sakk_answered()
645
+ self.pending[cid] = {"price": price, "worst": worst, "model": model}
646
+ md["sakk_call_id"] = cid
647
+ return data
648
+
649
+ def _sakk_answered(self) -> None:
650
+ """Sakk answered a reserve, with a reservation or a refusal: end an open outage."""
651
+ self._breaker_until = -math.inf
652
+ ended = self.status.answered()
653
+ if ended is not None:
654
+ log.warning("%s", json.dumps({"event": "sakk_outage_ended", **ended}))
655
+
656
+ # ---- reservations nobody settled (F-242) -------------------------------
657
+
658
+ def _maybe_sweep(self) -> None:
659
+ """Start a sweep in the background if one is due and none is running."""
660
+ if self._sweep_task is not None and not self._sweep_task.done():
661
+ return
662
+ loop = asyncio.get_running_loop()
663
+ if loop.time() < self._next_sweep:
664
+ return
665
+ self._next_sweep = loop.time() + SWEEP_EVERY_S
666
+ self._sweep_task = loop.create_task(self._sweep_expired())
667
+
668
+ async def _sweep_expired(self) -> int:
669
+ """Settle this gateway's reservations older than the TTL at their worst case.
670
+
671
+ Returns how many settles it sent. At most SWEEP_MAX per run; the rest
672
+ wait for the next run. Settles bypass the breaker, like every settle.
673
+ """
674
+ # settles this process finishes after the read below: the file read
675
+ # still shows them RESERVED, and a second settle would only refuse
676
+ self._settled_since_read = set()
677
+ try:
678
+ return await self._sweep_run()
679
+ finally:
680
+ self._settled_since_read = None
681
+
682
+ async def _sweep_run(self) -> int:
683
+ try:
684
+ doc = await asyncio.to_thread(_read_json, self.ledger)
685
+ entries = doc["entries"]
686
+ if not isinstance(entries, list):
687
+ raise ValueError("entries is not a list")
688
+ except Exception as e:
689
+ log.error("sakk_gateway: could not read the ledger to expire old reservations: %s", e)
690
+ return 0
691
+ cutoff = time.time() - self.reservation_ttl_s
692
+ sent = 0
693
+ for e in entries:
694
+ if sent >= SWEEP_MAX:
695
+ break
696
+ hit = _expired_reservation(e, cutoff)
697
+ if hit is None or hit[0] in self._settling or hit[0] in (self._settled_since_read or ()):
698
+ continue
699
+ cid, usd = hit
700
+ # one of ours still waiting for its provider: this settle replaces its own
701
+ self.pending.pop(cid, None)
702
+ sent += 1
703
+ try:
704
+ await self.client.call("sakk_settle", {"callId": cid, "actualUsd": usd})
705
+ except SakkRefused as err:
706
+ log.error("sakk refused expiring %s: %s", cid, err)
707
+ continue
708
+ except SakkUnavailable as err:
709
+ log.error("sakk settle failed expiring %s, the sweep stops until the next run: %s", cid, err)
710
+ return sent
711
+ log.warning(
712
+ "%s",
713
+ json.dumps(
714
+ {
715
+ "event": "sakk_reservation_expired",
716
+ "callId": cid,
717
+ "settled_usd": usd,
718
+ "created_at": e.get("createdAt"),
719
+ "ttl_s": self.reservation_ttl_s,
720
+ }
721
+ ),
722
+ )
723
+ return sent
724
+
725
+ # ---- after the provider -------------------------------------------------
726
+
727
+ async def async_post_call_response_headers_hook(
728
+ self, data, user_api_key_dict, response, request_headers=None, litellm_call_info=None
729
+ ):
730
+ md = data.get("metadata") if isinstance(data, dict) else None
731
+ if isinstance(md, dict) and md.get("sakk_unmetered") is True:
732
+ return {"x-sakk-status": "unmetered"}
733
+ return None
734
+
735
+ async def _settle(self, cid: str | None, actual: float, why: str) -> None:
736
+ if not cid:
737
+ return
738
+ rec = self.pending.pop(cid, None)
739
+ if rec is None:
740
+ return # already settled, or not ours
741
+ self._settling.add(cid)
742
+ try:
743
+ last: Exception | None = None
744
+ for _ in range(2): # settle is idempotent per callId, so one retry is safe
745
+ try:
746
+ # no gate: a settle releases cap, so it is sent even while reserves are not
747
+ await self.client.call("sakk_settle", {"callId": cid, "actualUsd": actual})
748
+ log.info("sakk settled %s %s $%.6f (%s)", cid, rec["model"], actual, why)
749
+ return
750
+ except SakkRefused as e:
751
+ log.error("sakk refused settling %s: %s", cid, e)
752
+ return
753
+ except SakkUnavailable as e:
754
+ last = e
755
+ log.error(
756
+ "sakk settle failed for %s, the reservation stays held until it expires (%gs): %s",
757
+ cid,
758
+ self.reservation_ttl_s,
759
+ last,
760
+ )
761
+ finally:
762
+ self._settling.discard(cid)
763
+ if self._settled_since_read is not None:
764
+ self._settled_since_read.add(cid)
765
+
766
+ @staticmethod
767
+ def _cid_from_kwargs(kwargs: dict[str, Any]) -> str | None:
768
+ for md in (
769
+ (kwargs.get("litellm_params") or {}).get("metadata"),
770
+ kwargs.get("metadata"),
771
+ ):
772
+ if isinstance(md, dict) and isinstance(md.get("sakk_call_id"), str):
773
+ return md["sakk_call_id"]
774
+ return None
775
+
776
+ async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
777
+ cid = self._cid_from_kwargs(kwargs)
778
+ rec = self.pending.get(cid) if cid else None
779
+ if rec is None:
780
+ return
781
+ slo = kwargs.get("standard_logging_object") or {}
782
+ usage = None
783
+ if isinstance(slo, dict) and isinstance(slo.get("prompt_tokens"), int):
784
+ usage = {"prompt_tokens": slo.get("prompt_tokens"), "completion_tokens": slo.get("completion_tokens")}
785
+ if usage is None:
786
+ raw = getattr(response_obj, "usage", None)
787
+ usage = raw.model_dump() if hasattr(raw, "model_dump") else raw
788
+ cost = _settled_cost(usage, rec["price"])
789
+ if cost is None:
790
+ await self._settle(cid, rec["worst"], "no usage reported, settled the worst case")
791
+ else:
792
+ # Never clamped: a cost above the reservation must halt the ledger.
793
+ await self._settle(cid, cost, "usage")
794
+
795
+ async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
796
+ cid = self._cid_from_kwargs(kwargs)
797
+ rec = self.pending.get(cid) if cid else None
798
+ if rec is None:
799
+ return
800
+ exc = kwargs.get("exception")
801
+ if _provider_answered(exc) and not kwargs.get("stream"):
802
+ await self._settle(cid, 0.0, f"provider error {getattr(exc, 'status_code', '?')}")
803
+ else:
804
+ await self._settle(cid, rec["worst"], f"unknown outcome ({type(exc).__name__})")
805
+
806
+ async def async_post_call_failure_hook(self, request_data, original_exception, user_api_key_dict, traceback_str=None):
807
+ # The proxy can fail a request after the provider billed it (a later
808
+ # hook raising, for one), so this path never settles below the worst
809
+ # case. It only catches reservations the provider-call log events
810
+ # missed, after giving them time to land.
811
+ md = request_data.get("metadata") if isinstance(request_data, dict) else None
812
+ cid = md.get("sakk_call_id") if isinstance(md, dict) else None
813
+ if cid and cid in self.pending:
814
+ asyncio.get_running_loop().create_task(self._late_settle(cid, type(original_exception).__name__))
815
+
816
+ async def _late_settle(self, cid: str, why: str) -> None:
817
+ await asyncio.sleep(LATE_SETTLE_S)
818
+ rec = self.pending.get(cid)
819
+ if rec is not None:
820
+ await self._settle(cid, rec["worst"], f"request failed in the proxy ({why})")
821
+
822
+
823
+ handler = SakkGate()