taskiq-llm-queue 0.1.0a1__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.
- taskiq_llm_queue/__init__.py +138 -0
- taskiq_llm_queue/_logging.py +40 -0
- taskiq_llm_queue/_version.py +5 -0
- taskiq_llm_queue/broker/__init__.py +86 -0
- taskiq_llm_queue/broker/acker.py +239 -0
- taskiq_llm_queue/broker/batching.py +188 -0
- taskiq_llm_queue/broker/broker.py +937 -0
- taskiq_llm_queue/broker/delivery.py +50 -0
- taskiq_llm_queue/broker/errors.py +178 -0
- taskiq_llm_queue/broker/events.py +51 -0
- taskiq_llm_queue/broker/filters.py +32 -0
- taskiq_llm_queue/broker/heartbeat.py +157 -0
- taskiq_llm_queue/broker/queue.py +141 -0
- taskiq_llm_queue/broker/s3_offload.py +346 -0
- taskiq_llm_queue/broker/scheduler.py +113 -0
- taskiq_llm_queue/broker/serializer.py +32 -0
- taskiq_llm_queue/cli.py +488 -0
- taskiq_llm_queue/client.py +1409 -0
- taskiq_llm_queue/config.py +592 -0
- taskiq_llm_queue/context.py +62 -0
- taskiq_llm_queue/core.py +832 -0
- taskiq_llm_queue/envelope.py +136 -0
- taskiq_llm_queue/errors.py +71 -0
- taskiq_llm_queue/failover.py +286 -0
- taskiq_llm_queue/http_transport.py +220 -0
- taskiq_llm_queue/keys.py +87 -0
- taskiq_llm_queue/langchain/__init__.py +64 -0
- taskiq_llm_queue/langchain/_client.py +142 -0
- taskiq_llm_queue/langchain/bedrock.py +291 -0
- taskiq_llm_queue/langchain/openai.py +103 -0
- taskiq_llm_queue/lifecycle.py +512 -0
- taskiq_llm_queue/metrics/__init__.py +45 -0
- taskiq_llm_queue/metrics/emf.py +100 -0
- taskiq_llm_queue/metrics/prometheus.py +66 -0
- taskiq_llm_queue/metrics_names.py +19 -0
- taskiq_llm_queue/protocols.py +140 -0
- taskiq_llm_queue/providers/__init__.py +62 -0
- taskiq_llm_queue/providers/bedrock.py +188 -0
- taskiq_llm_queue/providers/classify.py +251 -0
- taskiq_llm_queue/providers/embeddings.py +87 -0
- taskiq_llm_queue/providers/hosts.py +34 -0
- taskiq_llm_queue/providers/openai_compat.py +142 -0
- taskiq_llm_queue/providers/registry.py +173 -0
- taskiq_llm_queue/py.typed +0 -0
- taskiq_llm_queue/reliability/__init__.py +59 -0
- taskiq_llm_queue/reliability/_lua.py +461 -0
- taskiq_llm_queue/reliability/breaker.py +86 -0
- taskiq_llm_queue/reliability/claims.py +226 -0
- taskiq_llm_queue/reliability/config_store.py +148 -0
- taskiq_llm_queue/reliability/fallback.py +111 -0
- taskiq_llm_queue/reliability/limiter.py +482 -0
- taskiq_llm_queue/reliability/local_limiter.py +153 -0
- taskiq_llm_queue/results/__init__.py +25 -0
- taskiq_llm_queue/results/backend.py +226 -0
- taskiq_llm_queue/results/dispatcher.py +366 -0
- taskiq_llm_queue/results/s3_backend.py +105 -0
- taskiq_llm_queue/routing.py +173 -0
- taskiq_llm_queue/routing_hooks.py +90 -0
- taskiq_llm_queue/tasks.py +453 -0
- taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
- taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
- taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
- taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
- taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- taskiq_llm_queue-0.1.0a1.dist-info/licenses/NOTICE +15 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Taskiq result backend on S3: JSON results at `s3://bucket/<prefix>/<task_id>.json` (P10).
|
|
2
|
+
|
|
3
|
+
For results that are large or must outlive Redis. S3 has no per-object TTL, so expiry is an
|
|
4
|
+
S3 lifecycle rule on `prefix` (e.g. expire after 1 day), set up outside this library.
|
|
5
|
+
Adapted from taskiq-sqs `S3ResultBackend` (MIT, see NOTICE). Results are JSON, never pickle.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from botocore.exceptions import ClientError
|
|
13
|
+
from taskiq import AsyncResultBackend, TaskiqResult
|
|
14
|
+
from taskiq.exceptions import ResultGetError
|
|
15
|
+
|
|
16
|
+
from taskiq_llm_queue.broker.s3_offload import S3Connection, is_missing, object_key
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from aiobotocore.config import AioConfig
|
|
20
|
+
from aiobotocore.session import AioSession
|
|
21
|
+
from types_aiobotocore_s3 import S3Client
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class S3ResultBackend(AsyncResultBackend[Any]):
|
|
25
|
+
"""Stores each `TaskiqResult` as one JSON object; `get_result` may delete it after reading.
|
|
26
|
+
|
|
27
|
+
Several brokers may share one instance: `startup`/`shutdown` are refcounted and an injected
|
|
28
|
+
`client` is never closed. Expire old results with an S3 lifecycle rule on `prefix`; with
|
|
29
|
+
`delete_on_read=False` nothing here ever deletes them.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
bucket: str,
|
|
35
|
+
*,
|
|
36
|
+
prefix: str = "taskiq-results",
|
|
37
|
+
delete_on_read: bool = False,
|
|
38
|
+
declare: bool = False,
|
|
39
|
+
client: S3Client | None = None,
|
|
40
|
+
session: AioSession | None = None,
|
|
41
|
+
endpoint_url: str | None = None,
|
|
42
|
+
region_name: str | None = None,
|
|
43
|
+
aws_access_key_id: str | None = None,
|
|
44
|
+
aws_secret_access_key: str | None = None,
|
|
45
|
+
aws_session_token: str | None = None,
|
|
46
|
+
client_config: AioConfig | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Keep settings only; the S3 client is created in `startup` (no I/O here)."""
|
|
49
|
+
self.prefix = prefix
|
|
50
|
+
self.delete_on_read = delete_on_read
|
|
51
|
+
self.s3 = S3Connection(
|
|
52
|
+
bucket,
|
|
53
|
+
client=client,
|
|
54
|
+
session=session,
|
|
55
|
+
endpoint_url=endpoint_url,
|
|
56
|
+
region_name=region_name,
|
|
57
|
+
aws_access_key_id=aws_access_key_id,
|
|
58
|
+
aws_secret_access_key=aws_secret_access_key,
|
|
59
|
+
aws_session_token=aws_session_token,
|
|
60
|
+
client_config=client_config,
|
|
61
|
+
declare=declare,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def startup(self) -> None:
|
|
65
|
+
"""Create the S3 client and check (or declare) the bucket."""
|
|
66
|
+
await self.s3.startup()
|
|
67
|
+
|
|
68
|
+
async def shutdown(self) -> None:
|
|
69
|
+
"""Close the owned S3 client once its last user shuts down."""
|
|
70
|
+
await self.s3.shutdown()
|
|
71
|
+
|
|
72
|
+
async def set_result(self, task_id: str, result: TaskiqResult[Any]) -> None:
|
|
73
|
+
"""Write the result as JSON (overwrites an earlier result of the same task)."""
|
|
74
|
+
await self.s3.client.put_object(
|
|
75
|
+
Bucket=self.s3.bucket,
|
|
76
|
+
Key=object_key(self.prefix, task_id),
|
|
77
|
+
Body=result.model_dump_json().encode("utf-8"),
|
|
78
|
+
ContentType="application/json",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def is_result_ready(self, task_id: str) -> bool:
|
|
82
|
+
"""True when a result object exists for `task_id` (one HEAD request)."""
|
|
83
|
+
try:
|
|
84
|
+
await self.s3.client.head_object(
|
|
85
|
+
Bucket=self.s3.bucket, Key=object_key(self.prefix, task_id)
|
|
86
|
+
)
|
|
87
|
+
except ClientError as exc:
|
|
88
|
+
if is_missing(exc):
|
|
89
|
+
return False
|
|
90
|
+
raise
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
async def get_result(self, task_id: str, with_logs: bool = False) -> TaskiqResult[Any]:
|
|
94
|
+
"""Read one result (then delete it when `delete_on_read`); `ResultGetError` if absent.
|
|
95
|
+
|
|
96
|
+
Read-then-delete is two requests, so two concurrent readers may both get the result.
|
|
97
|
+
"""
|
|
98
|
+
key = object_key(self.prefix, task_id)
|
|
99
|
+
raw = await self.s3.get_bytes(key)
|
|
100
|
+
if raw is None:
|
|
101
|
+
raise ResultGetError
|
|
102
|
+
result = TaskiqResult[Any].model_validate_json(raw)
|
|
103
|
+
if self.delete_on_read:
|
|
104
|
+
await self.s3.client.delete_object(Bucket=self.s3.bucket, Key=key)
|
|
105
|
+
return result
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Canary router: picks `sqs` or `direct` per call from live, audited routing config (§15.1).
|
|
2
|
+
|
|
3
|
+
`RoutingConfig` lives as JSON at `KeySpace.routing` and is reloaded every 30 s, so an operator
|
|
4
|
+
can promote, roll back or kill the queue path with `llm-queue routing ...` and no restart.
|
|
5
|
+
`LLMQueue` calls `CanaryRouter.route` on every request; it never does I/O.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import random
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import TYPE_CHECKING, Annotated, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
from taskiq_llm_queue._logging import get_logger
|
|
19
|
+
from taskiq_llm_queue.config import DEFAULT_ID_HASH_SALT, Lane
|
|
20
|
+
from taskiq_llm_queue.envelope import keyed_digest
|
|
21
|
+
from taskiq_llm_queue.metrics_names import CONFIG_INVALID_TOTAL
|
|
22
|
+
from taskiq_llm_queue.reliability.config_store import LiveConfigStore
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
import redis.asyncio
|
|
26
|
+
|
|
27
|
+
from taskiq_llm_queue.keys import KeySpace
|
|
28
|
+
from taskiq_llm_queue.metrics import MetricsSink
|
|
29
|
+
|
|
30
|
+
logger = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
RoutePath = Literal["sqs", "direct"]
|
|
33
|
+
Pct = Annotated[int, Field(ge=0, le=100)]
|
|
34
|
+
DEFAULT_RELOAD_SECS = 30.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RoutingConfig(BaseModel):
|
|
38
|
+
"""Live canary config: kill switch, sqs percentage per model alias and lane, allowlist.
|
|
39
|
+
|
|
40
|
+
A model or lane that is not listed routes `direct` (0 %), so the empty default sends
|
|
41
|
+
everything direct. `allow_users` always go `sqs` unless the kill switch is on.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
45
|
+
|
|
46
|
+
kill_switch: bool = False
|
|
47
|
+
models: dict[str, dict[Lane, Pct]] = Field(default_factory=dict)
|
|
48
|
+
allow_users: frozenset[str] = frozenset()
|
|
49
|
+
|
|
50
|
+
def pct(self, model: str, lane: Lane) -> int:
|
|
51
|
+
"""Share of calls (0-100) for `model` (alias or `alias@target`) and lane sent to sqs."""
|
|
52
|
+
return self.models.get(model.partition("@")[0], {}).get(lane, 0)
|
|
53
|
+
|
|
54
|
+
def with_pct(self, model: str, lane: Lane, pct: int) -> RoutingConfig:
|
|
55
|
+
"""Copy with one model x lane percentage changed (promote / roll back)."""
|
|
56
|
+
models = {alias: dict(lanes) for alias, lanes in self.models.items()}
|
|
57
|
+
models.setdefault(model.partition("@")[0], {})[lane] = pct
|
|
58
|
+
return RoutingConfig.model_validate({**self.model_dump(), "models": models})
|
|
59
|
+
|
|
60
|
+
def with_kill(self, on: bool) -> RoutingConfig:
|
|
61
|
+
"""Copy with the kill switch set (`on` routes every call `direct`)."""
|
|
62
|
+
return self.model_copy(update={"kill_switch": on})
|
|
63
|
+
|
|
64
|
+
def with_user(self, user_id: str, *, allowed: bool) -> RoutingConfig:
|
|
65
|
+
"""Copy with `user_id` added to or removed from the allowlist."""
|
|
66
|
+
users = self.allow_users | {user_id} if allowed else self.allow_users - {user_id}
|
|
67
|
+
return self.model_copy(update={"allow_users": users})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bucket(key: str, salt: str | bytes = DEFAULT_ID_HASH_SALT) -> int:
|
|
71
|
+
"""Stable bucket 0-99 for a session or user id; equal on every pod sharing `salt`."""
|
|
72
|
+
return int.from_bytes(keyed_digest(key, salt), "big") % 100
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def routing_store(
|
|
76
|
+
client: redis.asyncio.Redis, keys: KeySpace, default: RoutingConfig | None = None
|
|
77
|
+
) -> LiveConfigStore[RoutingConfig]:
|
|
78
|
+
"""Live store for routing at `KeySpace.routing`, audited to `KeySpace.audit`."""
|
|
79
|
+
return LiveConfigStore(
|
|
80
|
+
client, keys.routing, RoutingConfig, default or RoutingConfig(), audit_key=keys.audit
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CanaryRouter:
|
|
85
|
+
"""`PathRouter` over a `LiveConfigStore[RoutingConfig]`, sticky per session (fallback user).
|
|
86
|
+
|
|
87
|
+
`route` reads the last good config only. `start` runs the reload loop as a task; an invalid
|
|
88
|
+
stored config keeps the last good one and emits `routing_config_invalid` (log + metric).
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
store: LiveConfigStore[RoutingConfig],
|
|
94
|
+
*,
|
|
95
|
+
metrics: MetricsSink | None = None,
|
|
96
|
+
reload_secs: float = DEFAULT_RELOAD_SECS,
|
|
97
|
+
rand: Callable[[], float] = random.random,
|
|
98
|
+
salt: str | bytes = DEFAULT_ID_HASH_SALT,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Keep the store and metrics sink; `rand` buckets calls that carry no id at all.
|
|
101
|
+
|
|
102
|
+
`salt` keys the sticky bucket hash (`QueueConfig.id_hash_salt`); all pods must share it.
|
|
103
|
+
"""
|
|
104
|
+
self.store = store
|
|
105
|
+
self._salt = salt
|
|
106
|
+
self._metrics = metrics
|
|
107
|
+
self._reload_secs = reload_secs
|
|
108
|
+
self._rand = rand
|
|
109
|
+
self._seen_invalid = store.invalid_count
|
|
110
|
+
self._task: asyncio.Task[None] | None = None
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def config(self) -> RoutingConfig:
|
|
114
|
+
"""The config `route` uses now (last good)."""
|
|
115
|
+
return self.store.current
|
|
116
|
+
|
|
117
|
+
def route(
|
|
118
|
+
self, model: str, lane: Lane, session_id: str | None, user_id: str | None
|
|
119
|
+
) -> RoutePath:
|
|
120
|
+
"""Decide the path of one call: kill switch, then allowlist, then sticky percentage."""
|
|
121
|
+
cfg = self.store.current
|
|
122
|
+
if cfg.kill_switch:
|
|
123
|
+
return "direct"
|
|
124
|
+
if user_id is not None and user_id in cfg.allow_users:
|
|
125
|
+
return "sqs"
|
|
126
|
+
pct = cfg.pct(model, lane)
|
|
127
|
+
if pct <= 0:
|
|
128
|
+
return "direct"
|
|
129
|
+
if pct >= 100:
|
|
130
|
+
return "sqs"
|
|
131
|
+
key = session_id or user_id
|
|
132
|
+
# No id at all: not sticky, but still honours the percentage on average.
|
|
133
|
+
point = bucket(key, self._salt) if key else int(self._rand() * 100)
|
|
134
|
+
return "sqs" if point < pct else "direct"
|
|
135
|
+
|
|
136
|
+
async def reload(self) -> RoutingConfig:
|
|
137
|
+
"""Load the stored config once; report `routing_config_invalid` when it was rejected."""
|
|
138
|
+
cfg = await self.store.load()
|
|
139
|
+
invalid = self.store.invalid_count
|
|
140
|
+
if invalid > self._seen_invalid:
|
|
141
|
+
# The store warns once per bad value; this event is what the alarm (§13) keys on.
|
|
142
|
+
logger.error("routing_config_invalid", key=self.store.key, invalid_count=invalid)
|
|
143
|
+
if self._metrics is not None:
|
|
144
|
+
self._metrics.incr(
|
|
145
|
+
CONFIG_INVALID_TOTAL, invalid - self._seen_invalid, {"kind": "routing"}
|
|
146
|
+
)
|
|
147
|
+
self._seen_invalid = invalid
|
|
148
|
+
return cfg
|
|
149
|
+
|
|
150
|
+
async def run(self) -> None:
|
|
151
|
+
"""Reload every `reload_secs` until cancelled; a failed reload never ends the loop."""
|
|
152
|
+
while True:
|
|
153
|
+
try:
|
|
154
|
+
await self.reload()
|
|
155
|
+
except Exception: # the loop must survive anything; route() keeps last good
|
|
156
|
+
logger.exception("routing_reload_failed")
|
|
157
|
+
await asyncio.sleep(self._reload_secs)
|
|
158
|
+
|
|
159
|
+
def start(self) -> asyncio.Task[None]:
|
|
160
|
+
"""Start the reload loop on the running event loop (idempotent)."""
|
|
161
|
+
if self._task is None or self._task.done():
|
|
162
|
+
self._task = asyncio.get_running_loop().create_task(
|
|
163
|
+
self.run(), name="llmq-routing-reload"
|
|
164
|
+
)
|
|
165
|
+
return self._task
|
|
166
|
+
|
|
167
|
+
async def aclose(self) -> None:
|
|
168
|
+
"""Stop the reload loop; safe to call twice."""
|
|
169
|
+
task, self._task = self._task, None
|
|
170
|
+
if task is not None:
|
|
171
|
+
task.cancel()
|
|
172
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
173
|
+
await task
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Extension points of `LLMQueue`: path routing, SQS/Valkey failover and observability hooks.
|
|
2
|
+
|
|
3
|
+
`PathRouter` (canary routing) and `FailoverPolicy` are implemented in `routing.py` /
|
|
4
|
+
`failover.py`; `QueueHooks` is subclassed by the metrics layer. This module has no heavy
|
|
5
|
+
imports, so implementations can depend on it freely.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from taskiq_llm_queue._logging import get_logger
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from taskiq_llm_queue.config import Lane
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
CallPath = Literal["sqs", "direct"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class PathRouter(Protocol):
|
|
25
|
+
"""Chooses how one call runs: through its model's SQS queue or `direct` in this process.
|
|
26
|
+
|
|
27
|
+
Without a router `LLMQueue` uses `sqs` when the model has queues, else `direct`.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def route(
|
|
31
|
+
self, model: str, lane: Lane, session_id: str | None, user_id: str | None
|
|
32
|
+
) -> CallPath:
|
|
33
|
+
"""Path for one call of model alias `model` in `lane` (sticky per session)."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class FailoverPolicy(Protocol):
|
|
39
|
+
"""Sender-side failover state: SQS outages per model and a pod-wide Valkey outage.
|
|
40
|
+
|
|
41
|
+
`LLMQueue` reports failures here and asks `path_override` / `redis_down` before each call;
|
|
42
|
+
implementations must never block or raise.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def path_override(self, model: str) -> Literal["direct"] | None:
|
|
46
|
+
"""`direct` while `model` is failed over from SQS, else None (use the router)."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def report_sqs_failure(self, model: str, exc: BaseException) -> None:
|
|
50
|
+
"""An SQS send for `model` failed; may start a failover window."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def report_redis_failure(self, exc: BaseException) -> None:
|
|
54
|
+
"""A Valkey call failed; may switch the pod to Valkey-down mode (DS18)."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def redis_down(self) -> bool:
|
|
58
|
+
"""True while the pod treats Valkey as unreachable."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class QueueHooks:
|
|
63
|
+
"""No-op callbacks for client and worker events; the metrics layer overrides what it needs.
|
|
64
|
+
|
|
65
|
+
Every hook is called through `safe_hook`, so a failing hook never breaks a call. Labels are
|
|
66
|
+
low-cardinality only (model alias, target, lane, path, kind, step).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def on_call(self, model: str, lane: str, path: str, ok: bool, secs: float) -> None:
|
|
70
|
+
"""One client call finished (`ok` False for errors and timeouts)."""
|
|
71
|
+
|
|
72
|
+
def on_attempt(self, model: str, target: str, kind: str, secs: float) -> None:
|
|
73
|
+
"""One provider attempt finished; `kind` is `ok` or an `ErrorKind` value."""
|
|
74
|
+
|
|
75
|
+
def on_fallback(self, model: str, step: str) -> None:
|
|
76
|
+
"""A call was served by fallback step `step` (`alias@target`) instead of the first."""
|
|
77
|
+
|
|
78
|
+
def on_queue_wait_ms(self, model: str, lane: str, wait_ms: int) -> None:
|
|
79
|
+
"""A worker started a task `wait_ms` after it was enqueued."""
|
|
80
|
+
|
|
81
|
+
def on_sync_fallback(self, model: str) -> None:
|
|
82
|
+
"""A sync call ran `direct` under the shared limiter (`llmq_sync_fallback_total`)."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def safe_hook(hook: Callable[..., Any], *args: Any) -> None:
|
|
86
|
+
"""Run one hook, logging (never raising) whatever it throws."""
|
|
87
|
+
try:
|
|
88
|
+
hook(*args)
|
|
89
|
+
except Exception:
|
|
90
|
+
logger.exception("routing_hook_failed", hook=getattr(hook, "__name__", repr(hook)))
|