58 KiB
MiniCPM-o 4.5 Full-Duplex Runtime Architecture
Relocation note: after the H20 validation recorded below, the full-duplex implementation moved out of
vllm_omni/experimental/fullduplex/into the stable tree (vllm_omni/engine/duplex/,vllm_omni/entrypoints/duplex/,vllm_omni/model_executor/models/minicpmo_4_5/duplex/, and companion modules). Module paths in this document reflect the new layout; the runtime evidence was produced on the pre-move tree, which is file-identical apart from module paths.
Purpose
This document records the runtime architecture implemented by PR #3907, the
cleanup applied after published commit e011d936, and the remaining work that
must not be advertised as complete. Implemented contracts and future
architecture work are called out separately below.
The checkpoint preserves the active runtime path already exercised on H20. It does not introduce another reducer, controller, worker provider, or shadow runtime.
Review Snapshot
- PR:
vllm-project/vllm-omni#3907 - Published head before this refactor:
e011d936 - Published base snapshot:
62589203 - Published diff: 93 files, approximately
+29.2k/-0.6k - Local runtime and pytest validation: intentionally not used
- Required validation environment: an NVIDIA H20 CUDA host
- PR head reviewed for the current cleanup:
f4f78fa555af4d65e46b003022d352ca2ea9d703 - Validated tree: the uncommitted refactor synchronized file-for-file to the isolated H20 worktree on 2026-07-20
The current tree has received fresh H20 focused and E2E validation against vLLM
0.25.0. The final affected matrix includes the pre-response continuation and
Stage1 CUDA Graph padding regressions. The checked-in MiniCPM profile admits
two concurrent sessions through an engine-owned limit; a third
session was rejected and capacity returned after one session closed. The two
accepted sessions independently completed their audio, transcript, response,
playback, and close lifecycles. This is isolation and admission evidence for the
tested two-session deployment, not a production fairness, arbitrary-capacity,
or failure-recovery claim. The validated paths include the MiniCPM duplex
runtime extension, typed resumable policy, extracted control plane and clients, request
preregistration, typed direct-output decision, engine-managed two-session
capacity in the checked-in deploy profile, separate public/runtime configuration
channels, private Session ledgers, ordered session.update, the MiniCPM runner
fast path, continuous
browser input, and the final Realtime input-lifecycle fixes. Exact evidence and
scope limits are recorded below.
Scope
The checkpoint keeps these verified contracts:
- MiniCPM Stage0 conversation KV continuity;
- Stage1 TTS and Token2Wav continuity;
- model-owned listen/speak decisions on the normal auto-response path;
- continuous browser PCM upload during assistant playback, without browser VAD or browser-generated input commits;
- segment EOS and turn EOS as different boundaries;
- transcript/audio cursors scoped to a response and turn;
- playback acknowledgement and history commit;
- Stage0 request metadata routed per batch row rather than inherited from the first request in the batch;
- Stage0 token timing snapshots and client-observed audio chunk cadence;
- scheduler data-plane append over a resumable request;
- stale epoch/turn/response fencing;
/v1/duplexand OpenAI Realtime projection;- optional OpenAI
server_vadturn detection backed by a process-shared Silero model and per-session VAD state; - existing JoyVL behavior.
The checkpoint does not claim:
- scheduler-native KV append;
- an acoustic-onset-to-cancel latency target across arbitrary client chunk sizes;
- production multi-session admission, fairness, capacity, or failure recovery;
- bounded long-session KV;
- audio/video synchronization beyond one camera frame per model unit (camera
frames ride the audio appends as
video_frames; the wire contract is in the Realtime Duplex API guide).
Active Runtime Path
The actual path is:
WebSocket
-> OmniDuplexSessionHandler
-> DuplexSession + MiniCPMO45ServingSessionState
-> DuplexRequestClient
-> AsyncOmni thin open/append/signal/close proxies
-> DuplexControlClient over the engine-owned correlated RPC transport
-> DuplexControlPlane + DuplexSessionRuntimeManager
-> MiniCPM DuplexRuntimeExtension + engine session/stage bindings
-> StagePool
-> resumable scheduler request
-> MiniCPM Stage0
-> Stage1 TTS / Token2Wav
-> output processor
-> Realtime protocol projection
-> WebSocket writer
The removed typed reducer/facade and worker-provider runtimes were not in this path. This refactor continues to modify only the path above.
State Ownership
The architecture allows several state objects only when their facts are orthogonal.
| Owner | Authoritative facts |
|---|---|
DuplexSession |
session state, epoch, turn, active/last response, pending input, overlap duration, playback cursor, history |
MiniCPMO45ServingSessionState |
MiniCPM PCM buffer, deferred overlap payload, data-plane task, continuation effect counter |
DuplexWebSocketActor |
inbound mailbox, outbound queue, writer, transport close state, effect task handles |
NativeRealtimeSessionProtocol |
wire-only response/item projection and input-buffer projection |
DuplexSessionRuntimeManager |
fence snapshot, append reservation, completed-operation cache, resource reservations, stage bindings |
DuplexControlPlane |
control-message validation, transactional open/append/update/close, extension invocation, typed segment/output decisions |
Important distinctions:
- Realtime response IDs are projection caches, not domain decision sources.
- Engine fences duplicate identity across a process boundary by design; they are immutable validation snapshots.
- Stage bindings are engine resources, not serving response state.
continuation_owner_idscopes an effect counter either to a visible response or, beforeresponse.created, to an explicit model turn. A pre-response continuation also revalidates request, epoch, turn state, and model-turn identity immediately before append; it cannot survive turn completion.- runtime open/close acknowledgements are handler-local effect bookkeeping, not Session lifecycle.
Implemented Concurrency Contracts
Single inbound mailbox
The Actor now uses one:
asyncio.Queue[event]
This replaces competing control/input/event Queue.get() tasks and the
deferred-event list while preserving WebSocket arrival order. A later close,
cancel, or clear must not overtake earlier append, playback ACK, or
session-update events. Slow runtime appends remain background tasks. Cancelling
their asyncio task does not stop a synchronous callable already running in the
default executor, so cancellation correctness is provided by the engine fence,
not by Task.cancel().
Properties covered by focused tests:
- every enqueued event is delivered exactly once;
- terminal control preserves wire order while cancelling background work;
- clear and cancel never scan or delete later mailbox events;
- one writer owns all WebSocket sends.
Correlated RPC result router
AsyncOmniEngine now has one consumer for rpc_output_queue. Results route by:
("duplex", control_id)
("collective", rpc_id)
The router replaces the global RPC lock. It supports out-of-order replies, timeout unregister, late-result rejection, fatal-error broadcast, and close unblocking.
Additional lifecycle rules:
- uncorrelated non-fatal errors are not broadcast to unrelated waiters;
- a latched fatal rejects new calls before they enter the request queue;
EngineDeadErroris broadcast directly to the RPC queue even though the orchestrator consumes that exception during teardown;- shutdown closes the router before joining the orchestrator;
- failure to enqueue shutdown closes the request queue and still attempts a bounded orchestrator join;
StageRuntimecleanup runs only after the orchestrator has stopped, avoiding concurrent client teardown.
The router removes wait-side head-of-line blocking. The orchestrator request handler remains ordered, but executor submission order is not itself a session ordering guarantee.
Extracted control ownership
The generic engine mechanism and the duplex plugin are split by responsibility:
| Module | Responsibility |
|---|---|
engine.messages |
ordinary engine queue messages and collective RPC envelopes |
engine.rpc_result_router |
generic correlation routing through each result message's rpc_correlation_key |
engine.duplex.contracts |
immutable duplex DTOs, extension/stage protocols, typed decisions, request-ID codec |
engine.duplex.lease |
duplex lease activity, TTL/grace configuration, and expiry records |
engine.duplex.messages |
identity fence and duplex control message/result envelopes |
engine.duplex.runtime |
extension loading, validation, and compatibility exports |
engine.duplex.session |
session resources, fences, append reservations, idempotency cache, binding cleanup |
engine.duplex.control_plane |
control algorithms and the narrow DuplexStagePort used to submit and clean up stage requests |
engine.duplex.control_client |
typed control-message construction and calls through the engine-owned correlated RPC transport |
entrypoints.duplex_request_client |
deterministic request identity, client-state preregistration, acknowledgement validation, data-plane collection, rollback |
Orchestrator, AsyncOmniEngine, and AsyncOmni retain explicit wiring and
compatibility proxies. They no longer own the corresponding duplex algorithms.
The ControlClient does not own waiters or consume the result queue;
RpcResultRouter remains the single correlation owner for both duplex and
collective RPC. The ControlPlane receives a narrow stage port rather than the
Orchestrator object.
Control-plane enablement is independent from extension presence.
PipelineConfig.duplex_control_enabled determines whether an Orchestrator
constructs a ControlPlane. This preserves extension-free
TURN_COMMIT_ONLY deployments while allowing ordinary deployments to use a
single None fast-path check. The MiniCPM pipeline explicitly enables control
and separately selects its runtime extension.
Transactional open and append
Opening a native session registers its engine state and reserves the Stage0 request resource as one externally atomic operation. If capability, sampling, extension, or request-state initialization fails, the ControlPlane removes the session, reserved request resources, and any preregistered request state before returning the error. Cleanup distinguishes a reserved resource from a submitted request, so it does not decrement the running counter for unrelated work. Closing a session that never appended also removes the preregistered resource.
Expiry and request-triggered cleanup use a two-step close. The runtime manager first marks the session closing but retains it in the admission set. Only a successful stage cleanup finalizes and removes the session. A blocked or failed cleanup therefore continues to consume its server-owned slot and is retried; replacement admission cannot overlap request or KV resources that are still owned by the closing session.
Append uses prepare/submit/commit ordering. prepare_append() computes the
next sequence values without mutating the session. Prompt planning and stage
submission use that reservation, and only a successful submit commits
input_seq, input_turn_seq, and the accepted fence. Serving uses a matching
PCM reservation and consumes bytes only after the append acknowledgement;
failure or a closed predecessor rolls the bytes back. An operation_id caches
completed append results so a retry cannot submit the same physical append
twice.
Ordered append and configuration effects
Native append effects form a per-session tail. Each append awaits its
predecessor before entering the engine, and session.update awaits that same
tail before changing either the serving aggregate or engine configuration.
Consequently, an append received before an update uses the old immutable
sampling snapshot, while the first accepted append after the update uses
the new generation.
The engine increments config_generation when it accepts the replacement
configuration. At the next append boundary, the control plane rebuilds sampling
parameters from stage defaults through the model extension. It never mutates
the snapshot of a segment already in progress.
Irreversible cancellation fence
Every cancel control carries the cancelled fence and the next session fence.
The AsyncOmni facade preserves both values across the serving,
entrypoint, engine-message, and orchestrator boundaries. The orchestrator then
atomically:
- advances the runtime state to the next fence;
- releases and aborts bindings owned by the cancelled fence;
- permanently rejects an append that arrives later with the old fence.
Missing next_fence is rejected without releasing bindings; the engine never
advances independently from serving. A late cancel cannot move a session
backward when the runtime is already on a newer fence. Idle playback clear does
not send an engine cancel because no runtime resource is being cancelled.
model_native_duplex also validates its engine-client contract before opening
the session. The open, append, signal, and close methods must exist and declare
the required fence arguments; signal must additionally accept next_fence.
An older or custom client that cannot preserve this boundary fails with
runtime_contract_invalid instead of silently running an unfenced native
session. The generic serving-session adapter retains its compatibility behavior.
DuplexFence also carries a server-side session incarnation. Reusing a public
session ID after close increments that incarnation, and resource request IDs
include it after the first incarnation. A synchronous append from a closed
session therefore cannot be accepted by a newly opened session with the same
public ID. Stale close validates the full fence before removing the runtime
registry entry or releasing bindings.
Implemented Serving Ownership
Session-owned response and overlap state
The Actor no longer stores:
- a Session reference;
- lifecycle mirror state;
- active or last response ID;
- overlap duration;
- response-in-progress or playback business predicates;
- runtime open/close acknowledgements.
DuplexSession.begin_response() owns both active and last response identity.
The Session owns overlap accumulation/reset and playback state.
One native_response_in_progress() predicate now covers:
- active response ID;
- active engine request ID;
- uncommitted ACK-only playback;
- active generic response task;
- response-bound append tasks;
- MiniCPM data-plane task;
- assistant-generating turn state.
The write-only MiniCPMO45ServingSessionState.response_emitted field and a dead
data-plane task mirror were removed.
Response-scoped options
response.create is represented by an immutable ResponseCreateOptions value.
The Session reserves it for one response, applies it to a response-local copy
when that response begins, and restores the base session configuration on
completion, listen, cancellation, or failure. Instructions, voice, modalities,
temperature, token limit, format, tools, and conversation metadata therefore do
not mutate the permanent session configuration or leak into a later response.
Explicit event effects and pure writer
Business paths call emit_event(). It serializes domain effects and outbound
queue insertion with one session-local lock. Deferred append starts outside the
lock so recursive event emission cannot deadlock.
The writer only:
- dequeues an event;
- applies late stale filtering to streaming model output;
- projects the event to the selected wire protocol;
- sends JSON.
The writer does not promote overlap, clear input, advance Session state, call the engine, or commit history.
Single terminal acceptance point
Terminal events are accepted or rejected before domain effects run:
response.done
response.listen
audio.cancelled
input.cancelled
session.closed
For a terminal carrying an epoch, that epoch must match the current Session. Stale cancellation therefore cannot reset overlap belonging to a later turn. Once a terminal is accepted and its effect is applied, the writer cannot revoke it because close or epoch state changed while it waited in the output queue.
Streaming audio/text deltas still receive late stale filtering at send time, which prevents queued old audio from leaking after a cancel or epoch bump.
Runtime Type Boundary
The stable kernel is deliberately limited to generic engine and scheduler mechanisms:
vllm_omni.engine.messages # ordinary queue and collective RPC envelopes
vllm_omni.engine.rpc_result_router # generic correlation routing
vllm_omni.engine.async_engine_utils # ordinary engine lifecycle helpers
The full-duplex implementation lives in dedicated duplex modules:
vllm_omni.engine.duplex.runtime
# extension loading and validation
vllm_omni.engine.duplex.contracts
# immutable DTOs and narrow protocols
vllm_omni.engine.duplex.lease
# duplex lease primitives
vllm_omni.engine.duplex.messages
# duplex identity and queue envelopes
vllm_omni.engine.duplex.session
# session transaction implementation
vllm_omni.engine.duplex.control_plane
# engine-side control algorithms
vllm_omni.engine.duplex.control_client
# correlated control client
vllm_omni.entrypoints.duplex_request_client
# entrypoint request/output lifecycle
Stable engine and entrypoint modules import the duplex kernel eagerly at
module load (top-of-file imports). Duplex behavior remains opt-in: the
ControlPlane, ControlClient, and request client are constructed only on
duplex-enabled deployments, and the model runtime extension still loads only
through the dotted-string plugin path. The client-side package
(vllm_omni/clients/, home of the public DuplexClient) is never imported
by runtime code.
PipelineConfig.duplex_control_enabled enables the generic mechanism, while
PipelineConfig.duplex_runtime_extension separately selects the model adapter.
The MiniCPM pipeline opts into both and installs
MiniCPMO45DuplexRuntimeExtension; ordinary pipelines construct neither a
ControlPlane nor a ControlClient. The model-neutral protocol exposes
sampling-parameter configuration, append planning, and a typed stage-output
decision. MiniCPM owns
stage-specific overrides, PCM-to-token budgeting, force-listen payload policy,
special-token interpretation, and listen response metadata. The generic
Orchestrator does not parse MiniCPM session keys, listen_token_id, or construct
MiniCPM metadata, and it does not import the adapter.
Extension loading is fail-fast. Startup validates every required callable and the number and type of returned stage sampling parameters. Invalid custom extensions fail before a session can open.
Direct listen/speak outcomes use DuplexOutputDecision on
OmniRequestOutput. That typed envelope is authoritative over inner completion
metadata for native projection, so a processed output cannot hide a listen
decision merely because it retains unrelated multimodal metadata.
Generic multimodal chunk accumulation lives in
vllm_omni.outputs.multimodal_accumulation. It owns snapshot replacement,
DELTA draining, and audio-finality interpretation; OmniRequestState only
invokes that policy while assembling request output. Model output processors no
longer need to add chunk-retention rules to the main output orchestration file.
The strict chunk-transfer schema still contains hidden_states.tts as the
generic AR-stage-to-TTS handoff and several MiniCPM streaming token IDs as
compatibility wire fields. The latter are not a preferred generic model
contract. Moving them behind a model-namespaced metadata envelope requires a
versioned producer/consumer migration because unknown msgspec fields are
rejected; silently deleting or renaming them in this checkpoint would break
existing Stage0-to-Stage1 transfer.
Public and runtime configuration channels
Control messages carry two independent snapshots:
session_configcontains the public Realtime/session values used by serving and model prompt policy;runtime_configcontains server-derived MiniCPM sampling, scheduler, context reserve, prefix-budget, and resolved reference-audio values.
The MiniCPM serving adapter derives runtime_config from deploy/model defaults
and validated public options. Clients cannot provide private runtime keys in
session.create or session.update; response-scoped extra_body drops those
keys rather than turning them into engine policy. The ControlPlane constructs
sampling parameters only from runtime_config,
while append planning receives both snapshots explicitly. Raw Realtime
extra_body is not used as the engine's sampling-policy channel.
session.update prepares both candidates, validates the runtime candidate in
the engine, and replaces the serving snapshots only after the engine ACK. A
rejected candidate therefore leaves both the public configuration and runtime
sampling generation unchanged.
DeployConfig.duplex_session is an immutable, server-owned
DuplexSessionRuntimeConfig. These limits are not read from client
extra_body or session payloads:
| Field | Default | Owner | Lifecycle or resource effect |
|---|---|---|---|
idle_ttl_s |
300.0 |
Engine lease manager | Expires an attached or detached session after inactivity; null disables only idle expiry. |
disconnect_grace_s |
30.0 |
Engine lease manager and API attachment registry | Bounds how long a detached session may retain engine resources before cleanup. |
reaper_interval_s |
5.0 |
Orchestrator | Sets the cadence for lease expiry and pending cleanup retries. |
resume_replay_ttl_s |
60.0 |
API attachment registry | Expires replay events retained for resume or takeover. |
resume_replay_max_bytes_per_session |
8388608 |
API attachment registry | Bounds each session's replay buffer. |
max_pending_input_bytes_per_session |
16777216 |
API session input ledger | Applies per-session byte backpressure before input is admitted. |
max_pending_turns_per_session |
4 |
API session input ledger | Bounds queued turns that have not completed runtime processing. |
max_sessions |
1 |
Engine session manager | Atomically enforces admission; closing sessions retain capacity until cleanup finalizes. |
completed_append_cache_size |
256 |
Engine session manager | Bounds completed append idempotency records per session. |
Deploy profiles may lower or raise these values to match scheduler capacity, but clients cannot override them. The engine remains authoritative for admission and cleanup ownership even when the API performs an early check.
Client-side companion package
The public client surface ships under vllm_omni/clients/ and stays on the
consumer side of the boundary above: vllm_omni.clients.duplex provides the
model-agnostic DuplexClient (typed events, per-response demux via
ResponseHandle, session.resume with acknowledgement-based replay dedup)
and the EventCollector helper used for timing and request-metric summaries.
Model-specific session defaults live in thin per-model preset modules —
vllm_omni.clients.minicpmo_4_5.create_duplex_session_config() and
vllm_omni.clients.personaplex.create_duplex_session_config() — so the core
client never imports model packages and the model duplex plugin packages
never import the client (both directions are enforced by tests:
tests/engine/test_duplex_import_boundary.py,
tests/entrypoints/openai_api/test_duplex_capability.py, and the subprocess
purity guards in tests/clients/test_model_session_configs.py).
examples/online_serving/barge_in_client.py (with its flow companion
barge_in_client_flow.md) is the runnable reference for the client library
against a live duplex deployment. The wire vocabulary the client speaks —
every /v1/realtime?duplex=1 event with its OpenAI-compatibility tier and a
JSON example, plus the native-to-Realtime name map — is catalogued in the
Realtime Duplex API serving guide; that
page derives from the normative contract table above and must be updated
with it.
Generic-Path Cleanup
Model-specific MINICPMO45_PROFILE_LOGS probes were removed from:
- the generic AR scheduler;
- orchestrator;
- AsyncOmni;
- the generic GPU AR runner.
The cleanup removes the temporary serving and generic-runtime probes. The
generic AR runner builds DuplexSamplingRow values only when the model exposes
the optional prepare_duplex_sampling() hook, then invokes that hook exactly
once before the normal sampler. Ordinary models do not scan request rows.
MiniCPM owns force-listen logits, turn-ended state, and its row-local sampling
policy behind that hook; the runner no longer reads _minicpmo45_* attributes
or retries sampling after matching TypeError text.
The scheduler no longer parses serving session_config, extra_body, or
duplex_stage_sampling_params. Stage-specific overrides are materialized as
ordinary SamplingParams by the model runtime extension before scheduler
admission. Existing vLLM stop-token, EOS, and max-token handling defines each
segment boundary; resumable=True parks the request for the next streaming
update. The scheduler does not inspect a duplex dictionary, Realtime
configuration, or MiniCPM sampling fields.
The generic runner's unused _duplex_force_listen_applied_segments cleanup and
the obsolete streaming_accumulated_keys accumulation hook were removed. The
latter had no model producer after its upstream Qwen removal; streaming input
still performs the ordinary replacement merge needed by active models.
Local turn.signal transitions such as user_started and
assistant_started no longer perform an engine RPC. Only cancellation and
session.update cross the engine boundary because those operations change
runtime resource identity or runtime configuration.
During a stage transition, StreamingInputState.source_token_decoder exposes a
short-lived, model-neutral decoder sourced from the upstream output processor.
The MiniCPM Stage1 input processor consumes that capability for token-faithful
delta transcripts. The generic orchestrator no longer installs or names a
MiniCPM-specific bridge-state key.
The stable API server imports the duplex serving package eagerly and inspects
the deployment session_mode before constructing the duplex handler. Ordinary
deployments do not create its registries and background state.
For an enabled deployment, the client still selects the Realtime duplex route
with ?duplex=1 (or an equivalent explicit true value). Model-name matching is
not used for routing or native-runtime activation. MiniCPM clients explicitly
set extra_body.native_duplex=true; the canonical way to do so is
the vllm_omni.clients.minicpmo_4_5.create_duplex_session_config() preset,
which the repository's barge-in example
(examples/online_serving/barge_in_client.py) uses in its session payload.
DuplexRequestClient derives the data-plane request identity from the accepted
fence and preregisters a resumable ClientRequestState before the append crosses
the engine boundary. The acknowledgement must return the same identity or the
operation fails. Unregistered outputs, including names that merely use the old
duplex- prefix, are dropped. Timeout, cancel, and close remove the
preregistered state.
Why Scheduler Changes Remain
Serving cannot preserve model KV after a segment ends. The scheduler must keep one resumable request in a waiting state and accept a later update:
RUNNING
-> segment stop
WAITING_FOR_STREAMING_REQ (KV retained)
-> next append
RUNNING
-> session close
FINISHED (KV released)
Scheduler responsibilities are limited to resumable request state, runtime context update, stop/boundary handling, and final release. Scheduler must not own response IDs, playback, overlap, Realtime events, or model policy.
Serving and Compatibility Boundaries
Normative Realtime event contract
The public contract in this section applies to /v1/realtime?duplex=1. Internal
duplex event names such as response.output_audio.delta are projection inputs,
not additional public aliases. Events for one attachment preserve mailbox
order; replay after resume preserves the original event order and event_id.
Accepted compatibility aliases are normalized before mailbox admission:
| Canonical input event | Accepted aliases | Contract |
|---|---|---|
input.text.append |
input_text.append, push_text |
Appends text to the open input item. |
input_audio_buffer.append |
input.audio.append, push_chunk |
Appends audio; legacy aliases default to WAV when no format is supplied. |
turn.signal |
signal_turn |
Sends an explicit turn signal; it does not imply response creation. |
playback.ack |
audio.playback_ack |
Advances playback/history only for the identified response and attachment. |
session.close |
close_session, close |
Closes the session after previously admitted mailbox events. |
The Realtime projector emits the following response lifecycle. Cardinality is
scoped to one response_id unless stated otherwise.
| Public event | Trigger and ordering | Cardinality |
|---|---|---|
response.created |
First event for a visible assistant response; precedes its output-item, content-part, speak, text, and audio events. | Exactly once for every visible response. |
response.speak |
The model selected speak; emitted after response.created and no later than the first response.audio.delta. It carries decision metadata, not transcript text. |
At most once. |
response.audio.delta |
Carries one ordered audio chunk. Every emitted audio delta is followed by a response.audio_transcript.delta for the same chunk. |
Zero or more. |
response.audio_transcript.delta |
Append-only transcript contribution paired with an audio delta; it may be empty for a text-less audio unit and must not repeat or overlap earlier text. | One per audio delta. |
response.audio.done |
Closes the audio stream after its final delta and before response terminal events. It is omitted for a response with no audio or transcript. | At most once. |
response.audio_transcript.done |
Contains the exact concatenation of all non-overlapping transcript deltas for the response. | At most once, and only for a non-empty transcript. |
response.output_item.done / conversation.item.done |
Finalize the assistant item after content-part terminal events and before response.done. |
At most once each. |
response.done |
Terminal event for a created response; follows audio, transcript, content-part, and item terminal events. | Exactly once for every created response that reaches a terminal state. |
rate_limits.updated |
Compatibility event emitted immediately after response.done; the current payload has an empty rate-limit list. |
Once per emitted response.done. |
response.listen |
The model selected listen at a model-unit boundary. It may be emitted without response.created; clients must not infer a missing or pending response from it. |
Zero or more per session, at most once per model-unit decision. |
Session lifecycle events (session.created, session.updated,
session.resumed, session.replaced, session.expired, and
session.resync_required) are session-scoped rather than response-scoped.
session.created is emitted before held response output on a new attachment.
Cancellation and truncation use terminal status/details on the affected
response and never reuse that response ID for later model turns.
Compatibility changes must update this table and its protocol contract tests in
the same change. The golden transcript test requires joined
response.audio_transcript.delta values to equal
response.audio_transcript.done.transcript; response tests separately enforce
at-most-once response.speak and terminal event cardinality.
Session internal ledgers
DuplexSession remains the serving aggregate root and now composes
InputBufferState, ResponseState, PlaybackLedger, and
ConversationHistory. The ledgers are private. Read-only properties return
tuples, snapshots, or mapping proxies, and serving modules mutate them through
Session transition methods for request, response-turn, lifecycle, playback,
capability replacement, and history. The Realtime session-update parser still
normalizes individual fields before atomically retaining or rolling back the
aggregate configuration. Further work may narrow the read-only compatibility
surface, but it must not introduce another reducer or a second session state
machine.
Realtime input ownership
The browser is a continuous PCM producer. While the session is open and the
microphone is unmuted, it sends input_audio_buffer.append every 200 ms,
including while assistant audio is being generated or played. It does not run
VAD and does not send input_audio_buffer.commit. Native Stage0 consumes the
stream in approximately one-second model units; MiniCPM listen, speak, and turn
EOS decisions advance the model conversation.
Explicit Realtime clients may still use input_audio_buffer.commit to create a
conversation item. The translator validates that wire input before producing a
commit carrying item_id. Native auto-response may already have
streamed those PCM samples into the runtime, so the runner accepts a validated
commit even when no runtime-side chunk remains. A truly empty explicit buffer
continues to return input_audio_buffer_empty.
The commit-to-response contract is:
Session state at input_audio_buffer.commit |
Runtime action | Required event outcome |
|---|---|---|
| Auto-response disabled, valid idle input | Commit and retain the input item. | Emit input_audio_buffer.committed; emit no response until an explicit response.create. |
| Auto-response enabled, valid idle speech | Submit the final native append for the current model turn. | Emit input_audio_buffer.committed before model output. Create a response only on the first visible text/audio output; a model listen decision may end with response.listen and no response. |
| Auto-response enabled, short overlap during an active response | Discard the short overlap as an acknowledgement. | Emit the committed/no-response acknowledgement; do not create or defer a response. |
| Auto-response enabled, meaningful overlap during an active response | Retain one deferred payload and promote it after the active response terminates. | Emit input_audio_buffer.committed with deferred metadata. A later response follows the same visible-output rule and never reuses the prior response ID. |
Explicit silence/noise (is_speech=false) |
Clear the physical input without a runtime append. | Emit the committed/no-response acknowledgement followed by response.listen. |
| Empty or invalid input | Reject without changing response ownership. | Emit one typed error; do not emit response.created. |
| Previous response cancelled or truncated and cleanup complete | Apply the corresponding idle rule above. | The prior response remains terminal; any later visible output receives a new response ID. |
Append tasks, explicit commits, cancellation, and deferred promotion are serialized by the session actor. Tests cover post-response commits, active playback deferral, truncated-response ownership, and two-turn response-required execution; the H20 validation record below is the golden multi-turn runtime evidence.
During auto-response overlap, preserve_realtime_input distinguishes "do not
append this silent chunk to the native buffer" from "clear the open Realtime
item". Silent overlap no longer discards earlier user PCM.
The first chunk of one overlapping input item also reserves its target model turn. A later Realtime commit uses that reserved identity even if response EOS has already advanced the Session turn; clear, cancel, close, and successful promotion release the reservation.
Physical input and model-turn identity
A Realtime input item, an optional input_audio_buffer.commit, and a MiniCPM
model turn are intentionally different identities. The browser normally keeps
one input stream open without commits. Native Stage0 evaluates that stream in
approximately one-second model units. A sampled <|turn_eos|> closes the
current model turn and allows a later streamed unit to start another turn.
Explicitly committed input may also produce only model-listen decisions and no
spoken response.
Therefore neither model_turn_id nor response cardinality is derived from the
number of physical inputs. model_turn_id advances at model EOS. The
response-required fixture may require exactly one response per requested
turn, but model-policy validation must accept additional model-owned responses
and cannot assert cross-turn transcript independence through physical-input
attribution.
Before closing, the model-policy demo drains every created response through
response.done, acknowledges every completed audio playback, and requires a
bounded quiet interval with no newly created response. This validates lifecycle
completion without suppressing or overriding the model's listen/speak policy.
Non-cancel signal semantics
turn.signal remains a public compatibility surface for local Session and
Realtime transitions. Serving forwards only cancellation and session.update
to the engine. The engine rejects arbitrary non-cancel signal names instead of
returning a misleading supported acknowledgement. session.update remains a
real engine operation because it replaces the configuration consumed by later
append plans.
Remaining Architecture Work
The following work is deliberately not hidden inside this checkpoint.
Serving composition
The generic runner and runtime bridge now depend on the model-neutral
ServingRuntimeAdapter protocol and no longer import MiniCPM modules. The
MiniCPMO45ServingRuntimeAdapter owns MiniCPM serving state, PCM preparation,
data-plane projection, client/runtime configuration validation, prefix policy,
and capability projection. PipelineConfig.duplex_serving_adapter explicitly
selects its import path; AsyncOmniEngine and AsyncOmni carry that path to the
API server, which passes it into the generic handler. The handler has no model
default and fails startup when the duplex endpoint is enabled without an
adapter. Tests may inject an adapter instance directly. Import-boundary tests
verify that loading the generic runner, bridge, or handler does not load MiniCPM
modules.
Behavior is still assembled through DuplexSessionRunnerMixin,
NativeRuntimeBridgeMixin, and ChatFallbackProjectorMixin, so explicit
adapter composition is only the model-policy boundary, not a completed removal
of host-method coupling. A later refactor may replace the remaining mixins with
explicit protocol and effect-runner dependencies while preserving one actor
mailbox and one DuplexSession transition owner. Splitting those components
must not create a second reducer or concurrent state machine.
The four private Session ledgers are data partitions, not independent owners.
DuplexSession intentionally remains the aggregate root. Further encapsulation
may narrow compatibility properties, but serving code must continue to perform
state changes through Session transitions.
Plugin descriptor and typed payloads
The pipeline currently selects the MiniCPM engine extension and Serving adapter through two explicit fields, but there is not yet one versioned plugin descriptor that binds those identities in an open handshake. Realtime client payloads do not select either implementation. Adding a second native model should first introduce that descriptor and reject serving/engine plugin mismatches explicitly.
The stable boundary has typed fences, plans, policies, decisions, and control
messages, but session_config, runtime_config, and append payloads still use
generic mappings or objects at the extension boundary. Model-neutral typed
session and input-chunk contracts remain follow-up work, especially before
adding video.
Output modality policy
The current Realtime duplex contract supports text and audio output. Unknown
output modalities fail closed with unsupported_response_modality; they are
never silently projected as text. This checkpoint deliberately does not add a
modality registry because there is only one native text/audio projection path
to register.
A registry becomes justified when a second non-audio/non-text implementation uses the same validation, encoding, lifecycle, and projection contract. At that point the abstraction must be extracted from two working adapters and retain the same fail-closed behavior. A speculative registry before that condition would add selection state without removing any existing branch.
Performance measurement contract
Stage0 token timing comes only from the engine's per-segment metric snapshot:
vllm_ttft_ms, vllm_tpot_ms, and vllm_itls_ms. A resumable Stage0 request
exports its segment-local num_generation_tokens; ordinary requests retain the
existing output-token fallback. Realtime response state clears its metric
accumulator at response.created, combines only model units owned by that
response, and ignores session-level events with no matching response_id.
Realtime projection carries the result under
metadata.vllm_omni.stage_metrics; clients do not estimate TPOT from transcript
deltas or subtract cumulative snapshots.
Audio timing uses the client's monotonic receive clock and is partitioned by
response_id. response_created_to_first_audio_ms measures projection startup
after response.created; commit_to_first_audio_ms measures end-to-end output
from an explicit input commit. Neither field is called TTFP without naming its
origin. Reports also include inter-chunk and chunk-duration
count/mean/p50/p95/max plus maximum chunk gap. Engine and client measurements
have different clock origins and must not be merged into one timeline.
Stage1 CUDA Graph boundary
Native Stage1 streaming advances Python-owned request generators and turn
state on every scheduler step. A full-model CUDA Graph would execute that
Python control flow only while capturing and then replay stale chunk and turn
metadata, even if its host-to-device tensor construction were made
capture-safe. The duplex deploy profile therefore keeps enforce_eager=false
but selects cudagraph_mode=PIECEWISE for Stage1. Python orchestration runs on
every step while fixed-topology compiled regions remain eligible for CUDA
Graph capture. This is a model execution constraint, not an E2E-only override.
Resource capabilities
At the time of this snapshot the checked-in MiniCPM duplex deploy profile set
Stage0 and Stage1 max_num_seqs to two and configured the engine runtime
manager with max_sessions=2; the duplex settings have since been folded into
vllm_omni/deploy/minicpmo_4_5.yaml, which admits four sessions
(duplex_session.max_sessions: 4, matching its stage max_num_seqs). The
Realtime capability response advertises
multi-session and same-replica multi-session support with
session_admission_mode="engine_managed". Client session fields cannot raise
this server-owned limit. Fresh E2E evidence covers two concurrent sessions in
both model-policy and response-required modes, rejection of a third session,
and admission of a replacement after one accepted session closes. It does not
establish behavior beyond that configured limit.
Session leases, lease TTL, resumable attachment, orphan reaping, and bounded Serving input/backpressure are implemented. A production admission controller, KV-aware capacity budgeting, fairness, starvation metrics, and multi-session worker-failure recovery remain follow-up work. Capability claims must stay limited to the mechanisms and two-session execution shape validated here.
Validation Evidence
All pytest and runtime evidence for this branch must run on the remote H20.
Current synchronized tree
The current dirty tree was synchronized to the isolated H20 worktree at
/home/admin/workspace/aop_lab/model_runner_v2/vllm-omni-worktrees/pr3907-boundary-cg-0721.
- Final affected matrix: 661 passed, one network-dependent config test
deselected, 20 warnings. It includes the complete MiniCPM native-duplex hook
file and all control/session/handler/Orchestrator/import-boundary suites. The
run is task
be221f67(/tmp/remote_gpu_logs/be221f67.log). The deselectedtest_resolve_when_autodetect_resolves_noneattempted a Hugging Face model lookup and hung in the isolated environment; the duplex deploy-config test passed independently. - Stage1 PIECEWISE CUDA Graph validation: the deploy-config assertion first
failed with no Stage1 compilation config, then passed in task
3ed6091d. Server task95827027started withenforce_eager=False, loggedcudagraph_mode=PIECEWISE, and captured batch sizes 1, 2, and 4 without the CPU-to-CUDA capture error. The first concurrent E2E exposed graph padding asrequest_token_spanscovering three valid rows in a four-row capture. A focused RED/GREEN now verifies both single-request and batched-request padding, and the full MiniCPM hook file passed 47 tests in taske89dba88. After the fix, two concurrent response-required sessions each completed two spoken responses, playback acknowledgements, and history lifecycles with no error or stale event. Artifacts are under/tmp/pr3907_piecewise_response_required_2x2_fixed_20260721. - An exploratory two-session, two-turn model-policy run still left one session
waiting after its second input commit while the other session completed both
turns. The same liveness symptom was reproduced with Stage1 eager mode before
the padding fix, and no Stage1 fatal occurred after the fix. This run is not
counted as PIECEWISE acceptance evidence; the remaining Stage0 resumable
reactivation path requires separate diagnosis. Artifacts are under
/tmp/pr3907_piecewise_model_policy_2x2_fixed_20260721. - Latest broad affected matrix before the final pre-response continuation fix:
608 passed, 19 warnings. This includes the
Stage0 row-routing regression, cleanup-held admission regressions,
segment-local token accounting, response-owned metric filtering, the generic
output-processor suite, direct E2E script execution, and continuous-response
overlap ownership. The run is task
e2146318(/tmp/remote_gpu_logs/e2146318.log). - The pre-response continuation regression group passed 5 focused tests. The
response-required two-turn scenario then passed 5/5 repeated runs in task
two-session semantic isolation plus resume, takeover, and
admission passed in task
49225794; model-unit overlap passed in task3888387d. The final affected matrix above supersedes the earlier pending rerun requirement. - Serving plugin selection was first reproduced as three RED tests: the generic
handler silently selected MiniCPM and the pipeline had no explicit Serving
adapter field. The final pipeline/import/fail-fast group passed 9 tests in task
4db6d959(/tmp/remote_gpu_logs/4db6d959.log). The token-metric and client audio-cadence producer-to-consumer group passed 8 tests in taskcf80f6b2(/tmp/remote_gpu_logs/cf80f6b2.log). - Stable-import isolation was first reproduced as a RED that loaded twelve
experimental.fullduplexmodules. After extracting the kernel and making implementation loading conditional, the isolated import regression passed. The focused ControlPlane/ControlClient/session/runtime/Orchestrator/AsyncOmni suite then passed 139 tests in taskfad613ea(/tmp/remote_gpu_logs/fad613ea.log). After the final contract-import cleanup, the import-boundary, runtime, and lease suites passed 45 tests in taskbc138887(/tmp/remote_gpu_logs/bc138887.log). - Concurrency and failure-path hardening now has direct regressions for
single-flight cancel/close cleanup, cleanup retry without dropping request
resources, failed-replica affinity cleanup and same-address reattachment,
append ordering across resume, takeover ownership of pending-turn
reservations, and a closed-transport send immediately after resumable
credentials are delivered. The final focused resume/takeover group passed 5
tests in task
dc2ac96e(/tmp/remote_gpu_logs/dc2ac96e.log). - Stage1 batching regression: the focused test first failed because only the
first request in
runtime_additional_informationreached the talker. After routing every request byrequest_token_spans, preserving per-row terminal flags, and emitting request-keyed sparse audio, the complete MiniCPM duplex hook file passed 42 tests. - Stage0 batching regression: two requests in one batch now retain different
duplex_prompt_token_idsandspecial_token_ids. The model emits row-aligned payload elements, so Stage0 no longer copies request zero's duplex metadata to later sessions. - Admission finalization regressions cover
max_sessions=1with blocked and failed expiry cleanup plus request-triggered cleanup. Every replacement is rejected withresource_exhausteduntil stage resources are cleaned and the old session is explicitly finalized. - Resumable token accounting exports each model unit's native generation-token count instead of recounting the request's cumulative token IDs. Realtime response state resets and accumulates those segment snapshots, and the client ignores unowned session-level metrics while summarizing a response.
- Engine-owned admission and two-session model-policy E2E: two random session
IDs were admitted and remained isolated, a third was rejected with
resource_exhausted, closing one accepted session returned capacity, and a random replacement session was admitted. The capability projection reportedconfigured_limit=2,advertised_multi_session=true, andsession_admission_mode=engine_managed. In the same run one session selected speak and the other selected listen without identity leakage. Detach/resume advanced attachment generation from one to two and rotated its token; active takeover rejected all four writes from the replaced connection. The run is task1d9dd1ed(/tmp/remote_gpu_logs/1d9dd1ed.log), with artifacts under/tmp/pr3907_resume_takeover_native_metrics. - Concurrent two-session, two-turn response-required E2E: both sessions produced
two complete spoken responses, committed two playback acknowledgements and
history records, and emitted no error, cancel, truncate, or stale events. The
semantically distinct inputs produced isolated transcripts containing
"当时" and "一加一" respectively. Per-response Stage0 output counts were 20,
38, 32, and 33 tokens rather than cumulative request totals. The run is task
a16f1467(/tmp/remote_gpu_logs/a16f1467.log), with artifacts under/tmp/pr3907_semantic_2turn_native_metrics. - Disconnect-grace E2E: a detached session expired after the configured
30-second engine grace, and a later resume was rejected with
session_resume_expired. The run is task9872d50c, with artifacts under/tmp/pr3907_0719_disconnect_grace_expiry. - A separate black-box takeover client exercised the current backend with two
semantically distinct concurrent inputs. Active resume replayed 17 events
including five audio deltas and committed playback history; takeover rejected
all four stale-attachment sends, emitted
session.replaced, and preserved attachment/session isolation. Both transcripts contained only their expected semantic token. The client task is6773bd34(/tmp/remote_gpu_logs/6773bd34.log), with artifacts under/tmp/pr3907_gaohan_final_takeover. The backend task was5abcd1ee. - Across the final response-required, overlap, and resume/takeover runs, engine
Stage0 TTFT ranged from 31.17 to 310.18 ms, TPOT from 15.18 to 21.18 ms, and
reported ITL p95 from 16.35 to 32.83 ms. Explicit
commit_to_first_audio_msranged from 386.80 to 1033.25 ms, while the client-monotonic maximum audio chunk gap ranged from 388.30 to 1316.37 ms. These are response-scoped observations from a small, warm-state-sensitive sample, not an SLO claim. The slowest response still does not satisfy a 200 ms end-to-end first-output target. - Post-merge focused handler/demo/Web/Stage0/runner/video suite: 324 passed,
19 warnings. The run is task
f78cb7a8(/tmp/remote_gpu_logs/f78cb7a8.log). The Web contract explicitly rejects browser VAD markers and browser-generated input commits while the merged video path retains camera-frame admission. - No-response listen and residual accounting: the focused handler regressions
passed after first reproducing both failures. A model-listen unit without a
Realtime response no longer defers the next committed input, and three
consecutive 1.2-second
pcm_f32leinputs each returned pending input bytes to zero. - Model-unit overlap E2E: the second physical input streamed while the first
response remained active. Seven overlap decisions had
defer_runtime_append=false; the one-second model-unit boundary occurred before the firstresponse.done, and no barge-in, cancel, truncate, stale, or error event occurred. The model-policy contract accepts a later unit ending the continuous response instead of requiring a second physical-input-owned response. The two response-owned Stage0 snapshots independently reported 20 tokens / 152.8 ms TTFT and 19 tokens / 43.3 ms TTFT. The run is taska4977622(/tmp/remote_gpu_logs/a4977622.log), with artifacts under/tmp/pr3907_overlap_native_metrics_scoped. - The split multi-session E2E runner is directly executable from the repository
even when another installed package owns the top-level
testsname. Its script-import and continuous-terminal regressions passed in taskf11b106c. - Pinned response-required E2E: the first 1400 ms of the pinned fixture produced
one complete spoken response, five audio/transcript deltas, and one playback
history commit, with symmetric created/audio-done/done lifecycle and no
duplicate
response.speak. The client task isa76dec52(/tmp/remote_gpu_logs/a76dec52.log). - Continuous-input model-policy E2E: 36 browser-like 200 ms chunks produced
seven model-listen decisions with zero input commits and no
error/cancel/truncate/stale events. The client task is
fb256595; artifacts are in/tmp/minicpmo_pr3907_continuous_model_policy_20260718. - Three-response continuous-input E2E: three consecutive 9469 ms streams,
composed of the original fixture plus four seconds of silence, produced three
spoken responses in one session. All three completed
response.done, audio transcript, playback ACK, and history commit; the run had 22 audio/transcript deltas, zero input commits, and no error/cancel/truncate/stale events. This is lifecycle evidence, not semantic cross-input independence, because the same fixture was repeated. The client task is2a5f29f0; artifacts are in/tmp/minicpmo_pr3907_continuous_three_response_20260718. - The fresh validation backend was stopped after the E2E runs and GPU0/1 returned to baseline. Audio-quality and ASR evaluation were not rerun for these lifecycle-only changes. Other GPU workloads were not modified.
Regression lineage
Earlier synchronized checkpoints passed broader affected matrices before the extension, policy, and ledger consolidation. Those runs were useful while developing the refactor but are superseded by the current file-identical H20 matrix and E2E evidence above; they are intentionally omitted to avoid mixing different trees in the acceptance argument.
Focused suites
- RPC router and AsyncOmni shutdown/error routing;
- orchestrator fatal and duplex control paths;
- WebSocket Actor mailbox and writer;
- duplex protocol and Session ownership;
- Realtime handler, terminal ordering, overlap, playback ACK;
- scheduler resumable boundary;
- MiniCPM runner and Stage0/Stage1 lifecycle.
E2E contract
Run two deliberately separate fixture contracts:
- model-policy uses three distinct inputs, no transcript hints, 200 ms real-time pacing, and continuous input with no browser commit. It allows natural listen or speak decisions and assumes no 1:1 mapping between physical inputs, model turns, and responses;
- response-required uses
minicpmo_pr3907_jiayan_16k.wavpinned to SHA-2562e5fd4eb3ee434ce107ee3a0591fa624a33f7683c7462f45fe651c443c9af941, sends its first 1400 ms, and requires one complete spoken response. It is an output-lifecycle fixture, not evidence that arbitrary no-hint, real-time input must select speak; - late and old-response playback ACK handling in the affected test matrix;
- final model-policy response drain before
session.close; - close/disconnect with no post-terminal audio;
- transcript delta/final consistency;
- generated WAV validity and external ASR sanity.
Distinct-input fixtures must also be semantically distinct. Different files or waveform hashes containing the same greeting can legitimately produce the same reply and are not sufficient evidence for cross-turn transcript independence.
For each run record the exact SHA/worktree, command, server log, client log, WAV path, and cleanup result.
Acceptance Criteria
This checkpoint is ready to publish only when:
- one mailbox passes exactly-once and wire-order tests;
- cancel advances the engine fence even when the old append continues in an executor, and the next-epoch append remains accepted;
- request completion removes MiniCPM helper and model-owned force-listen state;
- RPC fatal/close wakes every waiter and no new work enters after terminal;
- Session is the only serving response/overlap authority;
- stale terminal events cannot mutate a newer epoch;
- an accepted terminal cannot be dropped by the writer;
- no duplicate
response.speakor transcript delta appears; - Stage0 KV continuity and Stage1 turn reset pass;
- the real H20 multi-turn audio E2E passes;
- ordinary non-duplex paths in the affected matrix still pass;
- no local path, proxy token, temporary profiling probe, or test-only switch is present.
Passing this checkpoint supports the statement:
Single-session, model-owned MiniCPM-o 4.5 native duplex is reviewable on the validated H20 configuration.
It does not support claims for an acoustic-onset barge-in latency target across
arbitrary client chunk sizes, multi-session production concurrency, bounded
long-session KV, or scheduler-native append. Camera-frame input
(video_frames) landed after this snapshot and is covered by its own E2E
test rather than by the evidence above.