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,226 @@
1
+ """Exactly-once execution on at-least-once SQS delivery: the claim record per task (DS1-DS3).
2
+
3
+ `<prefix>:{r:<id>}:job` holds `state`, `worker`, `heartbeat_at`, `deadline_at` and `failures`;
4
+ every transition is one Lua CAS using Valkey `TIME`. Used by the claim middleware before a task
5
+ runs, by the heartbeat loop, and by `direct` failover (DS17: claim as `direct:<pod>`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from redis.crc import key_slot
16
+
17
+ from taskiq_llm_queue._logging import get_logger
18
+ from taskiq_llm_queue.keys import KeySpace
19
+ from taskiq_llm_queue.protocols import ClaimDecision, ClaimState
20
+ from taskiq_llm_queue.reliability import _lua
21
+
22
+ if TYPE_CHECKING:
23
+ import redis.asyncio
24
+
25
+ from taskiq_llm_queue.config import QueueConfig
26
+
27
+ logger = get_logger(__name__)
28
+
29
+ _TERMINAL = frozenset({ClaimState.DONE, ClaimState.CANCELLED, ClaimState.EXPIRED})
30
+
31
+
32
+ class ClaimOutcome(StrEnum):
33
+ """Result of an owner-checked `complete_owned` / `release_owned` call.
34
+
35
+ `NOT_OWNER` means another worker holds the claim now, so nothing was written.
36
+ """
37
+
38
+ APPLIED = "applied"
39
+ NOT_RUNNING = "not_running" # release only: the record was not running
40
+ NOT_OWNER = "not_owner"
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ClaimSettings:
45
+ """Claim timing: TTLs, worst-case execution and heartbeat staleness (DS3, DS5).
46
+
47
+ A heartbeat older than `stale_after_beats x heartbeat_interval_secs` is stale and the task
48
+ is reclaimed; at least 3 beats, so one missed or late beat never hands a live task over.
49
+ """
50
+
51
+ state_ttl_secs: int = 1800
52
+ exec_max_secs: int = 300
53
+ heartbeat_interval_secs: float = 10.0
54
+ max_failures: int = 3
55
+ stale_after_beats: float = 3.0
56
+
57
+ def __post_init__(self) -> None:
58
+ """Reject a staleness window that a single missed heartbeat could exceed."""
59
+ if self.stale_after_beats < 3:
60
+ raise ValueError("stale_after_beats must be >= 3 heartbeat intervals")
61
+
62
+ @property
63
+ def stale_after_secs(self) -> float:
64
+ """Heartbeat age after which a running claim may be taken over."""
65
+ return self.stale_after_beats * self.heartbeat_interval_secs
66
+
67
+ @classmethod
68
+ def from_config(cls, config: QueueConfig, **overrides: Any) -> ClaimSettings:
69
+ """Settings whose state TTL comes from `QueueConfig.state_ttl_secs`."""
70
+ return cls(state_ttl_secs=config.state_ttl_secs, **overrides)
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class FailureCount:
75
+ """Real failures counted for a task and whether it should now go to the DLQ (DS5)."""
76
+
77
+ count: int
78
+ exhausted: bool
79
+
80
+
81
+ class RedisClaimStore:
82
+ """`ClaimStore` on Valkey: claim, heartbeat, complete, release, failures and cancel flag.
83
+
84
+ A running task with a fresh heartbeat is never acked by another pod (DEFER, DS2); terminal
85
+ records outlive the message so duplicates are acked without a second provider call.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ client: redis.asyncio.Redis,
91
+ keys: KeySpace,
92
+ settings: ClaimSettings | None = None,
93
+ ) -> None:
94
+ """Register the Lua scripts on `client`; no I/O happens until the first call."""
95
+ self._client = client
96
+ self._keys = keys
97
+ self.settings = settings or ClaimSettings()
98
+ self._claim = client.register_script(_lua.CLAIM)
99
+ self._heartbeat = client.register_script(_lua.CLAIM_HEARTBEAT)
100
+ self._complete = client.register_script(_lua.CLAIM_COMPLETE)
101
+ self._release = client.register_script(_lua.CLAIM_RELEASE)
102
+
103
+ async def claim(self, request_id: str, *, deadline_at: float, worker: str) -> ClaimDecision:
104
+ """Atomically decide whether this worker runs the task (Lua CAS)."""
105
+ s = self.settings
106
+ decision = await self._claim(
107
+ [self._keys.job(request_id), self._keys.cancel(request_id)],
108
+ [
109
+ repr(float(deadline_at)),
110
+ worker,
111
+ s.state_ttl_secs,
112
+ s.exec_max_secs,
113
+ s.stale_after_secs,
114
+ ],
115
+ )
116
+ return ClaimDecision(_lua.as_text(decision))
117
+
118
+ async def heartbeat_owned(self, request_ids: Sequence[str], *, worker: str) -> list[str]:
119
+ """Refresh heartbeat and TTL of tasks this worker runs; return the ids it lost (DS6).
120
+
121
+ Lost means another worker owns the claim now or the record is gone; a claim this worker
122
+ already completed or released is skipped, not lost (its task is just finishing).
123
+ """
124
+ if not request_ids:
125
+ return []
126
+ s = self.settings
127
+ args: list[Any] = [worker, s.state_ttl_secs, s.exec_max_secs]
128
+ keys = [self._keys.job(rid) for rid in request_ids]
129
+ # Each job key has its own `{r:<id>}` slot and a cluster script may touch only one:
130
+ # one call per slot, all pipelined in a single round trip (standalone Valkey too).
131
+ by_slot: dict[int, list[int]] = {}
132
+ for index, key in enumerate(keys):
133
+ by_slot.setdefault(key_slot(key.encode()), []).append(index)
134
+ groups = list(by_slot.values())
135
+ replies = await _lua.run_many(
136
+ self._client, [(self._heartbeat, [keys[i] for i in group], args) for group in groups]
137
+ )
138
+ missing = sorted(
139
+ group[int(i) - 1] for group, lost in zip(groups, replies, strict=True) for i in lost
140
+ )
141
+ return [request_ids[index] for index in missing]
142
+
143
+ async def heartbeat(self, request_ids: Sequence[str], *, worker: str) -> None:
144
+ """Refresh heartbeats and TTLs for running tasks."""
145
+ await self.heartbeat_owned(request_ids, worker=worker)
146
+
147
+ async def complete(
148
+ self, request_id: str, state: ClaimState, *, worker: str | None = None
149
+ ) -> None:
150
+ """Mark a task done, cancelled or expired after its result is stored (DS8).
151
+
152
+ With `worker`, only the current owner may complete it (see `complete_owned`).
153
+ """
154
+ await self.complete_owned(request_id, state, worker=worker)
155
+
156
+ async def complete_owned(
157
+ self, request_id: str, state: ClaimState, *, worker: str | None
158
+ ) -> ClaimOutcome:
159
+ """Owner-checked `complete`: NOT_OWNER when another worker holds the claim now.
160
+
161
+ A record without an owner (released, or missing) is written; `worker=None` skips the check.
162
+ """
163
+ script, keys, args = self.complete_call(request_id, state, worker=worker)
164
+ return self.complete_outcome(await script(keys, args), request_id, worker=worker)
165
+
166
+ @property
167
+ def client(self) -> redis.asyncio.Redis:
168
+ """The client the scripts run on (lets a caller share one pipeline with it)."""
169
+ return self._client
170
+
171
+ def complete_call(
172
+ self, request_id: str, state: ClaimState, *, worker: str | None
173
+ ) -> _lua.ScriptCall:
174
+ """The `complete_owned` script call, to queue in a caller's pipeline (`_lua`)."""
175
+ if state not in _TERMINAL:
176
+ raise ValueError(f"complete() needs a terminal state, got {state!r}")
177
+ args = [state.value, self.settings.state_ttl_secs, worker or ""]
178
+ return self._complete, [self._keys.job(request_id)], args
179
+
180
+ def complete_outcome(self, reply: Any, request_id: str, *, worker: str | None) -> ClaimOutcome:
181
+ """Interpret a `complete_call` reply: APPLIED, or NOT_OWNER (logged)."""
182
+ if int(reply) < 0:
183
+ logger.warning("claim_complete_not_owner", request_id=request_id, worker=worker)
184
+ return ClaimOutcome.NOT_OWNER
185
+ return ClaimOutcome.APPLIED
186
+
187
+ async def release(self, request_id: str, *, worker: str | None = None) -> None:
188
+ """Hand a running task back so another pod can start it at once.
189
+
190
+ With `worker`, only the current owner may release it (see `release_owned`).
191
+ """
192
+ await self.release_owned(request_id, worker=worker)
193
+
194
+ async def release_owned(self, request_id: str, *, worker: str | None) -> ClaimOutcome:
195
+ """Owner-checked `release`: APPLIED, NOT_RUNNING, or NOT_OWNER (nothing written).
196
+
197
+ `worker=None` releases any running record (the old, unchecked behaviour).
198
+ """
199
+ reply = int(await self._release([self._keys.job(request_id)], [worker or ""]))
200
+ if reply < 0:
201
+ logger.warning("claim_release_not_owner", request_id=request_id, worker=worker)
202
+ return ClaimOutcome.NOT_OWNER
203
+ return ClaimOutcome.APPLIED if reply else ClaimOutcome.NOT_RUNNING
204
+
205
+ async def state(self, request_id: str) -> ClaimState | None:
206
+ """Current claim state, or None when there is no record."""
207
+ raw = await self._client.hget(self._keys.job(request_id), "state")
208
+ return None if raw is None else ClaimState(_lua.as_text(raw))
209
+
210
+ async def record_failure(self, request_id: str) -> FailureCount:
211
+ """Count one real failure (not a throttle or a release) against `max_failures` (DS5)."""
212
+ key = self._keys.job(request_id)
213
+ async with self._client.pipeline(transaction=True) as pipe:
214
+ pipe.hincrby(key, "failures", 1)
215
+ pipe.expire(key, self.settings.state_ttl_secs, nx=True) # never leave a key without TTL
216
+ raw_count, _ = await pipe.execute()
217
+ count = int(raw_count)
218
+ return FailureCount(count, count >= self.settings.max_failures)
219
+
220
+ async def cancel(self, request_id: str) -> None:
221
+ """Set the cancel flag that workers check before every attempt and sleep (DS14)."""
222
+ await self._client.set(self._keys.cancel(request_id), b"1", ex=self.settings.state_ttl_secs)
223
+
224
+ async def is_cancelled(self, request_id: str) -> bool:
225
+ """True when the caller cancelled or timed out on this task."""
226
+ return bool(await self._client.exists(self._keys.cancel(request_id)))
@@ -0,0 +1,148 @@
1
+ """Live JSON config in Valkey (routing, fallback chains) with last-good fallback and audit.
2
+
3
+ Readers validate with pydantic and keep the last good value when the stored JSON is invalid or
4
+ Valkey is unreachable, so a bad edit never breaks live calls. Every write appends who changed
5
+ what to `KeySpace.audit` in the same Lua call, trimmed to the retention window.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections.abc import Awaitable, Callable, Mapping
12
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
13
+
14
+ from pydantic import BaseModel, ValidationError
15
+ from redis.exceptions import RedisError
16
+
17
+ from taskiq_llm_queue._logging import get_logger
18
+ from taskiq_llm_queue.errors import ConfigError
19
+ from taskiq_llm_queue.keys import KeySpace
20
+ from taskiq_llm_queue.reliability import _lua
21
+ from taskiq_llm_queue.reliability.fallback import FallbackConfig
22
+
23
+ if TYPE_CHECKING:
24
+ import redis.asyncio
25
+
26
+ logger = get_logger(__name__)
27
+ ModelT = TypeVar("ModelT", bound=BaseModel)
28
+ AUDIT_RETENTION_SECS = 90 * 86400
29
+
30
+
31
+ class LiveConfigStore(Generic[ModelT]): # PEP 695 needs 3.12; package supports 3.11
32
+ """One live config value of type `model_type` at `key`, with audit trail and reload loop.
33
+
34
+ `current` is the last good value (the startup default until a load succeeds); hot paths read
35
+ it without I/O, and `run_reload` refreshes it every `interval_secs` (`LLMQ_CFG_RELOAD_SECS`).
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ client: redis.asyncio.Redis,
41
+ key: str,
42
+ model_type: type[ModelT],
43
+ default: ModelT,
44
+ *,
45
+ audit_key: str,
46
+ audit_retention_secs: int = AUDIT_RETENTION_SECS,
47
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
48
+ ) -> None:
49
+ """Keep the client and the startup default; nothing is read until `load`."""
50
+ self._client = client
51
+ self.key = key
52
+ self._type = model_type
53
+ self.default = default
54
+ self._audit_key = audit_key
55
+ self._retention_ms = audit_retention_secs * 1000
56
+ self._sleep = sleep
57
+ self._current = default
58
+ self._warned: set[bytes] = set()
59
+ self.invalid_count = 0
60
+ self._set = client.register_script(_lua.CONFIG_SET)
61
+
62
+ @property
63
+ def current(self) -> ModelT:
64
+ """Last good value; never raises and never does I/O."""
65
+ return self._current
66
+
67
+ def parse(self, raw: str | bytes | Mapping[str, Any] | ModelT) -> ModelT:
68
+ """Validate JSON, a mapping or a model instance; raises `ConfigError` when invalid."""
69
+ try:
70
+ if isinstance(raw, self._type):
71
+ return raw
72
+ if isinstance(raw, str | bytes):
73
+ return self._type.model_validate_json(raw)
74
+ return self._type.model_validate(raw)
75
+ except ValidationError as exc:
76
+ raise ConfigError(f"invalid {self.key}: {exc}") from exc
77
+
78
+ async def load(self) -> ModelT:
79
+ """Read and validate the stored value; on bad data or no Valkey keep the last good one.
80
+
81
+ A missing key means the startup default. Invalid data is logged once per distinct value
82
+ and counted in `invalid_count` (exported as `llmq_config_invalid_total`).
83
+ """
84
+ try:
85
+ raw = await self._client.get(self.key)
86
+ except RedisError as exc:
87
+ logger.warning("live_config_unreachable", key=self.key, error=str(exc))
88
+ return self._current
89
+ if raw is None:
90
+ self._current = self.default
91
+ return self._current
92
+ raw_bytes = raw if isinstance(raw, bytes) else str(raw).encode()
93
+ try:
94
+ self._current = self.parse(raw_bytes)
95
+ except ConfigError as exc:
96
+ self.invalid_count += 1
97
+ if raw_bytes not in self._warned:
98
+ self._warned.add(raw_bytes)
99
+ logger.warning(
100
+ "live_config_invalid",
101
+ key=self.key,
102
+ invalid_count=self.invalid_count,
103
+ error=str(exc),
104
+ )
105
+ return self._current
106
+
107
+ async def set(self, value: str | bytes | Mapping[str, Any] | ModelT, *, actor: str) -> ModelT:
108
+ """Validate and store a new value, appending an audit entry; returns the stored value.
109
+
110
+ Raises `ConfigError` for invalid input, so a bad CLI edit never reaches Valkey.
111
+ """
112
+ parsed = self.parse(value)
113
+ await self._set(
114
+ [self.key, self._audit_key], [parsed.model_dump_json(), actor, self._retention_ms]
115
+ )
116
+ self._current = parsed
117
+ return parsed
118
+
119
+ async def reset(self, *, actor: str) -> ModelT:
120
+ """Delete the stored value (back to the startup default), with an audit entry."""
121
+ await self._set([self.key, self._audit_key], ["", actor, self._retention_ms])
122
+ self._current = self.default
123
+ return self._current
124
+
125
+ async def history(self, count: int = 20) -> list[dict[str, str]]:
126
+ """Latest audit entries for this key, newest first (for `llm-queue ... get`)."""
127
+ entries = await self._client.xrevrange(self._audit_key, count=count * 4)
128
+ out: list[dict[str, str]] = []
129
+ for _id, fields in entries or ():
130
+ row = {_lua.as_text(k): _lua.as_text(v) for k, v in (fields or {}).items()}
131
+ if row.get("key") == self.key:
132
+ out.append(row)
133
+ return out[:count]
134
+
135
+ async def run_reload(self, interval_secs: float) -> None:
136
+ """Reload every `interval_secs` until cancelled; errors never end the loop."""
137
+ while True:
138
+ await self.load()
139
+ await self._sleep(interval_secs)
140
+
141
+
142
+ def fallback_store(
143
+ client: redis.asyncio.Redis, keys: KeySpace, default: FallbackConfig | None = None
144
+ ) -> LiveConfigStore[FallbackConfig]:
145
+ """Live store for fallback chains at `KeySpace.fallback`, audited to `KeySpace.audit`."""
146
+ return LiveConfigStore(
147
+ client, keys.fallback, FallbackConfig, default or FallbackConfig(), audit_key=keys.audit
148
+ )
@@ -0,0 +1,111 @@
1
+ """Fallback order for one call: same model on other targets first, other models only on `chain`.
2
+
3
+ Pure logic over `QueueConfig` and the live `FallbackConfig`. Embeddings never
4
+ switch model (vectors from another model are incompatible). The worker walks the steps in order
5
+ and checks limiter headroom itself where a step says `require_headroom`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
14
+
15
+ from taskiq_llm_queue.errors import ConfigError
16
+
17
+ if TYPE_CHECKING:
18
+ from taskiq_llm_queue.config import QueueConfig
19
+ from taskiq_llm_queue.envelope import FallbackPolicy, Op
20
+
21
+
22
+ class ChainStep(BaseModel):
23
+ """One other model in a chain (`alias` or `alias@target`); a plain string is accepted."""
24
+
25
+ model_config = ConfigDict(frozen=True, extra="forbid")
26
+
27
+ model: str = Field(min_length=1)
28
+ require_headroom: bool = False
29
+
30
+
31
+ class FallbackConfig(BaseModel):
32
+ """Live fallback chains stored as JSON at `KeySpace.fallback`.
33
+
34
+ `chains` maps a primary alias to the other models tried under policy `chain`;
35
+ `skip_targets` lists targets not usable yet per alias (e.g. APAC before sign-off).
36
+ """
37
+
38
+ model_config = ConfigDict(frozen=True, extra="forbid")
39
+
40
+ chains: dict[str, tuple[ChainStep, ...]] = Field(default_factory=dict)
41
+ skip_targets: dict[str, frozenset[str]] = Field(default_factory=dict)
42
+
43
+ @field_validator("chains", mode="before")
44
+ @classmethod
45
+ def _strings_to_steps(cls, value: Any) -> Any:
46
+ if not isinstance(value, dict):
47
+ return value
48
+ return {
49
+ alias: [{"model": s} if isinstance(s, str) else s for s in steps]
50
+ if isinstance(steps, list | tuple)
51
+ else steps
52
+ for alias, steps in value.items()
53
+ }
54
+
55
+ @model_validator(mode="after")
56
+ def _no_self_reference(self) -> FallbackConfig:
57
+ loops = [a for a, steps in self.chains.items() if any(_alias(s.model) == a for s in steps)]
58
+ if loops:
59
+ raise ValueError(f"chains must not list the primary model itself: {loops}")
60
+ return self
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class FallbackStep:
65
+ """One place to try: model alias and target, whether it changes model, headroom rule."""
66
+
67
+ alias: str
68
+ target: str
69
+ switched_model: bool = False
70
+ require_headroom: bool = False
71
+
72
+
73
+ def _alias(model: str) -> str:
74
+ return model.partition("@")[0]
75
+
76
+
77
+ def resolve_fallback(
78
+ config: QueueConfig,
79
+ model: str,
80
+ *,
81
+ policy: FallbackPolicy = "same_model",
82
+ op: Op = "converse",
83
+ live: FallbackConfig | None = None,
84
+ ) -> tuple[FallbackStep, ...]:
85
+ """Ordered steps for `model` (`alias[@target]`): the requested target, then its other targets.
86
+
87
+ With policy `chain` (and never for embeddings) the chain's models follow, each primary
88
+ target first. Unknown aliases or targets in the live chain are skipped, not raised, because
89
+ live config may lag the deployed `QueueConfig`; the requested model itself must exist.
90
+ """
91
+ live = live or FallbackConfig()
92
+ alias, cfg, first = config.resolve(model)
93
+ skip = live.skip_targets.get(alias, frozenset())
94
+ steps = [FallbackStep(alias, first)]
95
+ steps += [FallbackStep(alias, t) for t in cfg.targets if t != first and t not in skip]
96
+ if policy != "chain" or op == "embeddings":
97
+ return tuple(steps)
98
+ seen = {alias}
99
+ for step in live.chains.get(alias, ()):
100
+ other, _, pinned = step.model.partition("@")
101
+ if other in seen:
102
+ continue
103
+ try:
104
+ _, other_cfg, _ = config.resolve(step.model)
105
+ except ConfigError:
106
+ continue
107
+ seen.add(other)
108
+ other_skip = live.skip_targets.get(other, frozenset())
109
+ targets = [pinned] if pinned else [t for t in other_cfg.targets if t not in other_skip]
110
+ steps += [FallbackStep(other, t, True, step.require_headroom) for t in targets]
111
+ return tuple(steps)