Skip to content

feat(v1): model pools: bind each session to a deployment with room - #2971

Closed
RGaonkar26 wants to merge 1 commit into
feat/request-gate-adaptivefrom
feat/model-pools
Closed

RGaonkar26 wants to merge 1 commit into
feat/request-gate-adaptivefrom
feat/model-pools

Conversation

@RGaonkar26

@RGaonkar26 RGaonkar26 commented Oct 23, 2026 •

Copy link
Copy Markdown

An agent can name a model_pool of deployments instead of one model and client. Each rollout binds, at its first model request, to a deployment whose request gate has room, and stays on it. The trace records the deployment for every call.

Why
Spreading rollouts over several replicas or models within each one's concurrency cap (for example several replicas of one model, or GLM-5.3 and GLM-5.3-flash) needs a binding step. Each deployment's request gate already holds its cap host-wide, so a pool is just several gates in the same daemon, and the daemon needs no change.

Change

  • ModelPoolConfig(deployments, same_model=False) and ModelDeployment(id, model, client) in configs/model_pool.py, exported from verifiers.v1, plus AgentConfig.model_pool. Every deployment must set client.request_gate. Its scope defaults to the deployment id, so two deployments behind one URL and key keep separate caps.
  • model_routing.py: at a session's first model request, the interception server shuffles the deployments and binds to the first one that grants a non-queuing admission (ModelGate.try_acquire). Such an admission yields to requests already queued at that gate. When every deployment is full, it retries with a jittered backoff from 30 ms up to 1 s.
  • The slot taken while choosing is handed to that first request (reserved_permit), so a session is admitted once, not twice. An unused slot is returned.
  • Later requests use the bound deployment's model and client. On resume, a saved deployment that is still in the pool is kept; otherwise the session binds again.
  • Recorded on the trace: trace.info["model_binding"], trace.agent.config.model and .client, and a new ModelCall.deployment.
  • same_model = true rejects a pool whose deployments serve different model ids. It is meant for replica pools whose outputs must be attributable to one model.

Behaviour

  • Off by default: with no model_pool, no binding code runs. When unset, model_pool and deployment are left out of dumps, so configs and traces still load in older verifiers.
  • No failover mid-rollout: a deployment that errors fails that rollout's calls, as today, and a retried rollout chooses again.
  • In a pool of different models, a resumed session whose deployment has left the pool can change model. same_model rules this out.

Tests

  • Unit: 10 tests in tests/unit/request_gate/test_model_pools.py, with fake providers. They cover the spread under caps of 1, 2 and 3, the backoff bounds, one admission for the choice plus the first request, resume, config validation, and off by default. Unit suite: 283 passed, 18 skipped.
  • Live (Prime Inference, 2026-10-23):
    • 6 rollouts × 3 turns over two deployments with cap 2 each: both deployments were used, each rollout's calls recorded one deployment matching trace.info["model_binding"], and each deployment's upstream peak was 2.
    • With one deployment held at its cap, 3 of 3 new rollouts bound to the other.
    • same_model rejected a mixed pool. Two replicas at one URL and key kept separate caps (peak 1 + 1).
    • On resume, a saved binding was kept. A binding whose deployment had left the pool rebound, with one admission covering both the choice and the first request.
    • 20 waiting sessions settled at about 1 poll per second per session per deployment.
  • Not tested live: load at scale.

Notes

Parker, as with the gate: this could land on main and reach feat/pipelines at its next main merge, with switch_model staying on your branch until main has steering. Or you could open it from this branch yourself. Which do you prefer?

🤖 Generated with Claude Code

An agent can name a pool of deployments in place of one model and client:

    [env.solver.model_pool]
    deployments = [
      { id = "ds-a", model = "internal/deepseek-v4", client = { base_url = "...", request_gate = { max_concurrent = 64 } } },
      { id = "glm",  model = "glm-5.3",              client = { request_gate = { max_concurrent = 100 } } },
    ]

The design is Parker Pettit's (f04a56a), on top of the request gate.

- `ModelPoolConfig` / `ModelDeployment` (`configs/model_pool.py`, exported as
  `vf.ModelPoolConfig`, `vf.ModelDeployment`). Every deployment's client must
  set a `request_gate`: that is its capacity. The gate's new `scope` defaults
  to the deployment id, so deployments behind one URL and key keep separate
  budgets. Ids are unique; a training pool needs one model and renderer.
- `AgentConfig.model_pool` (and `ModelContext.model_pool`). With it set and
  `model` unset, the first deployment stands in until a session binds.
- At a pooled session's first model request, the interception server binds it
  (`model_routing.request_binding`): the deployments are tried in random
  order with one non-queuing admission each (`ModelGate.try_acquire`), and the
  first with a free slot wins. With none free it retries after a wait that
  doubles from 30 ms to about 1 s (jittered), so many waiting sessions cannot
  flood the daemon; a session waiting to bind yields to requests already
  queued at a gate (those of bound sessions). The
  admitted slot is handed to that request through `reserved_permit`, so the
  choice and the admission are one step. The session keeps the deployment,
  model and client for every later request, each passing that deployment's
  gate as usual. A resumed session keeps a saved deployment the pool still
  has, else binds afresh; in a pool of different models that can change the
  model mid-conversation (each call still records its own), which a
  `same_model` pool rules out.
- The binding is recorded: `trace.info["model_binding"]` (deployment, model,
  base URL), the trace's agent config (model, client), and a new
  `ModelCall.deployment` on every call. A per-call record means any label or
  score can be traced to the deployment and model that produced it.
- `same_model = true` requires every deployment to serve the same model id
  (replicas of one model and revision), for results that must not mix models
  (e.g. calibration labels).
- Off by default: with no pool, nothing is imported or bound, and the new
  `model_pool` and `deployment` fields are left out of dumps, so configs and
  traces stay readable by verifiers without them.
- A paragraph in docs/evaluation.md.

Sources (Parker Pettit, verifiers `feat/pipelines`): f04a56a ("Add
capacity-gated model pools and live session binding": `ModelPoolConfig`,
`_choose`, `request_binding`, `RequestPermit`, gate `scope`); #2946
c92797b (`try_acquire` on the daemon); dad67e0 (one gate across auth
dialects, so the choice's gate is the request's).

Differences from f04a56a:
- No live switch of a running session's model or pool (`steering.switch_model`,
  `SUPPORTS_MODEL_SWITCH`): main has no steering registry. A pool is chosen
  per rollout; flow can change which pool future calls get (as flow's
  031b6be does) without it.
- No `AgentConfig.session_id`: without a live switch, the trace id stays the
  affinity key.
- New: `ModelCall.deployment`, `same_model`, a backoff on the bind poll
  (fp polls every 30-80 ms without bound), and the gate for a deployment is
  built off the event loop.
- Tests (none existed on feat/pipelines): spread under caps, waiting at cap
  and taking the freed slot, random order, stickiness and per-call records
  through the interception server, the permit carrying the first request,
  resume and a removed deployment, config validation, and no change without
  a pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RGaonkar26

Copy link
Copy Markdown
Author

Closing in favour of Parker's #2946, which is the same request-gate / model-pool work and where it should land. Nothing on verifiers main or our pipeline needs it right now. The branch stays up: it has shared-host hardening (private 0700 dir, peer-uid checks, single-daemon lock, restart window), live tests against the Prime Inference gateway, and the pool poll backoff, all of which apply to #2946 and can be cherry-picked.

@RGaonkar26 RGaonkar26 closed this Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant