@sakk-dev/sakk 0.0.0 → 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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +27 -0
- package/CHANGELOG.md +1421 -0
- package/LICENSE +202 -0
- package/NOTICE +82 -0
- package/README.md +617 -10
- package/README.zh-CN.md +555 -0
- package/bin/sakk.mjs +1460 -0
- package/integrations/litellm/LICENSE +202 -0
- package/integrations/litellm/NOTICE +82 -0
- package/integrations/litellm/README.md +366 -0
- package/integrations/litellm/config.example.yaml +52 -0
- package/integrations/litellm/sakk_gateway.py +823 -0
- package/package.json +79 -5
- package/policies/guard-default.json +161 -0
- package/policies/permissive-dev.json +22 -0
- package/prices/snapshot.json +1404 -0
- package/src/append.mjs +81 -0
- package/src/archive.mjs +489 -0
- package/src/classify.mjs +914 -0
- package/src/console-login.mjs +374 -0
- package/src/console.mjs +1628 -0
- package/src/gateway-status.mjs +203 -0
- package/src/guard-install.mjs +387 -0
- package/src/hook.mjs +730 -0
- package/src/ledger.mjs +1229 -0
- package/src/match.mjs +52 -0
- package/src/mcp.mjs +299 -0
- package/src/policy.mjs +423 -0
- package/src/prices.mjs +152 -0
- package/src/secret-rules.generated.mjs +225 -0
- package/src/secret-scan.mjs +489 -0
- package/src/spend.mjs +443 -0
|
@@ -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()
|