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.
Files changed (65) hide show
  1. taskiq_llm_queue/__init__.py +138 -0
  2. taskiq_llm_queue/_logging.py +40 -0
  3. taskiq_llm_queue/_version.py +5 -0
  4. taskiq_llm_queue/broker/__init__.py +86 -0
  5. taskiq_llm_queue/broker/acker.py +239 -0
  6. taskiq_llm_queue/broker/batching.py +188 -0
  7. taskiq_llm_queue/broker/broker.py +937 -0
  8. taskiq_llm_queue/broker/delivery.py +50 -0
  9. taskiq_llm_queue/broker/errors.py +178 -0
  10. taskiq_llm_queue/broker/events.py +51 -0
  11. taskiq_llm_queue/broker/filters.py +32 -0
  12. taskiq_llm_queue/broker/heartbeat.py +157 -0
  13. taskiq_llm_queue/broker/queue.py +141 -0
  14. taskiq_llm_queue/broker/s3_offload.py +346 -0
  15. taskiq_llm_queue/broker/scheduler.py +113 -0
  16. taskiq_llm_queue/broker/serializer.py +32 -0
  17. taskiq_llm_queue/cli.py +488 -0
  18. taskiq_llm_queue/client.py +1409 -0
  19. taskiq_llm_queue/config.py +592 -0
  20. taskiq_llm_queue/context.py +62 -0
  21. taskiq_llm_queue/core.py +832 -0
  22. taskiq_llm_queue/envelope.py +136 -0
  23. taskiq_llm_queue/errors.py +71 -0
  24. taskiq_llm_queue/failover.py +286 -0
  25. taskiq_llm_queue/http_transport.py +220 -0
  26. taskiq_llm_queue/keys.py +87 -0
  27. taskiq_llm_queue/langchain/__init__.py +64 -0
  28. taskiq_llm_queue/langchain/_client.py +142 -0
  29. taskiq_llm_queue/langchain/bedrock.py +291 -0
  30. taskiq_llm_queue/langchain/openai.py +103 -0
  31. taskiq_llm_queue/lifecycle.py +512 -0
  32. taskiq_llm_queue/metrics/__init__.py +45 -0
  33. taskiq_llm_queue/metrics/emf.py +100 -0
  34. taskiq_llm_queue/metrics/prometheus.py +66 -0
  35. taskiq_llm_queue/metrics_names.py +19 -0
  36. taskiq_llm_queue/protocols.py +140 -0
  37. taskiq_llm_queue/providers/__init__.py +62 -0
  38. taskiq_llm_queue/providers/bedrock.py +188 -0
  39. taskiq_llm_queue/providers/classify.py +251 -0
  40. taskiq_llm_queue/providers/embeddings.py +87 -0
  41. taskiq_llm_queue/providers/hosts.py +34 -0
  42. taskiq_llm_queue/providers/openai_compat.py +142 -0
  43. taskiq_llm_queue/providers/registry.py +173 -0
  44. taskiq_llm_queue/py.typed +0 -0
  45. taskiq_llm_queue/reliability/__init__.py +59 -0
  46. taskiq_llm_queue/reliability/_lua.py +461 -0
  47. taskiq_llm_queue/reliability/breaker.py +86 -0
  48. taskiq_llm_queue/reliability/claims.py +226 -0
  49. taskiq_llm_queue/reliability/config_store.py +148 -0
  50. taskiq_llm_queue/reliability/fallback.py +111 -0
  51. taskiq_llm_queue/reliability/limiter.py +482 -0
  52. taskiq_llm_queue/reliability/local_limiter.py +153 -0
  53. taskiq_llm_queue/results/__init__.py +25 -0
  54. taskiq_llm_queue/results/backend.py +226 -0
  55. taskiq_llm_queue/results/dispatcher.py +366 -0
  56. taskiq_llm_queue/results/s3_backend.py +105 -0
  57. taskiq_llm_queue/routing.py +173 -0
  58. taskiq_llm_queue/routing_hooks.py +90 -0
  59. taskiq_llm_queue/tasks.py +453 -0
  60. taskiq_llm_queue-0.1.0a1.dist-info/METADATA +296 -0
  61. taskiq_llm_queue-0.1.0a1.dist-info/RECORD +65 -0
  62. taskiq_llm_queue-0.1.0a1.dist-info/WHEEL +4 -0
  63. taskiq_llm_queue-0.1.0a1.dist-info/entry_points.txt +2 -0
  64. taskiq_llm_queue-0.1.0a1.dist-info/licenses/LICENSE +21 -0
  65. taskiq_llm_queue-0.1.0a1.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,488 @@
1
+ """`llm-queue` operator CLI: routing, fallback chains, limiter/breaker state, queues and jobs.
2
+
3
+ Connection settings come from `QueueConfig.from_env()` (`LLMQ_*`). Live config starts from the
4
+ app's defaults (`LLMQ_FALLBACK_CHAINS`, `LLMQ_ROUTING_DEFAULT`) while nothing is stored, and
5
+ `set` edits merge into it. Every change is validated and audited with `actor` (`--actor`,
6
+ default the OS user). `job send` and `queues` touch only SQS.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import contextlib
14
+ import getpass
15
+ import logging
16
+ import sys
17
+ import time
18
+ import uuid
19
+ from collections.abc import AsyncIterator, Mapping, Sequence
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ import orjson
24
+ from pydantic import BaseModel
25
+ from redis.exceptions import RedisError
26
+
27
+ from taskiq_llm_queue.config import Lane, QueueConfig, create_redis
28
+ from taskiq_llm_queue.envelope import TASK_JOB
29
+ from taskiq_llm_queue.errors import ConfigError, LLMQueueError
30
+ from taskiq_llm_queue.keys import KeySpace
31
+ from taskiq_llm_queue.reliability.breaker import BreakerState, CircuitBreaker
32
+ from taskiq_llm_queue.reliability.config_store import LiveConfigStore, fallback_store
33
+ from taskiq_llm_queue.reliability.fallback import FallbackConfig, resolve_fallback
34
+ from taskiq_llm_queue.reliability.limiter import AdaptiveLimiter, LimiterSettings
35
+ from taskiq_llm_queue.routing import RoutingConfig, routing_store
36
+
37
+ if TYPE_CHECKING:
38
+ import redis.asyncio
39
+
40
+ DEFAULT_JOB_QUEUE = "jobs-ingest"
41
+ JOB_VERSION = 1 # job body version; must equal tasks.JOB_VERSION (not imported: CLI stays light)
42
+ _QUEUE_COUNTS = (
43
+ "ApproximateNumberOfMessages",
44
+ "ApproximateNumberOfMessagesNotVisible",
45
+ "ApproximateNumberOfMessagesDelayed",
46
+ )
47
+
48
+
49
+ def build_parser() -> argparse.ArgumentParser:
50
+ """The argparse tree for every `llm-queue` sub-command."""
51
+ parser = argparse.ArgumentParser(prog="llm-queue", description="taskiq-llm-queue operator CLI")
52
+ parser.add_argument("--actor", help="name written to the audit stream (default: OS user)")
53
+ groups = parser.add_subparsers(dest="group", required=True)
54
+
55
+ routing = groups.add_parser("routing", help="canary routing sqs vs direct")
56
+ r_cmd = routing.add_subparsers(dest="cmd", required=True)
57
+ r_cmd.add_parser("get", help="print the live routing config (app default if none stored)")
58
+ r_set = r_cmd.add_parser("set", help="set the sqs percentage of one model and lane")
59
+ r_set.add_argument("--model", required=True)
60
+ r_set.add_argument("--lane", required=True, choices=[lane.value for lane in Lane])
61
+ r_set.add_argument("--pct", required=True, type=int)
62
+ r_kill = r_cmd.add_parser("kill", help="kill switch: on routes everything direct")
63
+ r_kill.add_argument("state", choices=["on", "off"])
64
+ r_allow = r_cmd.add_parser("allow", help="allowlist users that always go sqs")
65
+ r_allow.add_argument("action", choices=["add", "remove"])
66
+ r_allow.add_argument("user_id")
67
+ r_cmd.add_parser("reset", help="delete the stored config (back to the app default)")
68
+
69
+ fallback = groups.add_parser("fallback", help="live fallback chains")
70
+ f_cmd = fallback.add_subparsers(dest="cmd", required=True)
71
+ f_get = f_cmd.add_parser("get", help="whole config, or the resolved chain of one model")
72
+ f_get.add_argument("--model")
73
+ f_set = f_cmd.add_parser(
74
+ "set", help="set one model's chain, or merge a config file (per model) into the live one"
75
+ )
76
+ source = f_set.add_mutually_exclusive_group(required=True)
77
+ source.add_argument("--file", type=Path)
78
+ source.add_argument("--chain", help='comma separated, e.g. "gpt120b@apac,gpt20b"')
79
+ f_set.add_argument("--model", help="required with --chain")
80
+ f_set.add_argument(
81
+ "--replace", action="store_true", help="with --file: replace the whole config, no merge"
82
+ )
83
+ f_test = f_cmd.add_parser("test", help="dry run: which step would serve now")
84
+ f_test.add_argument("--model", required=True)
85
+ f_cmd.add_parser("reset", help="delete the stored config (back to the app default)")
86
+
87
+ job = groups.add_parser("job", help="background jobs")
88
+ j_cmd = job.add_subparsers(dest="cmd", required=True)
89
+ j_send = j_cmd.add_parser("send", help="send one job message to SQS (send permission only)")
90
+ j_send.add_argument("name")
91
+ j_send.add_argument("--payload-file", required=True, type=Path)
92
+ j_send.add_argument("--queue", default=DEFAULT_JOB_QUEUE, help="queue name or URL")
93
+ j_send.add_argument("--lane", default=Lane.BULK.value, choices=[lane.value for lane in Lane])
94
+
95
+ limiter = groups.add_parser("limiter", help="adaptive limiter state")
96
+ l_cmd = limiter.add_subparsers(dest="cmd", required=True)
97
+ l_show = l_cmd.add_parser("show", help="limit, in-flight and pause per model target")
98
+ l_show.add_argument("--model", help="alias or alias@target (default: every configured model)")
99
+ l_reset = l_cmd.add_parser("reset", help="forget the learned limit and pause (back to start)")
100
+ l_reset.add_argument("--model", required=True, help="alias or alias@target")
101
+ l_reset.add_argument("--target", help="one target (default: every target of the model)")
102
+
103
+ breaker = groups.add_parser("breaker", help="circuit breaker state")
104
+ b_cmd = breaker.add_subparsers(dest="cmd", required=True)
105
+ b_show = b_cmd.add_parser("show", help="breaker position per model target")
106
+ b_show.add_argument("--model", help="alias or alias@target (default: every configured model)")
107
+ b_reset = b_cmd.add_parser("reset", help="close the breaker (forget recorded failures)")
108
+ b_reset.add_argument("--model", required=True, help="alias or alias@target")
109
+ b_reset.add_argument("--target", help="one target (default: every target of the model)")
110
+
111
+ groups.add_parser("queues", help="message counts per configured SQS queue and its DLQ")
112
+ return parser
113
+
114
+
115
+ def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None = None) -> int:
116
+ """Run one command; returns the exit code (0 ok, 1 failed, 2 usage error from argparse)."""
117
+ args = build_parser().parse_args(argv)
118
+ try:
119
+ config = QueueConfig.from_env(environ)
120
+ return asyncio.run(_dispatch(args, config))
121
+ except (LLMQueueError, RedisError, OSError, ValueError) as exc:
122
+ print(f"error: {exc}", file=sys.stderr)
123
+ return 1
124
+
125
+
126
+ def console_main() -> None:
127
+ """`llm-queue` console script: warnings and errors to stderr, so stdout stays pure JSON.
128
+
129
+ The library never configures logging; this entry point is an app, so it does.
130
+ """
131
+ logging.basicConfig(
132
+ level=logging.WARNING, stream=sys.stderr, format="%(levelname)s %(message)s"
133
+ )
134
+ sys.exit(main())
135
+
136
+
137
+ def _actor(args: argparse.Namespace) -> str:
138
+ if args.actor:
139
+ return str(args.actor)
140
+ try:
141
+ return getpass.getuser()
142
+ except (OSError, KeyError): # no login name (some containers)
143
+ return "unknown"
144
+
145
+
146
+ def _print_json(value: Any) -> None:
147
+ print(orjson.dumps(value, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode())
148
+
149
+
150
+ def _dump(model: BaseModel) -> Any:
151
+ return model.model_dump(mode="json")
152
+
153
+
154
+ @contextlib.asynccontextmanager
155
+ async def _valkey(config: QueueConfig) -> AsyncIterator[redis.asyncio.Redis]:
156
+ client = create_redis(config, client_name="llm-queue-cli")
157
+ try:
158
+ yield client
159
+ finally:
160
+ await client.aclose()
161
+
162
+
163
+ async def _dispatch(args: argparse.Namespace, config: QueueConfig) -> int:
164
+ if args.group == "job":
165
+ return await _job_send(args, config)
166
+ if args.group == "queues":
167
+ return await _queues(config)
168
+ keys = KeySpace(config.key_prefix)
169
+ async with _valkey(config) as client:
170
+ if args.group == "routing":
171
+ default = RoutingConfig.model_validate(config.routing_default)
172
+ return await _routing(args, routing_store(client, keys, default))
173
+ if args.group == "fallback":
174
+ return await _fallback(args, config, client, keys)
175
+ if args.group == "breaker":
176
+ return await _breaker(args, config, client, keys)
177
+ if args.cmd == "reset":
178
+ return await _limiter_reset(args, config, client, keys)
179
+ return await _limiter_show(args, config, client, keys)
180
+
181
+
182
+ async def _load_checked(store: LiveConfigStore[Any]) -> Any:
183
+ """Load for a read-modify-write; warn when the stored value was invalid and is replaced."""
184
+ before = store.invalid_count
185
+ current = await store.load()
186
+ if store.invalid_count > before:
187
+ print(
188
+ f"warning: stored {store.key} is invalid; editing the default instead "
189
+ "(the old value stays in the audit stream)",
190
+ file=sys.stderr,
191
+ )
192
+ return current
193
+
194
+
195
+ async def _routing(args: argparse.Namespace, store: LiveConfigStore[RoutingConfig]) -> int:
196
+ if args.cmd == "get":
197
+ _print_json(_dump(await store.load()))
198
+ return 0
199
+ if args.cmd == "reset":
200
+ _print_json(_dump(await store.reset(actor=_actor(args))))
201
+ return 0
202
+ current: RoutingConfig = await _load_checked(store)
203
+ if args.cmd == "set":
204
+ updated = current.with_pct(args.model, Lane(args.lane), args.pct)
205
+ elif args.cmd == "kill":
206
+ updated = current.with_kill(args.state == "on")
207
+ else:
208
+ updated = current.with_user(args.user_id, allowed=args.action == "add")
209
+ stored = await store.set(updated, actor=_actor(args))
210
+ _print_json(_dump(stored))
211
+ return 0
212
+
213
+
214
+ def _merge_fallback(current: Mapping[str, Any], update: Mapping[str, Any]) -> dict[str, Any]:
215
+ """Per-model merge: models in `update` replace theirs, the others keep their entries."""
216
+ merged = {k: (dict(v) if isinstance(v, Mapping) else v) for k, v in current.items()}
217
+ for key, value in update.items():
218
+ if isinstance(value, Mapping) and isinstance(merged.get(key), dict):
219
+ merged[key].update(value)
220
+ else:
221
+ merged[key] = value
222
+ return merged
223
+
224
+
225
+ async def _fallback(
226
+ args: argparse.Namespace, config: QueueConfig, client: redis.asyncio.Redis, keys: KeySpace
227
+ ) -> int:
228
+ default = FallbackConfig.model_validate(config.fallback_default)
229
+ store = fallback_store(client, keys, default)
230
+ actor = _actor(args)
231
+ if args.cmd == "reset":
232
+ _print_json(_dump(await store.reset(actor=actor)))
233
+ return 0
234
+ if args.cmd == "set":
235
+ if args.file is not None:
236
+ update = orjson.loads(args.file.read_bytes())
237
+ if not isinstance(update, dict):
238
+ raise ConfigError("the fallback file must hold a JSON object")
239
+ if not args.replace:
240
+ current: FallbackConfig = await _load_checked(store)
241
+ update = _merge_fallback(_dump(current), update)
242
+ stored = await store.set(update, actor=actor)
243
+ else:
244
+ if not args.model:
245
+ raise ConfigError("--chain needs --model")
246
+ chain = [s.strip() for s in args.chain.split(",") if s.strip()]
247
+ current = await _load_checked(store)
248
+ raw = _dump(current)
249
+ raw["chains"][args.model] = chain
250
+ stored = await store.set(raw, actor=actor)
251
+ _print_json(_dump(stored))
252
+ return 0
253
+ live = await store.load()
254
+ if args.cmd == "get" and not args.model:
255
+ _print_json(_dump(live))
256
+ return 0
257
+ if args.model.partition("@")[0] not in config.models:
258
+ if args.cmd == "test":
259
+ raise ConfigError(f"{args.model!r} is not in LLMQ_MODELS; cannot resolve targets")
260
+ print(orjson.dumps([_dump(s) for s in live.chains.get(args.model, ())]).decode())
261
+ return 0
262
+ steps = resolve_fallback(config, args.model, policy="chain", live=live)
263
+ if args.cmd == "get":
264
+ print(" -> ".join(f"{s.alias}@{s.target}" for s in steps))
265
+ return 0
266
+ limiter = AdaptiveLimiter(client, keys, LimiterSettings.from_config(config))
267
+ breaker = CircuitBreaker(client, keys)
268
+ serving = None
269
+ rows = []
270
+ for step in steps:
271
+ state = await limiter.state(step.alias, step.target)
272
+ position = await breaker.state(step.alias, step.target)
273
+ usable = position is not BreakerState.OPEN and state.headroom() > 0
274
+ if usable and serving is None:
275
+ serving = f"{step.alias}@{step.target}"
276
+ rows.append(
277
+ {
278
+ "step": f"{step.alias}@{step.target}",
279
+ "switched_model": step.switched_model,
280
+ "limit": state.limit,
281
+ "inflight": state.inflight,
282
+ "paused_secs": round(state.paused_secs, 3),
283
+ "breaker": position.value,
284
+ "usable": usable,
285
+ }
286
+ )
287
+ _print_json({"model": args.model, "would_serve": serving, "steps": rows})
288
+ return 0
289
+
290
+
291
+ def _pairs(
292
+ config: QueueConfig, model: str | None, target: str | None = None
293
+ ) -> list[tuple[str, str]]:
294
+ """(alias, target) pairs for `--model alias[@target]` / `--target`, or every configured one."""
295
+ if not model:
296
+ return [(a, t) for a, m in config.models.items() for t in m.targets]
297
+ alias, _, at = model.partition("@")
298
+ target = target or at
299
+ if target:
300
+ return [(alias, target)]
301
+ if alias in config.models:
302
+ return [(alias, t) for t in config.models[alias].targets]
303
+ raise ConfigError(f"{alias!r} is not in LLMQ_MODELS; pass alias@target or --target")
304
+
305
+
306
+ async def _audit(
307
+ client: redis.asyncio.Redis, keys: KeySpace, key: str, actor: str, before: Any
308
+ ) -> None:
309
+ """Append a reset to the audit stream (same fields as live-config changes)."""
310
+ fields = {
311
+ "key": key,
312
+ "actor": actor,
313
+ "before": orjson.dumps(before).decode(),
314
+ "after": "",
315
+ "ts": repr(time.time()),
316
+ }
317
+ await client.xadd(keys.audit, fields) # type: ignore[arg-type] # redis stub: str fields
318
+
319
+
320
+ async def _limiter_show(
321
+ args: argparse.Namespace, config: QueueConfig, client: redis.asyncio.Redis, keys: KeySpace
322
+ ) -> int:
323
+ limiter = AdaptiveLimiter(client, keys, LimiterSettings.from_config(config))
324
+ breaker = CircuitBreaker(client, keys)
325
+ rows = []
326
+ for alias, target in _pairs(config, args.model):
327
+ state = await limiter.state(alias, target)
328
+ position = await breaker.state(alias, target)
329
+ rows.append(
330
+ {
331
+ "model": alias,
332
+ "target": target,
333
+ "limit": state.limit,
334
+ "inflight": state.inflight,
335
+ "paused_secs": round(state.paused_secs, 3),
336
+ "breaker": position.value,
337
+ }
338
+ )
339
+ _print_json(rows)
340
+ return 0
341
+
342
+
343
+ async def _limiter_reset(
344
+ args: argparse.Namespace, config: QueueConfig, client: redis.asyncio.Redis, keys: KeySpace
345
+ ) -> int:
346
+ """Delete the learned state (limit, ramp, pause); live leases are kept (real in-flight)."""
347
+ limiter = AdaptiveLimiter(client, keys, LimiterSettings.from_config(config))
348
+ rows = []
349
+ for alias, target in _pairs(config, args.model, args.target):
350
+ before = await limiter.state(alias, target)
351
+ key = keys.limiter(alias, target)
352
+ await client.delete(key)
353
+ snapshot = {"limit": before.limit, "paused_secs": round(before.paused_secs, 3)}
354
+ await _audit(client, keys, key, _actor(args), snapshot)
355
+ rows.append({"model": alias, "target": target, "reset": True, "before": snapshot})
356
+ _print_json(rows)
357
+ return 0
358
+
359
+
360
+ async def _breaker(
361
+ args: argparse.Namespace, config: QueueConfig, client: redis.asyncio.Redis, keys: KeySpace
362
+ ) -> int:
363
+ breaker = CircuitBreaker(client, keys)
364
+ rows = []
365
+ for alias, target in _pairs(config, args.model, getattr(args, "target", None)):
366
+ position = await breaker.state(alias, target)
367
+ row: dict[str, Any] = {"model": alias, "target": target, "breaker": position.value}
368
+ if args.cmd == "reset":
369
+ key = keys.breaker(alias, target)
370
+ await client.delete(key)
371
+ await _audit(client, keys, key, _actor(args), {"breaker": position.value})
372
+ row = {**row, "before": position.value, "breaker": BreakerState.CLOSED.value}
373
+ rows.append(row)
374
+ _print_json(rows)
375
+ return 0
376
+
377
+
378
+ def _configured_queues(config: QueueConfig) -> list[str]:
379
+ """Every queue name or URL in the config (model lane queues, then job queues), deduped."""
380
+ seen: dict[str, None] = {}
381
+ for model in config.models.values():
382
+ for value in model.queues.values():
383
+ seen.setdefault(value, None)
384
+ for value in config.job_queues.values():
385
+ seen.setdefault(value, None)
386
+ return list(seen)
387
+
388
+
389
+ async def _queue_row(sqs: Any, queue: str) -> dict[str, Any]:
390
+ """Counts of one queue plus its DLQ (from `RedrivePolicy`); errors become an `error` field."""
391
+ name = queue.rstrip("/").rsplit("/", 1)[-1]
392
+ row: dict[str, Any] = {"queue": name}
393
+ try:
394
+ url = queue if "://" in queue else (await sqs.get_queue_url(QueueName=queue))["QueueUrl"]
395
+ attrs = (
396
+ await sqs.get_queue_attributes(
397
+ QueueUrl=url, AttributeNames=[*_QUEUE_COUNTS, "RedrivePolicy"]
398
+ )
399
+ )["Attributes"]
400
+ except Exception as exc: # one bad queue must not hide the others
401
+ return {**row, "error": f"{type(exc).__name__}: {exc}"}
402
+ row.update(_counts(attrs))
403
+ policy = attrs.get("RedrivePolicy")
404
+ if policy:
405
+ dlq_name = str(orjson.loads(policy).get("deadLetterTargetArn", "")).rsplit(":", 1)[-1]
406
+ dlq: dict[str, Any] = {"queue": dlq_name}
407
+ try:
408
+ dlq_url = (await sqs.get_queue_url(QueueName=dlq_name))["QueueUrl"]
409
+ dlq_attrs = (
410
+ await sqs.get_queue_attributes(QueueUrl=dlq_url, AttributeNames=[*_QUEUE_COUNTS])
411
+ )["Attributes"]
412
+ dlq.update(_counts(dlq_attrs))
413
+ except Exception as exc:
414
+ dlq["error"] = f"{type(exc).__name__}: {exc}"
415
+ row["dlq"] = dlq
416
+ return row
417
+
418
+
419
+ def _counts(attrs: Mapping[str, Any]) -> dict[str, int]:
420
+ visible, inflight, delayed = (int(attrs.get(name, 0)) for name in _QUEUE_COUNTS)
421
+ return {"visible": visible, "in_flight": inflight, "delayed": delayed}
422
+
423
+
424
+ async def _queues(config: QueueConfig) -> int:
425
+ from aiobotocore.session import get_session # SQS client only for this command
426
+
427
+ queues = _configured_queues(config)
428
+ async with get_session().create_client(
429
+ "sqs", region_name=config.aws_region, endpoint_url=config.sqs_endpoint_url
430
+ ) as sqs:
431
+ rows = list(await asyncio.gather(*(_queue_row(sqs, q) for q in queues)))
432
+ _print_json(rows)
433
+ return 0
434
+
435
+
436
+ def job_message(
437
+ name: str, payload: Any, *, lane: str, actor: str, queue: str, deadline_at: float
438
+ ) -> dict[str, Any]:
439
+ """Taskiq message for one job, the same body `LLMQueue.send_job` kiqs (task `TASK_JOB`)."""
440
+ job_id = f"job-{uuid.uuid4().hex}"
441
+ body = {
442
+ "v": JOB_VERSION,
443
+ "request_id": job_id,
444
+ "job": name,
445
+ "payload": payload,
446
+ "deadline_at": deadline_at,
447
+ }
448
+ return {
449
+ "task_id": job_id,
450
+ "task_name": TASK_JOB,
451
+ "labels": {"lane": lane, "job": name, "sender": actor, "queue_name": queue},
452
+ "labels_types": None,
453
+ "args": [body],
454
+ "kwargs": {},
455
+ }
456
+
457
+
458
+ async def _job_send(args: argparse.Namespace, config: QueueConfig) -> int:
459
+ from aiobotocore.session import get_session # SQS client only for this command
460
+
461
+ payload = orjson.loads(args.payload_file.read_bytes())
462
+ queue: str = args.queue
463
+ queue_name = queue.rstrip("/").rsplit("/", 1)[-1]
464
+ if not isinstance(payload, dict):
465
+ raise ConfigError("the job payload file must hold a JSON object")
466
+ message = job_message(
467
+ args.name,
468
+ payload,
469
+ lane=args.lane,
470
+ actor=_actor(args),
471
+ queue=queue_name,
472
+ deadline_at=time.time() + config.state_ttl_secs,
473
+ )
474
+ session = get_session()
475
+ async with session.create_client(
476
+ "sqs", region_name=config.aws_region, endpoint_url=config.sqs_endpoint_url
477
+ ) as sqs:
478
+ if queue.startswith(("http://", "https://")):
479
+ url = queue
480
+ else:
481
+ url = (await sqs.get_queue_url(QueueName=queue))["QueueUrl"]
482
+ sent = await sqs.send_message(QueueUrl=url, MessageBody=orjson.dumps(message).decode())
483
+ _print_json({"task_id": message["task_id"], "message_id": sent["MessageId"], "queue": url})
484
+ return 0
485
+
486
+
487
+ if __name__ == "__main__":
488
+ console_main()