Add per-alias model concurrency admission (#990)

* feat(models): add per-alias concurrency admission

Add registry-backed FIFO admission limits with queue-aware deadlines and full-stream leases. Expose max_concurrency through storage, admin configuration, OpenAPI, documentation, and diagrams, with role and live backend count coverage.

* fix(api): omit null concurrency schema default

Keep max_concurrency optional for presence-keyed updates without advertising a null default for its non-null integer OpenAPI shape.
This commit is contained in:
Patrick Buckley
2026-08-08 22:01:04 -07:00
committed by GitHub
parent 7a06f5e8bc
commit 98e96ab5f3
43 changed files with 2298 additions and 201 deletions
+18 -2
View File
@@ -920,6 +920,7 @@ configurations so workstreams can use different LLM backends.
[models.local]
base_url = "http://localhost:8000/v1"
model = "qwen3-32b"
max_concurrency = 1
# provider defaults to "openai"
[models.claude]
@@ -958,6 +959,20 @@ binding on mismatch. Failed reload validation changes neither maps nor
generation. If only non-transport fields changed, compatible client pools can
remain warm, but the session still receives a new coherent config/lane.
**Per-alias admission:** Each registry alias owns a stable, hot-resizable
`ModelAdmission`. `max_concurrency = 0` is unlimited; a positive value limits
simultaneous generations for that alias in one process. Every registry-backed
role carries the same gate on its `ModelLane`, so main turns, judges, task
agents, perception, compaction, and background generation coordinate through
one FIFO. Two aliases never share a gate implicitly, even when their URLs are
identical. `model_turn()` materializes attachment fallbacks before admission,
then holds one lease across eager stream creation and the complete drain,
releasing before retry backoff. Admission wait is credited out of deadline
accounting, preventing queued judges from spending their request budget before
dispatch. The gate survives cap-only reloads in place; the field is excluded
from semantic `ModelConfig` equality so a capacity edit does not reset judges
or output-guard state.
Primary loops, recursive compaction, judges, title generation, audio, and task
agents all consume `ModelLane` rather than inspecting provider/client handles.
Fallback is a lane change, so retry classification and result provenance come
@@ -1196,8 +1211,9 @@ Verified quirks of vLLM's Anthropic endpoint:
**Database model definitions:** On server entry points, models can also be
defined in the `model_definitions` table (admin Models tab). DB models support
the same per-model sampling overrides. Config.toml models override DB models
with the same alias in-memory (the DB rows are never modified).
the same per-model sampling overrides and per-alias `max_concurrency`.
Config.toml models override DB models with the same alias in-memory (the DB
rows are never modified).
**Lifecycle:**
1. `load_model_registry()` loads DB model definitions (if storage available),
+4 -1
View File
@@ -455,7 +455,10 @@ configuration editor.
The **Channels** tab links users to either a Discord or Slack account
via a per-row channel-type selector. The **Models** tab is a CRUD
editor for `model_definitions`, including static and dynamic backend-auth
modes. Model edits rebind existing workstreams at their next send while
modes and a per-process **Max concurrent generations** limit for each alias
(`0` means unlimited). The limit is shared by every model-backed role using
that alias and a streaming generation holds its slot through the full decode.
Model edits rebind existing workstreams at their next send while
in-flight requests keep their original definition snapshot; see
[Settings](settings.md#model-definition-reloads) for the full contract. The **Nodes** tab edits per-node
metadata, and the **TLS** tab manages CA and leaf certificates for the
+22 -1
View File
@@ -134,6 +134,7 @@ class "ModelLane" as ModelLane <<frozen>> {
+ capabilities: ModelCapabilities
+ extra_params: dict | None
+ registry: ModelRegistry | None
+ admission: ModelAdmission | None
+ backend_auth_config: ModelConfig | None
+ backend_auth_resolver: Callable | None
}
@@ -357,12 +358,13 @@ class "ModelRegistry" as ModelReg {
- _models: dict[str, ModelConfig]
- _clients: dict[str, Any]
- _providers: dict[str, LLMProvider]
- _admissions: dict[str, ModelAdmission]
- _client_lock: Lock
+ default: str
+ fallback: list[str]
+ agent_model: str | None
--
+ resolve_binding(alias) → (client, model, config, provider, generation)
+ resolve_binding(alias) → (client, model, config, provider, admission, generation)
+ get_client(alias) → Any
+ get_provider(alias) → LLMProvider
+ has_alias(alias) → bool
@@ -376,6 +378,22 @@ class "ModelRegistry" as ModelReg {
core/model_registry.py
}
class "ModelAdmission" as ModelAdmission {
- alias: str
- _limit: int
- _in_flight: int
- _waiters: deque
+ acquire(cancel_ref) → AdmissionLease
+ set_limit(limit)
+ snapshot() → AdmissionSnapshot
--
Per-process FIFO generation gate.
Stable across alias hot reloads;
queue time is deadline credit.
--
core/admission.py
}
class "ModelConfig" as ModelCfg <<frozen>> {
+ alias: str
+ provider: str
@@ -385,6 +403,7 @@ class "ModelConfig" as ModelCfg <<frozen>> {
+ temperature: float | None
+ max_tokens: int | None
+ reasoning_effort: str | None
+ max_concurrency: int
+ auth_mode: str
+ obo_audience: str
+ obo_scopes: str
@@ -475,12 +494,14 @@ KindAdapter ..> ChatSession : constructs
ModelReg --> "*" ModelCfg : holds
ModelReg --> "*" LLMProvider : caches
ModelReg --> "*" ModelAdmission : owns per alias
LLMProvider --> ModelCaps : returns
ModelReg --> ResolvedBinding : resolves atomically
ResolvedBinding --> ModelLane
ModelLane --> LLMProvider
ModelLane --> ModelCaps
ModelLane --> ModelCfg : auth/config snapshot
ModelLane --> ModelAdmission : admission lease
ModelTurnFn --> ModelLane
ModelTurnFn --> ModelTurnResult
ModelTurnFn ..> BackendAuth : per-call resolver
+6
View File
@@ -10,6 +10,7 @@ participant "SessionManager" as Manager
participant "ChatSession" as Session
participant "SessionUIBase" as UI
participant "model_turn()\n+ lowering" as Plant
participant "ModelAdmission\n(per alias)" as Admission
participant "LLM provider" as Provider
participant "Tool workers" as Tools
database "StorageBackend\n(SQLite / PostgreSQL)" as Storage
@@ -54,6 +55,9 @@ loop until final answer and no queued input
Session -> Plant : model_turn(active ModelLane, Turns,\n tools, cancel_ref, on_chunk)
activate Plant
Plant -> Plant : canonical Turns → provider wire\nrestore ids + repair + lane-specific fold
Plant -> Plant : materialize attachment refs\n(nested perception before outer slot)
Plant -> Admission : acquire(cancel_ref)
activate Admission
Plant -> Plant : resolve per-call backend credential\nfrom lane's pinned ModelConfig
Plant -> Provider : create_streaming(...)
activate Provider
@@ -68,6 +72,8 @@ loop until final answer and no queued input
Provider --> Plant : finish + usage + native blocks
deactivate Provider
Plant -> Plant : drain + re-ingest assistant Turn\nwith serving-lane provenance
Plant -> Admission : release before retry backoff
deactivate Admission
Plant --> Session : ModelTurnResult
deactivate Plant
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e1431edf3891785b922c52b7897e3af5d39ba9973a815f892d6afdb762c5297b
size 612662
oid sha256:c74e99c530c3a8af9ab35b1e4d8c4fef0ea35c0c04cc35da7cf3588e71382057
size 661175
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:79299b25ccc10484af13684a89ed9457abb9bc1781604bf6a9000ccb84362e55
size 311107
oid sha256:b237e00c28225c847b3e1a083c5c35535d1bf1549f6e6c7e2b6c4fd0e037a980
size 331906
+20
View File
@@ -54,6 +54,23 @@ When a per-model override is `NULL` (empty in the UI), the global default is
used. Switching models via `/model <alias>` re-resolves sampling parameters
from the new model's overrides or global defaults.
### Per-model concurrency
Each model definition may set `max_concurrency` to limit simultaneous model
generations for that alias in one Turnstone process. `0` or an omitted value
means unlimited. The gate is shared by every role using the alias—interactive
turns, coordinators, task agents, judges, output guards, perception, compaction,
and title generation—and a streaming generation holds its slot until the
stream is fully drained or closed.
Admission is strictly per alias. Two aliases remain independent even when they
point to the same URL; Turnstone does not infer shared capacity from endpoint
text. Queue time is excluded from judge/output-guard deadline accounting, and
each retry releases its slot before backoff and reacquires for the next wire
attempt. The cap is local to each process, not cluster-wide; account for the
number of nodes targeting the same inference server. Direct STT/TTS protocol
calls and Cohere/Jina reranking do not currently consume this generation cap.
### Model backend authentication
Model definitions support four backend credential modes:
@@ -445,6 +462,9 @@ send. Endpoint, provider, backend model ID, capabilities, extra parameters, and
backend-auth configuration are replaced as one immutable binding. In-flight
turns, judges, and task agents finish or cancel against the binding they
started with; an admin edit never tears one request across two definitions.
The alias's admission gate is retained and resized in place, so a concurrency
edit preserves in-flight accounting and does not reset cached judges or the
output-guard rate limiter.
Sampling and other saved workstream configuration remain workstream state. A
model-definition edit does not silently rewrite a live workstream's chosen