Speech and router in one session
agent.py
OPENAI_API_KEY. The SLNG_API_KEY must come from a project on the Execution Layer. See Your router config.
Speech and the router send the same two ids: the agent’s id and the call’s session id, from state.call. SLNG groups usage by them. See Agent and session ids.
SLNG_WORLD_PART must be your SLNG project’s region. Speech and the router both use it. RouterModelCall reads it from the settings. A key used in another region gets 401 from the router.
The three parts are independent. Use the SLNG speech plugins with your own LLM, or the router with your own speech plugins. The complete files are examples/parcel-desk/livekit/agent.py and examples/parcel-desk/pipecat/bot.py.
Speech plugins
The speech plugins are separate packages:livekit-plugins-slng for LiveKit and pipecat-slng for Pipecat. The slng extra holds only what the router client needs.
tracking_ids(state.call) gives both plugins external_agent_id and external_session_id. Build the call state before the session or the pipeline, so the ids exist.
On LiveKit, call stt.attach_to_session(session) after you build the session. Put both services in the session or the pipeline.
STT and TTS spans carry unmute.upstream.provider when the provider is a gateway and the model id reads [slng/]<upstream>/<model>. For soniox/speech-ai:rt-v5 the value is soniox, and for slng/fish/tts:s2.1-pro it is fish. A gateway is a provider that passes a request on to another one. Telemetry(gateways=) names them, and openrouter and slng are the default. A model id with no upstream part gives no attribute. The LiveKit SLNG STT plugin reports slng as its model, so its STT span has no upstream.
The router adapters
The router is aModelCall. Each framework runs it through a small adapter: ModelCallLLM on LiveKit, ModelCallLLMService on Pipecat.
- The adapter sends the prompt with its
{{placeholders}}still in it. - It sends the values of
statebeside the prompt, astemplate_variables. - The router fills the prompt. The model sees the same text it would see on a native LLM.
- The router retries and fails over by its own rules.
state=None when the prompt has no placeholders.
With UnmuteAgent, give the router to the agent’s spec instead: AgentSpec(PROMPT, model=router). On Pipecat, UnmuteFlows sends the requests through the ModelCallLLMService. Build the router and the spec per call, after the state, because the router reads state.call. See AgentSpec.
ModelCallLLM is a real LiveKit llm.LLM, so AgentSession accepts it. Its site attribute is the prompt site of the next request. A prompt site is the place where the prompt is used: the main prompt, or a task step. It starts as an empty string, the main prompt. Its model and provider come from the router, so spans show slng/auto and slng. On Pipecat, give ModelCallLLMService the usual settings. It builds no OpenAI client of its own.
RouterModelCall
Sends each request to the SLNG Context Router. It needs theslng extra and raises ImportError with the install command when the extra is missing.
/v1/chat/completions, and answers 400. The router sends the request there. So an agent with tools sends reasoning_effort "none". An agent with no tools may leave it out.
- It sends
configasslng_configwith every request. See Your router config. - It sends the prompt unfilled, with
{{a.b}}written{{a__b}}. - It sends the values as
template_variables. The router fills the prompt. - The
providerattribute isslng. Themodelattribute defaults toslng/auto. aclose()closes the client only when the call built it. A client you pass in stays open.last_responseholds thex-slng-*headers of the latest answer.- A placeholder name whose router key is over 64 characters is refused when the agent is built.
RouterModelCall.variable_key_maxis 64. See Placeholder names. - Each answer ends with its
Usage.
SLNG_* settings. See Settings.
RouterModelCall arguments
CallInfo
required
What the call carries: pass
state.call. Every request sends its agent_id and session_id. Anything else raises TypeError.str | None
default:"None"
The stored router config to use, when the key does not fix one. Falls back to
SLNG_LLM_CONFIG_ID.str | None
default:"None"
The model name. Falls back to the settings, then to
slng/auto. A name that is not in the config raises ValueError when the config sets tiers.str | None
default:"None"
The router’s OpenAI-compatible URL, ending in
/v1.str | None
default:"None"
The world part whose router to use, like
eu-north, when there is no base_url. It is checked by shape, as one DNS label, and read in lower case.str | None
default:"None"
The SLNG key. Falls back to the settings.
RouterConfig | None
default:"None"
A router config sent with every request as
slng_config. Always pass one. See Your router config.bool
default:"False"
Sends
slng_pure_proxy: no cache, no prompt edits, the default tier only.Mapping[str, Any] | None
default:"None"
Extra body fields for every request, like
reasoning_effort. A request’s params win over it. Its template_variables join each request’s own, and the request’s value wins on a shared name. Setting slng_config, slng_pure_proxy or stream_options here raises ValueError.Mapping[str, str] | None
default:"None"
Headers for every request. They never replace the identity headers.
openai.Timeout | float
default:"TIMEOUT"
The client timeout. See Token counts, timeouts and errors.
AsyncOpenAI | None
default:"None"
A ready client, for tests. The environment is then not read.
SlngConfigError.
Agent and session ids
Every call has both ids instate.call. The router, SLNG speech-to-text and SLNG text-to-speech all send them.
agent_idis required. Pass it toCallInfo,call_info,sip_call_infoortelephony_call_info. Else the call takesUNMUTE_AGENT_ID. With neither, building the call state raisesSettingsError.session_idis a fresh id per call, unless your dispatch passes one. It is the same on every turn and on both frameworks.- Each id is 1 to 128 characters after stripping. It may not hold a comma, whitespace,
{,}or|, and it must be printable ASCII. A bad id fails when the call state is built, not on the first request.
RouterHeaders builds the headers from these ids. RouterModelCall makes it for you.
What each request carries
The site goes in a header so the router scopes its cache by prompt. The router judges which turns are repeatable, and some never cache. A site must be
task: plus a lower snake case name of at most 64 characters, or summary, which goes out as task:summary.
Each answer logs one line at info level: slng router: scope=<agent id> source=<source> layer=<layer> model=<model> request_id=<id>. layer and model appear only when the router sends them.
Your router config
A router config names the models the router may use, their weights, their fallbacks and the keys they run on. Send your own config with every request. Your provider keys go in it.RouterConfig.singleputs one model in tier 1 with weight 100. The endpoint holds your OpenAI key.RouterModelCallsends the config asslng_configwith every request.- A pinned
modelmust be one of the config’s models when the config sets tiers.slng/autolets the router pick.
Which key may send a config
The router accepts aslng_config only from an SLNG key in a project on the Execution Layer. Any other key gets 403 project_mode_forbidden.
Why not the stored config
The router also keeps a config for your org or key. Do not rely on it. A request with noslng_config uses the stored config. When that config does not select the request’s model, the router answers 400:
tiers, as RouterConfig.single does.
RouterConfig
dict[Literal['1', '2', '3'], list[TierEntry]] | None
default:"None"
Tier to its models.
None keeps the stored tiers.Literal[1, 2, 3] | None
default:"None"
The tier
slng/auto uses when it does not pick one. None keeps the stored value.bool | None
default:"None"
Whether the router may answer from its cache. The router judges which turns are repeatable, and some never cache.
None keeps the stored value.int | None
default:"None"
The most characters of an earlier assistant reply the router keeps in the prompt, 1 to 10000.
None keeps the stored value.bool | None
default:"None"
Whether the router fills its cache ahead of requests.
None keeps the stored value.None by default, and None keeps the stored value:
RouterConfig, TierEntry and the endpoint classes are Pydantic models. RouterConfig.model_validate(data) reads a config from JSON or YAML data. A bad value raises a ValidationError that names the field. The config is checked when you build it:
- A tier needs an entry with priority 0. A higher priority is a fallback.
- The weights of one priority in one tier sum to 100.
- A model appears once per tier.
- An unknown field is refused. The JSON may be at most 256 KB.
- Some request fields never reach the model: the Context Router drops them. They are fields like
api_base,api_key,metadataorextra_headers, and any name that starts withaws_,vertex_,azure_orwatsonx_. The library still sends them. It logs one warning per field name that says the router drops it. A tier entry’skwargs,RouterModelCall(body=...)and a request’sparamsare all checked.reasoning_effort,extra_bodyandextra_queryare not dropped.
TierEntry
str
required
The name a request may pin, like
gpt-5.6-luna.int
required
Its share of the traffic of its priority, 1 to 100.
int
default:"0"
0 is tried first. A higher priority is a fallback. The range is 0 to 100.
Endpoint | None
default:"None"
Where the model runs.
None uses SLNG’s own deployment of the model.dict[str, Any] | None
default:"None"
Default request fields for this entry, like
{"temperature": 0}. A request’s own fields win.Providers
- Field names are the router’s own. A missing
providermeansopenai-compat. - Unmute’s validation has exercised the
openaiandopenai-compatupstreams against the live router.azure,vertexandbedrockfollow the router’s published contract, and have not been run live there. auth_headernames the header the key goes in when it is notAuthorization: Bearer. It can never beAuthorization.- Every key is a secret. Neither
reprnor a validation error shows it. Only the request body has it in clear. Never turn on the OpenAI client’s debug logging (OPENAI_LOG=debug) with an inline config, because it prints request bodies. - On
AzureEndpoint, the router ignoresmodel_id. It is accepted so a stored config validates. vertex_credentialstakes a dict, or text. The text is the key’s JSON, that JSON base64 encoded, or a path to the key file.read_vertex_key(text)does the same on its own.
Without the router
OpenAIModelCall is a native call for any OpenAI-compatible endpoint. It sends no SLNG header and no SLNG body field, and the library fills the prompt. See OpenAIModelCall.
Gemini thought signatures
Gemini 3 signs the first tool call of each reply. Through an OpenAI-compatible API, the signature comes as the call’sextra_content:
extra_content through as it is, both ways.
RouterModelCallreads each call’sextra_contentintoToolCall.extra. AModelCallof your own does the same.- The adapter keeps it with the call in the history. Each later request sends it back as the call’s
extra_content. Finish checks do too, and the finish a check chose keeps the check’s own signature. So does an agent that takes the call with the full history, on a model of its own. - On LiveKit, it becomes
FunctionCall.extra, which LiveKit sends back itself. - On Pipecat, the context keeps only a call’s id, name and arguments. So
ModelCallLLMService.tool_extraskeeps the extras by call id and puts them back on each request, before thebefore_modelhooks. - Both adapters send back only the
google,livekitandxaikeys, the ones LiveKit sends (REPLAYED_PROVIDERS).replayed_extra(extra)returns what goes back. A call with none of them goes back with noextra_content. - An
after_modelhook reads all the provider data the model sent, inToolCall.extra. On Pipecat this needs aModelCallLLMService. A native service keeps none.
RouterModelCall adds Google’s placeholder, skip_thought_signature_validator, to an assistant message whose first call has no signature. A call the runtime makes itself, or one from another model, then passes. The model counts as Gemini when:
- the pinned
modelnames Gemini, - a model of the
confignames Gemini, or - the history already holds a signature.
slng/auto with no config, the library cannot know the router picked Gemini until a signature comes back. A call the runtime adds before then, like a step’s opening call, goes without the placeholder. An answer from the router’s cache carries no signature. When the library knows the model is Gemini, the placeholder covers its calls on the next request.
signed_tool_calls(messages, gemini=True) applies the placeholder rule to any OpenAI-format chat, for a ModelCall of your own.
Gemini’s OpenAI-compatible endpoint keeps only the last system message. The router folds the library’s several system messages into one, so it avoids this limit. See Gemini and several system messages.
A native framework LLM keeps or drops signatures as its framework does. LiveKit’s OpenAI-compatible plugins keep them. Pipecat’s OpenAI services drop them, so reach Gemini 3 through GoogleLLMService or a ModelCallLLMService.
Token counts, timeouts and errors
The router sends the token counts at the end of each answer.RouterModelCall yields them as a Usage event. The llm span shows them as gen_ai.usage.input_tokens and gen_ai.usage.output_tokens. On Pipecat, turn on usage metrics:
RouterModelCall.aclose() closes the client only when the call built it. The default timeout is 15 seconds to connect, then 5 seconds for each read, the first token included. Pass timeout=openai.Timeout(...) or a number of seconds above zero to change it. timeout=0 is refused.
Where to go next
SLNG speech and the Context Router
Add SLNG speech or the router to your agent.
Model call reference
ModelCall, ModelRequest, events and FallbackModelCall.
Settings
The
SLNG_* variables and how to check them at startup.Telemetry
Where the
llm span and the router’s headers show up.