Changelog¶
All notable changes to Continuum Router are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
v2.0.0 - 2026-11-03¶
Added¶
-
Added
observability.callbacks(#2376), an opt-in first-party export of one trace per request to an LLM observability platform's native schema. Langfuse ships, over its OTLP/HTTP endpoint, reusing the prompt-log export mapper; Braintrust, Arize, and Langsmith are follow-ups. The section is off by default and independent oftracing.enabled. Prompt, completion, and tool-call text is exported only wheninclude_content: trueand prompt-log capture admitted the request after its redaction pass; credential values never leave the environment and are used only for HTTP Basic auth. Delivery is fail-open through a bounded queue with a dedicated worker and bounded retry per sink, so a slow platform never delays or fails a response;observability_callback_events_total{sink,result}andobservability_callback_queue_depthreport delivery. The configuration assistant, the generated IDE rule files, the design record (English and Korean), the example config, a template, and the admin config surface were updated. -
Added the optional
notifications:section (#2416), which pushes client-facing rate-limit and quota events to application webhooks. Withenabled: true, the routerPOSTs a signed, bounded JSON event forrate_limit_exceeded(local request-rate, token-budget, in-flight, and capacity refusals, plus Hub tier minute limits),quota_low(the first crossing of a Hub monthly budget fraction), andquota_exhausted(Hub monthly budgets, Hub cost-center and model budgets, local scoped budgets, and the legacy per-keymax_tokens_budget). Producers only gate, coalesce, and enqueue, so a slow or failing receiver never delays or fails a request; each target owns a bounded queue and one delivery task, with retries bounded byretry.max_attemptsand backoff capped atretry.max_delay. The payload carries the key id, static event and limit names, and bounded numbers, never key material, prompt or completion text, or request headers. Withsigning_secret, every request also carriesX-Continuum-Timestampand anX-Continuum-SignatureHMAC over the exact sent body, and the secret is masked in configuration output and supports${ENV_VAR}. Endpoints are validated at load and again at apply with the strict SSRF validator, resolved addresses are pinned, redirects are disabled, and a private receiver needsallow_private_network: true. New metrics:notification_deliveries_total,notification_retries_total,notification_queue_depth,notification_dropped_total, andnotification_coalesced_total. The example config, the configuration assistant, the generated IDE rule files, and the English and Korean webhook notification pages were updated. -
Added opt-in model variant suffixes on
/v1/chat/completions(#2405). Withproxy.variant_suffixes.enabled: true, a model name that does not resolve exactly may carry one recognized trailing suffix::nitroprefers the highest fresh measured generation throughput among healthy admissible candidates,:floorprefers the cheapest estimated cost using the same pricing and cost estimate asleast_cost,:thinkingraises an unsetreasoning_efforttohighon a model whose metadata declares thinking support, and:onlineruns the router-managedweb_searchloop. The full requested name is resolved first, so a served id containing a colon (an Ollamaname:tag) and an unknown suffix word keep their existing behavior, and the grammar is not interpreted on any other ingress.:nitroand:floornever refuse: with no ranking data the normal strategy serves the request and the response carriesx-continuum-model-variant-applied: false;:thinkingwithout declaring metadata and:onlinewithout a usable executor (including a streaming request) answer404 unsupported_model_variant. Per-keyallowed_models, the per-model rate limit, and the per-model token budget key on the base id, which is dispatched upstream, while the requested name is echoed in the responsemodel,x-continuum-model-variantnames the variant word, the request is counted incontinuum_model_variant_requests_total{variant, applied}, and a fallback-chain hop never inherits the variant. The configuration assistant, the example config, and the English and Korean configuration and design-record pages were updated. - Added optional inbound JWT/OIDC bearer validation under
api_keys.jwt, off by default and behind thejwt-authCargo feature (outsidedefault/full) (#2375). A static key always wins; only a key miss is examined as a JWT, and a token whose unverifiedissis not the configured issuer falls through to the old key-miss path. A token naming the issuer is verified against anhttpsor loopback JWKS (or inlinestatic_jwks) and must passiss,aud,exp, andnbfwith bounded clock skew, using only configured asymmetric algorithms;alg: none, HMAC,kty: oct, and token-suppliedjku/jwk/x5uare refused. The JWKS fetch has a hard timeout, follows no redirects, and streams under a size limit, and the cache uses a TTL plus a rate-limited single-flight refresh on an unknownkid. A valid token produces the usualAuthContextwith configurable subject, user, team, scope, model, and backend claim mappings; a JWT scope list is authoritative, so an empty mapped list grants no scopes. Rejections and JWKS outcomes are counted bycontinuum_jwt_auth_total{result}andcontinuum_jwt_jwks_fetch_total{result}. A build without the feature validates the section but warns that it is inert. If an enabled section cannot be built into a validator, static keys keep working and a token naming the configured issuer is refused and countedmisconfigured. The admin config schema, the configuration assistant, the generated IDE rules, the example config and a template, and the English and Korean security and design pages were updated. - Added a
/v1/vector_storespassthrough (#2412), so OpenAI SDK clients can create, list, retrieve, modify, and delete vector stores, attach, list, retrieve, and remove files, and search a store through the router while keeping its auth, API-key scopes, rate limiting, and usage attribution. A store call names no model, so create and list select one vector-store capable backend with the configured strategy, and the router records(principal, store id)to the backend that created the store; every store-scoped call re-checks that binding against the live configuration and the caller'sallowed_backends, and an unknown or foreign id answers404 vector_store_not_foundwithout contacting the backend. A list keeps only the caller's own stores, and JSON request bodies are bounded at 1 MiB. Onlyopenaiandgenerictypes onhttp/httpsare candidates, and a request no candidate can serve answers400 vector_stores_not_supportedrather than a router404. The routes do not run request-parameter policy, guardrails, response caching, or the per-model generation budget, and each call is metered as a non-completion request with zero tokens. The English and Korean API pages and a design record were added. - Added the
middle-outmessage transform for/v1/chat/completions(#2415). Withproxy.middle_out.enabledset and a client"transforms": ["middle-out"]opt-in, an input whose shared typical estimate exceeds the target model's context window minus its output budget is fitted by dropping whole messages from the middle while keeping the leading system or developer prompt and the current turn; a tool call and its tool results are dropped together. A fitted response carriesx-continuum-middle-outwith the dropped count, post-fit estimate, target, and context window, and the spent transform entry is removed before dispatch. A request that does not opt in, a disabled transform, an input that already fits, or one the kept messages alone cannot fit forward byte-identically, and a fitted request does not also take a context-window fallback hop. The configuration assistant, the generated IDE rule files, and the English and Korean middle-out design pages were updated. - Added per-key data-residency routing (#2374). A backend can carry an operator-defined
region_nametag, and an API key with a non-emptyallowed_regionslist may only route to backends carrying one of those tags, matched exactly and case-sensitively. A backend without a tag never qualifies, the list intersects withallowed_backends, and an empty intersection fails closed with403instead of routing outside the requested region. The effective list is applied on every dispatch path, fallback hops and hedges included, and keys with different region scopes never share a response-cache entry. A tag no configured backend declares produces a startup warning. The configuration assistant, a config template, the example config, and the English and Korean configuration pages were updated. - Added
circuit_breaker.latency_as_failure(#2964), so a backend that answers 2xx but takes longer thanttft_ms(streaming, provider dispatch to first byte) ortotal_ms(buffered, whole response) counts that dispatch as one terminal failure. The backend leaves rotation at the existing failure threshold and returns through the ordinary half-open probe, which a slow probe reopens; a fast result is an ordinary success, never a success followed by a failure, and the two thresholds never cross-apply.minimum_samples(default 10) is a warm-up in judged observations since the circuit last closed or had its window cleared, and half-open probes skip it;model_overridesgives a served model id its own thresholds and floor. The buffered chat path judges the completed response, and the standard chat streaming path defers the handshake outcome to the relay's first-byte stamp, while the other streaming relays still settle at the handshake. Transitions carryreason="slow"oncircuit_breaker_transitions_totaland slow failures are counted ascircuit_breaker_failures_total{error_type="slow"}. The configuration assistant, the IDE rule files, three config templates, the example and sample configs, the metrics and load-balancing pages, and the English and Korean circuit-breaker pages were updated. - Added
retry.prefer_untried_backends(#2965), off by default: when enabled, a retry selects among the eligible backends the same request has not dialed yet, and falls back to the full eligible set when every eligible backend has been dialed, so a single-backend deployment still gets every attempt. The dialed set lives on the retry state, shared by every attempt clone, and is recorded at the buffered dispatch seam after selection, the hop dial bound, and circuit/rate-limit admission succeed, so a backend that was selected but refused at admission is not counted.falsekeeps the prior behavior, where a retry may re-select the backend that just failed;replay: disabledstill governs side-effecting backends. The configuration assistant, the example config, the admin config schema, and the English and Korean configuration pages were updated. - Added
logging.access.completion_line,success_level,client_error_level, andserver_error_level(#2402). The request logging middleware now writes one completion line per request that reaches a handler, at the level its status selects:2xxand3xxusesuccess_level(defaultinfo),4xxuseclient_error_level(defaultwarn), and5xxuseserver_error_level(defaulterror), each acceptingdebug,info,warn, orerror. The line also carries the request id, user id, organization id, and client address as structured fields. The line is off by default, so a configuration that omitslogging.accesskeeps the existing output, an unknown level spelling fails the load, and an excluded path writes no completion line. A request refused before a handler stays withevent=request_rejected. Request and response body capture remains out of scope under the opt-in prompt-logging epic #2294, andlog_request_bodyorlog_response_bodyis a load error. The configuration assistant, the generated IDE rule files, the example config, the admin config schema, and the English and Korean logging pages were updated. - Added
streaming.mid_stream_fallback.same_model_first(#2966), off by default: after a mid-stream failure or stall, the committed OpenAI-wire relay first tries another backend that serves the same public model, and only then advancesfallback.fallback_chains. The sibling set is user-routable, permitted for the API key, servable by the relay, admitted by its circuit breaker, and excludes backends already dialed for that model in the request; the hop keeps the client'smodel, reuses the relay's continuation or restart prompt, and counts towardmax_fallback_attempts. A sibling that also fails, an open sibling circuit, or an empty set falls through to the chain unchanged. The superseded attempt recordsfallback_sourceand the sibling is a normal successor under the samerequest_id, withstreaming_fallback_total{reason="same_model_sibling"}; mid-stream recoveries still emit noX-Fallback-*headers because the response is already committed. The example config, the configuration assistant, and the English and Korean architecture and configuration pages were updated. - Added
backends[].model_configs[].upstream_id(#2963), so one public model ID can pool backends that serve it under different names. The configuredidstays the routing identity everywhere the router or the client sees it (selection, per-keyallowed_modelsandrequest_paramsscopes,/v1/models, fallback-chain lookup, and the usage ledger), whileupstream_idis themodelthe router puts on the wire for that backend, and themodels/{id}path segment on native Gemini calls. Each attempt is rewritten for the backend that receives it, so retry and fallback hops carry their own spelling, and the responsemodelis restored to the public ID in buffered JSON bodies and in OpenAI-wire SSE chunks. A backend's live catalog contributes the public ID, so the pool lists one entry, and the upstream spelling is not routable unless a backend publishes it literally.config validaterejects a blankupstream_idand a value that collides with anothermodel_configs[].idon the same backend; the same public ID may map to different upstream IDs on different backends. The English and Korean advanced-configuration pages were updated. - Refused requests now produce a
refusedusage record (#2368). A request the router declines before dispatch carries one record with the reason code, the requested model and key when they resolved, the client-visible status, and zero token counts, so the Hub can show the request that no backend ever saw. Only routing, entitlement, and per-backend rate-limit refusals qualify; the record uses therouterprovider sentinel and carries no attempt identity, and the Hub keeps it out of every usage rollup. The vendoredcontinuum-protocolmoved to Hub rev09f45d3c, which learned the outcome and itsrefusal_reasonfield. New metric label valueusage_records_total{outcome="refused"}; the English and Korean architecture pages were updated. - Added
fallback.empty_reply_retry(#2440), off by default: a non-streaming/v1/chat/completionsreply whose first choice has nomessage, or whosemessage.content(elsemessage.refusal) is missing or whitespace only, advances the existing fallback chain. The hop reuses thefallback.quality_checkhop path, so it continues the chain's attempt numbering and counts againstmax_fallback_attempts, the hop timeout multiplier, the fallback dial permit, both key allow-lists, and the non-streaming chain budget, whilemax_extra_hops(1-5, default 1) additionally caps this trigger's own hops. Atool_callsreply and a reply withfinish_reason: lengthnever trigger, and a requestfallback.quality_checkcovers skips the trigger. The trigger inspects only the body the guardrail output gate passed, a hop's reply passes the same gate, and a failed, over-budget, or blocked hop serves the latest gate-passed reply. Scope matchesproxy.internal_streaming: a buffered request with one choice, no log probabilities, atextorjson_objectresponse format, and an OpenAI-wire backend over HTTP(S), whileunix:, nativeazure/anthropic/gemini/bedrock, andreplay: disabledkeep their existing behavior. New metricfallback_empty_reply_retry_total{result}. The configuration assistant, the IDE rule files, a config template, the example config, the admin config schema, and the English and Korean architecture, configuration, and metrics pages were updated. - Added opt-in shadow traffic mirroring (#2468). The
shadowsection replays a sampled share of matching client requests to a second model after the primary request is dispatched, on a detached task, so the primary response, its headers, and its client-visible latency are unchanged and a shadow failure is recorded and never surfaces. The mirror charges no client rate-limit quota because it never re-enters the HTTP layer, charges no per-model or per-backend bucket, moves no circuit state, and writes no usage row. Client credential headers (authorization,x-api-key,api-key,x-goog-api-key,cookie,set-cookie,proxy-authorization,x-auth-token,x-access-token), router-owned attribution headers, client identity and tenant headers (x-user-id,x-tenant,x-org-id), router-control and forwarding headers, and framing headers are stripped, and the shadow backend authenticates with its own configured credential. Sampling is deterministic from a stable hash of the API key id or the user id (sample_key: api_keyoruser) and random per request fornone; a body overmax_request_bytesis skipped and counted,max_concurrentsheds rather than queues,timeoutbounds one whole attempt, and a streamed shadow response is drained without accumulating. New metrics arerouter_shadow_requests_total{result},router_shadow_latency_seconds{model,backend}, androuter_shadow_usage_tokens_total{model,backend,kind}. Prompt and completion content is never logged. An absent or disabled section leaves request handling unchanged. The configuration assistant, the IDE rule files, a config template, the example config, and the English and Korean shadow-traffic pages were updated. - Added per-response cost and token trailers for streaming responses (#2546).
proxy.response_cost_headers, which already covered non-streaming responses, now also putsx-continuum-cost-microusd,x-continuum-tokens-in,x-continuum-tokens-out,x-continuum-tokens-cached, andx-continuum-latency-mson the metered streaming pipelines (chat completions, completions, the Responses API, and their bridges) as HTTP trailers: the head announces them in aTrailerheader, and the trailer section after the last frame carries the values from the same pricing arithmetic and the same omissions as the headers. The router's own finalized stream usage is the source, so no backend usage-trailer reading is involved. On HTTP/1.1 a client asks for the fields withTE: trailers(hyper encodes them only then), and a client that ignores trailers still reads the complete body; HTTP/2 carries them without negotiation. HTTP/1.1 and HTTP/2 cleartext are both covered, and the configuration assistant and the English and Korean configuration pages were updated. - Added the
json_schemaguardrail provider, which validates the assistant's structured output against the JSON Schema the client declared on that request (#2470). It readsresponse_format.json_schema.schema(OpenAI Chat Completions),response_format.schema(llama.cpp),text.format.schema(OpenAI Responses), andoutput_config.format.schemaoroutput_format.schema(Anthropic Messages), so nothing is configured statically and a request that declares no schema is a no-op. The dependency-free validator coverstype,enum,const,required,properties,additionalProperties: false,items,minimum, andmaximum, ignores$refandpatternso a client schema cannot make the router fetch a URL or compile a costly regular expression, and is bounded by depth and violation count.monitorcounts violations and passes the response through;enforcereturns aschema_violationblock whose reason names bounded JSON Pointers and the failing keyword, rendered through the configuredblock_behavior.options.repair: {enabled, max_retries (0-5), instruction}re-dispatches a buffered reply with the validation errors appended as a system instruction and serves the first reply that validates, while streamed replies are validated as an assembled message and never repaired. New metrics arerouter_structured_output_checks_total{result},router_structured_output_violations_total{keyword}, androuter_structured_output_repairs_total{result}, all with bounded labels. The configuration assistant, the IDE rule files, and the English and Korean guardrails pages were updated. - Added
backends[].responses_drop, a per-backend opt-in that lets a/v1/responsesconversion backend drop the advisory native-only fieldsinclude(only when every requested value is allowlisted, today justreasoning.encrypted_content),prompt_cache_key,safety_identifier, andreasoning.summaryinstead of refusing the request (#2867). The default stays the fail-closed refusal: with no list configured, every response and message is unchanged. Semantic fields (text,conversation,prompt_cache_options,prompt_cache_retention,multi_agent,context_management,max_tool_calls,reasoning.mode,reasoning.context, tools, programmatic calling, and native input items) always refuse, an unknown or semantic name is a configuration load error, andresponses_api: nativeignores the list. The reply discloses the removed fields in a sorted, deduplicatedx-continuum-dropped-fieldsheader, exposed through CORS and replayed on a response-cache hit, andresponses_drop_fields_total{field}counts one increment per dropped field per served request. Droppingprompt_cache_keyleaves the router's prompt, prefix, and semantic cache keys unchanged, and an opted-in Codex CLI request stays response-cache eligible. The Codex CLI guide, the backend configuration docs, the configuration assistant, and the example and sample configs were updated in English and Korean. - Added usage-weighted backend selection (#2452).
routing.usage_weightadds an additive scorer term that ranks candidates by the share of their declaredbackends[].provider_tpm/provider_rpmbudget the router has not yet spent, from a router-local per-backend consumption window fed by the existing token metering, so it works for cloud backends with no new data source. A declared candidate scoresweight x remaining_share; a candidate that declares nothing, or has spent its whole budget, scores exactly0.0and keeps its base score, so an unknown quota is never treated as infinite or as zero. The term composes with the KV overlap and engine-load terms; it decides a pass when it strictly prefers one candidate and the non-usage terms would not have selected the same backend, so a genuine KV hit keeps its KV metrics. A tie falls back toselection_strategy.provider_tpmandprovider_rpmare declarations and enforce nothing. The section is off by default and selection is byte-identical when it is unset. Every pass the term decides or cannot settle recordsrouting_usage_weight_decisions_total{backend,reason}withreasoninapplied,no_declaration,no_differentiation. The configuration assistant, the IDE rule files, the example config, and the English and Korean load-balancing, advanced configuration, and metrics pages were updated. - Added a Codex CLI integration guide,
docs/en/guides/codex-cli.mdand its Korean pair (#2851). It separates the CLI as a router client from the ChatGPT-subscription upstream, shows~/.codex/config.tomlfor theresponsesandchatwires, states thatopenaiandazurepass/v1/responsesthrough by default while other OpenAI-wire types needresponses_api: native, and maps the observed400 unsupported_request_parameterrefusals to their cause and remedy. A newtests/responses/codex_cli_test.rsposts a capturedcodex-cli 0.166.0body at a native backend and asserts the forwardedinclude,prompt_cache_key,store, and tool list, then at a conversion backend and asserts the refusal namesprompt_cache_key. The native-only refusal now names both ways forward: setresponses_api: nativeon a backend that serves the Responses API itself, or send the request over the Chat Completions wire from a client that can omit the field. - Added the data-plane
budgets:section for long-window token and cost quotas (#2461).budgetsenforces rolling-window limits per API key, per model, and globally, independent of the control plane:windowsnames the rolling periods,scopessetstokensandcost_usdper scope,storageselects per-process memory or shared Redis, andfail_opendecides whether a storage outage admits or refuses. It extends the #2323 tracker rather than adding a second one:api_keys[].max_tokens_budgetstays the explicit per-key override and the section'sper_api_keylimit is the fleet default for keys without one, so a request is never charged twice for the same scope. Cost comes frommodel-metadata.yamlpricing in integer micro-USD, and an unpriced model counts tokens but no cost. A refusal is a429withtype: budget_exceedednaming the scope, window, and configured limit, in the ingress protocol's error envelope; remaining is reported only for the identity-bound per-key scope and withheld for shared scopes, andX-Budget-*headers carry the per-key level. Completed usage is recorded exactly once from the usage seam for buffered and streamed responses on both the OpenAI and Anthropic wires.GET /admin/budgetsreports current counters andPOST /admin/budgets/resetclears a scope's window, withrouter_budget_rejections_total{scope,window}androuter_budget_used{scope,window,unit}metrics. The section is hot-reloadable and absent orenabled: falseis a strict no-op. The configuration assistant, the IDE rule files, two config templates, the example config, and the English and Korean configuration, security, and design-record pages were updated. - Added opt-in fair queuing across tenants (#2397).
rate_limiting.fair_queuingholds a request an identity dimension (per_api_key,per_user,per_team,per_model) refuses in its tenant's bounded queue instead of answering with a bare429, and serves the queues by weighted fair scheduling so one tenant's flood cannot starve another. The tenant is the same user, key, or team identity the limiter already charges (tenant_dimension, defaultuser) andweightssets each tenant's share. Every level is bounded (max_queue_depth_per_tenant,max_total_queued,max_tenants,max_in_flight,max_wait_ms), queued time counts against the request's total timeout, and a queued request is re-checked against the limiter when its turn comes. A request refused at or after the queue carriesx-fair-queue-positionandx-fair-queue-wait-ms, an admitted request that waited carries those plusx-fair-queue-estimated-wait-ms, anderror.details.fair_queuenames the tenant dimension, reason, position, and wait. Global and per-client refusals stay immediate because their mounts run before authentication, anddeferred_admissionstill handles token-budget refusals, so both modes can be enabled together. New metrics arerate_limit_fair_queue_depth,rate_limit_fair_queue_tenants,rate_limit_fair_queue_wait_seconds{tenant_dimension},rate_limit_fair_queue_grants_total{tenant_dimension}, andrate_limit_fair_queue_refused_total{tenant_dimension, reason}. The configuration assistant, the IDE rule files, two config templates, the example config, the admin schema, and the English and Korean rate-limiting, architecture, and design-record pages were updated. - Added
rate_limiting.limits.*.max_in_flight_requests, a per-tenant concurrent in-flight cap onper_api_key,per_user,per_team, andper_client(#2383). A request over its tenant's cap is refused immediately with429,limit_type: "inflight",error.details.inflight_dimensionnaming the dimension, andRetry-After: 1, and it is never queued. The slot is taken after every request-count and token-budget check passes, so a refused request occupies none, and it is released by a guard attached to the response body, so a stream holds it until its last frame and a handler failure or a client disconnect frees it. The cap is off by default (0= unlimited), ignored onglobal,per_backend, andper_model(config validatewarns), accounted per replica, and exempted by the newinflightscoped-bypass class. New metrics arerate_limit_inflight_requests{dimension,tenant}andrate_limit_inflight_refusals_total{dimension,tenant}. The configuration assistant, the IDE rule files, three config templates, the example config, the design record, and the English and Korean rate-limiting, architecture, and changelog pages were updated. - Added
rate_limiting.tier_overrides(#2406), a map keyed by Hubtier_idthat gives a tier its own per-key request rate, burst, and minute token budget. The limiter resolves the tier from the Hub-synced key table on every request, the same identitysmart_routing.routing_policies[].when.key_tiernames, and an entry changes the effective rate and burst for keys of that tier, plustokens_per_minutewhen the entry writes one. An explicitapi_keys[].rate_limit(with its burst and minute-budget overrides) remains the most specific setting and wins over the tier, and the tier wins overlimits.per_api_key. The resolved tier is part of the request-rate and minute-budget bucket keys, so a key moved between tiers starts on the new tier's bucket, and thex-ratelimit-*headers report the limits actually applied. Without thecontrol-planefeature, an enabled control plane with policy enforcement, or a synced policy, the map is inert and every key keeps the settings it would have without it (config validateand the startup log warn). These are local limits: the Hub tier'srpm,input_tpm, andoutput_tpmwindows (with the shared counters configured undercontrol_plane.policy.minute_limits) are enforced separately and are neither replaced nor widened, so a request is admitted only when both admit it.config validaterejects a zero rate or burst,config_reportrejects a burst below the rate, and an unknown field inside an entry is a load error. The configuration assistant, two config templates, the example config, and the English and Korean rate-limiting and architecture pages were updated. - Added scoped per-key rate-limit bypasses and per-key burst and minute token-budget overrides (#2426).
rate_limiting.scoped_bypass_keysexempts a key from only the classes it names (request_rate,tokens_per_minute,tokens_per_day) whilebypass_keyskeeps its all-or-nothing meaning.api_keys[].rate_limit_burstsets the bucket capacity of the request rate in effect for that key, andapi_keys[].rate_limit_tokens_per_minutereplaces the sharedper_api_keyminute token budget for that key (0disables it); both are settable in the config file and throughPOST /admin/api-keysandPUT /admin/api-keys/{id}. A per-key limit change made through the Admin API now emits its ownapi_key_rate_limit_changeaudit line with the key id and the old and new values, without requiringadmin.audit.include_body. Per-key values are excluded from configuration exports and the WebUI diff. A budget shared across one user's keys remains issue #2381. The configuration assistant, two config templates, the example config, and the English and Korean rate-limiting and Admin API pages were updated. - Added the
rate_limiting.limits.per_userandper_teamdimensions (#2381), keyed on the identity an API key already resolves: the key's user id forper_userand its organization/team id forper_team. Several keys of one user, or every member of one team, now draw on one bucket instead of each key getting its own allowance. Both dimensions take the same settings shape as the others, includingtokens_per_minuteandtokens_per_day, which are shared the same way. A request whose key carries no resolved id (anonymous, a static key without one, or a Hub key with no syncedteam_id, whosecontinuum-hubplaceholder is not a team identity) skips the dimension and is counted onrate_limit_skipped_total{dimension="user"|"team"}, never charged to a bucket shared with other identity-less keys. The buckets are keyed on a replica-stable hash of the identity in memory and in the Redis key space (per_user:<hash>,per_team:<hash>), and a config reload applies new settings immediately and resets the tenant buckets. A refusal names the dimension inerror.details.limit_type(userorteam) and is counted onrate_limit_exceeded_total{limit_type}; both dimensions are shared by more than one credential, so their levels are withheld from response headers.config validateand the startup log warn when a tenant dimension is configured but the build and configuration resolve no API-key identity. The configuration assistant, the IDE rule files, two config templates, the example config, the admin schema, and the English and Korean rate-limiting and architecture pages were updated. - Added
responses_tools.deny_resource_fields(#2891), a closed list of client-supplied upstream resource references to refuse with400 unsupported_request_parameternaming the field. The default listsvector_store_idsandcontainer_ids, which apply on every backend and to nested tools.previous_response_id,conversation,prompt, anditem_referenceare opt-in and apply on the pass-through strategy only. - Added the
responses_toolsconfiguration section (#2873), so an operator decides which hosted tool typesPOST /v1/responsesmay forward.unmodeled_types(defaultdeny) covers types such asmcpandimage_generation,modeled_hosted_types(defaultallow) coversweb_search,file_search,code_interpreter,computer_use, andprogrammatic_tool_calling, andallowentries (exact) anddenyentries (any ASCII case) override both.function,custom,local_shell,tool_searchwithexecution: "client", andnamespaceas a container are always allowed, and namespace members are judged at any depth. A refusal is400 unsupported_request_parameternaming the tool, runs before backend selection and any cache lookup on every backend type, and an edit applies to the next request. - Added the
prompt_logging.export.hub_pushruntime (#2764). In acontrol-planebuild withcontrol_plane.policy.enabled: true, an enrolled router runs one more prompt-log export task that pushes redacted records toPOST /api/agent/v1/prompt-logswith the enrollment credential, on its own cursor starting at the end of the record files, everyusage_push_interval_secs, at most the Hub'sprompt_log_batch_max_recordsrecords (default 100, never above 500) and 16 MiB of encoded JSON per request, each side cut to 64 KiB. A record leaves only when its key is in the Hub's own key table withallowed, it was captured on thatallowedrather than ondefault_consent, and no localdeniedoutranks it; keyless records, records captured on the deployment default, local-only keys, and keys stating nothing never leave, counted asconsent_keyless,consent_deployment_default,consent_key_absent, andconsent_withdrawnundersink="hub". A Hub404drops the backlog and moves the cursor to the end of the files,413drops the request whole,400,403, and409are re-sent one record at a time so only the refused record counts, and401,408,429,5xx, and transport failures retry the same records with identical idempotency keys after a backoff capped at 60 seconds; records the Hub reports as already stored count asduplicate. A404discards the backlog for good, a router the Hub has retired has each record refused one by one, and a team'sdeniedreaches the router only through the Hub's key table. The agent's HTTP client no longer follows redirects on any endpoint. The vendored protocol crate moves to hub1e3dac51(thePromptLogBatchandPromptLogAckwire types andRouterSettings.prompt_log_batch_max_records), the Hub key table's consent is indexed by key id when a policy is applied,make hub-compatgains a prompt-log stage, and the inert-setting warning now applies only to a build without the feature or a deployment withoutcontrol_plane. - Added
rate_limiting.limits.*.tokens_per_day, a daily token budget besidetokens_per_minuteon every admission-time dimension (#2327), completing the epic #2401 token-budget surface. A text-generation request reserves its input estimate plus output limit against the day bucket of each configured dimension at admission and reconciles it against the reported usage like the minute bucket, and a request must fit both budgets of every configured dimension. The day bucket's capacity is one day of the rate and it refills attokens_per_day / 86400tokens per second as a leaky-bucket window, not a calendar-day reset at midnight; it composes with, and is distinct from, themax_tokens_budgetrolling-window quotas of #2323 at the auth seam. A refusal names the dimension inerror.details.token_dimensionand the exhausted budget inerror.details.token_budget(minuteorday), and the identity-scopedper_api_keyday budget reports on newx-ratelimit-tokens-day-{limit,remaining,reset}headers. The day bucket is Redis-backed under thetoken_budget_daykey namespace with a TTL raised to at least one day. New metrics arerate_limit_token_budget_period_tokens_total{dimension,budget,outcome}andrate_limit_token_budget_period_refusals_total{dimension,budget}.per_backend.tokens_per_dayis ignored andconfig validatewarns about it. The configuration assistant, the IDE rule files, two config templates, the example config, and the English and Korean rate-limiting, architecture, and design-record pages were updated. - Made the token-budget refusal's
Retry-Aftersize-aware and added the opt-inrate_limiting.deferred_admissionmode (#2423). A refusal'sRetry-Afternow reports the time the exhausted budget needs to refill the refused request's own cost rather than a fixed window: an in-burst denial keeps its deficit-over-refill wait, and an over-burst refusal (a request larger than the whole burst, which waiting can never admit) is computed from the request's cost against the budget'stokens_per_minute, floored at 1 second and capped at 86400. Withdeferred_admission.enabled: true, a refused request whose wait fitsmax_wait_ms(default 2000) is held for that wait instead of refused with a 429. The hold is bounded per router process bymax_held_requests(default 1000) on a store count each replica keeps for itself, a held request holds no backend slot and no token reservation until a wait succeeds, the request-count dimensions are not re-charged on the retry, a client cancellation releases the hold through the guard'sDrop, and a request larger than the whole burst is never held. A request past either bound gets the immediate 429 with the size-aware hint. The configuration assistant, the IDE rule files, the example config, the config report warning, and the English and Korean rate-limiting pages were updated. - Added operator recovery for prompt log export (#2820).
on_permanent_failure: skipon a sink gives up on a batch refused with status 400, 409, 413, 415, or 422, counting its undelivered records asprompt_log_export_total{result="dropped"}; the defaultblockkeeps retrying.cursor_idnames a sink's cursor file instead of a hash of its destination, so a rotated webhook URL token keeps the cursor and the backlog goes to the new destination. - Added
prompt_logging_consenton locally configured API keys (#2819). A key statesallowedordeniedinapi_keys[], an external keys file, the Admin API, or the WebUI, so a deployment with no Hub can run opt-in capture underdefault_consent: deniedor exclude one key underdefault_consent: allowed. Adeniedfrom a local or Hub key wins, then a localallowed, then a Huballowed, thendefault_consent, and the fail-closed Hub window still outranks a local value. A key without the field behaves as before. A disabled or expired key that statesdeniedstays uncaptured inpermissivemode, so disabling a key never widens consent; itsallowedopts nothing in. A YAML startup or reload error from a wrong value type now names the field path. - Added a
config validatewarning for a match-allbackends[].modelsentry (*ormodels/*) when another backend is enabled (#2792). Such an entry does not keep a metadata alias on its backend, so the request can go to a remote provider that serves the canonical ID. - Settled streaming usage against the token budget at the stream's end (#2420), completing the reservation lifecycle from #2395. A streamed completion that reaches its normal end settles the admission reservation against the finalized usage: the backend's usage block, or the input estimate plus the router's delta count when the backend reported none, so a normal end with no usage block still ends the reservation deliberately. A stream cut before its end (a client cancellation, a timeout, an upstream error, or a guardrail block) never settles; its reservation is released in full by the response body guard, and a truncated stream is never charged as if the stream completed. A mid-stream fallback hop never settles either; the successor attempt settles the reservation with the final reported usage. Settlement is exactly once, idempotent against the body guard's later release, and never blocks or delays the client response. The chat completions SSE pipeline, the Responses conversion and passthrough relays, the unix-socket relays, the responses-only bridge, and the Anthropic ingress stream-end row all settle; request-count dimensions are unchanged and the token dimension stays off by default. The design record, the English and Korean rate-limiting pages, and the configuration assistant were updated.
- Added
rate_limiting.limits.*.tokens_per_minuteas a public dimension on every admission-time rate-limit bucket (#2395), completing the reservation lifecycle from #2419. A text-generation request (/v1/chat/completions,/v1/completions,/v1/responses,/v1/responses/compact,/v1/messages,/anthropic/v1/messages) reserves its whole-request input estimate plus its output limit (max_tokens,max_completion_tokens,max_output_tokens, else 4096) againstper_client,per_api_key, theper_modelentry matching the client-facing model, andglobal, then reconciles that reservation against the usage the backend reports: unused tokens return to the budget and an overage is charged. Each bucket's burst is one minute of the rate, a request larger than the whole burst is refused immediately, and the first dimension that cannot cover the request answers429withlimit_type: "token_budget"anderror.details.token_dimensionnaming it; a Redis-backed check that cannot reach Redis followsredis.fallback_to_memory(503 withRetry-After: 5when fail-closed). Only theper_api_keybudget is reported, on newx-ratelimit-tokens-{limit,remaining,reset}headers.per_backend.tokens_per_minuteis ignored andconfig validatewarns about it. New metrics arerate_limit_token_budget_tokens_total{dimension,outcome}andrate_limit_token_budget_refusals_total{dimension}, and skipped dimensions appear asrate_limit_skipped_total{dimension="token_budget_<dimension>"}. Streaming replies hold their reservation until the body ends; streaming settlement is #2420. The configuration assistant, the IDE rule files, two config templates, the example config, and the English and Korean rate-limiting pages were updated. - Added the
/v1/fine_tuning/jobspassthrough (#2358), so OpenAI SDK clients can create, list, retrieve, and cancel fine-tuning jobs through the router and keep its auth, rate limiting, and usage attribution. Create selects a backend from the request'smodel, list selects one fine-tuning capable backend and returns only the caller's jobs, and retrieve and cancel reach only the backend and principal that created the job through a per-router binding. Onlyopenaiandgenerictypes onhttp/httpsare candidates, and a request no candidate can serve answers400 fine_tuning_not_supportedrather than a router404. Job CRUD does not run request-parameter policy, guardrails, response caching, or the per-model generation budget, and each call is metered as a non-completion request with zero tokens. - Added the prompt logging operator guide,
docs/en/guides/prompt-logging.md(#2333). It covers what leaves the router by default, enabling capture, consent and the fail-closed window, redaction, retention, the webhook, Langfuse, and S3 sinks with their at-least-once delivery rules, the routes that are and are not recorded, and the metrics to watch. It states that Hub push and Hub viewing are not available yet. The design record now matches what shipped. - Added per-backend
forward_cache_control(#2343): on/anthropic/v1/messages, a request converted to the OpenAI wire format now keeps the client'scache_controlbreakpoints when the selected backend opts in. A breakpoint on system text, message text, image, or document text parts is copied onto the OpenAI-wire part the conversion produces, and a system block array keeps its per-block breakpoints instead of being joined into one string. The receive side of a peer router rebuilds the Anthropic block-arraysystemand the imagesource.cache_controlfrom those parts. The default istruefortype: continuum-routerandfalsefor every other type, because strict cloud endpoints and most local engines reject or ignore the field; nativeanthropicandbedrockbackends are unaffected. - Added the
prompt_logging.export.sinksruntime (#2328). Whileprompt_logging.enabledis true, each configured sink tails the record files on its own owner-only cursor (.export-cursor-<id>.jsonunderdirectory) and delivers bounded batches off the request path, at least once, retrying with backoff. Every record passesredaction.patternsagain on read. Sinks:webhook(JSON array, credentials throughheaders_env),langfuse(OTLP JSON to{endpoint}/api/public/otel/v1/traceswith Basic auth frompublic_key_envandsecret_key_env, because Langfuse sunsets/api/public/ingestionon Cloud on 2026-11-16), ands3(one JSONL object per batch under a key derived from the file position, behind the newprompt-log-s3feature in official release binaries). A sink with no cursor file exports only what is written after it starts, with the start stored at once so a restart skips nothing;backfill: truesends every retained record first. A cursor that cannot be read is retried without moving the position, and an unparsable one followsbackfillwith a WARN: the tail when false, the oldest file when true. Anhttp://endpoint on a host that does not look private draws a clear-text warning, and ans3sink must setaccess_key_id_envandsecret_access_key_envtogether or neither. New metrics:prompt_log_export_total{sink,result}andprompt_log_export_backlog_bytes{sink}. The record file sequence now continues above existing files and cursors after a restart instead of reusing a free lower number. - Added
fallback.fallback_policy.content_policy_fallback(#2319). Whentrue, an input guardrail block on a non-streamingPOST /v1/chat/completionsrequest advances the dispatched model'sfallback_chainsinstead of ending the request, so a strict policy on the primary does not reject a request a laxer hop accepts. The hop re-runs the hop model's own input gate on the client document before dispatch, counts againstmax_fallback_attempts, runs with its ownfallback_timeout_multiplierwindow, takes a fallback dial permit, honors the key'sallowed_backendsand re-appliesallowed_models, and passes the hop model's output gate before its reply is served. A block from the hop's gate is served and ends the chain. A hop-served reply is then judged byfallback.quality_check(andusage.quality_judgmentwhen configured), which may advance the chain once more. A transport failure advances to the next chain member unless it is terminal (areplay: disabledbackend the request may have reached, or a partially generated internal stream), and the primary's block is served when none can answer. A clientmodelslist overlays the chain for the request. The primary and hop passes fold into one request-level input decision, a served hop reportsX-Fallback-Reason: content_policy, andfallback_content_policy_hops_total{result}countstriggered,hopped,no_chain,blocked_again, andbudget_spent. The field defaults tofalse, keeping the terminal-block contract byte-identical, and streaming requests, the classifier egress gate, the responses bridge, web-search loop requests, the other ingresses, and a request that opts out of cross-model hops (disable_fallbacks: trueor afallback: within_variantexperiment) keep it. - Added
fallback.default_chain, a catch-all fallback list for any model that has no explicit chain (#2348). Resolution staysmodel_settings[].custom_fallback_chain, thenfallback_chains[model], thendefault_chain: an explicit entry always wins, and an unset list preserves the previous behavior exactly, with no chain and no extra allocation. The list is flat and shared, so the dispatching model's own name is dropped from its resolved list (a model never hops to itself) and the executor never follows a hop's own chain (the list cannot cycle). Names follow thefallback_chainsrule and duplicates are refused. A hop target the requesting key'sallowed_modelsdoes not permit is skipped instead of dispatched, logged once per (origin, target) pair, and counted infallback_default_chain_hop_skipped_total{original_model,fallback_model}; a request whose every target is filtered behaves as if the model had no chain, and explicit chains keep their existing behavior. Because every unlisted model now counts as chained, aquality_checkwith an emptymodelslist judges it and the response cache route stays disabled for it.config validatewarns when a default-chain target is not served by any enabled backend, suppressed while an enabled backend discovers models at runtime. - Added the opt-in
alertingconfiguration section (#2356). Withalerting.enabled: trueand awebhook.url, the router pushes backend health transitions (backend_unhealthy,backend_recovered), circuit breaker transitions (circuit_opened,circuit_closed), and Hub-synced key budget milestones (budget_exhausted, plus onebudget_fractionwarning at a configurable fraction) to one operator webhook as a metadata-only JSON payload (event,occurred_at,router_id,subject,detail,suppressed). Delivery is fail-open: producers onlytry_sendinto a bounded queue (max_queue), a dedicated task performs the POST, a full queue drops and counts the event, retries use bounded exponential backoff, and a per event and subjectcooldowncoalesces repeats while the next event carries the suppressed count. Failures are logged without the URL and counted inalert_deliveries_total{event,result}besidealert_queue_depth,alert_dropped_total, andalert_coalesced_total{event}. The section is off by default and validated on every load:httpsonly, no embedded credentials, the strict SSRF blocklist unlessallow_private_network, and the resolved addresses are pinned. The two budget events require thecontrol-planebuild and are inert with a startup notice otherwise. A new webhook on hot reload applies to events queued after the reload, and the serving path starts the notifier (idle while disabled). - Added the
context_window_exceededfallback trigger (#2307). Withfallback.fallback_policy.trigger_conditions.context_window_exceeded: true, an input that exceeds the primary model's declared context window hops before dispatch to the first configured chain target whose own window fits it, for buffered and streamed requests alike, and judges that target on input size even when the request carries nomax_tokens. The trigger defaults tofalse, which keeps the existing400 context_length_exceeded. When no target fits, the client receives that same refusal naming the primary's window and estimated input tokens without a backend dial, and a primary with no known limit fails open. A dispatched hop reportsX-Fallback-Reason: context_window_exceeded. Hub config sync accepts the key, and the hub must gate it on a router version that does. The router does not report the key in its config snapshot, because a hub whose observed-path registry lacks it rejects the whole heartbeat (#2844). Reporting can resume only once every supported hub, including the compat floor, has the key. - Added the
LeastCostselection strategy (#2335), which routes a request to the eligible backend with the lowest estimated cost among candidates serving the same model. Cost is the request's tokenizer-free input estimate plus its completion ceiling (max_tokensormax_completion_tokenson chat,max_output_tokenson Responses,max_tokenson Anthropic Messages) priced through the foldedPricingInfoin USD per 1M tokens: the backend'smodel_configs[].metadata.pricingfirst, then the model-metadata catalog, with the long-context tier applied from the estimate. A candidate without pricing ranks last rather than as free, equal costs break on the output rate and then rotate, and a fleet with no priced candidate behaves exactly likeRoundRobin. Dispatch paths without a parsed body (audio, tokenize, realtime, ACP) use the documented rate-only basis of one million input tokens and no completion.config validatewarns when a model served by two or more backends has fewer than two priced replicas, and the configuration-assistant knowledge, CLI help, admin schema, sample config, and load-balancing guides document the strategy. - Added the
webhookguardrail provider (#2267). ItPOSTs{stage, model, texts: [...]}to an operator-run HTTP service and reads{decision: allow|block|transform, category?, confidence?, reason?, spans?}, so an in-house rules service, a kept-alive LiteLLMCustomGuardrail, or a customer-list, checksum, or company-dictionary check runs through one documented contract instead of impersonating a guard model, a chat endpoint, or a Presidio analyzer.transformmasks the returned spans, whose offsets are Unicode code points; masking reuses the PII provider's overlap resolution and redaction path.phase: sanitize,on_error,timeout_ms, and a per-provider circuit breaker behave as for the other providers, the call uses the shared HTTP client with a bounded retry and a 1 MiB response cap, andendpointis required whilebackendis refused. - Wired opt-in prompt logging into the request path (#2320). With
prompt_logging.enabled: true, eachPOST /v1/chat/completions(the A2A bridge included),POST /v1/responses, orPOST /anthropic/v1/messagesrequest, buffered or streamed, that passes the per-key consent gate appends one redacted JSONL record toprompt-log-<sequence>.jsonlunder the newprompt_logging.directory(default./prompt-logs, created0700, files0600, rotated and pruned byretention). The record carries the prompt, the completion and tool calls, the usage, the consent basis, and the outcome (ok,refused,backend_error,client_cancelledwith the partial reply, orstream_errored); a fallback across hops writes one record for the hop that answered, and each content field is cut at 256 KiB with the cut fields named in a new additivetruncatedfield. The gate runs after the policy-enforcement middleware and before any body is copied, so a disabled section, adeniedkey, or a cold Hub policy store copies nothing. Redaction runs once on the request task, with the built-in credential patterns andredaction.patterns, each match replaced literally withredaction.masked_replacement(a$can no longer expand, and a zero-width pattern such as\bis skipped rather than corrupting the record). Writing is fail-open: a dedicated thread owns the file, the request path hands it the record through a bounded queue, and a full queue or a failed write drops the record and countsprompt_log_records_total{result}(written,queue_full,sink_error) without touching the request./v1/completions, embeddings, and ACP are not captured yet. A response-cache hit writes no record. The section stays restart-class, andenabled: trueno longer logs an inert-setting warning. A stream that ends early (client_cancelled,stream_errored, orrefused) has its output trimmed back to the last whitespace, at most 4 KiB, before redaction, so a partial token cannot evade a pattern, and the cut field is listed intruncated, as is a field the 1 MiB masking bound cut.modelis capped and masked like other client text, built-in patterns now match a credential that directly follows Hangul or CJK text,key_idnames a Hub key in permissive mode, and a disaggregated request is recorded buffered and streamed, with an orchestrator refusal asbackend_error.retention.max_age_daysholds on a quiet router: the writer thread applies it hourly, the active file rolls at one eighth of the bound, and a file is removed early enough that no record outlives it, so a record lasts between about seven eighths of the bound and the bound. - Added
POST /admin/routing/explain, a read-only explanation of the routing decision for a model and an optional API key (#2467). For each candidate backend it reports whether a chat request would be routed to it or the first live-path check that excludes it: an inactive key, the key'sallowed_modelsorallowed_backends, a disabled orinternal: truebackend, an open circuit, an unavailable Hub credential, an unhealthy backend, or an exhausted per-backend rate limit (withretry_after_ms). It is a dry run that consumes no rate-limit token, moves no circuit, and changes no health or usage counter, and it does not run the selection strategy, whose pick advances shared counters. An unknown model answers404 model_not_found, an unknown key404 api_key_not_found, and the response never carries a backend URL or key value. - Served
/metricson a dedicated listener atmetrics.port(#2388). The field was parsed with a default of9090but nothing bound it, while the shipped Kubernetes manifests and Helm chart scrape port 9090 and their NetworkPolicy admits the monitoring namespace only there. With metrics enabled, the router now binds a second TCP listener onmetrics.bind_address(new; an IP address orlocalhost, defaulting to the host of the first TCPserver.bind_addressentry) andmetrics.portthat serves only the metrics path, behind the samemetrics.auth; API, admin, and health routes answer404there. The listener starts with the server and drains on shutdown. A port that cannot be bound fails startup with an error namingmetrics.portwhenmetrics.portormetrics.bind_addressis set, so a host where 9090 is already taken needsmetrics.portmoved, ormetrics.port: 0, which turns the listener off. When neither is set, the router logs a warning and starts without the listener (#2845). Upgrade note: the router now opens port 9090 by default, including with nometrics:section, so a router on a host where that port is taken logs the warning and keeps serving/metricson the main listener;metrics.port: 0turns the listener off. A file saved fromconfig showor the Admin API export of an earlier release carriesport: 9090, which counts as set. Ametrics.portequal to a main listener port starts no second listener, a Unix-socket-only bind withoutmetrics.bind_addressskips it with a warning (also fromconfig validate), and an AppProxy ROUTERtraffic_portequal tometrics.portis refused./metricsstays on the main listener as well. Changingmetrics.enabled,metrics.port, ormetrics.bind_addresson hot reload logs that a restart is required. The three Helm values variants that omittedmetrics.auth(and therefore answered401to the unauthenticated ServiceMonitor) now setmethod: nonelike the default values file. - Added
qandlimitfilters toPOST /v1/models/queryand acceptedcapabilityas a spelling ofcapabilities(#2758).qis a case-insensitive substring match on the model id and display name, andlimit(1 to 1000) caps the filtered result. Globbackends[].modelsentries now also count in the Hub alias local-collision check and in alias dispatch for a backend with no catalog entries, and matching them no longer allocates. - Added
backends[].supports_response_format, an operator declaration of whether a backend honorsresponse_format(#2759). Absent keeps inference from the backend type.falseroutes a request carrying the field around the backend, refuses it with 400 when no candidate remains, and removes the field fromsupported_parametersonGET /v1/models.truehas no effect onanthropicandbedrock, whose conversion drops the field, andconfig validatewarns.continuum_response_format_inferred_dispatch_total{backend}counts candidates retained on inference. - Added per-key prompt-logging consent on the router side (#2324).
KeyEntrygains the optionalprompt_logging_consent(allowedordenied), and the router advertisesprompt_logging_consent_v1, keeps the value in the persisted key table, and exposes it on the request in both auth modes. A new gate decides capture:prompt_logging.enabled: falsenever captures,deniedis never captured,allowedis captured even underdefault_consent: denied, and a key that states nothing or a request with no key followsdefault_consent. While a Hub policy store holds no policy (before the first sync, or after a restored key table expires), a key-authenticated request is not captured whateverdefault_consentsays. No Hub sends the field until continuum-hub#1975. Prompt-log records gain an optionalconsentfield (key_allowedordeployment_default), andPROMPT_LOG_SCHEMA_VERSIONis now 2. - Added the
prompt_loggingconfiguration section (#2301). It defines the opt-in capture switch,default_consent, capture scopes, redaction patterns, finite retention bounds, and an export placeholder. It is off by default and validated under every feature set, andexport.hub_pushandexport.sinksload with a warning and send nothing. Sinkendpointvalues andredaction.patternsare masked in Admin config,config show --resolved,config diff, andDebugoutput, pattern errors never quote the pattern, a$inredaction.masked_replacementis refused, and an unresolved${VAR}in a sinkendpointfollows the usualconfig validatewarning. - Added the
request_presetsconfiguration section and thepresetrequest field onPOST /v1/chat/completions(#2436). A preset bundles an optional model default with the seven canonical request parameters, and a request selects it by slug. An explicit request field wins, the preset wins overrequest_params.defaults, andrequest_params.overrides/limitsstill apply. The preset model is used only when the request carries no usable model. Presets are validated at load and hot reload: a slug is 1-64 characters of[A-Za-z0-9._-], at most 256 presets may be declared, a non-emptymodel(at most 256 bytes, no control characters) orparamsis required, and parameter bounds matchrequest_params; unknown keys are rejected. An unknown slug answers400 unknown_request_preset(naming a well-formed slug), a non-string value answers400 invalid_type,nullis ignored, and the field is never forwarded to a backend. The section is separate fromrequest_params, so Hub config sync never overwrites it. - Accepted glob patterns in
backends[].models(#2357). An entry with*(prefix, suffix, or infix, for examplegpt-*oropenai/*) routes every matching model id to the backend, so a provider's new model is routable without a configuration edit./v1/modelsnever lists the pattern string: it lists the concrete ids the backend's own model list advertises that match it, and a backend without a live list (Bedrock) lists nothing for it. An exact entry on any backend (or a dated snapshot of one) wins over a pattern, overlapping patterns on two backends draw a load warning and aconfig validatewarning, and an entry containing**or a blank entry is a load error. Per-keyallowed_modelsstill matches the requested id, so a pattern never widens a key, and Continuum Hub backend sync exports a backend using a pattern as preview-only because Hub profiles carry model ids. - Added the
LeastBusyselection strategy (#2334). It selects the eligible backend with the fewest requests the router itself has in flight against it, read from the pool's per-backend in-flight tracker, so it works over any backend type and requires no engine statistics (whereEngineLoaddefers torouting.engine_load.base_strategyfor a backend that publishes none). Candidates the tracker has never counted read as zero, and ties rotate through the pool counter so equally loaded candidates share traffic evenly. Accepts theleast-busy,least_busy, andleastbusyspellings inselection_strategy,--selection-strategy, andCONTINUUM_SELECTION_STRATEGY. - Added opt-in hedged requests (#2317). With
proxy.hedge_delay_msset (0to60000, unset by default), a buffered/v1/chat/completionsor/v1/completionsdispatch that has not answered within the delay is sent to a second admissible backend serving the same model; the first response wins and the other attempt is cancelled and recorded as a usage row withoutcome: hedge_cancelled. Client streams, fallback-chain hops, embeddings, rerank, image generation, andreplay: disabledbackends are never hedged. - Honored a per-request model fallback list on
/v1/chat/completions(#2403). Amodelsarray besidemodel, OpenRouter's fallback form, is extracted at the ingress and the key is consumed, so no backend sees it; any error on the primary fails over to the next model in the list through the same machinery a configured chain uses (per-attempt retries,X-Fallback-*headers, per-hop timeouts, the fallback dial bound, the mid-stream relay), the responsemodelnames the model that answered, and usage is recorded under it. Whenmodelis absent, the first entry names the primary. Each entry follows the configured-chain name rule, entries are deduplicated in the client's order with the primary never re-attempted, and a list that does not parse is a400naming the offending entry. The list applies only when the operator has the fallback feature on: without afallbacksection, or withenabled: false, it is consumed and ignored, and it is also ignored when the dispatched model is not the model the client named as primary, because a Hub decision or a smart-routing rewrite owns that choice. It overlays only the dispatched model's chain for the request; every other configured chain, the fallback policy, andmid_stream_enabledstay as configured, and the model'snotify_on_fallbackchoice is kept. Every entry must be in the key's model allow-list (an entry outside it is refused before dispatch, not skipped), anddisable_fallbacks: truetakes precedence: the list is consumed and the request stays on its primary. - OpenAI-style batch creation now accepts an
input_file_idfrom the router's local/v1/filesstore (#2733). The router checks ownership,purpose=batch, the 200 MiB scan bound, and model restrictions before sending customer content, then streams the file once to the admitted backend and substitutes its provider file ID. Provider-owned IDs still pass through unchanged; native Anthropic inline batches reject local file IDs, and provider copies keep the provider's retention lifecycle. - Added
continuum-router usage, a token usage report for a running router (#2525). It prints one row per model (default), backend, API key, or user (--by models|backends|api-keys|users) from theGET /admin/stats*endpoints, ordered by tokens with a share bar, failures, tokens per second, and average latency. Backend rows carry no token counters, so they are ordered and barred by requests.--seriesappends the global daily token series (GET /admin/stats/series) as a chart split into prompt and completion tokens.--windowapplies to the models and backends dimensions only, since the per-key and per-user counters are all-time. Authentication,--json,--timeout, and the control-character handling followcontinuum-router status. - Added the
logging.accesssection for the request logging middleware (#2402).log_headersturns theTRACE-level header line on or off (credential headers stay redacted), andexclude_pathsreplaces the default/health,/health/ready, and/healthzexclusions; each entry must start with/and also covers the paths below it. An absent section keeps today's behaviour. Body capture is not configurable here, and unknown keys in the section are refused at load. - Added age-based retention for rotated process logs,
logging.file.rotation.max_age_days(#2369). When non-zero, the file sink also removes matching log files whose modification time is older than that many days, at startup and after each rotation, on top of themax_filescount cap. The open file and files that do not match the configured pattern are never removed, a modification time in the future is not treated as stale, and0(the default) keeps count-only retention. The value is validated at load (0 to 36,500). - Exported
backend_health_status{backend,url}as a numeric enum gauge (0=unknown, 1=healthy, 2=warming_up, 3=unhealthy) beside the legacy binarybackend_healthy(#2315). The health state machine already tracksWarmingUpinternally with accelerated 1s checks and a max-warmup deadline, but the metric collapsed it to 0, identical to a dead backend, so autoscaling and dashboards could not tell a backend loading weights from one needing intervention. The legacy gauge derives from the same status, so existing scrape configs keep working unchanged. - Published capability metadata on
GET /v1/modelsandPOST /v1/models/refresh(#2417). Each entry carriescontext_length,max_output_tokens,pricing, andcapabilitiesfrom the resolved model metadata when they are known, and omits each one otherwise rather than sendingnull. Without operatorlimits,context_lengthis the minimum of the windows the serving engines report. Chat models also carrysupported_parameters, the sampling and logprob parameters every serving backend's conversion carries to the wire (temperature,top_p,max_tokens,presence_penalty,frequency_penalty,top_k,min_p,logprobs,top_logprobs), derived from the same per-backend table the chat ingress enforces rather than maintained per model. Clients that filter on these no longer need/v1/models/extended. - Exported per-provider failure details for routing debugging (#2435). Every backend hop that answered with an error HTTP status records its status code and a redacted error summary into a rolling per-provider tracker, surfaced at the new
GET /admin/provider-failuresendpoint and counted asprovider_failures_total{backend, status}. At most 20 failures per backend and 256 backends are retained, a backend removed from the pool leaves the view, and the summary is the sanitized copy the client error already carries (issue #2253, cut at 200 characters), so no credential or internal endpoint is stored. The metric'sstatuslabel is the exact code from400to599andotheroutside it. - Added the OpenAI-shaped
POST /v1/moderationsendpoint (#2336), soclient.moderations.create()works against the router. Each input (a string, or an array of up to 32 strings or text parts) gets its ownresults[]entry withflagged, per-category booleans, andcategory_scores, classified by the configured guardrails: thedenyandallowmatch lists, then every input-stage provider of the route the optionalmodelselects, at the same thresholds as live traffic but regardless of the globalguardrails.mode. Categories are the router's guardrail categories; provider names and reasons are never returned, and a provider's fail-policy verdict never flags text. The route needs thewritescope. With guardrails disabled it answers400 moderation_unavailable, a request whose every provider failed answers503 moderation_unavailable, and an empty, oversized, or non-textinputanswers400(invalid_inputortoo_many_inputs). - Added the per-request
disable_fallbacksbody parameter on/v1/chat/completionsand/v1/completions(#2347).trueskips every cross-model fallback hop for that request, before the first byte and mid-stream, buffered and streamed, and returns the primary model's error; retries and failover between backends serving the same model still apply, and other requests keep their chains. The field is removed before dispatch whatever its value, so a backend never receives it. - Added the
mock_responserequest parameter on/v1/chat/completionsfor testing and debugging (#2346). A non-streaming request that carries it is answered from the value without contacting a backend or writing a usage record: a string becomes the assistant content of onechat.completionchoice withfinish_reason: "stop", and a JSON object is returned verbatim. Authentication, request validation, and the key'sallowed_modelsstill apply, and any other type answers400 invalid_type. A streaming request is dispatched normally, and the field is never forwarded to a backend. - Applied Continuum Hub-declared per-model backend aliases (#2723). A Hub backend profile's
model_aliases(up to 50 caller-facing names per allowed model) materialize asmodel_configsaliases on the Hub-owned backend, so a request for an alias is dispatched as the target model (aninternalprofile's aliases are validated but not applied) through the existing alias rule: selection runs over every user-routable backend serving the target, the upstream receives the target id, and/v1/modelslists the alias. The router advertisesbackend_model_aliases_v1in heartbeat capabilities whenever backend sync runs (backends.modeis notdisabled). A snapshot is rejected asmodel_policy_invalidwhen an alias names a model outside the profile'sallowed_models, exceeds 50 per model, is malformed, or collides with another Hub profile's alias or model or with a preserved local backend's model or alias. A restored snapshot whose alias now collides with a local edit still activates with a warning. Realtime audio capability lookup no longer stops at a backendmodel_configsentry without metadata, so an alias-only entry cannot hidemodel-metadata.yamlcapabilities. - Reported per-key rejection counts for Continuum Hub-issued keys in the heartbeat supply block (#2515). Each rejection of a key the router resolved against the Hub key table is counted per key and stage in
rejections_by_key, cumulative since process start; the first 32 such keys keep their rows until restart, and rejections of any other Hub key are summed inrejections_by_key_other, which is always sent (0included). Local keys, AppProxy keys, and unattributed rejections still reach the Hub only throughrejected_requests, which is unchanged. The vendoredcontinuum-protocolcrate is re-vendored at lablup/continuum-hub37d98a1aca96cbc5b708aece86f3bee1f74f7e3f, which also brings backend-profile model aliases (applied since #2723). - Added the opt-in
usage.ledger_export.max_filesretention knob (#2428). When set, the oldest rotated export files (<path>.<sent_at_ms>and its-<n>variants) beyond the cap are removed after each rotation; the live file is not counted, failures are logged at most once a minute, and the push is never affected. Unset (the default) keeps every rotated file, so tamper-evidence consumers that want the full history are unchanged. - Added a fail-closed option and an outage signal for Redis-backed rate limiting (#2424, #2408).
rate_limiting.redis.fallback_to_memorydefaults totrue, the existing fail-open behaviour in which an unreachable Redis degrades every check to the replica's own bucket (a fleet of N replicas admits up to N times the configured rate). Set it tofalseto refuse instead: admission checks answer503(type: service_unavailable,Retry-After: 5) until Redis answers, and aper_backendcharge refuses the dispatch like an exhausted backend. Both cases are counted onrate_limit_redis_unavailable_total{dimension, action}(actionisfallbackorrefused), and a fail-closed refusal is not counted as a quota violation. Each Redis check is now bounded at 500 ms, so a partitioned Redis no longer holds requests for the OS connect timeout. Changing the field requires a restart. - Added the per-key token budget (#2323). A key with
max_tokens_budget(whole tokens, input + output) is refused with429 rate_limit_erroronce its usage within the rollingbudget_window(1d,7d, or30d, default30d) meets the budget, in the Anthropic error envelope on the/anthropicmount. Admission reserves an estimate of each in-flight request's prompt until its response body ends, so concurrent requests cannot all pass a nearly spent budget. Only budgeted keys are tracked, with fixed memory per key, and a key that cannot get a window is refused rather than admitted untracked. Withapi_keys.persistence_fileset, the windows persist to<persistence_file>.budget.jsonevery 30 seconds and survive a restart; otherwise they are in memory. The windows are per replica.max_tokens_budget: 0and any other window spelling are load errors, and a key without a budget is unchanged. - Served the Anthropic Messages API at the server root (
POST /v1/messages,POST /v1/messages/count_tokens) as root-level aliases of the/anthropicingress endpoints (#2276). A client whose base URL is the server root (without the/anthropicprefix) reaches the same handlers with no configuration changes;GET /v1/modelsstays the OpenAI-compatible model list. The root paths carry the same API-key protection andread/writescope classification as their/anthropiccounterparts, and the same path-keyed behavior: pre-route refusals (rate limits, Hub policy, concurrency ceiling, hop guard) use the Anthropic error envelope, responses carry the Anthropicrequest-idheader,POST /v1/messagesis charged againstper_modelrate limits and Hub cost-center budgets, andcount_tokensstays exempt from both. - Publish backend
model_configs[].aliaseson the model lists (#2248). Each alias an enabled backend declares is listed as its own entry onGET /v1/models,/v1/models/extended,/v1/models/{model}, and/anthropic/v1/models, with the canonical model's backends and metadata, exactly while alias dispatch would resolve it; it leaves the list on the refresh that drops the canonical model. A per-keyallowed_modelslist must name the alias itself, and the backend allow-list, internal backends, and the routable-models rule apply through the canonical model's backends. A literally served name keeps its own entry, a model experiment's client-facing name stays with the experiment, andmodel-metadata.yamlaliases and themodel_aliasesslots are not listed.GET /admin/modelsreports declared aliases in the canonical entry'saliases. - Reported the monetary cost in the usage block of a buffered
/v1/chat/completionsresponse (usage.costplususage.cost_details.upstream_inference_prompt_costandupstream_inference_completions_cost, in USD) so a client can meter spend per request without joining usage records with pricing metadata offline (#2404). The cost is computed from the routed model'spricingmetadata (the long-context tier when the prompt exceeds its threshold, andcached_input_discounton the cached prompt tokens), the same arithmetic thex-continuum-cost-microusdheader uses. A model withoutpricingmetadata, a response without ausageblock, and a response whose backend already reported its ownusage.costare left unchanged. The stamped prices are the catalog's, visible to every client of the route. - Completed the routing-decision explainer (#2467).
POST /admin/routing/explainnow previews the configured strategy over the eligible set and reportsselected,deterministic, per-candidaterank,latency_ms, andscore, plusvirtual_modelandexcluded_modelsfor smart routing. It accepts an optionalrequestbody for the prefix key and the cost estimate, and it previews the strategy without advancing the round-robin counter, consuming randomness, writing the hash-ring cache, or callingprepare()on a scorer, so two identical calls return byte-identical bodies. Eligibility is now the live path's own seam, and the response addscapability_mismatch,model_not_allowed_by_tier(from the applied Hub policy tier),credential_unavailable,not_in_backend_pool,unhealthy, andrate_limitedwithretry_after_ms. The newserver.explain_response_header(defaultfalse, restart required) addsx-continuum-route: <backend>to ordinary API responses for debugging without admin access. - Added
GET /v1/quota, a read-only endpoint reporting the caller's own limits and remaining values (#2872). It reports the per-key request and minute-token buckets, the per-user and per-team request buckets, the in-flight count, the long-windowmax_tokens_budget, and Hub tier windows, each with itsscope(fleetorreplica). It charges no identity-bound bucket and creates no entry, so repeated polling and a key already at its limit both read correctly. The sharedglobalandper_clientmargins are still charged at the/v1/modelscost of 0.1 but are never reported, preserving the rule that shared levels stay off the wire (#1170). The route requires thereadscope, carries nox-ratelimit-*orx-continuum-ratelimit-*header, and returns an empty document to an unrecognized caller, byte-identical to a recognized key with no configured limit, which extends the existing permissive-mode key-validity residual fromper_api_keytomax_tokens_budget. - Added
proxy.client_provider_preferences(#2396), off by default:POST /v1/chat/completionsinterprets the client's top-levelproviderobject as OpenRouter-style per-request routing preferences.only,ignore, andmax_pricenarrow the candidate set,orderranks it, andsort(price,throughput,latency) ranks it by live telemetry, all applied after backend visibility, the key'sallowed_modelsandallowed_backends, and Hub and policy narrowing, so a client can never widen what the operator permits. A non-empty candidate set emptied by a filter answers400 invalid_request_errornamingprovider.<field>instead of dispatching; an empty input set stays the usual503. Unknown fields, and the two OpenRouter directives the router cannot honor (require_parameters: true,data_collection: "deny"), are refused rather than ignored, and the object is limited to 16 KiB, 16 names per list, and 1 to 128 bytes per name. With the switch on the router removes the key before dispatch, so an aggregator backend no longer receives it verbatim; an operator forwards aproviderobject deliberately withbackends[].request_extensions.body.overrides.provider, which wins over the removed client key.x-backendstill wins the first attempt. With the switch off the request path is unchanged.
Changed¶
- Hardened the token-budget path (#2899). A token-budget
429now still carries the caller's own request-countx-ratelimit-*headers, which describe an independent bucket that admitted the request before the token check ran. A reloadedtokens_per_minuteortokens_per_dayapplies to the next request: the reload rebuilds the budgets and resets the bucket maps. A full token-bucket map fails open and recordsrate_limit_skipped_total{dimension="token_budget"}(minute map) orrate_limit_skipped_total{dimension="token_budget_day"}(day map) instead of skipping silently. A dimension with both periods configured reserves against them in one Redis round trip, and a denial by either consumes neither.RateLimitErroris#[non_exhaustive]so a future refusal detail does not break downstream matches. The request-countper_api_keybucket is now keyed on a deployment-stable, domain-separated hash of the credential, so withstorage: redisevery replica charges one shared bucket; the existing request-countper_api_keybuckets re-key once on upgrade and the old Redis keys age out at the configured TTL.per_modeltoken budgets are admission-time dimensions, so unlikeper_backendthey are enforced andconfig validatedoes not warn about them. -
Recorded the four token-budget design decisions in the design record (#2898). The local
tokens_per_minute/tokens_per_daydimensions keep one window per period, with no input/output split; the admission hold is exactly the whole-request input estimate plus the output ceiling, with no extra margin; a request that deduplication coalesced onto an in-flight leader is released in full while only the serving request settles its own reservation; and a response-cache hit charges nothing, its input tokens included. Each decision is pinned by a test, and no runtime behavior or configuration surface changed. -
Re-vendored
continuum-protocolat lablup/continuum-hubd7903743f4942e33117bb23e4159b614d097a38e(#2817), so the crate is again a byte-identical copy with noLOCAL-AHEADentries. A Hub at or after continuum-hub#1994 now delivers per-key prompt logging consent through the key table, and the operator guide says so. The router compiles against the new PII custom entity and PII provider row types but does not advertiseguardrail_pii_provider_row_v1orguardrail_provider_content_v2, so a Hub keeps projecting those statements to the legacy form. A statement that nonetheless carries PIIcustom_entitiesis declined as a whole and reported asguardrails_unavailable, because this router does not run them.make hub-compatgains a consent stage,scripts/hub-compat-consent.sh, that proves delivery against a live Hub (skip withHUB_COMPAT_SKIP_CONSENT=1). - Emitted the refusal-log window summary on a timer (#2427). The
WARNsummary that reports how many refusals a 60-second window suppressed was written only by the next refusal after the window, so a burst that stopped mid-window never logged its final count. A background flush, started by the first refusal and run every 30 seconds, now writes the summary for every closed window that suppressed repeats; it shares the line format and the bounded table with the lazy path, and whichever runs first writes the summary, so it is never duplicated. An idle router writes no summary. - Tracked the 256 most recently rejected key ids on
request_rejected_totalinstead of the first 256 seen (#2514). When a rejection arrives for a new key id while 256 are tracked, the least recently rejected key is evicted: its counts are added tokey_id="other"and its own series are removed, so totals summed overkey_idstay exact and the series count stays bounded. Because the evicted key's lifetime count lands inotherat once,increase()andrate()overkey_id="other"jump on an eviction; alert onkey_id!="other"series. A key that starts failing late in the process lifetime now gets its own series instead of sharingotheruntil restart. The per-minuterequest_rejected_summarywindow still admits key ids first come, first served.
Deprecated¶
- Deprecated
metrics.latency_buckets, which was parsed but never applied (#2514).http_request_duration_secondsalways uses the compiled bucket layout (0.01 to 1200 seconds);continuum-router config validateand startup now warn atmetrics.latency_bucketswhen the field differs from its default. Remove the field to silence the warning.
Fixed¶
- Restored the observed config report for
fallback.fallback_policy.trigger_conditions.context_window_exceeded(#2956). Router v2.0.0 requires a Hub that registers the leaf in its observed-path registry (continuum-hub #2038), because an older Hub rejects the whole heartbeat over the unknown path. Without the report, a Hub that delivers the leaf digests it as desired while the Router's observation omits it, so the configuration can never converge and reads as permanent drift. The Router parses and applies the leaf as before; only the value-free observation is restored, and the English and Korean configuration pages were updated. - Fit the Anthropic ingress's derived
reasoning_efforteven when noreasoning_effortsvocabulary is declared (#2321). The fitting step now resolves, in order, the backend'smodel_configsdeclaration, the shared model metadata layer, and the backend type's documented default vocabulary before forwarding the derived value unchanged. Anopenaiorazuretarget uses the built-in OpenAI reasoning-family table, and the self-hosted OpenAI-wire types takelow,medium, andhighwithxhighsent ashigh. The chosen source and the fitted value are logged at debug level asvocabulary_source=backend_model_config|model_metadata|backend_type_default|passthroughtogether withbackend,model, andeffort_field. An operator declaration still wins, a target with no known vocabulary still receives the derived value unchanged, and a client-suppliedreasoning_efforton/v1/chat/completionsis still never rewritten. The English and Korean reasoning-effort architecture and API pages were updated. - Closed four gaps in
responses_tools.deny_resource_fields(#2930). The opt-ininput_item_resource_idsentry refuses any input item carryingcontainer_id,file_id,vector_store_ids,previous_response_id, orconversationat any depth, names the location asinput[code_interpreter_call].container_id, and applies toPOST /v1/responses/compact; it is off by default because a client replaying the model's own output resends those IDs, and afile_idthe router resolves to a local file is accepted either way. The list also judges every nestedtoolslist a forwarded tool declares, including aprogrammatic_tool_callingand a rawtool_choicesuch asallowed_tools. Anallowordenyentry must now be printable ASCII, so an invisible look-alike such as a zero-width space is a load error instead of a name that never matches. A request the pass-through strategy refuses is no longer served from or stored to the response cache, so a reload that adds an entry takes effect against an answer cached under the old policy. The configuration assistant and the English and Korean configuration and API pages were updated. - Fixed the Hub prompt-log push losing its backlog silently (#2880). A
404now drops the backlog only when the body is the ingest handler's own{"error":"prompt-log ingest disabled"}; any other404, such as a proxy misroute, retries so the records stay on disk. The records a closed channel jumps over are counted asdropped(an upper bound), single-record isolation requests are paced, and a wholly refused batch pauses the push for one interval. The dead clear-text warning was removed, because such ahub_urlis refused when the configuration loads, and a persistent redirect is retried and named once per process with its status. - Kept unknown fields on a replayed
messagefor native backends (#2896). A pass-through backend now receives a message'sid,status, and any other field the router does not model, including on a role-and-content item without atype, on streaming and non-streaming requests. A backend may reject a replayedidit does not know, for example understore: false, as it already may forfunction_call. Conversions ignore these fields and their output is unchanged. The fields count toward the input size limit, and the input guardrail reads them. The same reading now also applies to amessagethe parser keeps verbatim, whose unmodeled fields previously reached a native backend uninspected. - Fixed two prompt-log consent gaps (#2879). Export now decides consent on the key id as captured, before redaction masks it, so a
redaction.patternsentry or the built-insk-pattern that matches a key id no longer turns that key's records intoconsent_key_absent; only the masked id is written or sent, and the Hub push refuses a record whose id masking changed, counting it asrejectedwithout a request and logging onewarnwithout the id, because the Hub matches the id against its own table. With no Hub policy store, a presented token that does not authenticate is no longer captured underdefault_consent: an unknown token, a deleted or rotated key's old value, and a disabled or expired key statingallowedor nothing all write no record, so a client that sends a placeholder token must send no token to followdefault_consent. With a policy store attached, only an inactive local key'sdeniedstill carries over, so a valid Hub key statingallowedstays captured. The English and Korean prompt-logging guides, design page, architecture pages, and the consent design record were updated. - Fixed Gemini native
/v1/chat/completionsrequests with only built-in tools hanging or failing upstream (#2890). The native body now carriestoolConfigonly when the request declares functions, sotool_choicewith onlyweb_search_optionsorcode_executionis validated but not enforced, instead of reaching Google as a function calling config that answered with a 400, an empty reply, or no reply at all. -
Fixed prompt-log export startup and delivery gaps (#2848): a live writer no longer hides an implausible cursor, past-EOF cursors retry from a complete-line boundary, disabled capture keeps retention active, S3 credential endpoints are validated and pinned per delivery with bounded DNS checks, and S3 service-side request faults such as
OperationAbortedkeep retrying. The global URL validator now rejects 6to4 and Teredo addresses that embed blocked IPv4 endpoints. -
Documented
rediss://trust for private CAs and made a rejected Redis server certificate visible in the logs (#2865). A connection failure caused by certificate verification now adds a hint namingSSL_CERT_FILEto the record'serrortext in the response cache, rate limiter, Hub tier minute limits, KV cache index, and AppProxy subscriber, and the rate limiter logs that case as a warning instead ofdebug. Fallback behavior is unchanged and verification still cannot be disabled.SSL_CERT_FILEreplaces the system trust store, on Linux also for HTTPS backends and the Hub connection, so the deployment guide adds a private CA throughSSL_CERT_DIRalongside the system bundle, and the Helm chart gainsextraVolumesandextraVolumeMountsto mount it. The KV listener container smoke workflow now asserts the image carries a non-empty CA bundle. - Fixed
rediss://Redis URLs, which passedconfig validateand then failed at start-up because the client was built without TLS (#2747). Every Redis consumer (the response cache, Redis rate limiting, the KV index, Hub minute limits, and AppProxy events) now connects over TLS and verifies the server certificate. A deployment that followed the documentedrediss://form and was silently counting minute limits per router now shares them. - Stopped exporting backlog for a key whose prompt-logging consent was withdrawn (#2821). A sink now re-checks each record's key between the read and the delivery:
denied, or a key stating nothing underdefault_consent: denied, is skipped asprompt_log_export_total{result="consent_withdrawn"}, a key no table holds is skipped asconsent_key_absent, and while a Hub table cannot state consent the sink holds at that record without moving its cursor. An enabled AppProxy ROUTER holds a record of one of itsappproxy-keys the same way until the router has its first key snapshot, and a ROUTER key its table no longer holds is skipped asconsent_key_absent. A local key source that failed to load holds every record with a key until a restart. Delivered records cannot be recalled, local files are not rewritten, andcapturescope narrowing stays unenforced. - Closed streaming relay terminal and split-frame correctness gaps across native Gemini,
/v1/responses,/anthropic/v1/messages,responses_onlychat bridges, and image generation (#2829).GeminiStreamTransformernow buffers partial SSE lines and split UTF-8 sequences across chunk reads viaSseLineBufferandUtf8Buffer, enforcesMAX_SSE_FRAME_BYTESon each line, and flushes any unterminated final line throughfinish()at stream end. Whenframe_json_refusedrejects a structure-heavy SSE frame on the/v1/responseschat, Anthropic, or Gemini source relays, the Anthropic OpenAI and Responses bridges, or theresponses_onlychat bridge and the stream subsequently ends at[DONE]or EOF without a valid finish or terminal event, the router now emits that surface's error terminal and recordsupstream_errorwith any usage observed before the refused frame instead of synthesizingresponse.completedormessage_stop. On/anthropic/v1/messages, an oversized trailing SSE frame or upstream read error arriving aftermessage_stophas already been emitted is now ignored with a warning so no trailingevent: erroris appended and the usage outcome remainscompleted. Image-generation SSE error terminals now wait up to 5 seconds (IMAGE_TERMINAL_SEND_TIMEOUT) for queue capacity instead of usingtry_send, so a full queue that drains one slot receives the terminal error without stalling indefinitely on a dead client. - Kept a Hub key's
deniedprompt-logging consent through the capability upgrade window (#2818). A Hub stripsprompt_logging_consentfrom the key table of a router whose stored capabilities omitprompt_logging_consent_v1, so an already-enrolled router upgraded to a build that advertises it applied a stripped table before its first heartbeat and captured adeniedkey underdefault_consent. The router now treats a key table as carrying consent only when it was requested after a heartbeat the Hub accepted; until then key-authenticated requests are not captured, as in the no-policy window. The first heartbeat is sent at startup instead of after one interval, the policy is requested again as soon as the first heartbeat is acknowledged, a stream session opened before that reconnects, and aconsent_confirmedflag in the persisted key table keeps a restored table's standing (a file from an older build restores as carrying no consent). A Hub that accepts heartbeats but sends no consent opens the gate and its keys followdefault_consent, with oneinfoline; a router whose heartbeats never succeed logs onewarnand keeps key-authenticated capture closed. Underunmatched_key: fail_open, a presented token that no table recognizes is no longer captured. - An export cursor that cannot be read at startup no longer stops the router (#2820). After 5 retries, 200 ms apart, the process serves traffic with no capture and no export, logs one
ERROR, and setsprompt_log_capture_disabled{reason="cursor_unreadable"}to 1; restart after fixing the file. A directory that cannot be read or created still stops startup. - Attributed rejected SSE event-name warnings and their 60-second suppression windows per serving backend (
backend = %backend, orunknownwhen no relay backend is in scope) instead of a process-wide bucket, escaped and capped rejected image-generation SSE event names atwarnwithbackendattribution, removed the unreachable raw- data: {}payload interpolation from the Anthropic streaming fallback warning, and stripped backend URLs (without_url()), bounded upstream error-body reads (read_bounded_upstream_error_body), and capped logged error bodies (cap_error_body_for_log) on the ACP prompt handlers (#2828). - Corrected ACP
session/promptfailure outcomes and bounded stalledsession/updatesends (#2827). A backend SSE stream that reaches EOF with neither afinish_reasonnor[DONE]now fails with-32603(Backend stream ended before the reply finished), leaves the session history unchanged, and recordsupstream_errorwith any usage seen so far instead ofcompleted. A requesttotaltimeout that expires while reading a streamed or buffered response body now returnsBackend stream timed outwith{"timeout":"total",...}and recordstimeout_mid_streamonce a body chunk was seen (timeout_first_byteotherwise) instead ofupstream_error. Backend selection failures (-32602unserved model and-32603no available backend) now record one failed row undernot_dispatchedwith no attempt counted, while a cancel before dispatch and an overlong model name still record nothing. Eachsession/updatesend now racessession/cancelandtimeouts.request.streaming.chunk_interval, so a client that stops draining its transport no longer holds the prompt open. - Warned in
ContinuumRouter::into_router()whencontrol_plane.enabledis true and the control-plane usage or batch status receiver is still unclaimed, gatedBatchManagerand the batch status channel on the samecontrol_plane_agent_runsflag as the usage channel, and honoredContinuumRouterBuilder::without_control_plane_agent()inserve()by skipping agent startup with a warning (#2826). - Capped model names on the Anthropic Messages ingress, in
StatsCollector, and inacp.default_modelvalidation at 256 bytes (#2825).POST /anthropic/v1/messages,POST /anthropic/v1/messages/count_tokens, and their root aliases (POST /v1/messages,POST /v1/messages/count_tokens) now reject amodellonger than 256 bytes before alias resolution or backend selection with400 invalid_request_error(Model name exceeds maximum length of 256 characters) without echoing the value.StatsCollectornow folds any model name longer than 256 bytes intounknowninper_model,per_api_key_model, andper_user_modelwhile still counting the request, and drops overlong model keys when restoring a snapshot with a single warning naming the dropped count.acp.default_modelvalues longer than 256 bytes after trimming are now rejected during configuration validation andcontinuum-router config validate. - Bounded the per-stream SSE relay queue, the process-wide streaming buffer footprint, and accumulated A2A/ACP reply text (#2824). Each
SseTxrelay queue now has a 256 MiB ceiling (2 * MAX_SSE_FRAME_BYTES), and activeSseParserbuffers,SseLineBufferbuffers, and relay queues share a process-wide byte budget configured byserver.max_stream_buffer_bytes(default 4 GiB, minimum 1;0is rejected at parse time, and changing it requires a restart). When a slow reader reaches the per-stream queue ceiling or exhausts the process budget,SseTx::sendlogs one warning, drops further client frames without pacing the upstream reader (always admitting one frame when the queue is empty), and returnsOk(())so the relay keeps reading the upstream to its terminal usage event;DoneSentinelomits the trailingdata: [DONE]frame on an overflowed stream so a truncated stream cannot appear complete. AnSseParserbuffer that exceedsserver.max_stream_buffer_bytesends the stream withSseLimitKind::ProcessBudget. Accumulated reply text in the A2A streaming task store and in ACP streaming and bufferedsession/prompthandlers is now capped at 64 MiB (MAX_ACCUMULATED_REPLY_BYTES), failing an oversized A2A task withUpstream reply too largeand an oversized ACP prompt with JSON-RPC error-32603(Backend reply exceeds the size limit) andupstream_errorusage without mutating session history. - Failed closed on structure-heavy SSE frames at streaming guardrail gates and bounded frame parsing in the Anthropic and Gemini stream transformers (#2823). An SSE frame with more than 1 MiB of structural JSON bytes outside strings cannot be parsed into a
serde_json::Valuewithout multi-megabyte or gigabyte heap expansion (#2795). Streaming output gates, stream usage tracking, and the Anthropic and Gemini stream transformers now route every frame JSON parse throughparse_frame_json. Whenframe_json_refusedtrips atStreamingOutputGate::observe, the router logs a warning with the model andcategory = "guardrail_frame_uninspectable"without logging the frame: an active enforcing gate (mode: enforcewithstreaming_modeset tobuffer_fullorchunked) marks the stream blocked and returnsStreamGateStep::Blockso the relay emits its wire block terminal, drops any held text or deferred/v1/responsesmirror events, stops reading, and records aguardrail_blockedusage row with the last usage reported before the refused frame (or the router's delta count); an inactive gate (mode: monitor, ormode: enforcewithstreaming_mode: passthrough) forwards the frame verbatim without building a JSONValue, and monitor mode's end-of-stream check runs without that frame's text. In the Anthropic and Gemini stream transformers, a structure-heavy frame is skipped or forwarded unparsed instead of callingserde_json::from_str. - Hardened prompt log export after the #2328 security review (#2810). The strict SSRF validator behind Admin API backend URLs, Converse image fetches, client-supplied file URLs, and the export sinks now also refuses the shared address space
100.64.0.0/10(carrier-grade NAT and the100.100.100.200metadata address),198.18.0.0/15,0.0.0.0/8, multicast, reserved, and NAT64-embedded addresses, so an admin-created backend on one of those ranges fails validation where it passed before; backends in the source configuration do not go through this validator and are unaffected. The export sinks resolve their endpoint before every delivery, refuse a name that does not resolve, and connect only to the addresses that passed, so a rebinding name cannot move a delivery; an S3 sink applies this only when an endpoint is in use, and also checksAWS_ENDPOINT_URL_STSandAWS_METADATA_ENDPOINTat startup and counts them in its cursor identity. Record masking, span building, and serialization run on the blocking pool; a batch delivered in part moves the cursor past the delivered prefix; retry delays carry jitter; a cursor file is read without following symlinks, without blocking on a FIFO, to 64 KiB at most, and a cursor beyond the record files or past its file's end is treated as corrupt or clamped; only regular files under their canonical name are exported;outcomeandtruncatedare masked on read;bucketandprefixare redacted inDebug; a group- or world-writable capture directory warns at startup; an S3 bucket name with..or an IP shape is refused; one half of the ambientAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYpair is named at startup and byconfig validate; the S3 credential clients keep their one second connect timeout; and every tracked streaming tool call is cut at a token boundary once a call past the cap stops collection. -
Bounded the SSE parser so a backend cannot exhaust router memory or burn quadratic CPU with one stream (#2795). A line or standard-mode event over 128 MiB, or an
event:name over 1 KiB, now ends the stream with that relay's normal error terminal and anupstream_errorusage record, and the parser scans in linear time. The image relay's 10 MiB limit becomes the same 128 MiB, and its queue gains a per-stream byte budget of 128 MiB. The A2A relay fails the task withUpstream stream frame too large, and a buffered dispatch stops reading on a breach. A frame with more than about a million structural JSON bytes outside strings is not parsed: a pass-through relay forwards it, a conversion relay skips it as an unparseable frame, and its usage falls back to the missing-usage path. Long strings, such as base64 images, keep their usage. These limits bound one frame, not a stream: there is no global byte budget, the relay queue has no byte bound for endless small frames, and the guardrail gates still parse every frame in full. A line now ends at the first of\r,\n, or\r\n, as the SSE specification defines, so a bare\rno longer merges two fields, and a blank line clears a pending event name -
Stopped a malformed upstream SSE event name from aborting the router process (#2791). A name holding
\ror\n, taken from a rawevent:line or a payload'stypefield, made axum panic, and the release profile'spanic = "abort"turned that into a process exit that ended every in-flight request. The frame now goes out asevent: unknown, and the router logs the capped, escaped name atwarnonce per 60-second window with a suppressed count, and atdebugfor repeats. Consumers that dispatch on the event name, including the router's own Responses conversions, drop such a frame. A\r-separatedevent:line can still lose itsdata:line in the parser, which is a separate defect. -
Aligned two model-name checks with routing (#2792). A Hub model alias that is the dated form of a preserved local
modelsentry (or the reverse) is now rejected as a local collision, and an entry that matches every name (*ormodels/*) no longer holds a name back from alias dispatch, so one match-all backend cannot turn dispatch off for the whole router. -
Reject chat inputs whose typical token estimate reaches the routed model’s effective context window before backend dispatch (#2326), including streaming requests without an explicit output budget. Unknown or zero limits remain permissive. Configured per-backend limits override cached engine reports, and the smallest known serving window applies after routing and compression. Operators whose metadata understates the real backend window must correct it to restore requests previously accepted beyond that advertised limit.
-
Published the same client-facing model names to the Hub inventory as to the model listings (#2434). The heartbeat's router-level
models_servedcame from the raw aggregation, so backendmodel_configs[].aliasesand model-experiment names, whichGET /v1/modelsadvertises and dispatch resolves, were absent from the Hub's endpoint model observation, and the portal marked a served model as not served. Per-backend attribution stays on the raw catalog. - Treated
CONTINUUM_DEV_MODEas a boolean (#2361). The built-in development API keys, one of which carries theadminscope, were loaded whenever the variable was set, soCONTINUUM_DEV_MODE=falseor0turned them on in a release build. Onlytrueor1(case-insensitive) now enable them;false,0, an empty value, and any unrecognized value (which logs a warning) keep them off. An unset variable keeps the previous build-type default: on in debug builds, off in release builds. - Bounded stalled ACP
session/promptstreams and metered ACP prompts (#2762). A streamed prompt now fails with JSON-RPC error-32603and data such as{"timeout":"chunk_interval","timeout_ms":1000}when the backend exceedstimeouts.request.streaming.first_byte(from dispatch to the first body chunk) orchunk_interval(every later gap), instead of holding the session until the total budget. Every prompt that reaches a backend now records one stats row and its token counters, with outcomecompleted,length,client_cancelled,timeout_first_byte,timeout_mid_stream, orupstream_error. Streamed prompts requeststream_options.include_usage; without a usage block the record carries the router's input estimate withtoken_source: none. - Stopped queuing undeliverable ACP usage in
--mode stdio, and bounded ACP model names and per-model statistics (#2790). Withcontrol_plane.enabled: true, stdio mode runs no control-plane agent, so nothing drained its usage channel and every record logged a warning once the channel filled. It now opens no usage channel or usage write-ahead log, logs one warning at startup, and keeps usage in local statistics.session/promptnow rejects a model name longer than 256 bytes before backend selection, with JSON-RPC error-32602and data{"field":"model","code":"model_too_long"}. On every ingress,per_modelstatistics now track at most 10,000 names: a new name past the bound counts underunknown, and names already tracked, including those in a restored snapshot, keep their own rows. - Enforced the per-key
allowed_modelslist on the realtime WebSocket upgrade (GET /v1/realtime,GET /realtime) (#2695). A key restricted to some models could open a realtime session on any other audio model the catalog serves, while the same model on/v1/chat/completionsanswered 403. The handshake now runs the sharedenforce_model_accessgate on the requested name before the reserved-variant check, alias resolution, and the upgrade, so a refused key gets 403permission_error, never completes the handshake, and dials no backend; an alias of a model outside the list is refused as well. - Fixed
make docker-runso the container reaches the advertised backends and is reachable on host port 8000 (#2646). The target passedROUTER_BACKENDS, which nothing reads, and published container port 8000, where nothing listens. It now passesCONTINUUM_BACKEND_URLS, the backend override the configuration loader reads, maps host port 8000 to the image's port 8080, and adds--add-host=host.docker.internal:host-gatewaysohost.docker.internalresolves on Linux Docker Engine as it does on Docker Desktop. - Flushed unterminated final lines from image-generation and KV-index SSE streams, preserving split UTF-8 sequences in KV events (#2751).
- Applied one file-access policy to Responses, Anthropic, and chat-completion file references, so an admin-scope key can reference an admin-uploaded file only when
files.auth.admin_can_access_allis enabled (#2751). - Propagated one managed tracing context from native Anthropic Messages ingress to TCP, Unix-socket, and Bedrock dispatches without duplicate operator or client trace headers (#2751).
- Bounded every asynchronous wait in the Dynamo broker tunnel integration test so a stuck dependency reports its expired operation instead of hanging the target (#2751).
- Kept a
response_formatrequest off the backends that cannot carry it (#2438). Only the OpenAI-compatible wire and the Gemini conversion carry a clientresponse_formatthrough; the Anthropic Messages and Bedrock conversions drop it, so a structured-output request could be dispatched to such a backend and answered without the requested format. A chat completion that carriesresponse_formatis now routed only to candidates whose conversion keeps it (across the routed model and its fallback models), and is refused with400(param: response_format, codeunsupported_request_parameter) naming the routed model when no candidate of that model keeps it. A request withoutresponse_formatroutes as before.GET /v1/modelslistsresponse_formatinsupported_parameterswhen every serving backend carries it. - Metered a streamed
/v1/completionsrequest (#2331). The legacy ingress answersstream: truefrom the buffered funnel, which returned the backend's SSE text without a usage block and handed the stats seam a body it could not parse, so no usage was recorded. The router now asks the backend forstream_options.include_usage: true(other clientstream_optionsfields kept) and meters the usage-only chunk it sends. The reply follows the client's owninclude_usagelike a chat stream: nousageunless the client set it, and then only on the trailing usage-only chunk. The reply is still buffered and served astext/plain; a streaming relay for this ingress is a separate change. - Shaped every
/v1/chat/completionsstream to the client'sstream_options.include_usage(#2271). A client that did not setinclude_usage: truereceives nousageat all: the chat ingress removesusagefrom every chunk (nullincluded) and drops the trailing usage-only chunk, after the usage record, the response cache, and the guardrail gate have read the stream, so metering is unchanged. A client that setinclude_usage: truereceivesusageonly on the trailing usage-only chunk, the shape the OpenAI stream sends: a chunk that carriesusage: nullpasses as it was, and a chunk that carries a non-nullusagewith choices has theusageremoved (the running usage a per-chunk engine reports). When such a stream reaches[DONE]without a usage-only chunk (an engine that reports usage only on its last content chunk), the router writes one carrying the last removedusage, so the client still receives it once. Clients that accumulatechunk.usageper chunk no longer double-count tokens, and the per-modelproxy.honor_include_usage_modelsopt-in list is deprecated and ignored: listing a model changes nothing, and the field still parses. - Recorded
POST /v1/responses/compactin request statistics and the usage ledger (#2690). The backend served and billed each compact request, but the router wrote no row, so compact traffic was missing from/admin/stats*, per-key and per-user usage, and the control-plane usage ledger. A served request now records one row with the backend's token usage, and a backend error, unreadable or unparsable reply, or connection failure records one failed row with no tokens. A request refused before dispatch (validation, the per-key model or backend allow-list, or a backend type without compact support) still records nothing. - Refused a chat completion whose output budget is below the wire minimum, before dispatch (#2366). A
max_tokens(either OpenAI spelling) of0used to be forwarded, and the upstream that rejected it named its own internal field instead of the one the client sent (max_completion_tokens: Validation error: range ...), so an SDK or IDE plugin that maps errors by theparamor message-prefixed field showed and rewrote a field the client never sent, and auto-retry logic keyed on the offending field misfired. The router now answers400with the OpenAI envelope naming the field the client sent (max_tokens must be at least 1., withparamset to that field), before any backend dial; a unit budget and above is forwarded unchanged. - Stopped reporting a
/v1/responsesreply the backend cut short ascompleted(#2715). A chatfinish_reasonoflengthorcontent_filternow yieldsstatus: "incomplete"withincomplete_details.reasonset tomax_output_tokensorcontent_filter, on both the buffered response and the stream, which ends withresponse.incompleteinstead ofresponse.completed. An Anthropic backend'smax_tokensandrefusalstop reasons map the same way. Partial output is kept. - Stopped turning an empty Anthropic
system(system: [], or blocks with no text) into an empty system message on conversion backends (#2721). The empty entry billed one extra input token on every request compared with omittingsystem. The OpenAI-compatible and Gemini conversions now add no system entry for it; the Bedrock, Anthropic, and Responses conversions already skipped empty system text. - Stopped forwarding
stopsequences on the Anthropic wire when the backend will reason (#2279). Backends matchstop_sequencesinside their hidden reasoning phase, so a stop string that appeared early in the reasoning text (common for short sequences such as"e") aborted generation before any user-visible content existed and the response carriedcontent: nullwithfinish_reason: "stop". The OpenAI→Anthropic transform now dropsstop_sequenceswhen the transformed request carries athinkingparameter or targets an adaptive-thinking model (Claude 4.6+) that reasons by default, and logs the drop atdebug. A model that only thinks when asked keepsstop_sequenceswhen no thinking parameter is present, andthinking: {"type": "disabled"}keeps them on any model, so visible-content stop matching is unchanged there. The Bedrock Converse transform applies the same rule whenadditionalModelRequestFields.thinkingturns thinking on. The trade-off: on a reasoning request the client's stop sequence is no longer enforced and visible content runs past it, instead of the completion being cut off. - Delivered the action-safety verdicts where Claude Code reads them on streamed Anthropic Messages replies (#2682). With
safeguards.action_safetyenabled, the verdicts went out as a standalonesafeguard_resultsSSE event, which claude-cli skips, so every auto-mode session fell back to its own billed classifier and showed the "isn't eligible" notice. The verdicts now ride on the turn's lastmessage_deltaasdelta.safeguard_results. A reply that proposes no tool use, streamed or not, now carries anavailablecheck with an emptytool_usesmap, because a missing result also moved the session to the client's classifier. Without an evaluator nothing changes, and the/v1/responsesChat Completions bridge keeps its event before[DONE]. - Relayed a sanitized excerpt of a backend
4xxbody the router does not recognize, instead of a bare per-status message (#2353). A backend that explained a rejection in an unfamiliar JSON shape reached Anthropic Messages clients only asInvalid request sent to backend; the body is now redacted and bounded like an extracted message and prefixed withBackend rejected the request: HTTP <status>:. The/v1/responsesrelays (Chat Completions conversion, Responses-only streaming, and pass-through) apply the same fallback. Empty, HTML, and binary bodies and every5xxkeep the generic message. - Carried Gemini thought signatures through
/anthropic/v1/messages(#2312). A signed Gemini function call now returns its signature on athinkingblock placed before thetool_use(signature_deltawhen streaming), and a replayed block writes it back onto the function call, so a Gemini 3 function-calling loop no longer fails on its second turn. Other backends never receive the field. - Enforced the per-key
allowed_modelslist onPOST /v1/responses/compact(#2686). The endpoint never checked it, so a key restricted to some models could compact a conversation on any other model the router serves, while/v1/responsesrefused the same request. The check now runs on the model name the client sent, before alias and experiment dispatch, guardrails, and backend selection, and refuses with the same403(permission_error,insufficient_permissions) as/v1/responses; a model outside the list answers that403whether or not any backend serves it, and an alias is admitted only when the key lists the alias itself. - Fixed the Hub tier minute-limit store staying on a demoted primary after a failover (#2745). A managed failover can demote the old primary without closing its clients. Those pooled connections kept answering
PING, so the recovery probe reported the store back while every script failed withREADONLY, and each router counted on its own until it restarted. AREADONLYreply or a dropped connection now discards the pooled connections, so the next call resolves the endpoint again, and the probe writes a short-lived<key_prefix>probe:<router id>key instead of pinging. - Dialled the Hub tier minute-limit store ahead of requests (#2748). The pool dialled lazily, so the first call after start-up, or after a recovery, paid for the name lookup and connection inside the 100 ms store deadline and could send that router back to its own windows for a probe interval. The maintenance task now opens a few pooled connections at start and on every recovery, under its own 1 s bound, and probes through them.
- Stopped the ACP
initializehandshake from creating a phantom session (#2430, #2457). The negotiated capabilities were held in a real store session that was never removed, so everyinitializeadded a session toGET /admin/acp/sessionsuntil the 1-hour TTL cleanup reclaimed it. The capabilities now live on the connection's handler context:session/newand thesession/loadcapability gate read them from there, andsession/set_modeorsession/cancelsent betweeninitializeandsession/newanswers "No active session". - Kept the ACP
session/promptturn when asession/updatenotification fails to send (#2431, #2458). A transport failure on the notification used to fail the whole prompt and skip the history update, although the backend had already answered and billed the turn. The failure is now logged, later notifications for the turn are skipped, and the accumulated reply still lands in the session history. - Derived the ACP prompt's backend timeout from the
timeoutsconfiguration instead of a fixed 300 seconds (#2425). A streamed prompt uses thestreaming.totalbudget and a buffered one usesstandard.total, read from the hot-reloadable timeout snapshot, so a tighter limit is enforced and a longer one no longer cuts long completions at five minutes. - Made ACP
session/cancelabort the in-flight backend request (#2418). The prompt handler used to await the whole backend response, so a cancel only stopped relaying a body the upstream had already generated and billed. The request and the incremental body read now race the session's cancellation token, and a cancelled prompt reportsstopReason: "cancelled"without adding the stopped reply to the session history. - Kept System One choice options in the order the client sent them (#2453).
POST /v1/decisionsre-sorted achoicequestion'scriteriaby option id before forwarding it, so a decision server that labels options by position saw a different question than the client wrote. The passthrough target and the emulated chat prompt and tool schema now receive the options in request order. The router's own built-in batteries (smart-routing domain, action safety) still send their options in id order, as calibrated. - Fit the Anthropic ingress's derived
reasoning_effortonto the vocabulary the serving model declares for the DeepSeek models (#2268).model-metadata.yamland the15-deepseek.yamlfragment now declarereasoning_effortsfordeepseek-v4.1-flash(low,high,xhigh,max, the accepted set measured from the API's own rejection) anddeepseek-v4-pro(low,high,max, its documented three levels). Claude Code'soutput_config.effort: mediumnow fits tohighinstead of being forwarded verbatim and rejected, which had failed every Claude Code request on DeepSeek models with a 400. - Removed the unwired
ServiceRegistryfromsrc/services/mod.rs(#2300). Itshealth_check()always reportedHealthyfor four services it never registered or ran, and itsinitializeregistered nothing; nothing outside its own unit tests constructed it. TheServicetrait andServiceConfigstay, andHealthServiceImplis unchanged. - The published container image now starts without a mounted configuration (#2429). Its default command passes
--config /etc/continuum-router/config.yaml, but no file was ever placed there, sodocker run -p 8080:8080 ghcr.io/lablup/continuum-routerexited at once.DockerfileandDockerfile.cinow write the binary's own--generate-configoutput to that path at build time and runconfig validateon it, so the build fails if the shipped default stops validating; a mounted file still overrides it. GET /v1/models/{model}now resolves the dateless form of a dated model id (#2577). The endpoint matched ids with raw string equality, soGET /v1/models/claude-opus-4-5answered404while/v1/chat/completionsrouted the same name toclaude-opus-4-5-20251130through the date-suffix fallback. Both the cached and the aggregating lookup now try an exact match first and then the same date-suffix fallback the routing path uses; the response carries the matched catalog id.- Resolved the Anthropic
[1m]context-window marker that Claude Code appends to the configured model name (#2274). A request for<id>[1m]on/v1/chat/completions,/anthropic/v1/messages, or any other model-routed ingress now routes like<id>: the Continuum Hub tier allowlist and the per-keyallowed_modelslist judge the plain id (an entry spelled either way matches), alias dispatch applies to it, and the upstream receives the plain id, while client-facing errors still quote the name the client sent. Previously the marked name failed the tier allowlist with a misleading403 model_not_allowed. Only the exact[1m]suffix is stripped;model[1]and other bracketed names are unchanged. - An error-terminated OpenAI-wire stream now ends with
data: [DONE](#2441): a chat-completions stream that ended on an error (an error-status handshake, a timeout, an upstream error terminal, the max-errors cut, an exhausted mid-stream fallback chain, or aresponse.failed/response.cancelled/errorterminal on the Responses-bridged chat stream) stopped right after itsevent: errorframe, leaving strict clients without the sentinel they close the stream on. Every OpenAI-wire relay body now ends with exactly onedata: [DONE], appended only when the stream did not already relay one, and typed events renderevent:beforedata:. This replaces the earlier behavior of ending a cut Responses-bridged chat stream without a sentinel (#2122); the error event, not the sentinel, still carries the failure. Anthropic Messages and Responses API streams are unchanged. - Stopped forwarding a null
reasoningfield on/v1/chat/completionsresponses from OpenAI-compatible backends (#2275). The router renames a non-nullreasoningto the canonicalreasoning_contentbut passedreasoning: nullthrough, so the same model's responses alternated between the two spellings. A nullreasoningis now removed from bufferedmessageobjects and streameddeltachunks, including next to an existingreasoning_content, and noreasoning_content: nullis added. An existingreasoning_contentis never overwritten. - Made dropped tools visible when converting requests for Anthropic and Gemini backends (#2285). A declared tool whose
typethe conversion cannot carry (an OpenAI built-in such asfile_searchorweb_searchon Anthropic, or an unknown type) was removed from the upstream request with at most adebugline, and the native Gemini conversion did not log it at all. Each drop is now logged atwarnwith its type and counted on the newtool_conversion_dropped_total{target, tool_type}counter, whosetool_typelabel is limited to the OpenAI built-in names andother. Web search and code interpreter tools sent to Gemini are still mapped to its built-ins and are not counted. The request is still forwarded without the dropped tool. - Made the library function
core::tool_calling::transform_tool_message_to_geminialways produce an objectfunctionResponse.response(#2314). Array content, numbers, and strings that parse to an array or scalar were passed through bare, which Gemini rejects with a 400; they are now wrapped as{"result": value}, and a string that parses to an object is still used as that object. The native Gemini chat transform already wrapped these values and now shares the same helper. - Kept array-form tool results when converting chat history for Anthropic backends (#2305). A
role: "tool"message whosecontentis an array of content parts, which the OpenAI schema allows, was sent upstream as an emptytool_result, so the model lost the tool output. The text parts are now joined with newlines; an array with no text part and an object or number value are sent as their JSON text instead of being dropped. String content is unchanged. - Stopped the AppProxy ROUTER worker from advertising port 8080 when the router listens only on Unix sockets (#2433, #2459). With
appproxy_routerenabled,traffic_portunset, and onlyunix:entries inserver.bind_address, the worker registered8080, a port nothing listened on, so the coordinator's ingress sent traffic nowhere while the router looked healthy. That configuration now fails at startup with an error namingserver.bind_addressandappproxy_router.traffic_port. An explicittraffic_portis still honored, and the advertisedapi_portis now the port of the first TCP listener actually bound (including aunix:-first list with a TCP entry and an address passed toContinuumRouter::serve) rather than one parsed from the first configured address. - Sent the same tracing headers to Unix-socket backends as to TCP ones (#2528). The buffered chat, streaming chat, and Anthropic streaming chat Unix-socket transports always added the router's
X-Trace-IDand forwarded the client's copy as well, so a backend could receive two conflicting values, and with tracing enabled they never injectedX-Request-ID,X-Correlation-ID, ortraceparent. They now drop the client's managed tracing headers when tracing is enabled and inject the router's set once; with tracing disabled the router adds none and a clientX-Trace-IDpasses through unchanged. - Kept the
event:type on a streaming image generation event retried after the client channel was full (#2530). The single 100 ms retry rebuilt the frame without its type, so a backpressuredpartialorcompleteevent reached the client as an untypeddata:frame. The retry now resends the exact frame the full channel handed back. - Stopped corrupting multi-byte characters split across upstream network chunks on streaming relays (#2529). The Responses API relays (Anthropic, Gemini, chat-completions bridge, and pass-through), the Responses-only chat-completions bridge, and streaming image generation decoded each chunk on its own, so a Korean, CJK, or emoji character cut at a chunk boundary reached the client as two U+FFFD replacement characters. They now decode from raw bytes and hold a split sequence until the next chunk completes it. The shared SSE byte parser also no longer drops a whole chunk when an upstream byte is genuinely invalid UTF-8: that byte becomes U+FFFD and the events beside it, including
[DONE], are still parsed. - Attributed files uploaded through
POST /admin/filesto the synthetic owner__admin__instead of storing them ownerless (#2644). Withfiles.auth.enforce_ownershipon, an ownerless file is served to every authenticated caller, so any key holding thefilesscope could read, download, or delete an admin upload once its id was known. Tenant keys now get403on those files; admin-scope keys still reach them throughadmin_can_access_all, and the Admin API is unchanged. The owner is reserved: no caller identity matches it, including a key whoseuser_idis__admin__, on the public Files API, its listing, and the Responses and Anthropic file resolvers. Files an admin uploaded before this change stay ownerless. - Kept the uploaded filename on inline PDFs resolved from a
file_id(#2411). The router used to discard the name the file was stored under and send{file_id}.pdfupstream, leaking the internal storage id as the document name. The inlinefilepart now carries the stored filename with control characters removed, surrounding whitespace trimmed, and the length capped at 255 characters;{file_id}.pdfremains the fallback when no usable name was stored. The default and OpenAI file transformers behave the same. - Rejected malformed file references on
/v1/chat/completionsbefore dispatch (#2409). Animage_fileorfilecontent part whosefile_idis missing or not a string used to drop out of file resolution silently, so the raw part reached the backend and the client got an upstream error about an unknown content type; a payload mixing one valid and one malformed reference was partially resolved. Such a request now answers400(file_resolution_failed) naming the offending field, for examplemessages[0].content[1].image_file.file_id. An inlinefilepart that carriesfile.file_dataand nofile_idis not a reference and is forwarded as before. - Ran the persistent metrics
VACUUMon its own connection and off the snapshot task loop (#2303). Compaction used to hold the store's only connection lock and run inline in the task loop, so snapshot inserts andGET /admin/metrics/historyqueries waited for the whole file rewrite and snapshot ticks were skipped, leaving a gap in stored history. Snapshots now keep firing during compaction; an insert that meets theVACUUMwrite lock is retried on the next tick, and a compaction still running from an earlier slot is not started twice. - Exported the
rate_limit_*families on/metrics(#2578). Ten of them registered into the Prometheus default registry, which/metricsdoes not serve, so the limiter recorded checks, violations, skips, bypasses, and bucket counts that no scrape returned. Every family now registers into the scraped registry, besiderate_limit_redis_unavailable_total, which already did. - Served
/metricswhen the config has nometrics:section (#2385). A missing section now means the documented defaults (enabled, path/metrics, fail-closed untilmetrics.authis set) for the route, the request-recording middleware, the rate-limit exemption, andGET /admin/capabilities, instead of turning metrics off. The persistent metrics log still runs only with an explicitmetrics:section, andContinuumRouter::enable_metrics(false)still disables metrics when the section is absent. - Served malformed JSON request bodies in the router's error envelope instead of the plain-text framework rejection (#2298). A body that failed to parse as JSON on an OpenAI-compatible route returned Axum's default plain-text 400, which JSON-parsing clients and SDKs could not read; it now returns the OpenAI error shape (
invalid_request_error, codeinvalid_json) carrying the framework's rejection message, and the Anthropic shape on/anthropic/v1/messages,/anthropic/v1/messages/count_tokens, and their root/v1/messagesaliases. A missing or wrongContent-Type(415) and a body that does not match the endpoint's request type (422) use the same envelope with their status unchanged. - Enforced the per-key
allowed_modelslist on/v1/images/generations,/v1/images/edits, and/v1/images/variations(#2506). A key whose non-empty list excludes the requested model now gets403 insufficient_permissionsfrom the router and the request never reaches a backend, as on chat completions and the audio routes; before, the image routes forwarded it. The gate judges the name the client sent and runs before alias and experiment dispatch, so a reserved experiment variant the key may not use answers the same403as an unknown name. The image generation log line for a per-backend rate-limit refusal now names a rate-limit store outage (fail-closed) apart from every backend being at its limit. - Rejected a non-integer or negative
max_tokensormax_completion_tokenson/v1/chat/completionsand/v1/completionswith400 invalid_request_errornaming the field (code: "invalid_type", orinteger_below_min_valuefor a negative number) before dispatch, whether or not arequest_paramspolicy is configured (#2389). A string such as"64"used to be forwarded verbatim and could leave the generation uncapped until the client timed out.nulland a missing field are still forwarded unchanged, so the backend's default applies; arequest_paramsdefault or limit gives every request a router-side budget./v1/responsesand the Anthropic Messages ingress already refuse a non-integer budget when they parse the body. - Rejected a
/v1/chat/completionsor legacy/v1/completionsrequest whosemodelis missing,null, not a string, or blank with400 invalid_request_error(param: "model",code: "missing_model", messageyou must provide a model parameter) instead of routing it to an arbitrary serving model with200(#2269). An AppProxy ingress-pinned request with no bodymodelstill routes, because the check runs after the ingress override supplies the model. - Named the offending request field in
error.paramon the OpenAI surface when a backend validation error leavesparamnull but names the field in its message (#2363). vLLM writesmax_tokens=1000000000 cannot be greater than ...and appends(parameter=<name>, value=...); either spelling now fillsparamwhen the name is a known request parameter (max_tokens,max_completion_tokens,temperature, and so on). An explicit upstreamparamalways wins, 5xx responses are untouched, and the backend's message is still relayed as before. - Answered an unmatched path on the Anthropic wire with
404in the Anthropic error envelope ({"type":"error","error":{"type":"not_found_error","message":...}}) instead of the plain{"error": "..."}object, so an Anthropic SDK can type the error (#2432). This covers every path under/anthropic, the root/v1/messages/*subpaths, and the mis-prefixed Messages paths that get the base-URL hint, which stays in the message. Every other unmatched path keeps the plain object, and/and/v1/modelsstill answer200for LM Studio. - Kept the upstream status and sanitized message when a backend answers a round of the router-side web_search tool loop with an HTTP error on a non-streaming
/anthropic/v1/messagesrequest, instead of502 web_search loop failed(#2339). The loop now answers through the same mapper as the single-shot path, so a context-window overflow returns400 invalid_request_errorwith the backend's message, a native Anthropic error envelope is forwarded unchanged, and the upstream retry and correlation headers are relayed. Transport and loop-internal failures still return502. - Accepted the
server_tool_use(web_search) andweb_search_tool_resultblocks of an earlier web search turn on Anthropic Messages requests to conversion backends, instead of rejecting the whole request with 400Unsupported content block type(s)(#2340). Claude Code resends that history on the next turn, so every session that searched through the router's web search emulation against a self-hosted backend failed one turn later. The Chat Completions, Gemini, and Responses API transforms render the blocks as text in the same role and position (Web search: <query>, one- <title> (<url>): <snippet>line per result, orWeb search failed: <error_code>). Malformed web search blocks and other server tools (web_fetch,code_execution) are still rejected, and native Anthropic backends still receive the blocks unchanged. - Stopped turning backend reasoning into a
thinkingblock on the Anthropic Messages ingress when the request did not enable thinking (#2311, #2400). A backend that reportsreasoning_contenton every completion (DeepSeek, newer vLLM) gave every response a leading thinking block, so clients that readcontent[0]as text broke. Thinking blocks and deltas now appear only forthinking.typeenabledoradaptive, on the Chat Completions conversion path (buffered and streaming) and on the buffered Responses API path, whose streaming translator already applied the rule. Native Anthropic and Bedrock responses are unchanged. - Rejected a malformed
thinking: {"type": "enabled"}budget on the Anthropic Messages ingress (/anthropic/v1/messagesand the root/v1/messages) with400 invalid_request_errorbefore any backend is dialed (#2342). A missingbudget_tokens, a budget below 1024, and a budget at or abovemax_tokensare refused, the last one unless theanthropic-betaheader carries aninterleaved-thinking-token. Previously the router rewrote or dropped the field per backend and returned 200, and the native Anthropic transform raisedmax_tokensabove the client's value. The check usesmax_tokensafterrequest_paramsis applied, so a policy that lowers it below the budget gets the same 400. - Streamed
session/promptresponses on the ACP stdio server incrementally instead of buffering the whole SSE body (#2414). The handler sentstream: trueto the backend but read the entire response withresponse.text()before emitting anything, so time-to-first-token for an ACP client equaled the full completion time. The body is now read chunk by chunk, complete SSE lines (data:with or without a space) are parsed as they arrive, and each content delta is sent as asession/updatenotification immediately; only one incomplete line is held between reads, capped at 16 MiB. Every read races the session's cancellation token (#2418), a failedsession/updatesend stays non-fatal for the turn (#2431), and a backend read error mid-stream fails the prompt with-32603as the buffered read did. - Routed the client's
session/cancelJSON-RPC notification in the ACP stdio server to its registered handler instead of dropping it (#2379). The ACP spec sendssession/cancelas a notification, and the stdio main loop dropped every notification, so a spec-compliant client's cancel never reached the session. Asession/cancelnotification now cancels the session itssessionIdnames (the current session when none is given) and installs a fresh token, so the session stays usable; the request form resolvessessionIdthe same way and answers-32001for an unknown session. Only the methods the spec defines as client notifications run, and only afterinitialize; a request-only method sent without anid, such assession/neworsession/prompt, is ignored. Notifications have no response, and handler failures are only logged. The stdio loop still handles messages one at a time, so a cancel sent while a prompt runs is processed after that prompt returns (#2380). - Hardened eight residual rate-limiting, admin, metrics, and stats paths (#2718). A fail-closed Redis outage on the
per_backenddimension now answers503 service_unavailablewithRetry-After: 5instead of the capacity429, and does not start a fallback chain. After a connection-level Redis failure the rate limiter skips Redis for 5 seconds and lets one request probe it, and its pool times out at 250 ms, so a partition no longer costs up to 500 ms per Redis-backed dimension on every request. The token bucket keeps fractional refill credit across frequent refills. Without hot reload,PUT/PATCH /admin/config/{section},POST /admin/config/applyand/admin/config/importwith a change to apply,POST /admin/config/rollback/{version}, and the backend mutation endpoints answer503instead of200, and an unapplied rollback records no history version./anthropic/v1/messagesand/v1/messagesshare oneendpointlabel value. A streamed/v1/audio/speechresponse that fails midway is recorded as an upstream error, and one the client drops as cancelled. - Re-split a hub-rejected (413) usage batch instead of dropping it (#2360). The hub answers
413when a batch holds more records than itsusage.max_batch_records; the router used to drop such a batch whole, losing up tousage_batch_max_recordsrecords of spend accounting and retiring their cost-center sequences. The records now return to the front of the queue and the next push builds half the batch, until the hub accepts or a single record remains (a single record the hub still rejects is dropped as before). Re-split records are sent before any spooled batch, so batches still reach the hub inrouter_usage_seqorder. The abandoned batch is achain_gapon the next one (the hub classifies it asgap, and its records arrive in the following batches under the same idempotency keys, so the local ledger export lists them twice). The halved size is kept for 5 minutes after the last 413 before the full size is tried again. - Kept the in-memory token bucket's refill credit under contention (#2531). The refill window was claimed and the token count updated in two separate CAS steps whose failures were ignored, so a lost race could drop a window's credit, and a refused request never banked the credit it observed; per-client, per-key, per-user, and per-model limits could enforce below the configured
requests_per_secondunder concurrent load. The window is now claimed exclusively and its credit is always written into the bucket, including on a refusal. - Ran the in-memory rate limiter's idle-bucket eviction on the monotonic clock its refill already uses (#2422). The per-client, per-key, per-user, and per-model buckets recorded their last access with the wall clock, so a forward clock step (VM resume from pause, NTP correction) evicted live buckets and reset their quota, and a backward step kept idle buckets alive past their 10-minute idle TTL. Last access is now measured in monotonic microseconds since process start.
- Named the mounted Anthropic Messages paths in the 404 for a mis-prefixed Anthropic request (#2398). A
POSTto an unrouted path ending in/messagesor/messages/count_tokens, such as/v1/v1/messagesfrom an SDK whose base URL is<root>/v1, now answers404with a message that the API is served at/v1/messagesand/anthropic/v1/messages(or thecount_tokenstwins) and that the client base URL should be the server root or<root>/anthropic. Other unmatched requests keep the plain message. - Marked retryable error responses with
x-should-retry: trueand relayed upstream retry headers on the/anthropicconversion paths (#2341). Router-authored errors with status 408, 409, 429, or 5xx (529 included) now carryx-should-retry: trueon/anthropic/v1/messagesand on/v1/*; other statuses carry no hint, and the direct-mode shim refusal keeps its header set. When/anthropic/v1/messagesconverts a request for an OpenAI-wire or Responses-wire backend, and on web-search emulation andcount_tokens, a backend error now relays the backend'sretry-after,x-should-retry,request-id, andanthropic-ratelimit-unified-*headers as the native path already did, and an upstreamx-should-retryreplaces the router's value instead of adding a second one. - Served the concurrency ceiling
503and the router hop guard400and508on/anthropic/*routes in the Anthropic error format (overloaded_error,invalid_request_error, andapi_error) instead of the OpenAI envelope, and gave refusals by the global and per-client rate limiters, the concurrency ceiling, and the hop guard thex-continuum-request-id,x-request-id, and (on Anthropic routes)request-idheaders, so they can be quoted by id and match theevent=request_rejectedlog line (#2511). OpenAI routes keep their bodies unchanged, and a response from inside the API routes keeps its single id. - Recorded the client request ledger for legacy
/v1/completionsrequests: thestreamflag and theprompttext counts (a string, or each string in an array), which the chat, Responses, and Anthropic ingresses already note (#2444). A/v1/completionsrequest was recorded withstream = falseand no input counts, so a streamed legacy completion was metered as a buffered request without its input text. - Extended the
http_request_duration_secondsbuckets past 10 seconds with 30, 60, 120, 300, 600, and 1200 seconds, so model calls longer than 10 seconds no longer land only in+Infand p95 and p99 can be computed (#2260, PR #2469). The original nine buckets are unchanged, so existing dashboards andleselectors keep working. - Counted every request the router rejects before backend selection per API key and stage, including rejections whose
event=request_rejectedlog line the per-minute cap suppresses, and exported the count asrequest_rejected_total{stage, key_id}(#2256). At most 256 key ids are tracked individually; later keys sharekey_id="other", rejections without a resolved key usekey_id="unattributed", and unusable key ids usekey_id="invalid". The Hub usage ledger is unchanged because its protocol has no slot for rejected requests. - List only models a caller can request (#2263, PR #2475). A model whose every backend the health checker marked unhealthy stayed on
/v1/models,/v1/models/extended,/v1/models/refresh, and/anthropic/v1/models, so a client could pick it and the call failed. Such a model is now withheld unless an enabled fallback chain is configured for it (the outage retention of #1422 still keeps it reachable then);/v1/models/{model}keeps answering200withavailable: false. A catalog refresh that served a backend's list from configuration or a discovery fallback (the curated Anthropic list, the Codex configured list, the Bedrock and OAuth static lists) no longer marks that backend healthy; only a live answer does. - Made the process-log fallback use the
loggingdefaults: when the configuration file is missing or fails to load, the router now logs JSON lines atinfo, matching a configuration without aloggingsection, instead of single-line text (#2259). Both defaults come from one constant, and a configuration file that fails to load (or a missing--configfile) now produces aWARNline in the process log saying the defaults are in use, before the router reports the load error on stderr and exits. - Served rate-limit and Hub policy refusals on
/anthropic/*routes in the Anthropic error format ({"type":"error","error":{"type":"rate_limit_error","message":...}}) instead of the OpenAI envelope, so Anthropic clients recognize a key's429as a rate limit (#2255). This covers the local rate limiter and the Hub tier, budget, cost-center, revocation, model-allowlist, andunmatched_key: rejectrefusals. OpenAI routes, status codes, andretry-afterand rate-limit headers are unchanged. - Reported which keys were rejected while the
event=request_rejectedlog cap was suppressing lines (#2257). When a 60-second window that hit the cap ends, the router now writes oneevent=request_rejected_summaryline withphase="window_closed"carrying the window'ssuppressedtotal, the top 20 keys by suppressed count insuppressed_by_key, the remainder inunlisted_keysandunlisted_suppressed, andsuppressed_by_stage; the summary is written by the next request after the window ends. The notice at the first suppression now carriesphase="cap_reached"and no longer reports asuppressedcount that was always 1. - Preserved the backend's own message on 4xx errors (#2253).
/anthropic/v1/messagesused to replace any non-Anthropic error body with a generic message such asInvalid request sent to backend, so a context-length overflow and an invalid parameter looked identical; it now returns the backend's message for OpenAI, Gemini, FastAPIdetail, top-levelmessage, and plain-text bodies, and a backend 400 maps toinvalid_request_errorinstead ofapi_error(413 torequest_too_large, 529 tooverloaded_error). The Responses API streaming paths for Anthropic and Gemini backends and the Responses-only bridge keep the backend message on 4xx as well. Every relayed message, on these paths and on the OpenAI ingress that already relayed it, now has absolute URLs, inline credentials, bearer and provider API keys,key=valuesecrets, IP addresses, andhost:portendpoints replaced by placeholders, with control characters removed and a 500-byte limit. 5xx responses keep their generic message, and the operator log still records the full backend body. - Stopped relaying raw backend error bodies, bounded every upstream error-body read, and hardened the Unix-socket chunked decoder (#2512). The
/v1/responsespassthrough,/v1/responses/compact, the Responses-only bridge, Anthropiccount_tokens,/v1/audio/transcriptionsand/v1/audio/translations, OpenAI/v1/images/editsand/v1/images/variations, the Nano Banana image paths, and the native Gemini multimodal embedding path forwarded or echoed the backend's error body, in some cases even for 5xx; they now return the sanitized 4xx message (with the backend'stypeandcodewhere present) or the generic per-status message, so a backend URL, internal host, or inline key no longer reaches the client. Upstream error bodies on every ingress, streaming, image, audio, Files API, and backend path are read up to 64 KiB instead of in full, and logged as an escaped field of at most 2000 bytes, so the operator log no longer records the full backend body as the #2253 entry above states. The Unix-socket streaming branches of/anthropic/v1/messagesnow read the backend's error body, so a 4xx carries the backend's message over a socket as it does over HTTP; every Unix-socket error-body read stops atContent-Length(unless the body is chunked) and gives up after 5 seconds, so a keep-alive engine cannot hold the request open. The chunked transfer-encoding decoder no longer allocates from the backend-declared chunk size: a size above 16 MiB or a size line over 1 KiB fails the stream, whereFFFFFFFFFFFFFFFFused to abort the process and40000000allocated 1 GiB. - Stored one row per series per value change in the persistent metrics log instead of one row per series per snapshot tick (#2290). Every snapshot used to re-insert the whole registry, so an idle router grew by
series × (86_400 / snapshot_interval_seconds)rows per day regardless of traffic; a snapshot now persists only the samples whose value differs from the last stored one, and the first snapshot after a restart stores the baseline. An unchanged series is still stored once an hour, so retention pruning never removes the only row of an idle series.GET /admin/metrics/historyreturns these change points and hourly rows; a consumer carries the last value forward between them. A failed insert is retried on the next tick instead of being dropped. - Enforced
timeouts.request.streaming.chunk_intervalon the thinking-pattern relay and the Unix-socket chat, thinking-pattern, and Anthropic relays (#2513). These relays bounded the gap between chunks with a fixed 60-second window, so a stalled stream on them waited 60 seconds even with a lowerchunk_interval, and a model with a longer per-modelchunk_intervalwas cut at 60 seconds. They now use the same per-model resolution as the plain chat relay, and a stall is reported as{"timeout":"chunk_interval","timeout_ms":<value>}instead ofinactivity. - Enforced
timeouts.request.streaming.first_byteon the plain chat, thinking-pattern, and Unix-socket chat, thinking-pattern, and Anthropic relays, and bothfirst_byteandchunk_intervalon the relay that serves an OpenAI-compatible client from an HTTP Anthropic backend (#2656). The first five relays bounded the wait for the first chunk withchunk_intervaland reported it aschunk_interval; the HTTP Anthropic relay had no read window, so a stalled upstream ran until the whole-request timeout (streaming.total) and ended with a generic stream error. All six now wait up to the per-model resolvedfirst_bytefor the first chunk and up tochunk_intervalfor every later gap. A silent upstream is reported as{"timeout":"first_byte","timeout_ms":<value>}and recorded with thetimeout_first_byteusage outcome, as on the mid-stream fallback relay and the Bedrock and Gemini pipelines. With the defaults, the first-chunk wait on the plain, thinking-pattern, and Unix-socket relays grows from 30 seconds (chunk_interval) to 120 seconds (first_byte); lowerfirst_byteto keep the old bound. - Backpressured the usage WAL send path (#2367). When the line channel is full, a start or end line now waits up to 250 ms (
WAL_SEND_TIMEOUT) for the writer to drain instead of being dropped at once, so a burst that outpaces one drain keeps its lines and therouter_restartsafety net keeps those attempts. A send that times out marks the log saturated: later lines are dropped at once (counted underusage_records_dropped_total{reason="wal_dropped"}) until a send finds room again, so a wedged state disk delays request handling by at most one wait. On the multi-threaded runtime the wait runs insideblock_in_place, so other tasks keep running. A dead writer still drops immediately. - Refused a chat completions or completions request that asks for logprobs (
logprobs: true, the legacy integerlogprobs, or a non-nulltop_logprobs) with400 unsupported_request_parameterwhen a candidate backend conversion cannot carry them (#2364). The Anthropic Messages and Bedrock Converse conversions and the Chat-to-Responses bridge forresponses_onlymodels used to drop the fields silently, so a request routed (or fallen back) onto one answered200with choices that carry no logprobs at all. OpenAI-compatible backends keep forwarding the fields unchanged, and the rejection names the enablinglogprobsfield when both are present. - Refused a chat completion whose output budget exceeds the model's declared limits, before dispatch (#2365). A
max_tokensormax_completion_tokensabove thelimits.max_outputorlimits.context_windowthe metadata layer declares for the routed model (frommodel-metadata.yaml, amodel-metadata.d/drop-in, or the metadata download) used to be accepted and could hang with no bytes or error until the client timed out, while the backend kept generating or billing for an abandoned request. The router now answers400with the OpenAI envelope naming the field the client sent, the requested value, and the binding limit (for examplemax_tokens is too large: 200000. This model supports at most 128000 output tokens.). Limits resolve per serving backend: a backend's ownmodel_configsentry takes precedence over the shared metadata layer, and the request is refused only when every candidate backend declares a limit it exceeds (the message names the largest such bound). A model with no declared limit on any candidate, and a zero window or output cap (an image model's "not applicable" marker), are forwarded and the provider stays the authority. Buffered and streamed requests are checked alike.
Removed¶
- Removed the duplicate
BackendStatisticsandLoadBalanceReportfromsrc/services/backend_service.rs(#2337). Nothing imported them: everyBackendStatisticsuser readscore::traits::BackendStatistics, and neitherLoadBalanceReportnor the module was referenced outside its own tests.
Compatibility¶
- A build from
mainafter #2956 reportsfallback.fallback_policy.trigger_conditions.context_window_exceededin its observed config. A Hub older than continuum-hub #2038 rejects the whole heartbeat over that unknown path, and a Hub that carries the registry withholds the leaf from a Router below v2.0.0, so such a Router's fallback section never matches the desired digest. The restore therefore ships only in v2.0.0, together with that Hub release; cutting a v1.36.x release frommainafter this lands either breaks the heartbeat against an older Hub or leaves the fallback section permanently drifted against a newer one. - A build from
mainafter #2877 that forwardsfile_search.vector_store_idsor a namedcode_interpreter.containernow answers400 unsupported_request_parameteruntil the operator setsresponses_tools.deny_resource_fields: [](#2891). This also breaks a client upgrading from a release: a released version dropped these fields and answered normally, and the same request now gets400on every backend type. The upgrade exposes no resource in exchange. - A build from
mainafter #2850 that forwards an unmodeled tool type such asmcporimage_generationnow answers400 unsupported_request_parameterfor it until the type is added toresponses_tools.allow(#2873). Released versions answered422for these types, so upgrading from a release opens nothing by default. - Each
http_request_duration_secondsseries now exports six more_bucketsamples (16 instead of 10, counting+Inf), so each label set stores 18 series instead of 12 including_sumand_count(#2260, PR #2469). - A router started without any configuration file now writes its process log as JSON lines instead of single-line text (#2259). To keep text output, start the router with a configuration file that sets
logging.format: pretty.
v1.36.1 - 2026-10-27¶
Added¶
- Added the opt-in
request_extensions.sd_cpp_image_parametersbackend setting for stable-diffusion.cppsd-server(#2233, PR #2247). On/v1/images/generations, the router translatesnegative_prompt,seed,clip_skip,steps,sampler,scheduler, andcfg_scaleinto the<sd_cpp_extra_args>JSON block insideprompt, merges a valid existing block with request fields taking precedence, escapes<in generated JSON, and rebuilds the body from the original for every retry and fallback hop so the tagged prompt never reaches an ordinary backend.
Changed¶
- Breaking (security): Fail closed when
admin.authis absent, default a present auth block withoutmethodto shared-storeapi_key, and require explicitmethod: nonefor unauthenticated development access (#2234, PR #2242).admin.authis now startup-owned: Admin section writes and full-document import, apply, or rollback refuse changes, while exports always omit it and ignore the legacyinclude_sensitive: truerequest with an audit record. Admin-authored new or changed backend, engine-metrics, and outbound OAuth destinations, including transient probes and full configuration write paths, reject private, loopback, link-local, non-public-at-validation-time, and Unix-socket targets; health overrides must remain same-origin and status lists are bounded. Trusted startup files may still declare local or Unix backends, and unrelated Admin edits preserve an unchanged private destination. The URL check resolves hostnames off Tokio workers and filters the addresses returned at validation time; deployments that need protection against a later DNS answer change must also enforce network-layer egress. Admin credential masking is Unicode-safe. - Harden optional observability and Dynamo event surfaces (#2236, PR #2241). A configured
/metricsendpoint now returns401whenmetrics.authis omitted; deployments that deliberately allow anonymous Prometheus scrapes must setmetrics.auth.method: none, whileauth: {}selects bearer authentication and fails validation withoutbearer_token. Bearer and Basic credentials use constant-time comparisons. Dynamo direct-mode subscriptions acceptdynamo.load.direct_allow_cidrs; when set, the router resolves each fleet-advertised publisher once and dials only an address in the configured worker networks. An unset list preserves existing in-cluster behavior, andconfig validatesuggests adding it.
Fixed¶
- Dropped client
temperature,top_p, andtop_kon native/anthropic/v1/messageshops to Anthropic models or API versions that reject them, for buffered and streaming HTTP and Unix-socket requests (#2245, PR #2246). The clientanthropic-versiontakes precedence over the backend version. Other providers keep the client values, Bedrock behavior is unchanged, and the original values are preserved for later fallback hops. Policy defaults and overrides for these fields still fail closed, and a fallback hop is refused when it would drop a policy-written value or forward a value hidden from a primary-hop limit. - Masked every value in
health_check.headersandtracing.otlp.headersin Admin reads, exports, history snapshots, and CLI and MCP redaction while keeping header names, restored masked values by header name on write, and redacted health-check header values from debug output (#2235, PR #2239). - Masked Redis URL credentials in AppProxy startup logs and configuration debug output, and added a warning in both worker modes when the shared coordinator client uses plaintext HTTP outside loopback (#2237, PR #2238).
- Made
make cleanalso clean the standalone crates undercrates/and theperf/harness, which keep their owntarget/directories.
Compatibility¶
- Deployments without
admin.authnow receive401on/admin/*. Configure an authenticated method before upgrading, or setmethod: noneexplicitly for isolated development (#2234). - Deployments that scrape
/metricswithoutmetrics.authnow receive401. Setmetrics.auth.method: noneto keep anonymous scraping, or configure bearer or Basic credentials (#2236). - Admin APIs no longer accept new or changed private, loopback, link-local, or Unix-socket backend destinations. Move intentional local backends into the startup configuration file (#2234).
v1.36.0 - 2026-10-27¶
Added¶
- Added per-backend
anthropic_versionfor the 2026-10-21 API version and updated Haiku 5.5 compatibility and model metadata (PRs #2187, #2191, #2205). - Added shared Hub tier minute limits across Router replicas (PR #2193).
- Added WebUI controls and Hub synchronization for per-key Anthropic user profiles (PRs #2203, #2221). Hub-synced profiles use the same attribution rules as local keys in both authentication modes.
Fixed¶
- Rejected provider batch submissions when their assigned cost center has exhausted its request, token, or sell-spend limit. Admission uses the completion-accounting assignment and does not charge usage before completion (#2240, PR #2243).
- Bounded provider-batch input checks by per-key admission and global capacity, and released reservations when requests fail or are canceled (#2240, PR #2243).
- Enforced model access on Anthropic requests and model/backend access on provider batches (PRs #2200, #2202).
- Prevented callers from overriding Router-owned Anthropic user-profile attribution (PRs #2189, #2204).
- Restored platform CA trust for outbound WebSocket clients (PR #2201).
- Honored per-model
include_usage, carried assistant reasoning to Dynamo, and truncated streamed error bodies at character boundaries (PR #2211). - Corrected Dynamo idle-worker handling, equal-cost rotation, aggregated-fleet scoring, subscription restart load, and prefill/decode worker pairing (PRs #2218, #2219, #2225, #2232).
- Fixed character-boundary masking of rate-limit identity IDs and synchronized the idle log-flush regression test (PRs #2215, #2207).
Changed¶
- Raised the Rust source-build minimum to 1.97 and aligned the vendored Hub protocol minimum (PRs #2199, #2210).
- Updated Rust and WebUI dependencies, including the
ctutilsfix for RUSTSEC-2026-0385 (PRs #2223, #2230, #2231).
CI¶
- Cleared stale Windows signing staging directories, reserved space for Redis shared-state tests, moved test workflows to dedicated runner pools, and skipped pull-request workflows on draft PRs (PRs #2190, #2214, #2226, #2227).
- Updated
astral-sh/setup-uvto 10.3.2 (PR #2229).
Compatibility¶
- Earlier Router releases cannot restore a persisted key table containing Anthropic user profiles. Plan downgrade recovery before enabling Hub-synced profiles (PR #2221).
- Cost-center enforcement remains asynchronous across replicas. Concurrent work can exceed a limit before the next authoritative budget update.
v1.35.1 - 2026-10-22¶
Fixed¶
- List llama.cpp
llama-serverandmlxcel-servermodels again (#2181, PR #2182). Both servers answerGET /v1/modelswith an Ollama-stylemodelsarray next to the OpenAIdataarray, and v1.35.0 rejected every such body as having a duplicatedatafield, so their models disappeared from the aggregated/v1/models. Model discovery now readsdatawhenever it is present and skipsmodels; the System One TypeSafemodels[].namecatalog is read only whendatais absent. - Stop reporting a
timeouts.health_checkconflict when the deprecated block is not written (#2179, PR #2180). An omitted block loaded as 30s/5s defaults, sohealth_checkswith non-default intervals or timeouts logged a false conflict warning. The block is now absent unless configured and is omitted from serialized configuration; a written block keeps its validation and conflict warning.
CI¶
- Fix the Windows x86_64 release build that failed for v1.35.0 (PR #2178). The ARM64 signing job left a sparse checkout at the root of the workspace it shares with the x86_64 build; it now checks its script out into
signing-checkout/, andscripts/check_release_signing.pyrejects a signer sparse checkout without a dedicated path. - Install
protocthrough a local composite action (.github/actions/setup-protoc) that downloads and caches the pinned 36.2 release, replacingarduino/setup-protoc, which targets the deprecatednode20runtime (PR #2177).
v1.35.0 - 2026-10-21¶
Added¶
- Automatic Gemini Native API routing and bidirectional transformation for exclusive features (#85, #163, PRs #2161, #2162). When routing to a
type: geminibackend across/v1/chat/completions,/v1/responses,/v1/images/generations, and/v1/images/edits, requests that use Google Search grounding (toolswithgoogle_searchorweb_search,web_search_options, orgoogle_search: trueon image endpoints), Python Code Execution (toolswithcode_executionorcode_interpreter), image output modalities (modalities: ["text", "image"],image_config, or a Gemini image generation model), multi-turn assistantimageshistory,extra_body.googlenative options (grounding,response_modalities,safety_settings,media_resolution,cached_content), orgemini_context_cache.prefer_native_api: trueautomatically dispatch to Gemini's native/v1beta/models/{model}:generateContentand:streamGenerateContent?alt=sseendpoints usingx-goog-api-key(or OAuth / service-account Bearer auth). Responses normalize thoughts intoreasoning_content,executableCodeandcodeExecutionResultintocode_execution, grounding links intoannotations(url_citation, restricted tohttp(s)schemes and capped at 128 entries) andgrounding_metadata, and generated images intochoices[].message.imageswhile preserving round-tripthoughtSignaturemetadata on tool calls and assistant turns. Addsnano-banana-2(gemini-3.1-flash-image, up to 4K) andnano-banana-2-lite(gemini-3.1-flash-lite-image, 1K) aliases and SSRF validation (UrlValidator::strict_without_dns) on remote image URLs before native inline fetching. - Catalog System One decision models in
model-metadata.yamland model discovery (#2163, PR #2166). Adds source-verified catalog entries and aliases for TypeSafejev-1.13.0(jev-latest) andjev-1.14.0(jev-preview), Azure AI Foundrymicrosoft-decision-1,laya,kev-0.8b,kev-4b,julia-1,lev,openjev,clef,pplx-decider-v1.2,bespoke-nimble-9b-v3,decision-2.0-{eos-0.8b,kai-0.6b,lux-9b,nox-4b,sol-2b,vega-27b},d3andd3-{edge,lite,nano,mini,flash}, Liquid AId1-3bandd1-omni-600m, and dual-capabilitygranite-switch-4.1-{8b,30b}. Model discovery parses TypeSafeGET /v1/modelsobjects andllama.cpparchitecture.output_modalitiesto advertisedecisionsin model methods, requires an explicitchatcapability before multimodalimageoraudiotags imply text generation, and redacts URL query strings and userinfo in Gemini image fetch warning logs. - Provider profiles, multimodal media support, and token accounting for System One decision targets (#2164, PR #2168). Extends the shared
SystemOneTargetConfig(decisions.targets[],smart_routing.classifier.system_one,guardrails.providers[].systemone,safeguards.action_safety.systemone,fallback.quality_check.target, andusage.quality_judgment.target) withprotocol(generic,typesafe,foundry,llama_cpp),auth(auto,bearer,api_key,entra),model_family(generic,openjev,lfm2_d1,lfm2_d1_omni,vllm_d3),typesafe_images,typesafe_long_context,max_questions(1..=256, default64), andmax_media_bytes(1..=16777216, default4 MiB). Supports structured,null, and omitted question fields on non-generic profiles, TypeSafe JSONassetsandboxlocalization questions onjev-1.14.0(jev-preview), Azure AI Foundry/providers/microsoft/v1/systemoneendpoint normalization and TLS/credential validation, and bounded inlinefiles/imagesdata URLs andstate: nullon supportedllama_cppfamilies, while preserving upstream-reportedoutput_tokensin usage records independently of $0 output pricing. - Integrate System One provider profiles into the smart-routing classifier (#2165, PRs #2169, #2170).
smart_routing.classifier.system_onenow dispatches through the profile-aware System One client acrossgeneric,typesafe,foundry, andllama_cpptargets, excludes decision-only models (decisionswithoutchat) from chat candidate pools (reported asexcluded_incompatible_modelsonGET /admin/smart-routing/status), keys classifier cache entries on the resolved target profile, model, question battery, and backend topology generation (cache_target_identity), records malformed HTTP-2xx decision payloads as upstream errors, and preserves successful token and cost accounting when a valid decision response omitsconfidenceand falls back to the rule or LLM classifier. - Source-aware model metadata catalog updates and packaged
model-metadata.dvendor fragments (#2158, PR #2174). Metadata documents now accept an optional top-levelsource_urlvalidated as anhttps://URL without userinfo, query strings, fragments, or private/loopback/link-local literals.continuum-router metadata download(aliasmetadata update) can refresh a single self-sourced file from its embeddedsource_urlor update every source-bearing file inmodel-metadata.d/via--all/--dir <DIR>while preserving source-less operator files, enforcing the 64-file / 8 MiB catalog limits before writing, and rejecting symlinks on write targets. Ships 19 vendor and developer catalog fragments undermodel-metadata.d/(10-alibaba.yamlthrough28-zhipu-ai.yaml, 323 models including six Upstage Solar models) in release archives, Debian packages, and Docker images alongsidemodel-metadata.yaml.
Changed¶
- Narrow Anthropic's default health check
accept_statusfrom[200, 400, 401, 429]to[200, 400]so authentication failures (401) and rate-limited probes (429) no longer mark an Anthropic backend healthy by default (#2175, PR #2176). Operators who want the previous behavior can setbackends[].health_check.accept_statusexplicitly. - Overhaul the English and Korean documentation sets (
docs/en/,docs/ko/) for conciseness, structural parity, and direct technical prose, and establishdocs/manual_writing_rules.md(PRs #2167, #2172, #2173).
Fixed¶
- Bound provider health check probe traffic and fallback overhead (#2175, PR #2176). Default health probes now request small single-model or lightweight endpoints (
/v1/models/gpt-4o-minion OpenAI,/v1beta/models/gemini-2.5-flashon Gemini,/v1/models?limit=1on Anthropic,/healthon vLLM, SGLang, TensorRT-LLM, llama.cpp, mlxcel, Azure, generic, and Continuum Router, and/api/versionon Ollama) instead of downloading full model catalogs on every health tick. Catalog fallback probes (/v1/models,/v1beta/models,/api/v1/models,/api/tags,/) run only when the primary probe returns HTTP 404 or 405, cache positive fallback results for 5 minutes per backend and check context, coalesce concurrent fallback probes without skewing latency or threshold counters, enable automatic gzip decompression on the dedicated probe and model-discovery HTTP client while keeping identity transfer on proxied inference and SSE streams, and bound Unix socket health probes to 64 KiB of response headers and the configured timeout across connect, request write, header read, and body drain.
Security¶
- Sign Windows x64 and ARM64 release binaries, enforce fail-closed signature verification in CI, and upgrade TLS root certificates (#520, PR #2171).
.github/workflows/release.ymlnow signscontinuum-router.exe,continuum-kv-listener.exe, andcontinuum-event-broker.exefor bothx86_64-pc-windows-msvcandaarch64-pc-windows-msvcwith SSL.com KMS-backed Authenticode (signtool.exe sign+signtool.exe verify /pa /tw), hardens macOS Developer ID signing, notarization, and Homebrew OpenSSL discovery, and guards the release workflow withscripts/check_release_signing.pyin CI. Upgradesrustls-webpkito0.103.16and removes vulnerablewebpki-roots0.26.11from the dependency graph by switching outbound WebSocket TLS (tokio-tungstenite) to bundled Mozilla root certificates (webpki-roots1.0.10) with an explicitringcrypto provider.
v1.34.0 - 2026-10-20¶
Added¶
- Per-operation rate limiting for the Files API (#114, PR #2143). A new optional
files.rate_limitingblock (enabled, defaultfalse;uploads_per_minute, default10;downloads_per_minute, default100;operations_per_minute, default1000; nonzero required when enabled) enforces per-caller token buckets acrossfiles_upload(POST /v1/files),files_download(GET /v1/files/{id}/content), andfiles_operations(GET /v1/files,GET /v1/files/{id},HEAD /v1/files/{id}, andDELETE /v1/files/{id}). Callers are keyed by SHA-256 hashed API key, authenticated user ID, or client IP, and requests above the limit answer429 Too Many RequestswithRetry-Afterandx-ratelimit-*headers. - Refresh the built-in model metadata catalog (
model-metadata.yaml) with late-September and October 2026 releases (PR #2160): Googlegemini-4-argon, Metamuse-wonder-1.0andmuse-spark-open-816b-a42b, Mistralmistral-large-4, Upstagesolar-pro-4andsolar-mini-4, NVIDIAnemotron-3.5-super/nemotron-3.5-ultra/nemotron-labs-dory1.0-4b, Liquid AIlfm2.5-*andd1-*, Jina AIjina-ocr-v1, DeepSeekdeepseek-v4.1-flash-1017, Alibabaqwen3.8-*andqwen-image-2.1-turbo, Tencenthy-memory-30b-a3b, Meituanlongcat-*, Ant Groupling-3.*, Shanghai AI Labatria-dawn-previewandintern-s2-*, and StepFunstep-5-preview.
Changed¶
- Coalesce only in-flight deterministic requests in the request deduplicator (#2157, PRs #2156, #2159). Request deduplication now coalesces identical concurrent requests only while the leader is in flight and removes the 30-second post-settle cache of successes and durable errors (cached post-settle responses belong to
response_cache). Chat and legacy completions (/v1/chat/completionsand/v1/completions) are eligible only when the request carries an explicit numerictemperature: 0(0or0.0), and/v1/images/generationsis never coalesced. A waiter joined to an in-flight leader is metered as a local cache hit (backend: not_dispatched,cache_hit_type: exact, the leader's prompt tokens, zero completion tokens) and is not charged a second time against token rate limits or Hub budgets. Library breaking changes: removedAdmission::Cached,DeduplicationEntry::{with_success, with_error, response, response_backend, error, in_flight},DeduplicationManager::{cache_success_with_backend, cache_error}, andDeduplicationStats::{total_entries, cached_responses, cached_errors}(replaced bywaiting);FileService::list_files_metadata(#133, PR #2140) now returnsFileListPageinstead ofVec<FileMetadata>, and the unusedBatchRequestandBatchOptionstypes (#2135, PR #2145) are removed fromcore::models.
Fixed¶
- Deliver the leader's outcome to deduplicated waiters within the request budget and count fallback-hop dial waits (#2133, #2149, PRs #2141, #2151).
DeduplicationManager::admituses a single atomicDashMap::entrycheck (LeadorJoin) with atokio::sync::watchchannel and a drop guard that clears the in-flight marker if the leader is cancelled, and the waiter budget sums each attempt's request timeout plus the retry backoff ceiling (andtimeouts.connectionon fallback hops) with saturating deadline arithmetic. - Key deduplicated requests on a closed header allow-list and canonical JSON (#97, #2150, PRs #2142, #2154). The header portion of the deduplication key now hashes only a closed allow-list of credential headers (
authorization,x-api-key,x-goog-api-key), provider behavior headers (anthropic-version,anthropic-beta,openai-beta,openai-organization,openai-project), router control headers (x-backend,x-continuum-debug), and headers referenced by the active config snapshot'srouting.rules[].when.headerorsticky_key, in sorted length-prefixed form, so per-request tracing and SDK telemetry headers (X-Trace-ID,x-request-id,x-stainless-*,user-agent) no longer split identical requests into separate upstream calls. JSON objects in deduplication and response-cache keys are canonicalized with sorted keys and-0.0normalized to0.0. - Resolve Anthropic runtime registration, URL, model matching, cache, and health check bugs (#2139). Prevent
/v1/v1/messagespath duplication when an Anthropic backendurlends with/v1, populate the default provider URL in Admin backend create, update, and probe requests whenurlis omitted, match datelessallowed_modelsentries and model aliases against date-suffixed live Anthropic model IDs (preferring the newest date), avoid overwriting a cached model list with an empty discovery response on revalidation, and sendx-api-keyandanthropic-version: 2023-06-01on Anthropic health checks. - Filter file listings by owner and purpose before applying
afterandlimit(#133, PR #2140).GET /v1/filesandGET /admin/filesnow filter bypurposeand caller ownership before slicing the page (limitin1..=10000, default10000), computehas_morefrom the filtered set, and treat anaftercursor that is missing or belongs to another owner as an empty page so callers cannot probe other owners' file IDs. - Accept multiple edit images on
POST /v1/images/edits, resolve model aliases before the edit check, and fix Nano Banana documentation (#2134, PR #2144)./v1/images/editsnow accepts up to 16imageorimage[]multipart fields with per-model count limits (16forgpt-image-*,14fornano-banana,nano-banana-pro, and Gemini image models, and1fordall-e-2and/v1/images/variations), forwards multiple images to OpenAI-compatible backends asimage[]parts and to Gemini as multipleinlineDataparts, and resolvesmodel_aliasesbefore checkingSUPPORTED_EDIT_MODELS. - Reject router-local file IDs on
POST /v1/batches(#2135, PR #2145). Whenfiles.enabledis on andinput_file_idnames a file stored in the router's local Files API,POST /v1/batchesnow fails fast with400 Bad Request(invalid_request_error, coderouter_local_batch_input_file) before routing deferral or batch admission instead of forwarding a router-local ID to an upstream provider. - Capture live vLLM 0.32.0 and SGLang 0.5.22 tokenizer fixtures and refuse options SGLang ignores or misrenders (#2020, PR #2153). SGLang
/v1/tokenizesilently ignoredreturn_token_strs,add_generation_prompt,add_special_tokens,continue_final_message, andchat_template, and concatenatedmessagesas raw role-colon lines instead of applying the model's chat template whentoolswere absent; the router now refuses those unsupported options on SGLang with400 tokenize_option_unsupported(or routes to a capable sibling backend) and never synthesizes token strings the engine did not send. Added an end-to-end SGLang Prometheus metrics scraper test against the captured 0.5.22 exposition fixture (#1486, PR #2148).
CI¶
- Guard release binary feature union parity across
.github/workflows/release.yml,.github/workflows/ci.yml,scripts/local-ci.sh, andCargo.toml(#1311, PR #2152).scripts/assert-release-feature-parity.shand its mutation test suite run in theshell-syntaxjob. - Bump
dtolnay/rust-toolchainin/.github/actions/rust-toolchain(PR #2147).
Dependencies¶
- Update
lru0.16.3 to 0.19.0,tokio1.53.2 to 1.54.0, andaws-smithy-types1.6.1 to 1.9.0 across the root and standalone crate lockfiles (PR #2155). - Bump the minor-and-patch group with 2 updates (PR #2146):
arc-swap1.9.2 to 1.9.3 andtokio1.53.2 to 1.54.0.
v1.33.0 - 2026-10-15¶
Added¶
proxy.internal_streamingmeters a buffered client request that is cancelled mid-generation (#2043, epic #1624). With the newproxysection'sinternal_streaming(defaultfalse) or a per-backendbackends[].internal_streamingoverride on, an eligible buffered/v1/chat/completionsor/v1/completionsrequest is sent to an OpenAI-wire backend (vllm,sglang,tensorrt-llm,llamacpp,mlxcel,ollama,lmstudio,openai,generic,continuumrouter, over HTTP(S)) withstream: trueandstream_options.include_usage: true(pluscontinuous_usage_statsonvllm, through the #2041 writer, so a cancel there records the engine's own count asengine_continuous), read as a stream, and answered with the ordinary buffered body the router assembles from it (content,reasoning_content,refusal,tool_callswithcontent: nullwhen only tool calls streamed, per-choicefinish_reason, and the engine'susage). A client that disconnects mid-generation is now recorded once asoutcome: client_cancelledwith the router's count of the streamed deltas,stream: false, andhttp_status: 200, where it used to leave no record; a completion without engine usage recordsusage_missing; a stream that fails after it generated output recordsupstream_error(ortimeout_mid_stream) with the partial count, answers502(or504), and is final, with no retry and no fallback hop, while one that fails before any output retries and falls back like a failed buffered read. Only the client chat and completions routes opt in, so the/v1/decisionsemulation, the Anthropic Messages and Responses ingresses, embeddings, images, probes, and router-initiated calls stay buffered byte for byte, as do requests withn > 1, log probabilities, ajson_schemaresponse format, legacyfunctions, audio output, orweb_search_options.trueis a load error onanthropic,gemini,bedrock,azure, andunix:backends; both fields reload immediately; the Admin API accepts the override and the Hub local export reports it as unsupported. The streaming cache replay now uses the same assembler, so a cached stream replayed to a buffered client also carries its tool calls and reasoning. See "Internal streaming for buffered requests" indocs/en/configuration/index.md.- Usage reconciliation against engine generation counters (#2046, epic #1624). A new optional
usage.reconciliationblock (enabled,window10s to 24h, default5m;max_driftin (0, 1], default 0.05;history1 to 1000, default 24) compares, per backend and window, the output tokens the usage ledger recorded with the delta of the engine's cumulative generation counter thatengine_statsalready scrapes (vLLM, SGLang, llama.cpp, and mlxcel report one; other types reportno_engine_data). Each window closes aswithin_tolerance,drift(one warning),no_engine_data, orreset; the first engine value only sets the baseline, and a window without a fresh scrape carries its ledger tokens forward. Results are served atGET /admin/usage/reconciliationand asusage_reconciliation_drift_ratio,usage_reconciliation_ledger_tokens_total,usage_reconciliation_engine_tokens_total,usage_reconciliation_windows_total{result}, andusage_reconciliation_dropped_samples_total.backends[].reconciliation: falseopts a backend out (for an engine shared with other traffic). The reconciler is always compiled, independent ofcontrol-plane, and taps the same two recorder seams under the same rule as the usage export. Out-of-range values are load errors (reconciliation_window_out_of_range,reconciliation_max_drift_out_of_range,reconciliation_history_out_of_range), andconfig validatewarns when the engine side cannot be read or whenwindowis shorter than twice a reconciled backend's effectiveengine_stats.interval(its per-backend override, else the global one). A backend outside the reconciled set never takes a slot in the 8192-sample queue, so a busy opted-out engine cannot drop a reconciled backend's samples.tests/usage_reconciliation_load_test.rsis the epic's mixed-load evidence: completed, cancelled, mid-stream-fallback, and chunk-timeout streams agree with two mock engines within 1%. - Engine-accurate partial usage for streams cut before their final usage block (#2041, epic #1624 unit D). Every streaming dispatch over an OpenAI-wire chat completion (each
/v1/chat/completionsattempt and fallback hop, the native Gemini stream, the/anthropic/v1/messagesconversion, and the/v1/responsesconversion) now shapesstream_optionsthrough one helper that merges the client's object instead of replacing it:include_usageis forced on, avllmbackend is also asked forcontinuous_usage_stats(a cumulativeusageon every chunk, recorded asengine_continuouswhen the client cancels or a timeout cuts the stream) unless the request asks for more than one choice (nother than 1), since vLLM's per-chunk usage counts only one choice, and every other backend type, and every multi-choice request, has a client-sentcontinuous_usage_statsremoved. SGLang and TensorRT-LLM carry the option in their upstream source but are not injected without a captured stream; the engine table and the capture procedure are in the architecture document andtests/fixtures/continuous_usage_stats/README.md. A new optionalusage.tokenize_correctionblock (enabledfalse,timeout_ms2000 in 100-30000,max_concurrent4 in 1-256) recounts a stream recorded with the router's own delta count: after the client stream ended, the text the router received is posted once to the serving engine's tokenizer route (vllm,sglang,llamacpp,mlxcel,continuumrouter, withadd_special_tokens: false) and the record is emitted once, at mosttimeout_mslater, asengine_tokenizewith its outcome unchanged; the count is a lower bound, since template control tokens between segments are not in the text. The engine call never touches the backend's circuit and carries the hop count captured at dispatch.usage_tokenize_corrections_total{result}counts each considered stream once (applied,timeout,unsupported,skipped_truncated,shed,error); an engine answer over 1 MiB, or a count of zero for non-empty text or above the text's UTF-8 byte length, is not applied and countserror. Superseded mid-stream fallback attempts are recorded at once and never corrected, and a correction still in flight when a graceful shutdown starts emits after the control-plane agent's final push and is lost (a known limitation, bounded bytimeout_ms). The/anthropic/v1/messagesconversion no longer emits a second, identicalmessage_deltafor the trailing usage chunk of a per-chunk usage stream. - Tamper evidence for the usage ledger (#2047, epic #1624). Every usage batch the control-plane agent builds now carries three additive
UsageBatchfields:batch_hash(SHA-256 overprev_batch_hash,router_id,batch_id,sent_at_ms,chain_gap, and each record's canonical JSON),prev_batch_hash(the last batch built before it, delivered or not), andchain_gap(built batches lost since, for example after a413or a spool eviction).sent_at_msis now fixed when the batch forms, so a retried batch is byte-identical. The chain head persists in<state_file>.usage-chain.json(0600, bound to the enrolledrouter_id); a failed write or rename removes it, so a crash before the next write restarts the chain instead of forking it. The newusage.ledger_exportblock (path,max_bytes,rotate) appends every built batch to an owner-only JSONL file that is never truncated, rotating to<path>.<sent_at_ms>with an anchor line; a file started after the chain began opens with an anchor too, so every file the router writes verifies on its own, andscripts/verify-usage-ledger.pyverifies a file with the Python standard library (--prevjoins it to the file before). An emptypath, one with a control character, amax_bytesbelow 1 MiB, and apaththat iscontrol_plane.state_fileor a file named after it (ledger_export_path_conflicts_with_state_file, which the writer also checks before every open) are load errors. The block reloads at the next push tick without pausing the usage loop: the new writer waits on the blocking pool for the old one (at most 5 seconds, after which the old one drops what it has not started), so two writers never append to one file. New metrics:usage_batches_hashed_total,usage_chain_gaps_total,usage_ledger_export_failures_total{target}, andusage_ledger_export_bytes. The protocol is synchronized with Hub PR #1397 (continuum-hub#1393); no local-ahead protocol files remain. - Router-initiated LLM calls are metered (#2051, epic #1624). Every call the router makes on a client request's behalf produces one usage record per dispatch, each retry included, tagged with its
purpose:guardrail(the LLM guardrail providers and thesystemoneprovider, one record perchunkedwindow check),classifier(the smart-routing LLM and System One classifiers, including a parse-failure retry),action_safety(the action-safety LLM and System One evaluators), andquality_judgment(the usage-quality judgment and the fallback quality check). The record carries the triggering request'skey_id,request_id, and its ownattempt_seqwith noparent_attempt; a pinned backend is named by itsbackends[]name and an inline endpoint by itshost[:port]. A reply without usage recordsusage_missingwithchar_estimatecounts, a non-success statusupstream_errorwith that status, a call abandoned after its dispatch started (the guardrail service'stimeout_ms, a dropped client request)timeout_first_byte, and a call refused before dispatch nothing. The router's local key budget is never charged for these calls (they are attributed throughPolicyStore::hub_identity_for_token, which charges nothing), and/v1/decisionsstill records only the client. Sequence 1 is reserved for the client's first attempt, internal calls are not counted inattempts_total, andGET /admin/statsgains aby_purposesplit, whosecliententry counts client requests and whose other entries count internal calls; their tokens are inoverallandllm_tokens_totalunder the client key, and the windowed view keeps internal calls in their own bounded ring so they never evict client rows from the load summary or the percentiles. An internal record never takes the last tenth of the control-plane usage channel, so during a slow hub push it is dropped first (counted onusage_records_dropped_total{reason="channel_full"}) rather than crowding out client records, and a mid-stream fallback hop (#2039) takes the request's next sequence from the same allocator, so it never reuses a guard call'sattempt_seqand may skip past its parent. Billing-visible on the hub. See "Router-initiated calls" indocs/en/architecture.md. - Admin usage export by period, model, backend_id, outcome, and purpose (#2045, epic #1624).
GET /admin/usage/exportreturns the router's per-attempt usage records as JSON or CSV, raw (sorted by(completed_at_ms, request_id, attempt_seq), paged bylimitand a cursor that CSV carries inX-Next-Cursor) or grouped byperiod:1h/period:1d,model,backend_id,outcome,purpose, andkey_idwithrequests,attempts, and token sums, filterable by outcome, purpose, model, and backend_id. CSV uses CRLF line ends and minimal RFC 4180 quoting, and a field a spreadsheet would evaluate as a formula (leading=,+,-,@, tab, or CR) or one that already starts with'is written with a leading'.share_ofadds the FAQ 12 output-token share, excluding local cache replays and counting every outcome and, absent apurposefilter, every purpose. The records come from a bounded ring behindadmin.stats.usage_export(enabled, default false and also needingadmin.stats.enabled;retention, default7d;max_records, default 1,000,000 within 1,000 to 10,000,000; a bad value is a load error; every field reloads immediately; while either switch is off the endpoint answers404 usage_export_disabled), appended for every attempt that reached a backend, every relayed stream, and every local cache replay (backend: local-cache,cache_hit_typeset, no output), never for a request that reached no backend. They ride the stats snapshot when persistence is on, at about 300 bytes per record, andusage_export_records_evicted_total{reason}counts what the ring evicts. The export is per router; on a fleet, sum every router's export or use the hub. See "Usage export" indocs/en/admin-api.md. - Usage ledger durability across hub outages and restarts (#2040, epic #1624). A write-ahead attempt log,
<state_file>.usage-wal.jsonl(0600), records a start line for every buffered and streaming/v1/chat/completionsbackend dispatch (one per retry attempt and fallback hop) and an end line when it finishes; after a kill or crash the next boot pushes each unfinished attempt as oneoutcome: router_restartrecord with the router's input estimate,output_tokens: 0,token_source: none, andestimated: true, sequenced and charged like any record. A bounded disk spool,<state_file>.usage-spool/(0700), replaces the push buffer's drop-oldest: an overflowing buffer writes its oldest batch there, the push loop drains it first under the originalbatch_idandrouter_usage_seq, and a shutdown spools the unacked batch and the buffer for the next process. Two hot-reloadable keys bound them,control_plane.usage_wal_max_bytes(default 64 MiB, 1 MiB to 1 GiB) andcontrol_plane.usage_spool_max_bytes(default 256 MiB,0keeps drop-oldest, otherwise 1 MiB to 64 GiB). Every remaining loss is counted inusage_records_dropped_total{reason}(channel_full,buffer_overflow,spool_overflow,batch_rejected_413,ack_oversized,wal_dropped), skipped corrupt files inusage_durability_corrupt_total{kind}, and the spool inusage_spool_recordsandusage_spool_bytes; spooled records count toward the heartbeat backlog and the control-plane status queue depth. The/v1/responses,/anthropic/v1/messages, and passthrough ingress do not write WAL lines yet. A WAL or spool file that is a symlink, not a regular file, or not owner-only is skipped (a spool file is deleted and counted), and a spool batch written under another router enrollment is discarded and counted; graceful shutdown waits up to one hub request timeout plus 5 seconds for the agent to flush the spool and settle the WAL. See "Durability across outages and restarts" indocs/en/architecture.md. - Model artifact and draft-model identity on model metadata and usage records (#2048, epic #1624).
ModelMetadatagains an operator-declaredartifact(digestassha256:orblake3:plus 64 hex, canonicalized to lowercase;registry_version;revision;source, a URI whose userinfo is refused) anddraft_model(name, optionalartifact), resolved per backend and model one key at a time: a backend'smodel_configsentry wins overmodel-metadata.yamland its drop-ins and replaces the shared artifact whole, and the built-in catalog is never consulted. Every usage record that reached a backend carries the additivemodel_digestanddraft_model(a fallback hop carries the serving backend's identity, a batch completion the one captured at submit, a cache replay neither), now synchronized with the Hub protocol after continuum-hub#1385;PROTOCOL_VERSIONstays 0.draft_modelnames the draft and counts nothing: accepted tokens belong to the target model.GET /v1/models/{model}shows the first serving backend's identity,GET /admin/modelslists every serving backend's underartifacts. A malformed identity is a load error inmodel_configs, warned and dropped in the metadata layer (listed bymetadata show --resolved), and refused bymetadata download;config validatewarns on avllm,sglang, ortensorrt-llmdraft without digests and on one model id declared with different digests. See "Model artifact identity" indocs/en/configuration/index.md. - Attempt-scoped usage ledger schema (#2053, epic #1624). Every control-plane usage record now carries fourteen additive fields:
outcome(how the attempt ended:completed,length, and the reserved terminating causes),token_source(how the output count was obtained,engine_finaltoday),estimated,stream,http_status(the upstream status of the attempt),request_id(a router-mintedreq_id shared by every attempt of a client request, never the client'sx-request-id),attempt_seqandparent_attempt,purpose(clienttoday),reasoning_tokens(read from the Chat Completions and Responses usage detail objects intoTokenUsage), and the UTF-8input_chars,input_bytes,output_chars,output_bytesof the text sent and received. The vendoredcontinuum-protocolcrate is re-synced to the hub revision that defines them (continuum-hub#1380);PROTOCOL_VERSIONstays 0. Every producer stamps them from day one through a request-scoped ledger installed on the API route group, and the router applies the hub's drop rules before sending. Locally,GET /admin/statsgainsby_outcome(a stats snapshot written before the upgrade restores its windowed rows ascompleted, while the all-timeby_outcomesplit starts empty and counts only requests recorded after the upgrade),llm_tokens_totalgains theoutcomelabel, andusage_records_total{outcome, token_source, purpose}counts every record. See "Usage record outcome and attempt identity" indocs/en/architecture.md. - Router-observed output token counting and one stream-usage finalizer (#2052, epic #1624 unit C1). A streamed completion whose backend reports no usage block (an engine without
stream_options.include_usage, a proxy that strips it, a/v1/completionsengine, a lost terminal frame) is now charged instead of recorded as nothing: every streaming relay owns aStreamUsageFinalizerfrom its first event and records exactly one usage event at its normal end, withoutcome=usage_missing,token_source=router_delta_count,estimated=true, the router's own count of the streamed content, reasoning, and tool-argument deltas (one per chunk, the engine's granularity on vLLM-class engines) as the output, the request's estimated input (or the engine's prompt when it reported only that), and the UTF-8output_chars/output_bytes. The chat pipeline tap is latest-wins and finalizes once (client_cancelledwhen the client leaves before the end),min_accumulated_tokensfor mid-stream fallback now counts delta events rather than characters divided by four and a reasoning-only partial never continues, and the newusage_missing_total{backend}counter shows the rate of router-estimated records. See "Router-observed output counting and the stream-usage finalizer" indocs/en/architecture.md. - Realtime usage metering (#2044, epic #1624).
GET /v1/realtimesessions now meter the usage their backend reports: every serverresponse.donethe router relays becomes one usage record through the shared stats recorder, reaching/admin/stats, the per-keyllm_tokens_totalcounter, and the control-plane usage record withstream: true,http_status: 101, the session'srequest_id, a per-sessionattempt_seq, and an outcome mapped fromresponse.status(incompletewithmax_output_tokensislength,cancelledisclient_cancelled,failedisupstream_error). Records are charged to the model the session was dispatched for, never to aresponse.modelorsession.modelon the wire, which the backend authors and the router does not validate. A failed or cancelled response with no usage is still recorded, with zero tokens andtoken_source: none. The relayed bytes are unchanged: only a copy of each backend text frame of at most 256 KiB is parsed, after it was sent, and binary and client frames are never inspected. The newrealtime_usage_events_total{backend, result}counter reportsrecorded,oversized,unparsed, andsession_closed_in_flight(a response the session ended on before itsresponse.done, which records nothing). See "Usage metering" indocs/en/configuration/realtime.md. - Hub-declared guardrail provider rows that reference a served backend (#2082, PR #2128). A
control-planerouter now advertisesguardrail_provider_rows_v1and composes the Hub policy'sprovider_rows(llamaguard,llm_judge, and abackend:-referencedopenai_moderation) after its local rows in declared order. A local row of the same name wins and is reported withoverrides_hub: true; Hub rows are reported withsource: "hub"in the guardrail inventory andGET /admin/guardrails, turn guardrails on for a router that has none locally, and are never written to disk byPATCH /admin/guardrails. A row whosebackendis missing or disabled in the effective configuration (Hub-synced backends included,internal: trueaccepted) is refused withguardrail_backend_missing,guardrail_backend_disabled, orguardrail_providers_unavailable. The vendoredcontinuum-protocolcrate moves to Hub revision47e111b0. - Select the guardrail route from the Hub tier snapshot (#2096, PR #2129). The router advertises
guardrail_route_v1and readsTierLimits.guardrail_route, so keys on different tiers receive different guardrail policies for the same model without aconfig.yamledit at key issuance. The effective policy layers global defaults, thenguardrails.routes[<tier route>], thenguardrails.routes[<model>](the strictermodewins, stages are unioned, lower per-category thresholds win), and a Hub tenant guardrail policy still composes strictest-wins over all local routes. A tier route that names no configured route falls back to the global policy and countsguardrail_route_unknown_total. The tier route is part of the response-cache and tool-verdict-cache scopes, survives the persisted key table, is shown onGET /admin/control-plane/policy,GET /admin/guardrails, andPOST /admin/guardrails/test, and labelsguardrail_blocks_totalandguardrail_verdicts_totalwith a boundedroute(16 values, thenother). - Sanitize-then-inspect guardrail staging (#2100, PR #2127). Guardrail providers take
phase: sanitize | inspect(defaultinspect); only transform-capable providers (pii,bedrock_guardrail/aws_bedrock) may usesanitize, checked at startup and hot reload. Input documents, non-streaming andbuffer_fulloutput,chunkedstream windows, and dry runs are evaluated in the order deny, allow, sanitize, mask, inspect on the masked view, final verification, so aninspectprovider never sees text asanitizeprovider masked. A failingsanitizeprovider followson_error(fail_closedblocks before anyinspectprovider runs), and inmonitormode and dry runs a sanitize block or masking failure stops before unmasked text reaches a remoteinspectprovider. Measured staging overhead is about 0.2 ms at p50. - Per-route guardrail streaming mode and window settings (#2099, PR #2112).
guardrails.routes[]entries acceptstreaming_mode,streaming_chunk_size,streaming_context_size, andstreaming_stream_first, inheriting the global values when unset, so a latency-sensitive route can runchunkedwhile a strict route on the same router runsbuffer_full.streaming_mode: passthroughon a route is refused unless that route's effective mode ismonitor.GET /admin/guardrailsshows the resolved values per route; Hub composition leaves them router-local. - Korean PII identifiers and custom regex entities in the built-in
piiprovider (#2095, PR #2115). Newkr_rrn,kr_frn, andkr_brnscanners for resident, foreigner, and business registration numbers run ahead ofcredit_cardandphone, so those numbers are no longer reported as cards or phone numbers; an unhyphenated 13-digit number needs a nearby context word.options.custom_entities(name,pattern, optionalcontext_wordsandcontext_window, at most 64 entities and 1024 bytes per pattern) defines operator entities whose action comes fromactions[name]ordefault_action.GET /admin/guardrailslists the effective entities andPUT /admin/guardrails/providers/{name}can updateoptions. - Enforce Hub-delivered request priority on vLLM backends (#2093, PR #2126). A Hub tier can carry
TierLimits.request_priority(-100 to 100, lower runs first under vLLM--scheduling-policy priority), advertised asrequest_priority_v1and persisted in the key table sidecar. On/v1/chat/completionsand/v1/completionsto avllmbackend the router injects or clamps the top-levelpriorityfield andX-Vllm-Priorityafterrequest_extensionsare applied, and clamps an existing/v1/embeddingspriority, so neither the client nor a backend default can schedule above the tier. A response that had priority applied carriesx-continuum-request-priority; a tiered request routed to a non-vLLM backend countscontinuum_request_priority_skipped_total{reason="backend_type"}. - Priority-labelled request and TTFT series (#2094, PR #2130).
continuum_request_priority_requests_total{priority, status_class}andcontinuum_request_priority_ttft_seconds{priority}compare request rate and router-observed time to first token across Hub-delivered priority classes, even when several tiers share onevllmbackend. Theprioritylabel is bounded at 16 values (thenother), andunmanagedmarks a request with no priority in effect. - OpenAI audio transcription and translation routes (#2102, PR #2120).
POST /v1/audio/transcriptionsandPOST /v1/audio/translationsforward themultipart/form-dataupload to the selected backend and relay its status,Content-Type, and body unchanged for everyresponse_format. Themodelform field drives per-model rate limits, the Hub tier model allowlist, and cost-center checks;stream=trueanswers400 streaming_not_supported, and an upload is dispatched once without retries. New limits:proxy.audio.max_body_size(default 25 MiB,413above it without buffering the body),proxy.audio.max_concurrent_uploads(default 8),timeouts.request.audio(60s first byte, 300s total), andtimeouts.limits.max_audio_timeout(default 600s, ceiling 1800s). Both routes require thewritescope. - Audio-seconds metering on usage records and the Hub price table (#2103, PR #2125). Usage records gain an optional
audio_secondsand Hub price entries an optionalaudio_microusd_per_second. A transcription or translation response reports billed duration fromusage.seconds(whenusage.typeisduration) or theverbose_jsondurationrounded up to a whole second, and its token fields stay0, so key TPM windows, token budgets, and cost-center token budgets do not move. backends[].record_usage_missing(#2101, PR #2117). With this per-backend switch on (defaultfalse, aliasrecord_missing_usage, reloads immediately), a2xxnon-streaming response that carries no usableusageblock still writes a usage record withoutcome: usage_missing,token_source: none, and zero tokens./v1/embeddingsand/v1/reranknow forward request headers to the usage recorder so key, user, and team attribution is kept. The field is accepted by the Admin backend create, update, and probe requests.- Structured log line for requests rejected before a backend attempt (#2107, PR #2121). Every rejection at the
auth,rate_limit,policy,admission,guardrail_input, orroutingstage writes oneWARNline withevent=request_rejected, carrying the trace id,x_request_id,client_ref,request_idwhen a ledger scope exists, method, route, status, error type and code,limit_type,stage,key_id(never key material), and latency.logging.rejections.max_per_minute(default 600) caps the lines; excess lines are counted inrequest_rejected_log_suppressed_totaland summarized once per window asevent=request_rejected_summary. - Export the authentication, policy, and input-guardrail stages as child spans (#2105, PR #2118). With OTLP export active,
router.auth,router.policy, androuter.guardrail_inputare ordered, non-overlapping children ofrouter.request, each carryingrouter.stage_outcome(allowed,blocked, orerror); the guardrail span also carriesrouter.guardrail.provider. Without export the spans are not created. - Derive the outbound
traceparentfrom the active span context (#2106, PR #2119). With OTLP export active, thetraceparentandtracestatesent to a backend name therouter.backend_callspan, and arouter.requestwith no inboundtraceparentuses the router's own trace id as its OpenTelemetry trace id. The sampled flag (-01or-00) follows the resolved sampling decision with or without export, and an invalid inboundtracestateis dropped before propagation. - Persist the configuration history across restarts without secrets (#2098, PR #2113). With
admin.history_dirset, every file-edit hot reload writes the raw configuration text with its${VAR}placeholders intact (never resolved secrets) tohistory-<version>.json(0600, directory0700), trimmed byadmin.max_history_entries(default 100) andadmin.max_history_bytes(default 10 MiB).GET /admin/config/historyandPOST /admin/config/rollback/{version}merge the persisted entries with in-memory ones; a rollback re-resolves the placeholders. Changes applied throughPOST /admin/config/applystay in memory. - Report a model's accepted
reasoning_effortlevels onGET /v1/models/extendedandGET /v1/models/{model}(#2081, PR #2109).metadata.reasoning_effortsis filled from the built-in OpenAI reasoning families when neithermodel_configsnormodel-metadata.d/declares it (noneis excluded, sincerejects_reasoning_effort_nonegoverns it), and the single-model endpoint now also returnsreasoning_effortsandrejects_reasoning_effort_none. - Tokenize chat
messageson llama.cpp and mlxcel and through a chained router (#2002, PR #2019).POST /tokenizewithmessagesrenders the chat through the engine'sPOST /apply-templateand tokenizes the result under one admission and one 30-second budget. An option the engine would ignore (a per-requestchat_template, a non-text content part, an unsupported switch) answers400 tokenize_option_unsupportednaming it, and selection prefers a backend that can honor it. Acontinuumrouterbackend is now a tokenizer type: the body is forwarded withmodelrewritten, and a downstream404or501answers501 tokenization_not_supportedunless the code ismodel_not_found.
Security¶
- Bound router-to-router forwarding loops with a hop counter (#2025). A backend whose URL is the router's own listener, or a cycle of routers (A to B to A), nested one inbound request per hop without bound, each hop holding its request open while it waited on the next, which could exhaust connections and memory on every router in the cycle. The loop was not specific to
type: continuumrouter: an untyped (generic) oropenaibackend pointing at a router looped the same way, and a Hub-managed backend topology could create one. Every request now carriesx-continuum-router-hops(absent counts as 0), read before authentication, rate limiting, and the concurrency ceiling: a request that arrives with 8 or more is refused with508 router_loop_detectedbefore any handler starts (a JSON answer even forstream: true), and every other request is forwarded with the count incremented, through every client-header forwarding path and explicitly on the tokenizer, count_tokens tokenize, image edit, realtime handshake, and/v1/decisionspassthrough calls. A non-numeric value answers400 invalid_router_hops. The508is not retried, does not hop a fallback chain, and does not count against the circuit breaker, so a self-loop ends after nine nested requests. The header is reserved forrequest_extensions.headers(a load error in any spelling), andconfig validateand the configuration load warn about a backend URL equal to aserver.bind_addressentry. See "Forwarding Loops and the Hop Limit" indocs/en/configuration/backends.md. - Carry the router hop count on calls the router makes on a request's behalf (#2032). The hop counter of #2025 covered every path that forwards client headers, but several internal calls built their headers from scratch, so a call whose target was a router that made the same call again restarted the count at every level and nested without bound. These now carry the count of the request they serve: the guard call of an LLM-transport guardrail (
self_hosted_classifier,classifier,custom_classifier, byendpointorbackend), every System One decision call (thesystemoneguardrail, the smart-routingsystem_oneclassifier,action_safetywithevaluator: systemone, andfallback.quality_check), the control-plane provider batch calls (/v1/batchescreate, retrieve, list, cancel, and results), and theusage.quality_judgmentcall, which runs after the reply and sends the count captured from the request it judges, so a judgment cycle between two routers ends within four judgments instead of one client request sustaining paid judgments forever. A guardrail classifier endpoint's508is no longer retried or counted against its breaker, and a guard target's508now fails closed whateveron_errorsays: the count starts from a value the client sends, so underfail_opena client could otherwise skip a guardrail served through another router by sendingx-continuum-router-hops: 7. A/v1/decisionstarget's508now answers508 router_loop_detectedand never advances the fallback chain; it answered502 decision_upstream_error, which sent every level above to the chain's emulation hop. The508refusal line is rate limited (one WARN per minute, repeats at DEBUG), since the guard runs before authentication and any client could make every request write one. - Return a fixed
error.messageon guardrail block responses (#2097, PR #2110). Underblock_behavior: errorthe block response put the verdict'sreasonintoerror.message, so acustom_classifierprovider's model-written reasoning, which can carry injected text or PII, reached the caller. Every block on/v1/chat/completions,/v1/completions,/v1/responses, and/anthropic/v1/messages, input or output, buffered or mid-stream, now answersThis request was blocked by a content-safety policy and cannot be completed.; the status, envelopetypeandcode, andx-guardrail-*headers are unchanged. Clients that matched on provider wording must match on the code.
Changed¶
- Default
control_plane.policy.unmatched_keytorejectwhencontrol_plane.policy.enabledistrue(#2092). Once a Hub policy has been applied (or a non-stale key table has been restored from<state_file>.policy-keys.json), a missing credential or a presented API key that matches no synced Hub entry now returns401(authentication_error,invalid_api_key) unless the request already carries a locally authenticatedAuthContext, so expired or revoked Hub keys that age past the Hub's 30-day retention window and leave the policy envelope cannot fall through to permissive local authentication. Cold start before any policy is applied (policy_sync.source: none) still fails open, and locally configured keys remain unaffected. Note that this rejection also applies to newly created Hub-issued keys during the propagation window until the router applies the policy containing them (default poll 5 s). Operators can measure impact beforehand usingcontrol_plane_unmatched_key_total{action}before switching or upgrading. Migration note: deployments that rely onapi_keys.mode: permissiveadmitting unauthenticated or non-Hub keys alongside Hub policy enforcement must setcontrol_plane.policy.unmatched_key: fail_openexplicitly; doing so emits a startupWARNlog and acontinuum-router config validatewarning naming the consequence. - Usage is recorded per backend attempt (#2049, epic #1624). Every dispatch that reached a backend is now one usage record linked to its client request by
request_id,attempt_seq, andparent_attempt: a buffered retry, a fallback-chain hop, a pre-stream streaming hop, a quality-check hop, and every turn of aweb_searchtool loop. This changes what is billed: a failed attempt whose error body reported tokens now charges those tokens; a zero-token failed attempt (a 5xx, a 429, a connect error, a dial timeout) appears as its own record withoutcome: upstream_error(ortimeout_first_byte), its ownhttp_status, zero output, and an estimated input flaggedestimated: true; a quality-check hop is one more attempt of the same request instead of a second request in/admin/stats; and aweb_searchloop charges every turn, not only the final one.GET /admin/statsgainsattempts_total,successful_attempts, andfailed_attemptsonoverall,models[], andbackends[]whiletotal_requestskeeps counting client requests. A stats row orfallback_attempts_total/fallback_success_totallabel no longer readsunknownfor a dispatched attempt: it names the backend the attempt dialed, and a request that never reached a backend is recorded undernot_dispatched. See "Per-attempt records" indocs/en/architecture/model-fallback.md. - Write the process log in the format and at the level the configuration declares (#2042). A file declaring
format: "json", which every generated sample does (Config::default(), the configuration builder,config.yaml.example), now produces one JSON object per line instead of single-line text, so a log pipeline that parsed the text lines must parse JSON or setformat: pretty, which keeps the previous lines byte for byte.RUST_LOGis now honored on every path that installs the process subscriber (the server and theconfig,metadata,auth login,mcp-serve, and migration subcommands) as a fulltracingfilter directive that overridesCONTINUUM_LOG_LEVELandlogging.level; a value that does not parse is reported on stderr and ignored. BecauseRUST_LOGused to be inert, a directive that names onlycontinuum_routertargets, such as theRUST_LOG=continuum_router=infothe systemd unit in the installation guide used to set, now silences theadmin_auditandguardrail::auditlines and every dependency's warnings; keep a bare default level (info,continuum_router=debug) or remove the variable, and the router warns at startup when Admin audit logging is enabled but filtered out. The sampledocker-compose.ymlno longer setsRUST_LOG=infoby default (it passes an empty value, which counts as unset), so thelogging.levelof a mounted file applies.
Fixed¶
- Close the post-merge gaps in the usage ledger (PR #2073, epic #1624). Finalized Chat and Responses streams and intermediate attempts now count in aggregate and windowed token statistics without adding requests, Responses streams are exported without per-key statistics, an internal stream cancelled after the response head but before the first event is recorded, and internally streamed SSE responses are bounded at 16 MiB with partial usage kept and no retry or fallback after the refusal.
- Take no TTFT sample on an early exit of a native Anthropic stream (PR #2072, epic #1624). A client-cancelled passthrough stream sampled
continuum_backend_ttft_seconds, against the ledger rule that only a completed attempt reports time to first token. - Decouple the W3C trace id from
X-Request-IDand addtracing.headers.client_refandx-continuum-request-id(#2104). The default middleware chain no longer stamps a hyphenated UUID onto inboundx-request-idwhen the client sent none, andTraceContextnever adoptsX-Request-IDorX-Correlation-IDas the trace id:trace_idis always 32 lowercase hex characters ([0-9a-f]{32}, not all zeros), taken from a valid inboundtraceparent, else a 32-hexX-Trace-ID, else freshly generated, so the outboundtraceparentsent to backends is always W3C-valid (^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$) and a valid inboundtraceparentkeeps its trace id andtracestatewhile minting a new outbound span id. Valid clientX-Request-IDvalues ([A-Za-z0-9._:/-]{1,128}) are logged asx_request_idand echoed on the responsex-request-idheader without overwriting outboundX-Request-ID/X-Trace-ID/X-Correlation-ID, which carry the 32-hextrace_id. A new optionaltracing.headers.client_reffield (default"", disabled; e.g.X-Client-Ref) accepts caller correlation references matching^[A-Za-z0-9._:-]{1,128}$, forwards them to the backend, logs them asclient_ref, and exports them onrouter.requestasrouter.client_ref, while invalid values are stripped without failing the request and incrementclient_ref_rejected_total{reason}(empty,too_long,control_char,invalid_char). Every response inside the usage ledger scope (200 buffered and streaming, and 4xx/5xx errors) now carriesx-continuum-request-id: req_<32 hex>matching the request's usage-recordrequest_idand therouter.request_idspan attribute, andx-request-idechoes the client's validX-Request-IDor falls back tox-continuum-request-id(earlier rejections outside the ledger scope omitx-continuum-request-idand echo only a valid clientX-Request-ID). Migration note: log/trace pipelines that previously joined on a non-hex or UUIDX-Request-IDinsidetrace_idorrouter.trace_idshould readx_request_idin structured logs,x-request-idon responses, or configuretracing.headers.client_ref: "X-Client-Ref"for caller correlation references (client_ref/router.client_ref). Seedocs/en/trace-export.mdanddocs/en/configuration/advanced.md. - Interrupted streams now charge what they generated (#2039, epic #1624 unit C2). Every early exit of the OpenAI-wire streaming relays (the chat transformer's OpenAI and thinking arms, the three Unix-socket relays, the SSE pipeline, and mid-stream fallback) records exactly one usage record with the output counted up to the exit: a client that cancels records
client_cancelled(the engine's numbers when a usage chunk already arrived, else the router's delta count), afirst_bytetimeouttimeout_first_bytewith zero output, a total-budget or inactivity timeouttimeout_mid_stream, a handshake error status or repeated upstream errorsupstream_errorwith the status, and a guardrail block before the usage chunkguardrail_blocked, counting the deltas the gate held. Before this, an early return recorded nothing and a timeout or block was recorded as a normal end. A mid-stream fallback hop records the failed attempt's partial output asfallback_source(orupstream_errorwith zero output when it generated none) and the successor only its own generation, asattempt_seq2 withparent_attempt1. A direct (no-chain) stream now begins a ledger attempt, so its/admin/statsrow names the backend, a handshake answered with a non-success status counts as one failed attempt with one record, andstreaming_fallback_totalgains abackendlabel naming the backend whose attempt failed. See "Stream exits and their usage outcome" indocs/en/architecture.md. - Interrupted streams charge what they generated on the remaining relays (#2050, epic #1624 unit C3). Every early exit of the
/anthropic/v1/messagesstream arms (native passthrough over HTTP and a Unix socket, Bedrock runtime, and the OpenAI-wire and Responses-wire conversions), the/v1/responsespass-through and its Anthropic, Gemini, and Chat Completions conversions, theresponses_onlybridge, and the chat transformer's TCP Anthropic-source and Gemini arms now records exactly one usage record counted to the exit: a client that leaves isclient_cancelled, a guardrail blockguardrail_blocked, a read timeouttimeout_first_byteortimeout_mid_streamby whether a frame arrived, and any other read error or aresponse.failedterminalupstream_error, with the engine's usage when it arrived before the exit and the router's delta count otherwise. On the Anthropic ingress a client that leaves while the upstream then stalls or fails isclient_cancelled, not the timeout or the error, and gets noerrorevent. A report that states the engine's input but not its output (an Anthropicmessage_start, or a GeminiusageMetadatawithoutcandidatesTokenCount, which Gemini sends only on its terminal chunk) is charged as that input beside the router's delta count, so a/v1/responsesstream from an Anthropic or Gemini source cut before the output count no longer records zero output asengine_final. Before this, those exits recorded nothing, or a zero-token stats row, although the engine generated tokens up to the cut. Billing-visible changes: a guardrail block on a stream that carried no usage report now records the router's count instead of nothing (reversing the #1833 rule on the Responses conversions and the bridge); a Chat Completions conversion on/v1/responsesthat completes without engine usage recordsusage_missing; aresponse.incompleteformax_output_tokensrecordslength; and an Anthropic ingress stream that ends after a data-level error event writes a stats row withsuccess: falsethat still counts its tokens. A block the SSE pipeline's guardrail gate decides at[DONE]or at the end of the source (the Gemini and Bedrock arms of/v1/chat/completions) now recordsguardrail_blockedrather than a normal end. A failed attempt row (outcome other thancompleted,length, orusage_missing) reports no TTFT and takes noresponse_headersTTFT sample, and an/admin/statsrequest row written on a stream relay task now counts the failed attempts made before the stream. See "Stream exits and their usage outcome" indocs/en/architecture.md. - Carry the router hop count on streaming output-guardrail checks and streamed action-safety decisions (#2054). Every streaming surface (
/v1/chat/completions,/v1/responses,/anthropic/v1/messages, and theresponses_onlybridge) moves its output gate into a spawned relay task, and a tokio task-local is not inherited by a spawned task, so thechunkedwindow checks, thebuffer_fullend-of-stream check, and the monitor-mode final check ran outside the request's hop-count scope and sent nox-continuum-router-hopsheader. The same held for the System One action-safety decision on streamed tool calls on/anthropic/v1/messagesand theresponses_onlybridge. A guard target that is a router therefore restarted the count at zero, thefail_closed-on-508rule of #2032 never fired on a streaming route, and a guard self-loop on a streaming route ended afterMAX_ROUTER_HOPS + 2inbound requests instead ofMAX_ROUTER_HOPS + 1.StreamingOutputGateandActionSafetyEvaluatornow capture the count when they are built on the request task and re-enter it around every provider call they make from the relay. - Keep a client-supplied
reasoning_efforton the auto-selection streaming arm for a self-hosted OpenAI-compatible backend declaredtype: openai(#2030). #2015 scoped the legacy-model removal to the OpenAI cloud and Azure innormalize_openai_streaming_payload, butstream_with_auto_backend_selectionbuilds its own payload and still called the unscopednormalize_openai_reasoning_request, so that arm removed the value for everytype: openaibackend: a local engine lost it there while the direct streaming route and the non-streaming route kept it. The arm now answers the same endpoint question as its sibling —Azureunconditionally,Openaionly when the URL containsapi.openai.com— so the three routes agree. The arm is library-only (it has no in-crate caller; it ispubthroughsrc/http/streaming/mod.rsandsrc/streaming.rs), so this reaches embedders.OpenAIBackend::transform_request, the other site #2015 left unscoped, is unchanged. - Pin Dynamo shim sends from the Anthropic Messages and Responses ingresses (#2029). Both ingresses convert the request to a chat body and sent it to a
--router-mode directshim backend without thex-dynamo-*worker pins, so the frontend refused it. Every send to a shim-pool backend (buffered, streaming, eachweb_searchloop round, retry, and fallback attempt, plus/v1/responsespassthrough andresponses_onlysends) now carries the chat path's pin, a clientnvext.token_datais always dropped on those sends, and a direct-mode refusal is answered503 disaggregated_prefill_decode_unavailablein the ingress' own envelope. Not covered: a refusal inside an opened 200 SSE stream, a non-2xx on a Unix-socket streaming backend, and/v1/responses/compact. - Pass a downstream router's structured
4xxthrough on/tokenizeand/detokenize(#2024). Acontinuumrouterbackend's refusal such as400 tokenize_option_unsupportedwithparam: continue_final_messagereached the client astokenize_upstream_rejectedwith a fixed message and noparam. A downstream4xxwhose body is a router error object now keeps its status, code, type, and param, with the message capped at 256 bytes, so a chained deployment answers what a direct router does (a downstream404 model_not_foundnow answers404 model_not_found). A downstream401,403, or407still answers502, any other body staystokenize_upstream_rejected, and other engine types are unchanged. - Apply
logging.level,logging.format, andadmin.audit.include_body, which the router validated and documented but never read (#2042). Every deployment logged atINFOin text whatever the file said, andCONTINUUM_LOG_LEVELchanged nothing; the logging keys now configure the process subscriber at startup (they stay restart-required), withcompactas a third format.include_body: truenow adds the JSON body of AdminPOST,PUT,PATCH, andDELETErequests, up to 16 KiB and masked by key name with the configuration export masker (plus the new key value inPOST /admin/api-keysand the configuration text inPOST /admin/config/importand/admin/config/validate), to theadmin_auditline; a body that is not recorded is noted asbody_omitted(not_json,too_largewithbody_bytes_read, orinvalid_json), a masked form over 16 KiB is cut and flaggedbody_truncated, and the handler still receives every byte. Withinclude_body: false, the default, the line is unchanged. - Complete the accounting seams for relayed streams (#2078, PR #2087).
/v1/responses, theresponses_onlychat bridge,/anthropic/v1/messages, and the SSE pipeline now record each stream under its real terminal outcome inGET /admin/statsby_outcome(once, not also ascompleted), match concurrent streams to their connect-time rows byrequest_id, and feedGET /admin/usage/exportand the aggregate token counters even whenadmin.stats.track_tokensisfalse. - Enforce
chunk_intervalon the plain streaming relays and stop on a client disconnect during upstream silence (#2079, PR #2083). The chat transformer, thinking, and Unix-socket relays now end a stalled stream after the model's streamingchunk_intervalas a mid-stream timeout, and the Anthropic, Gemini, and chat source relays drop the upstream as soon as the client leaves instead of waiting for the next byte. Usage on non-terminal/v1/responsesevents stays provisional until the stream ends. - Validate the
usagesection at load and honorreconciliationin the Admin backend API (#2080, PR #2085).usage.quality_judgmentandusage.tokenize_correctionrules now apply at startup, hot reload, config sync, and Admin configuration writes, andcontinuum-router config validatereports them under their dotted paths.POST /admin/backends,POST /admin/backends/probe, andPUT /admin/backends/{name}acceptreconciliation(defaulttrueon create), and a change to it counts as a backend change on reload. - Preserve incomplete, refusal, reasoning, and failed outcomes on the
/v1/responseschat bridge (#2122, PR #2124). A Responses reply that endedincomplete,failed, orcancelled, or that carried a refusal or reasoning item, was answered502 responses_parse_failedor as an emptyfinish_reason: "stop"completion. Refusals now map tomessage.refusalandfinish_reason: "content_filter", reasoning toreasoning_content, a content-filter truncation tocontent_filter, and a truncated function call tolength, in buffered and streamed replies. A stream the upstream cut off before its terminal event now ends with anevent: errorframe (upstream_error) instead of a synthesizedstopchunk and[DONE]. Chat Completions clients ofresponses_onlymodels now receive reasoning summaries asreasoning_content, as Anthropic and Gemini backends already sent them; output guardrails inspect that text only withguardrails.inspect_reasoning: true. - Discover Anthropic models live from
GET /v1/models(#2089, PR #2114). Anthropic backends returned a fixed pair of Claude 3.5 ids, one of which does not exist. Discovery, candidate probes, and model aggregation now page through the key's catalog withx-api-keyandanthropic-version, and fall back to a refreshed built-in catalog (with the error recorded on the probe result) only when the call fails. The built-in Haiku 3.5 id is corrected toclaude-3-5-haiku-20241022. - Accept map-encoded KV cache events from vLLM 0.29 and later in
continuum-kv-listener(#2091, PR #2108). vLLM 0.29 encodesEventBatchand its events as tagged maps instead of arrays, and the listener rejected every batch; both encodings now decode to identical output. The listener also answers/healthbeside/healthz. - Keep the local-only
kv_routingfields across Hub config sync (#2090, PR #2111). Applying a Hubkv_routingsection resetprefix_routing.salt_echotofalseandkv_cache_index.backendtomemory, so a router onkv_cache_index.backend: redisrebuilt its KV index in memory on every sync. Both fields now stay as configured locally. - Attribute web-search tool-loop usage to the serving backend (#1968, PR #2016). A non-streaming chat request on a self-hosted backend with
web_searchenabled was recorded with backendunknownand nobackend_id; the usage event, provider, andx-served-backendnow name the backend that served the final turn. - Harden the tokenizer routes and the
count_tokenstokenize arm (#2001, PR #2017). Thecount_tokensengine call reads through the shared 16 MiB cap and falls back to the local estimate on an oversized body, tokenizer requests are admitted through a half-open circuit without taking a probe slot (a transport failure still reopens it), andtoken_strsis held as one buffer with offsets. - Keep
reasoning_efforton streaming requests to self-hostedtype: openaibackends (#2000, PR #2015). The streaming route dropped the field for any model outside the OpenAI reasoning families; the strip now applies only toapi.openai.comandtype: azure, which reject it. - Keep an all-features debug build within the default 2 MiB thread stack (#2136, PR #2137). The chat ingress inlined its streaming, disaggregated, and semantic-cache futures into one 391 KB poll frame, so four all-features test targets aborted with a stack overflow. The largest awaitees are now boxed behind non-async wrappers, the task-local scope helpers return the scoped future, and
ContinuumRouterBuilder::buildreturns a boxed future;.build().awaitis unchanged for callers.
CI¶
- Pin every third-party action reachable from a self-hosted runner by full commit SHA, and fail CI on a mutable ref there (#1488, PR #2037).
- Run a toolchain-free
shell-syntaxjob when scripts or workflows change (#1489, PR #2035). - Sync Dependabot pull requests to the project board by running the board workflow on
pull_request_target(#2022, PR #2023). - Bump
astral-sh/setup-uv10.1.0 to 10.3.0 (PRs #2012, #2075),actions/setup-go6.5.0 to 7.0.0 (PR #2013),Azure/setup-kubectl5.1.0 to 5.2.0 (PR #2076), and thedtolnay/rust-toolchainpin in the local toolchain action (PR #2077).
Documentation¶
- Re-capture the mlxcel tokenizer fixtures from
mlxcel-serverat commit1e561f1e(#2020). From mlxcel#2127 onward, mlxcel answers an out-of-vocabulary id on/detokenizewith500 server_error, as llama.cpp does, so the router now answers such a request to anmlxcelbackend with502 tokenize_upstream_errorinstead of returning a200whose text silently lacks the id. The router itself is unchanged: an older mlxcel build still answers200and drops the id, and that answer still reaches the client as a200. Themlxcelverification note and the502row in the tokenizer section ofdocs/en/api.mdsay so.
Dependencies¶
- Replace unmaintained
unsafe-libyaml(RUSTSEC-2024-0449) by switchingserde_yamltoserde_norway0.9 across root and standalone crates (continuum-event-broker,continuum-kv-listener), and remove the advisory exception fromdeny.toml(#2084). - Re-pin the vendored
continuum-protocolcrate to Hub revision0165a2b8, which completed the usage batch hash chain, and clear its fourLOCAL-AHEADentries; the protocol bytes are unchanged (PR #2086). - Bump the minor-and-patch group with 5 updates (PR #2014):
lazy_static1.5.0 to 1.5.1,redis1.7.0 to 1.7.1,rmcp3.4.1 to 3.5.0,tokio-rustls0.26.5 to 0.26.6, andtokio-test0.4.5 to 0.4.6. - Bump the minor-and-patch group with 3 updates (PR #2038):
tokio1.53.1 to 1.53.2,uuid1.26.1 to 1.27.0, andlibc0.2.189 to 0.2.190. - Bump the minor-and-patch group with 6 updates (PR #2074):
toml1.1.6 to 1.1.7,rmcp3.5.0 to 3.5.1,hyper1.11.1 to 1.12.0,dynamo-kv-hashing1.5.0 to 1.5.1,h20.4.19 to 0.4.20, andrust-embed8.12.0 to 8.13.0. - Update
rustls0.23.45 to 0.23.46 in the root and standalone crate lockfiles, which fixes RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348, and RUSTSEC-2026-0349, and refresh the other semver-compatible dependencies (PR #2137). - Update
etcd-client0.20 to 0.21,minijinjaandminijinja-contrib2 to 3 (with theserdefeature, which replacesValue::from_serialize), andtokenizers0.22 to 0.23, and raise thehyper,http, andhttp-bodyrequirements of the standalone crates (PR #2137).
v1.32.1 - 2026-10-05¶
Fixed¶
- Cancel a silent streaming
/v1/chat/completionsrelay when the client disconnects (#2008). The OpenAI-wire, thinking-pattern, Anthropic, Gemini, Unix-socket, andresponses_onlychat relays awaited only the next upstream byte, so a client that left while the provider was silent (a long reasoning pause) kept the upstream request open until the provider emitted again ortimeouts.request.streaming.totalelapsed, and a single-slot local model kept generating for nobody. Each relay now watches the downstream close beside every upstream read and drops the upstream at once. With mid-stream fallback enabled, a client that leaves during a silent phase is reported as disconnected rather than as an inactivity failure, and no fallback backend is dialed for it. A connected slow reader is still charged from the terminal usage chunk (#1815). - End a
continuum-kv-listenerpush data stream cleanly when it falls behind its live feed before the router's response head arrives (#2010). The listener waited only for the head and then dropped the stream, so the router could see a reset instead of the request's clean end alongside thebuffer_overflowclose. It now also waits, within the same 2-second grace, for the router to close its side, as it already did once the head had arrived.
CI¶
- Run the tests from two builds, all features and default features, instead of rebuilding the library once per feature combination, in both
make ci-localand the hostedci.yml(#2005, PR #2007). A full local run dropped from 94 steps and 3616 s to 35 steps and 2387 s.make ci-local-gated(--gated) adds an opt-in local run that skips the steps of jobs the hosted gate would skip, and the test discovery audit now checks that the batched step covers every feature-gated target.
v1.32.0 - 2026-10-03¶
Added¶
- Add
POST /tokenizeandPOST /detokenize(aliases/v1/tokenize,/v1/detokenize), which reach the tokenizer of the engine serving the requested model (#1998).vllm,sglang,llamacpp, andmlxcelbackends are supported; the client contract is the vLLM and SGLang shape and the router translates it for llama.cpp and mlxcel (add_special_tokensdefaults totruethere, andmessagesis refused). Dispatch is exact-model and single-shot: no retry, no fallback chain, no cache, no model experiment, no usage record, and no per-model rate-limit charge. Both routes need thereadscope; a model served only by other backend types answers501 tokenization_not_supported./anthropic/v1/messages/count_tokensnow takes its engine paths and payloads from the same wire table. A backend URL ending in/v1(for examplehttp://host:8000/v1) has that one trailing/v1dropped before the engine path is appended, sovllm,llamacpp, andmlxcelare called at/tokenizeand/detokenizeandsglangat/v1/tokenizeand/v1/detokenize; before, such a URL produced/v1/tokenizeor/v1/v1/tokenizefor count_tokens, which the engines do not serve. Any engine5xxon these routes answers502 tokenize_upstream_error, an engine401,403, or407answers502, and an engine429keeps itsRetry-After. For the backend's circuit breaker, any HTTP response from the engine on these routes is neutral whatever its status, so a tokenizer2xxcannot close or keep closed the circuit of a backend whose generation path is failing and a client-caused5xxcannot open one; only a connection failure or a timeout counts, as a failure, and an open circuit still refuses the request. Engine answers are read from the capped 16 MiB body without a JSON tree, so the largest allocation for a dense id list is the 64 MiB id vector rather than a 256 MiB tree. - Add
backends[].responses_api: nativeso a self-hosted backend that implements the Responses API itself is served through passthrough without being declaredtype: openai(#1987). Any OpenAI-wire type accepts it;anthropic,bedrock,gemini, and aunix:URL refuse it at load. Nothing else the type decides changes, and the credential goes out in the backend's own scheme (Basic withauth.type: basic). On passthrough the router now forwards request fields it does not model (conversation,background,service_tier, and later additions) and input items of types it does not model (mcp_approval_response), where it used to drop the fields and answer 422 for the items;background: trueis forwarded only on passthrough, where the queuedPOSTrecords nothing and the first router-mediatedGETorcancelof the terminal result records its usage once and, under a guardrail, gates it (a streamed background request is refused under a guardrail; a result never fetched through the router is not metered), andtop_k,min_p,max_tokens, andmax_completion_tokenssent there are refused whilerequest_paramsor a hub tier limit governs them; a conversion strategy refusesconversationand such items with400 unsupported_request_parameter, and a request carrying an unmodeled field is never served from the response cache.GET,DELETE, and the newPOST /v1/responses/{id}/cancelon a response a passthrough backend served now reach that backend instead of answering 404: the router keeps a bounded per-router binding of response id to backend under the authenticated principal, re-checks the backend and the caller'sallowed_backendsagainst the live configuration on every call, and never sends an unrecorded id anywhere. The option reloads immediately; the Hub local export reports it as an unsupported field. - Add the
input_tokenssmart-routing policy condition (#1988). Awhen.input_tokensrange (gt,gte,lt,lte) matches the typical whole-request input-token estimate that fallback budget planning already uses, now shared fromcore::prompt_estimate: every message of the conversation rather than the latest turn,tools,functions, andresponse_format. A long prompt can reach a long-context tier on the first attempt instead of failing with400 context_length_exceededon a small-context primary. The estimate is computed once per request and only when a loaded policy reads it, is reported in theX-Smart-Route-Input-Tokensdebug header, theRouting policy matcheddebug log, andPOST /admin/smart-routing/simulate, and round-trips throughGET/PUT /admin/smart-routing/policies. Validation refuses an empty range, two lower or two upper bounds, and a range admitting one estimate or none. It applies on/v1/chat/completions, the only ingress that runs smart routing; the hub policy contract has no member for it, so a size-gated rule is local. - Add
backends[].replay: enabled | disabledfor backends whose requests have side effects, such as an agent runtime (#1993). Withdisabled, once a dispatch could have delivered the request, a dropped connection, a timeout, or a retryable status ends the request with that failure: no retry on the same or another backend, no fallback chain hop (the attempt reports the newreplay_disabledtrigger, which never hops), no pre-stream walk or mid-stream switch, no quality-check hop away from its reply, and no disaggregated unified fallback. A failed TCP or TLS connect is provably undelivered and still retries and falls back on the buffered routes; a streaming request selected onto the backend is dispatched once, a streaming chain hop onto it stops there unless the connect failed, and the Responses and Anthropic ingresses stop at any failure after committing to it. Requests such a backend may serve skip request deduplication, so independent requests sharing a trace id are no longer answered by one execution; the response cache never stores or serves their replies, Hub synthetic probes skip the backend, and an emulated/v1/decisionschain does not hop after it. Read per dispatch (immediate reload), accepted by the Admin API and the WebUI backend form, and reported as unsupported in the Hub local export.RetryState::backend_attemptedand its two methods are deprecated: the router never recorded an attempted backend, so the selection filter that read the list never excluded anything, and it no longer reads it. - Add
smart_routing.unprofiled_models(#1975).default_tier(the default) keeps today's behavior;excludeadmits only models amodel_profilesentry matches (exact or pattern, local, hub-stated, or added through the Admin API), so a model that would join a tier through auto-inference ordefault_tieris never a target, and a policy tier with no profiled model takes the ordinary tier fallback. The option reloads immediately,config validatewarns whenexcludeis set withmodel_profilesempty, andGET /admin/smart-routing/statuslists the pool ascandidate_models(with each model's tier and profile source) and the dropped models asexcluded_unprofiled_models. - Add an end-to-end test of the in-cluster event broker tunnel and a runbook for checking it on a live fleet (#1919, epic #1703).
tests/dynamo_broker_tunnel_test.rsruns the router's event bus listener, the realcontinuum-event-brokerbinary (built fromcrates/continuum-event-brokerinto that crate's owntarget/or$CARGO_TARGET_DIR/continuum-event-broker, or taken fromCONTINUUM_EVENT_BROKER_BIN), andzeromqpublishers ofkv_metricsandkv-events, with a fake Kubernetes API server servingDynamoWorkerMetadatato the broker. It checks that the load store and the KV index end in exactly the state a direct dial of the same frames leaves, that a SIGKILLed broker leaves every subscriptionpendingwith stale but held load and no worker excluded until a new broker process resubscribes them, and that a publisher the metadata does not advertise is refused.scripts/dynamo-broker-live-check.shand the new "Live check of the broker tunnel" section of the Dynamo integration guide describe the live check onkbm-g-np-kt-a(Pods reaching the bus, load freshness againstdynamo.load.max_staleness, and relay latency); that check has not been run yet. -
Assign broker subscriptions by Dynamo namespace across several brokers (#1920, epic #1703). With
dynamo.load.transport: broker, akv_metricsorkv-eventssubscription now goes to one of the live broker sessions covering its publisher's namespace by a fixed rendezvous ranking (SHA-256 of the publisher target and topic with each broker id) instead of the least-loaded session, so subscriptions spread across equally eligible brokers, a reconnect returns to the same broker on every run and router replica, adding a broker moves no open stream, and losing one moves only its subscriptions, each to its next-ranked broker. A subscription whose broker session ends reconnects through a remaining covering broker after the floor of the backoff (at most 500ms, never escalated), so load freshness recovers within one reconnect;kv-eventscursors carry across the move. A subscription waking frompendingwaits a random delay of at most 500ms before its first connect, and akv-eventssubscription that stays without a connection for 60s has its ranks cleared from the KV index (logged). Underbroker,GET /admin/dynamo/workersaddsbroker_coverage(per configured namespace:covered, the covering broker ids, and pending subscriptions per family), and two gauges report the same per configured namespace, bounded by the 16-namespace cap and removed when a namespace leaves the set:continuum_dynamo_broker_namespace_sessions{namespace}(0 = uncovered) andcontinuum_dynamo_broker_pending_subscriptions{family, namespace}. Router replicas still do not coordinate: a replica with no covering session keeps its subscriptions pending. The gauges are recomputed at most once per 250ms, so a mass failover does not make the reporter spin. -
Add
dynamo.load.transport: direct | broker(#1917, epic #1703), shared by thekv_metricsload andkv-eventsKV subscriptions and defaulting todirect. Withbroker, each subscription is carried over a livedynamo.event_busbroker session whose granted namespaces cover the publisher's Dynamo namespace, and the router's own bounded ZMTP client, decoder, publisher-id check, sequence dedup, andkv-eventsgap handling run over the relayed bytes exactly as with a direct dial. A subscription with no covering session ispending: it dials nothing, its worker reads as stale load (round-robin, never excluded), and it starts as soon as a covering session is welcomed; when a broker's session ends, its subscriptions reconnect through the normal backoff and endpendingif no covering broker remains.brokerneeds thedynamo-event-busbuild feature; a binary without it warns at startup and keeps the subscriptionspendingrather than dialling directly. The field reloads immediately and restarts only the subscriptions, never discovery.GET /admin/dynamo/workersgainssubscriptions(family, publisher id, namespace, component, state, transport, and carrying broker id; never the publisher endpoint), andcontinuum_dynamo_broker_subscriptions{family, broker}counts the connected subscriptions each broker carries. A socket close after a receive error is now bounded at 1s so a stalled tunnel cannot hold a subscription task. Onlybroker-role sessions carry subscriptions, neverkv_listenersessions, and only a change to the set of placeable broker sessions wakes pending subscriptions. A restartedkv-eventssubscription for the same publisher (a transport switch included) resumes its per-rank event cursors, so events missed while it was stopped or pending are handled as a gap, and the ranks of a publisher that will not resume are cleared from the KV index. -
Add the
kv_listenerpush role on the event bus, so acontinuum-kv-listenerthe router cannot reach dials out to the router and feeds the KV cache index (#1921, epic #1703).dynamo.event_bus.brokers[]gainsrole: broker | kv_listener(defaultbroker, omitted when serialized so older files round-trip) andbackends(kv_listeneronly: required, non-empty, each 1-64 bytes of[A-Za-z0-9_-], unique, at most 256;namespacesstays broker-only and each is a load error on the other role), andkv_cache_index.event_sources[]gainstransport: sse | event_bus(defaultsse;endpointbecomes optional in YAML, required forsseand forbidden forevent_bus, whosebackend_namemust be a valid source id used by no other source). Thecontinuum-kv-listenergains an optionalevent_bussection (url,tls_server_name,ca_file,client_cert_file/client_key_file,client_id,credential_file/credential_env,reconnect_backoff,drain_deadline,connect_timeout) and keeps its SSE server unchanged; the push stream carries the same bytes asGET /events/{backend}, asserted against a shared golden fixture, and the router resumes from the last forwarded event id after a reconnect (a source with no live listener session is retried atreconnect_interval_msand does not back off further, and one SSE record'sdata:is capped at 1 MiB like the line buffer). The wire contract stays at protocol version 1 with two additive changes:AnnouncedSourceTarget.last_event_id(the resume position) and aHello::validaterule that refuses akv_listenerhello with no sources. Security: the event bus stays TLS-only with token-hash or mTLS authentication, a hello is accepted only for the role its credential's entry is configured for, a listener may announce and be commanded only for the backends its entry grants (a reload that narrows the grant or changes the role closes the session withforbidden), and the token is never written in YAML or logged.config validateand the startup log warn about a bus source with the bus disabled or ungranted and a grant no source consumes; bus sources are not exported to the control-plane hub config sync. Seedocs/en/architecture/kv-cache.md,docs/en/architecture/dynamo-integration.md, andconfig.kv-listener.yaml.example. -
Add deployment assets for the in-cluster event broker (#1918, epic #1703).
deploy/helm/continuum-event-brokeris a chart of its own, installed in the GPU cluster in the fleet's namespace, anddeploy/kubernetes/event-broker/is the equivalent standalone Kustomize bundle. Both run one Deployment of exactly one Pod per broker identity (two in the examples), because the router keeps one session per broker id and a second session with the same id supersedes the first. They render a Role granting only get, list, and watch ondynamoworkermetadatas.nvidia.comin each watched namespace, mount each broker's bearer token from an existing Secret (never rendered, never a value) and the router's CA bundle, probe/readyzand/healthz, keep one broker through a node drain, and apply a default-deny NetworkPolicy whose egress reaches only DNS, the API server, the router's bus address, and the workers' publishers. An emptied egress list removes its rule instead of rendering an allow-all one. The chart refuses a missing or plaintext bus URL, a broker without a credential Secret, and a repeated broker id. On the router side,dynamoEventBusin the router chart exposes thedynamo.event_buslistener (container port, a separate Service, the TLS Secret mounted outside the read-only config directory, and a NetworkPolicy ingress rule), andvalues-dynamo-event-bus.yamlis the complete overlay.scripts/validate-deployment-assets.shlints and renders all of it and checks the RBAC, credential, probe, identity, and egress contract, the chart's refusals, and that the router's listener port, TLS mount, and configured paths agree. The deployment guide gains an "In-cluster event broker" section covering the router's listener, the broker credentials, and reachability from the cluster to the router. -
Add the router side of the in-cluster Dynamo event broker tunnel (#1915, epic #1703), behind the new
dynamo-event-busCargo feature on top ofdynamo(not indefault/full, compiled into official release binaries) and the always-validateddynamo.event_bussection, which is off by default and independent ofdynamo.enabled; a binary without the feature logs at startup that the broker listener is inactive. A router outside the Kubernetes cluster cannot dial the workers' Pod-IP ZMQ publishers, so an in-clustercontinuum-event-broker(#1916) dials out to a dedicated listener atdynamo.event_bus.listen, a port of its own that may not collide withserver.bind_address, serving thecrates/continuum-event-buswire contract (#1913):POST /bus/v1/sessionandPOST /bus/v1/streams/{stream_id}. The listener speaks TLS 1.3 with ALPNh2only and refuses plaintext on the first byte (an HTTP-looking connection gets a best-effort403 tls_required). A broker authenticates with a bearer token whose SHA-256 is configured asbrokers[].token_hash(the token itself never is), compared in constant time, or with an mTLS client certificate chaining totls.client_cawhose DNS SAN equals itsid; itshellomust use rolebroker, aclient_idequal to thatid, and only Dynamo namespaces in the broker'snamespacesallowlist (an empty allowlist accepts any namespace and draws aconfig validateand startup warning). One session per broker, at mostmax_sessionssessions (default 64,429 too_many_sessionsbeyond it) and 4 xmax_sessionsconnections (a connection with no request accepted within 10s of connecting is closed and counted asauth_timeout, and one is closed 30s after its last accepted request ended; refused requests never keep a connection open), at mostmax_streams_per_sessionstreams (default 1024), data streams accepted only for stream ids the router commanded in that session, and per-stream buffers and HTTP/2 windows derived frommax_session_buffer_bytes(default 64 MiB, 32 KiB per stream per direction) so a session never holds more than its budget in flight. Every field reloads without a restart:enabledstarts or drains and stops the listener,listenrestarts it, TLS files are re-read for new connections, and a removed broker, changed hash, changedclient_ca, or narrowed allowlist closes only the affected sessions (credential_revokedorforbidden), a changedclient_caalso stopping connections verified under the old CA from authenticating again; a listener that fails to start is retried every 30s.EventBus::open_streamis the command API the ZMTP connector (#1917) will use. New closed-label/metricsfamilies:continuum_dynamo_event_bus_connections_refused_total{reason},continuum_dynamo_event_bus_sessions,continuum_dynamo_event_bus_session_refusals_total{reason},continuum_dynamo_event_bus_sessions_closed_total{reason},continuum_dynamo_event_bus_streams,continuum_dynamo_event_bus_streams_opened_total,continuum_dynamo_event_bus_stream_refusals_total{reason},continuum_dynamo_event_bus_streams_closed_total{reason}, andcontinuum_dynamo_event_bus_bytes_total{direction}. Seedocs/en/architecture/dynamo-integration.mdand thedynamo-event-busconfiguration template. -
Add
continuum-event-broker, the in-cluster end of the event bus tunnel (#1916, epic #1703). A router outside the GPU cluster cannot reach worker Pod IPs, so the broker runs inside the cluster, dials out to the router's bus over HTTP/2 and TLS (https://only, bearer credential from a file or the environment, never logged), and for eachsubscriberelays the Dynamo ZMQ publisher's raw bytes over one HTTP/2 stream without parsing ZMTP. It dials onlytcp://host:portpublishers advertised in theevent_channelsofDynamoWorkerMetadatain the Dynamo namespaces it covers, as seen by its own read-only list and watch (RBAC: get/list/watch ondynamoworkermetadatas.nvidia.comonly), optionally limited toallowed_cidrschecked after name resolution, and never loopback, link-local, or other special-purpose addresses unless explicitly allowed; anything else is refused withtarget_not_advertised,target_not_allowed, orunsupported_target, logged, and counted. Each stream buffers at most the session'smax_stream_buffer_bytestoward the router, all streams of a session together at most 64 MiB, each chunk counted with a fixed per-chunk overhead, and a stream that would exceed either bound is closed withbuffer_overflowrather than stalling the publisher. The session redials with jittered exponential backoff, and SIGTERM sendsdrainingand lets open streams wind down untildrain_deadline. Opening a stream is bounded to fit under the router's 30 second stream-open timeout (upstream connects at most 10 seconds in total across resolved addresses, 5 seconds for a free HTTP/2 stream slot, 10 seconds for the router's response head), and on expiry the upstream socket is closed and the stream is reported as refused or closed. Arebalancedrain redials after a small jittered minimum rather than at once, and repeated rebalances fall back to the normal backoff. The per-refusal warning log is limited to the first per error code per minute plus a suppressed count, while the refusal metric counts every one. Settingallowed_cidrsto the Pod CIDR is strongly recommended, because an empty list trusts every advertised address that is not special-purpose. Prometheus metrics (continuum_event_broker_*) with/healthzand/readyzare served onmetrics_listen. Ships in the Linux release archives and the Debian package with thecontinuum-event-broker(1)man page, and as theghcr.io/lablup/continuum-event-brokerimage. Seeconfig.event-broker.yaml.exampleanddocs/en/architecture/event-broker.md. -
Add a per-backend
http_version(auto,http1,http2) and stream-limit observability (#1925, epic #1703). Over HTTP/2 the router multiplexes every request to a host onto one connection, so a server'sSETTINGS_MAX_CONCURRENT_STREAMS(100, 128, or 250 on common ingresses) caps concurrent requests and parks new ones inside the HTTP client while the engine sits idle.http_version: http1gives that backend its own HTTP/1.1-only client and lifts the cap;auto(default) keeps the shared client, so existing configurations are unchanged, andhttp2forces HTTP/2 prior knowledge.http2on aunix:backend URL is a load error. The field is accepted by the admin backend API, listed in the config schema, and read per dispatch, so a hot reload switches new requests without dropping in-flight ones. New/metricsfamilies:continuum_backend_http_in_flight{backend, http_version}(in-flight requests, sampled at scrape time) andcontinuum_backend_response_wait_seconds{backend, http_version, outcome}(time from handing a request to the HTTP client until its send settles, withoutcomeofresponse,error, orcancelled), so a stall shows as an in-flight plateau at the server's stream limit with a long wait tail. Seedocs/en/configuration/backends.mdanddocs/en/metrics.md. -
Report KV offloading effects per backend on
/metrics(#1565, #1877). Newcontinuum_backend_cached_prompt_tokens_total{backend}adds the cached prompt tokens the serving backend reported (prompt_tokens_details.cached_tokens, Responsesinput_tokens_details.cached_tokens, Anthropiccache_read_input_tokens), and newcontinuum_backend_ttft_seconds{backend, source}observes router-measured time to first token from provider dispatch, split bysourceintostreamed_first_tokenandresponse_headers(a non-streaming call's header arrival), with buckets from 25 ms to 60 s that separate local prefix hits, KV connector hits, and cold prefills. Both record on the default release build, not only with thecontrol-planefeature: the stream-end dispatch instant is now stamped in every build. Each request counts once; local cache replays, failures, and usage-less responses add nothing. Streamed/v1/responsesnow builds its stream-end usage context on every build rather than only while the Hub agent or a policy is active, so it records both families and, like streamed chat completions, adds its tokens tollm_tokens_totalandmodel_experiment_tokens_total. Thebackendlabel uses the existing sanitizer and cardinality guard, and the/metricsscrape drops series of backends no longer in the pool. vLLM reports cached tokens only with--enable-prompt-tokens-details. The vLLM engine-stats adapter now also readsvllm:external_prefix_cache_queriesandvllm:external_prefix_cache_hitsinto the newbackend_engine_external_prefix_cache_hit_ratiogauge (KV connector hits such as LMCache, beside the local GPUbackend_engine_prefix_cache_hit_ratio), absent when the engine runs without a connector; SGLang has no equivalent counter pair, so the gauge is vLLM-only.continuum_kv_index_routing_decisions_totalgains atierlabel (gpu_hot,storage_warm,none) naming where the chosen backend holds the prefix, returned by the same scoring call that picked it, so a concurrent request cannot drop it; queries that sum over the family are unaffected. The Grafana router overview adds panels for all four. -
KV-overlap-aware prefill selection for a Dynamo fleet (#1702, epic #1703), behind the new
dynamo-kvCargo feature on top ofdynamo(in official release binaries, off indefault/full, denied on theembedgraph) and the new always-compileddynamo.kvsection, whose every field reloads immediately. Withkv.enabled: truethe router subscribes to each worker'skv-eventschannel (discovered from the samev1/event_channels/records askv_metrics, read over the same bounded ZMTP client under a 1 MiB frame ceiling for block batches, and decoded position-tolerantly likeActiveLoad) into a bounded per-fleet radix index keyed bytokens_hashfrom the root with per-rank ownership (1,048,576 blocks in all, 262,144 per rank, gated on the registry's live worker set and the data-parallel ranks each worker's card declares, with a rank's ownership of a block counted per engine hash so a block stored twice survives one removal, evicted when a worker leaves; anevent_idgap clears the rank and countsgap, and batches under a dropped parent countorphan_parentuntil the chain re-roots, since there is no request-plane snapshot recovery), renders and tokenizes each request with the fleet's own tokenizer and chat template resolved from the model card'stokenizer/prompt_formatter/chat_template_fileslots into a blake3-verified, content-addressed cache underkv.artifact_cache_dir(local paths always;kv.allow_http_artifactsfor a worker's metadata URL, default on;kv.allow_hf_artifactsfor the Hub, default off), and only when the file's checksum is in the operator'skv.pinned_artifacts(default empty: a chat template is fleet-authored code whose memoryminijinjacannot bound, sincefuelcounts instructions and one concatenation can allocate without limit, so an unpinned file is never fetched, parsed, compiled, or rendered; the model scores without a KV term asartifact_unpinned, the log names the slot, URI kind, and the card's declared checksum once per model and artifact for review, a removed pin drops the loaded tokenizer built from it at once, andconfig validatewarns whenkv.enabledwith nothing pinned and refuses a malformed entry by index), hashes the prompt withdynamo-kv-hashing1.5.0 (XXH3-64 over little-endianu32ids per full block, seeded by the request'scache_saltand LoRA name, block size from the card, at most two sizes of 16 to 65536 per request, hashed on the blocking pool beside the render) so its hashes equal the workers'tokens_hashbyte for byte, and applies Dynamo's own cost formula withkv.overlap_score_credit,kv.prefill_load_scale, andkv.router_temperature(argmin with a cursor for ties at 0, upstream's softmax sampling above). A rank's backlog counts only from a sample fresher thandynamo.load.max_staleness: ranks with one outrank ranks without, and a fleet with none (adynamo.vllmfleet publishes noactive_prefill_tokens) is ordered by overlap alone, with ranks nothing tells apart rotating asround_robin_stale. Decode stays load-only unlesskv.conditional_disaggregationis on, which implements theisl_bounding/prefill_load/isl_or_loadbypass and the decode-busy gate and then sends onlyx-dynamo-worker-instance-id; a Dynamo 1.5.x direct-mode frontend refuses that while prefill workers are registered, whichconfig validatewarns about.kv.send_token_datasends the rendered ids asnvext.token_data(overwriting a client-supplied value); the prompt is rendered from the body as the shim pool is sent it, streamingglobal_promptsand the single-leading-system merge included, and the ids are withheld when the pool's replicas would be sent different bodies. A tokenizer not loaded yet, a prompt above the 1 MiB render bound, an empty index, or a card without capacity never excludes a worker: the load-only rule of #1699 serves that request. New closed-label familiescontinuum_dynamo_kv_subscriptions{state},continuum_dynamo_kv_events_total{result},continuum_dynamo_kv_index_blocks,continuum_dynamo_kv_selection_total{result}, andcontinuum_dynamo_kv_artifacts_total{result};continuum_dynamo_selection_total{reason}gainskv_awareandconditional_bypass;GET /admin/dynamo/workersreportskv_event_publishers. Thedynamo-kv-awaretemplate is embedded. The dependency decision is recorded inCargo.toml:dynamo-kv-routerwas measured (16 new crates, no runtime) but not taken because its unbounded indexer panics on malformed fleet-authored input underpanic = "abort", anddynamo-tokenizers/dynamo-rendererwere not taken because they pull C and C++ backends; the pure-Rusttokenizersandminijinjathey wrap are used directly. Thekv-eventsfixture is synthesized from the pinnedv1.5.0sources with an independent encoder and asks for a real capture, because the only fleet available runs withuse_kv_events=False. -
Discover a Dynamo fleet on Kubernetes from the
DynamoWorkerMetadataresources its workers write and the EndpointSlices that say which are ready (#1701, epic #1703).dynamo.discovery.source: kubernetesshares thedynamo.discovery.kubernetesblock withkubernetes_pods(namespace,dynamo_namespace,watch_reconnect_backoff;modelis required bykubernetes_podsonly and ignored here, with aconfig validatewarning when set) and reads what Dynamo's own discovery daemon reads in Kubernetes discovery mode: each worker'sspec.datacarries the same endpoint, model-card, and event-channel records an etcd fleet registers, so the router hands them to the etcd reducer under the keys etcd would hold them at and a Kubernetes fleet yields the same workers, capacity, data-parallel ranks, retained cards, andkv_metricsload publishers as an etcd fleet;GET /admin/dynamo/workersreports"signals": {"load": true, "capacity": true}. A worker is published only while an EndpointSlice endpoint naming its Pod is ready, joined ontargetRef.nameas upstream's daemon joins it; a resource whose endpoint or card ids disagree withhash_pod_nameof its name is refused whole and counted ascontinuum_dynamo_discovery_events_total{kind="mismatch"}, and a per-container resource of container discovery mode (owned by a Pod of another name) is refused and counted askind="refused". Both resource types are listed and watched together, either watch ending relists both, and the reducer is fed by diff so an unchanged relist counts nothing andfirst_seensurvives a readiness flip. The record's transport (a Pod IP in this mode) is held as under etcd and never exported. Both lists are read in pages of 500 that stop at 16384 objects, each watch has its own idle deadline and a watch that closes well before the server timeout is an error, as forkubernetes_pods, and nothing reaches the reducer until both watches are open. A model card over the 1 MiB retained-card ceiling is reduced to its typed subset when the resource is read, the held resources keep at most 256 MiB of record bytes in total (a new resource past it is refused and counted asmalformed), a reducer key two resources share leaves the reducer only with its last contributor, an endpoint or card path too short to carry an id is amismatch, and a resource whosespecis not an object is counted asmalformedinstead of failing the whole list. The Helm chart adds get/list/watch ondynamoworkermetadatas.nvidia.comandendpointslices.discovery.k8s.ioto the same Role behinddynamoPodDiscovery.metadata: true,deploy/kubernetes/overlays/dynamo-metadata-discovery/is the Kustomize opt-in, and a newdynamo-kubernetestemplate is embedded. Wire shapes are pinned to upstream v1.4.2 and checked against v1.5.0; the fixtures are synthesized from that source around the four live Pod names and ids, not captured, and ask for a real capture. -
Discover a Dynamo fleet on Kubernetes from its Pods alone (#1869, epic #1703).
dynamo.discovery.source: kubernetes_pods(the default staysetcd) with adynamo.discovery.kubernetesblock (namespace,dynamo_namespace,model,watch_reconnect_backoff) lists and watches the Pods the Dynamo operator labels for Kubernetes discovery, needing only get/list/watch onpods, for clusters where the router may not readDynamoWorkerMetadataor EndpointSlices. A Pod is a worker while it isRunning,Ready, and not terminating; its role comes from the operator's component-type labels, and its instance id is upstream'shash_pod_name(SipHash-1-3 with zero keys over the Pod name, masked to 53 bits), implemented locally and pinned to ids read from a live 1.4.2 fleet. Pods carry no model card and no load publisher, so this source provides no load and no capacity and the shim picker rotates round-robin;config validatewarns, andGET /admin/dynamo/workersnow reportssource,signals, and each worker's decimalworker_id. Container discovery mode Pods are refused and counted ascontinuum_dynamo_discovery_events_total{kind="refused"}, andcontinuum_dynamo_discovery_connectedgains asourcelabel. A reload that switches the source or edits the active block replaces the watcher and empties the registry. The Helm chart renders a namespaced read-only Role and RoleBinding behinddynamoPodDiscovery.enabled(off by default), anddeploy/kubernetes/overlays/dynamo-pod-discovery/is the Kustomize opt-in. Thedynamosection's field rules are now enforced on the load and hot-reload path, not only byconfig validate. Thedynamofeature addskubeandk8s-openapi(rustls, nokube-runtime), both denied on theembedgraph. -
Discover and pin across several Dynamo namespaces (#1910, follow-up of #1884 and #1870, epic #1703).
dynamo.discovery.etcd.namespacesanddynamo.discovery.kubernetes.dynamo_namespaceslist theDYN_NAMESPACEof every DynamoGraphDeployment one router fronts (unique, at most 16, exactly one of scalar or list per block; single-namespace files are unchanged), anddynamo.shim.pools: [{namespace, backends}]binds each namespace to its own--router-mode directfrontend replicas, required once more than one namespace is configured, one pool per namespace and no backend in two pools. Every discovered worker carries the namespace it was found under (the etcd key prefix, the metadata record path, or the Pod'snvidia.com/dynamo-namespacelabel, which the Pod source now selects within (a,b)), its identity is(namespace, instance_id),GET /admin/dynamo/workersaddsnamespacesand a per-workernamespace, andcontinuum_dynamo_workersplus the three per-worker load gauges gain anamespacelabel. A request is dispatched only through the pool of the namespace its pinned workers belong to, a namespace whose pool has no usable replica is skipped rather than mis-pinned, a prefill/decode pair always comes from one namespace, and between namespaces serving one model name the picker's normal load comparison decides.kubernetes_podsmodelsentries gain an optionaldynamo_namespace, required with several namespaces. Hot-reloading a namespace in or out prunes only that namespace's workers and keeps the others published without a gap (transitionnamespaces); a source, connection, or Kubernetes namespace change still empties the registry. A reload never pins from a snapshot that lags the live configuration: the shim path offers the picker only namespaces in both, and pins nothing from a snapshot of another discovery source. -
Map worker components to models in the
kubernetes_podsDynamo discovery source (#1885, follow-up of #1869). One Dynamo namespace can host several worker pools serving different models, told apart only by the operator'snvidia.com/dynamo-componentPod label, and publishing every Pod under the singledynamo.discovery.kubernetes.modelpinned requests for one model to workers of the other. The newdynamo.discovery.kubernetes.modelslist ({component, model}entries, mutually exclusive withmodel, exactly one of the two required underkubernetes_pods) publishes each ready worker Pod with the model its exact, case-sensitivenvidia.com/dynamo-componentvalue maps to; a ready worker Pod whose component is not listed, or that has no such label, is held but not published and logged at debug once per appearance, without counting as malformed or refused. Entries need a valid label value and model name, a component may not repeat (two components may share a model), and the list holds at most 256 entries; errors name the entry, as indynamo.discovery.kubernetes.models[1].component. Amodelsedit replaces the watcher and empties the registry like amodeledit. Likemodel,modelsis ignored bysource: kubernetes(#1701), whereconfig validatewarns that it is set and editing it restarts nothing.dynamo.shim.model_mapis not a substitute, since it only renames the model the picker looks for. Single-modelfiles parse and behave as before. -
Attach a per-request quality judgment and the model-experiment arm to control-plane usage records (#1728, epic #1719; protocol change continuum-hub#1264). The optional
usage.quality_judgmentblock (a new top-levelusagesection) asks a System One decision model, after a non-streaming/v1/chat/completionsreply passes every guardrail stage, whether it answers the request, refuses it, is written in the request's language (asked only when the request has letters), and, only wheninclude_tool_results: trueand the current turn carried tool results, whether it agrees with them. The answers ride on that request's usage record asquality(answered_bps,refusal_bps, optionalgrounded_bpsandlanguage_match_bps, integer basis points fromround(p * 10000), plus the resolvedjudge_modelsuch asjev-1.13.0); a missing or non-finite answer, or one failingQualityJudgment::validation_problem, drops the judgment and keeps the record. The client response never waits, and the record's accounting (router_usage_seq, cost-center and model-budget charging) and its push never wait either: the judgment fills a slot the usage loop seals when it forms the record into a push batch, so a judgment that finishes later is dropped and countedlaterather than re-sent under the record'sidempotency_key.sample_rate(default1.0),models, head-and-tail state caps (512 / 1,536 / 1,024 bytes), and a per-routermax_in_flightcap (default32, excess shed and counted, never queued) bound the cost, and only the last user message and the reply (plus tool results on the opt-in) reach the decision model. Every usage record of a model-experiment request now carriesexperiment_idandexperiment_variant_id, with or without a judgment. New metricusage_quality_judgments_total{outcome}, new templateusage-quality-judgment. The vendoredcontinuum-protocolcrate mirrors the hub's additiveQualityJudgmentand the threeUsageRecordmembers byte for byte;PROTOCOL_VERSIONstays 0. Streamed replies (judged since #1801), the/anthropic/v1/messages,/v1/responses, and/v1/completionsingresses, and non-streaming chat served throughdisaggregated_servingor the Dynamo shim are not judged. Late judgments are the slowest ones, so a highlateshare skews the attached judgments toward fast requests; a longercontrol_plane.usage_push_interval_secsor a faster self-hosted target reduces it. -
Judge streamed
/v1/chat/completionsreplies forusage.quality_judgment(#1801, follow-up of #1728). A streamed request is sampled right after the input guardrail and judged the moment its stream-end usage record is emitted, over the head and tail of the text the client read, kept in memory bounded bymax_reply_bytesand capped exactly as a buffered reply is (reasoning text is never read). Because the record and the whole reply exist at the same moment, a target that answers well insidecontrol_plane.usage_push_interval_secsleaves almost nolatejudgments. A streamed reply is not judged, and countsskipped, when the output guardrail cut or rewrote the stream (buffer_fullorchunkedblock, or masking), a mid-stream fallback hop spliced two generations into one reply, the stream carried an error or timed out, or the client disconnected.usage_quality_judgments_totalgains astreamlabel ("true"or"false") so the two modes share one ratio. Streamed chat served by a native Anthropic, Gemini, or Bedrock backend, over a unix socket, or throughdisaggregated_servingor the Dynamo shim is still not judged, nor are/anthropic/v1/messagesand/v1/responses(#1803, #1804). -
Judge
/anthropic/v1/messagesreplies forusage.quality_judgment, buffered and streamed, on every backend path: native Anthropic over HTTP or a unix socket, Bedrock mantle, runtime, and converse, the OpenAI-wire conversion (Gemini included), andresponses_onlymodels (#1803, follow-up of #1728). Tool results travel inside user messages on this wire, so the judged user turn is the lastrole: usermessage that carries notool_resultand has non-blank text other than Claude Code's<system-reminder>blocks, the rule the action-safety evaluator uses; reminder blocks, the top-levelsystem,role: systemmessages, andthinkingblocks never leave the router, andtool_resulttext leaves only underinclude_tool_results. The reply is itstextblocks as the client receives them: atool_useorserver_tool_usereply, apause_turnstop, an output-guardrail block, a masked or cut stream, a stream that relays anerrorevent, and a client disconnect (including one after the last event) countskipped, while amax_tokensstop is judged. Theweb_search_20250305server-tool emulation and response-cache hits are not judged. -
Judge
/v1/responsesreplies forusage.quality_judgment, buffered and streamed, on every strategy: passthrough (toopenaiandazurebackends, includingresponses_onlymodels, and to any backend withresponses_api: nativesince #1987) and the chat, Anthropic, and Gemini conversions (#1804, follow-up of #1728). The judged user turn is the last user message of the request's owninputwith non-blank text other than harness text (<system-reminder>and Codex harness messages such as<user_instructions>,<environment_context>, and# AGENTS.md instructions), so aprevious_response_idcontinuation is judged on its delta only, and one whoseinputholds onlyfunction_call_outputitems countsskipped;instructions,systemanddevelopermessages, and reasoning items never leave the router, and tool output leaves only underinclude_tool_results. The reply is theoutput_textof itsmessageitems as the client receives it (a buffered reply the output guardrail masked is judged on the masked text), read from the terminalresponse.completedorresponse.incompleteevent when streamed: a client-executed call item, afailedorcancelledstatus, an output-guardrail block, a masked or cut stream, and a client disconnect countskipped, while astatus: incompletereply is judged./v1/chat/completionsrequests bridged to an upstream/v1/responsesfor aresponses_onlymodel are now judged too, buffered and streamed (the streamed half rides the stream-end record #1809 added). Response-cache hits and/v1/responses/compactare not judged. -
Add a client-facing System One decisions endpoint,
POST /v1/decisionswith/v1/systemoneas an alias (#1726, epic #1719). Amodelnamed under the newdecisions.targets[](a name plus the sharedsystemoneblock) is passed through to that System One server and its answer returned unchanged except thatusage.output_tokensis reported as0; any other name is emulated on the chat model of that name as one forcedemit_decisiontool call attemperature: 0, answered with one-hot probabilities, noconfidence, andemulated: true. The question map is validated before any dispatch (a 256-optionchoiceanswers422, a paddedmodelanswers400), the route needs thewritescope in blocking mode, per-key and per-model rate limits, allow-lists, and budgets apply as on chat completions, a key with a non-emptyallowed_backendsnever reaches an inline-endpoint target, an emulation hop runs chat'srequest_paramspolicy and input guardrails and resolves aliases and model experiments like chat, usage is recorded on the caller's key under the model that served, and a target name works infallback.fallback_chains, so a failing real target can fall back to an emulated chat model. The section is optional and reloads immediately; absent means enabled with emulation only. Newdecisions-endpointconfiguration template. -
Govern smart routing policy and guardrail provider content from Continuum Hub (epic #1706, hub epic continuum-hub#1212). v1.32.0, this release, is the first router version that executes both:
smart_routingunder the newsmart_routing_policy_v1capability, andprovider_contentinside the existingguardrail_policy_v1statement and itsguardrail_policy_digest, advertised under the newguardrail_provider_content_v1capability (#1818). A router older than v1.32.0 advertises neithersmart_routing_policy_v1norguardrail_provider_content_v1, so the hub reports both asunsupported. A hub gated onguardrail_provider_content_v1never sends provider content to a v1.17.0 to v1.31.x router, which keeps receiving the rest of the guardrail policy (mode, stages, thresholds, and routes); only against a hub that sends provider content ungated does such a router refuse the whole guardrail statement asdigest_mismatchand keep its previous guardrail policy. A hub that predates hub#1212 sees no change beyond the new capability strings. No statement in either stream can reach provider or backend endpoints, credentials,api_key_env,bypass_api_keys, streaming buffer tuning, or the classifier backend's endpoint or credential, weaken a localenforce, or override a localsmart_routing.enabled: false. The new Smart Routing Governance page, the provider content sections of the guardrails guide, and the governance compatibility matrix in the architecture guide describe the surface, and three new integration suites pin it:control_plane_governance_sovereignty_test,control_plane_governance_compatibility_test(restart and mixed-version behavior), andcontrol_plane_governance_docs_examples_test, which runs the documented worked examples (#1713). -
Advertise
guardrail_provider_content_v1to Continuum Hub (#1818, hub epic continuum-hub#1212). A router whose guardrail policy reconciler is attached lists it inRouterPolicyStatus.capabilitieson enrollment and every heartbeat, directly afterguardrail_policy_v1and on the same latch, so the two appear together or not at all. The hub gatesprovider_contenton it (hub#1221): a router that does not advertise it receives the guardrail statement withoutprovider_content, digested over that stripped policy, and the hub reports the content as unsupported for that router. The statement shape, its digest, the refusal codes of #1709 and #1711, andPROTOCOL_VERSION(0) are unchanged, and a statement withoutprovider_contentis still accepted and acknowledged. The vendoredcontinuum-protocolcrate addsGUARDRAIL_PROVIDER_CONTENT_CAPABILITYlocally ahead of an upstream re-sync (hub#1215). -
Advertise
smart_routing_policy_v1to Continuum Hub (#1712). A router whose smart routing is governed by the hub reconciler (acontrol-planebuild started from a configuration file withcontrol_plane.policyenabled) now lists the capability inRouterPolicyStatus.capabilitieson enrollment and every heartbeat, so the hub stops reporting it asunsupportedand derivespending,active, orerrorfrom the acknowledgedsmart_routing_policy_digestand the typed rejection. The capability is advertised on the same latch that turns the acknowledgment on, so it never appears without the execution-gated acknowledgment and typed rejection of #1711. A router whose localsmart_routingsection hasenabled: falsestill advertises it and reports every hub policy assmart_routing_unavailable(an authoritative clear is still acknowledged), matching howguardrail_policy_v1stays advertised on a router whose guardrails cannot run a policy.guardrail_policy_v1and its digest acknowledgment are unchanged and already coverprovider_content, which rides the same digest; the separateguardrail_provider_content_v1capability (#1818) is what tells the hub that a router executes it. A hub that never sendssmart_routingsees no other change. -
Report refused hub smart routing and guardrail statements with typed codes, and acknowledge a digest only after it runs (#1711). A smart routing body refused when the envelope is applied is reported on
RejectedPolicyRevision.smart_routing_policy_digestasinvalid_smart_routing_policy(failed validation, or a tier or classifier method this version cannot interpret) ordigest_mismatch, and never reaches composition; a valid statement this router cannot execute, including one composed onto a local section withenabled: false, is reported assmart_routing_unavailablein place of an active digest. For both smart routing and guardrails, an accepted digest is reported active only once the reconciler has recorded executing it (a clear, once the hub layer is dropped), so a heartbeat sent between an envelope apply and the next reconcile pass reports the statement as pending instead of claiming it. The single rejection slot carries a guardrail or smart routing digest only under that track's own code. After an execution-time refusal both tracks run local configuration alone, not an earlier hub policy held in memory. -
Report read-only smart routing and guardrail provider inventory to Continuum Hub (#1710). Enrollment and every heartbeat now carry
RouterInventory.smart_routing_inventory(enabled, the policy and explicit profile counts, the classifier method, the LLM classifier backend name when the method can call it,virtual_model, andintercept_all) andRouterInventory.guardrail_provider_inventory(each configured provider'sname,type, stages, order, and whether it runs). Both are read from the live pipeline, so a reload, an Admin API edit, or an applied hub policy shows on the next beat, and neither is gated on a capability. An absent member means unknown,[]means no providers, andenabled: falsemeans configured but not running; a list that does not fit the bounds is left out whole with one warning rather than truncated. No prompt, pattern, routing condition, endpoint, or credential name is sent. This is an additive local protocol change ahead of an upstream re-sync (hub#1213);PROTOCOL_VERSIONstays 0. -
Compose hub-authored guardrail provider content into local guardrails (#1709). A
guardrail_policy_v1statement'sprovider_contentis keyed by the name of a provider this router already runs: apolicy_promptis appended after acustom_classifierrow's local prompt under a fixed router-owned joiner, piidefault_actionandactionstake the stricter action per entity (blockovermaskoverallow) and a statedplaceholder_formatwins,deny_patternsunder the reserved keymatch_list_denyblock next to every local deny rule, andallow_patternsundermatch_list_allowonly narrow the local allow lists. Governed patterns are compiled once per statement into one boundedRegexSetper list (256 KiB per pattern, 8 MiB per statement, a 2 MiB lazy-DFA cache), are never written into configuration or shown byGET /admin/guardrails, and disappear with a hub clear. Content naming a provider this router does not run, a member with no local target, or a target that never runs is refused whole asguardrails_unavailable, and patterns outside the compile limits asinvalid_guardrail_policy; the router then runs its local configuration alone.match_list_denyandmatch_list_alloware now reserved provider names: a local provider row using either fails to load withreserved_provider_name. -
Apply a hub-distributed smart routing policy (#1708). On a
control-planebuild withcontrol_plane.policyenabled, thesmart_routingmember of the policy envelope is now verified, stored, and composed with the localsmart_routing:section: the hub's routing rules and model profiles apply when it states them, a localenabled: falsealways wins,intercept_allis on only when both sides say so, and a hub classifier backend is only a name resolved under the existingsmart_routing.classifier.llm.backendrule. A statement the router cannot execute is declined whole and the local section keeps running. While hub policy governs smart routing, local reloads andPUT /admin/smart-routing/policiesapply through the composition. -
Add the smart routing policy and guardrail provider content wire surfaces to the vendored protocol crate (#1707), locally ahead of an upstream re-sync (hub#1214, hub#1215).
PolicyEnvelope.smart_routingcarries aSmartRoutingPolicy(routing rules, model tier profiles, and a classifier selection whose backend is only a name) with the same tri-state asguardrails, its ownsmart_routing_policy_digest, thesmart_routing_policy_v1capability string,smart_routing_policy_digestonAppliedPolicyRevisionandRejectedPolicyRevision, and theinvalid_smart_routing_policyandsmart_routing_unavailablecodes.GuardrailPolicy.provider_contentcarries policy prompts, pii actions and placeholder, and deny/allow patterns keyed by provider name, covered by the existingguardrail_policy_digest. Each member is bounded at 256 KiB of canonical encoding with a matching term in the policy-sync response ceiling, apolicy_promptthat looks like a URL or a credential refuses the whole statement, and an envelope that uses neither member is byte-identical to one from an older hub.PROTOCOL_VERSIONstays 0, and the router-authored wire fixtures are to be replaced with the hub's copies on the next re-sync. -
Make an absent usage-record
ttft_msdiagnosable (#1763). Every control-plane usage record now carries exactly one ofttft_msand the additivettft_absent_reasonprotocol field (TtftAbsentReason:batch,no_first_frame,transport_unobservable,not_observed), so the hub can tell absence by design from a timing the router failed to observe.into_usage_recordenforces the invariant: a reason on a record that carriesttft_msis dropped, and a record with neither getsnot_observed. The batch completion tap stampsbatch, a dispatched stream that ended without a provider frame stampsno_first_frame, and the non-streaming tap stampstransport_unobservableornot_observedfrom the upstream-TTFT cell. Two non-streaming seams that used to report no TTFT now report a realresponse_headersspan: the Unix-socket transport, whose buffered client stamps the instant the response header block arrives (UnixSocketResponse::headers_received_at), and Bedrock Runtime / Converse, whose SigV4 client marks around its own non-streaming send.PROTOCOL_VERSIONstays 0; this is an additive local protocol change ahead of an upstream re-sync, and hub-side consumption is continuum-hub#1164. -
Hop to the next fallback model when a non-streaming chat reply is well-formed but unusable (#1727). The optional
fallback.quality_checkblock asks a System One decision model (hosted Jev or a self-hostedlaya-serve) whether the reply answers the request, refuses it, or is cut off, over only the last user message and the reply (each head-and-tail capped, 512 and 1,536 bytes by default to fit Laya's multilingual context; system prompts and tool results never leave the router), and when1 - answered,refusal, ortruncatedreachesthreshold(default0.8) it advances the existing chain. It ships inmode: shadow, which judges and records without hopping, because the threshold is not yet measured; setmode: enforceafter reading the would-be trigger rate. The hop counts againstmax_fallback_attempts, runs with its ownfallback_timeout_multiplierwindow, never exceedsmax_extra_hops(default1), re-applies the key'sallowed_models, and must fit the non-streaming chain budget (the primary'sstandard.totaltimesstreaming_fallback_budget_multiplier). A reply cut at the client's ownmax_tokensnever triggers on truncation. Guardrail blocks short-circuit before the check, a hop's reply passes the same output gate, and every failure serves the latest reply that passed it. A dispatched hop setsx-continuum-quality-fallback: 1and countsfallback_quality_triggered_total{reason}; a triggered request records two usage rows, one per generation. The question battery and state builder live inservices::answer_qualityfor reuse. New templatefallback-quality-check. -
Fail CI when the vendored
continuum-protocolcrate drifts from its hub revision (#1795, Phase 3 of epic #1796).crates/continuum-protocol/UPSTREAM.sha256records the hub's per-file SHA-256 digests undersrc/andtests/at the revision on the crate'sCargo.tomlprovenance line (and the digest of the hub's own crateCargo.toml), andscripts/check-vendored-protocol.shfails, naming each path, on a revision mismatch, a modified, missing, or added file, or a malformed or stalecrates/continuum-protocol/LOCAL-AHEADentry (the allow-list of files carried ahead of the hub, each naming the hub issue that upstreams it). It also refuses what could feed the build from outside that comparison: another crate-root entry, a crateCargo.tomloutside an allowlist of shapes (only[package],[features], and registry dependency tables, no quoted or dotted keys, no target, build-script,path, orgitkeys), a targetcargo metadatareports outsidesrc/andtests/, and a router dependency other thanpath = "crates/continuum-protocol".--base <commit>fails a manifest edit that kept its# rev:line, a vendored file edited together with its digest; CI passesHEAD^1andmake ci-localthe merge base withorigin/main. WithCONTINUUM_HUB_DIRset it also regenerates the manifest from a hub clone. The check needs no network or hub credentials and runs first in theci-release-graphjob andmake ci-local, next toscripts/test-check-vendored-protocol.sh, which proves each failure mode on throwaway trees;crates/continuum-protocol/**now triggers the Rust CI jobs, so a fixture-only change is checked too.scripts/refresh-vendored-protocol-manifest.sh --hub <clone> --rev <sha> [--sync]regenerates the manifest and re-vendors the crate from a local hub clone, refusing to--syncover uncommitted work unless given--force. - Use Hub-delivered per-user web-search credentials (#1636, PR #1966). A
control-planebuild with aweb_searchsection advertisessearch_credentials_v1and applies the policy envelope'ssearch_credentialsmember: a request made with a Hub key searches with its owner's provider and credential (serper,brave, orexa) in preference toweb_search.api_key, a tier withweb_search_enabled: falsegets no search tool, and once the Hub has delivered a set, an owner without a credential on a deployment that cannot authenticate gets none either. The credentials are held in memory only, indexed by the key's user, and never written to the key-table sidecar; a statement with more than 1,000 entries, a duplicate user, an unknown provider, or a missing or mismatched digest is refused whole. Each search is reported on the usage record asweb_search_queries,web_search_provider, andweb_search_credential_source. Requests without a Hub key, and Hubs that deliver nothing, behave as before. - Persist the synced key table for Hub outages (#1453, PR #1965). With the opt-in
control_plane.policy.persist_key_table: true(bounded bykey_table_max_age_secs, default 86400, 60 to 604800), the router writes an allowlisted projection of the applied key table (key ids and hashes, tier bindings and limits, owners, revocations, rotation overlaps, usage snapshots, cursor, enrollment identity) to the owner-only<state_file>.policy-keys.jsonand restores it at agent startup, so a router restarted while the Hub is unreachable keeps admitting Hub keys instead of rejecting every one. Provider, backend-scoped, and search credentials are never part of the file. A sidecar from another enrollment, past its bound, wider than0600, or malformed is ignored; a restored table is never reported to the Hub as applied and never outranks a Hub delivery.GET /admin/control-plane/statusaddspolicy_sync.source(synced,restored,none),policy_sync.restored, andpolicy_sync.key_table_persistence. - Sync model fallback configuration from Continuum Hub (#1632, PR #1923).
control_plane.config_syncgains a closed schema-1fallbacksection besiderequest_paramsandkv_routing, with durable apply, rollback, restoration at restart before listeners open, value-free observations, and validation of the effective chain graph. Local fallback policy, chain records, and per-model settings stay atomic pins, remote content is bounded (64 model keys, 10 targets per chain, 64 trigger codes), andfallback.quality_checkstays local. Each admitted request keeps the configuration snapshot it entered with. Companion Hub change: continuum-hub#1314. - Ship
continuum-kv-listenerin the router container images (#1626, PR #1912).Dockerfile.cicopies the router and the listener from the same verified musl bundle and keeps the65532:65532image user, so a deployment no longer maintains a derived image for the listener; the deployment guides describe architecture selection, the entrypoint override, and listener health probes. - Add
GET /health/ready, an unauthenticated readiness endpoint that answers503until at least one enabled, non-internal backend is active (#1558, PR #1808). The Helm and Kubernetes examples use it as the readiness probe while/healthstays the liveness probe; like/healthit is exempt from rate limiting. A control-plane replica that boots with an empty local backend section stays unready until its first Hub assignment reaches the pool. - Add
make hub-compat, which builds this checkout with the control-plane features and runs Continuum Hub's ownscripts/router-compat.shagainst the binary in itsfloorandlatestmodes (PR #1858, epic #1796). It needsCONTINUUM_HUB_DIRpointing at a hub clone that contains the vendored protocol revision, and no GitHub credentials.
Changed¶
continuum_dynamo_load_subscriptionsandcontinuum_dynamo_kv_subscriptionsgain atransportlabel (direct,broker) and apendingstate (#1917). A query that selects{state="connected"}now returns one series per transport; wrap it insum without (transport)to keep a single series.-
.github/workflows/dynamo-e2e.ymlbuilds the router image it tests from the dispatched commit by default (#1776, epic #1703). The newest release predated the router fixes the harness exercises andghcr.io/lablup/continuum-router:latestrefused anonymous pulls, so the workflow could only reach the pull failure. A newbuild_routerinput (default1) compiles a static musl binary with the release feature set insiderust:<pinned channel>-alpine, packages it withDockerfile.ciasghcr.io/lablup/continuum-router:e2e-local, and prints the commit it was built from;build_router=0pulls the.envpin as before. Therunner_labelsdefault moves to a GitHub-hostedubuntu-latestrunner, which pulls the pinned Dynamo image (about 0.7 GB compressed) in minutes, and the job timeout rises to 90 minutes to cover the cold release compile.scripts/e2e-dynamo-shim.shgainsSKIP_ROUTER_PULL=1, which pulls every image but a locally built router's, sinceSKIP_PULL=1also skipped the Dynamo and etcd pulls. -
Run every feature-gated integration test target in CI and in
make ci-local(#1898, supersedes #1792). A file gated with#![cfg(feature = ...)], or a gated test or inlinemod, compiles to nothing under the defaultcargo test --testsstep, and nineteen targets, thecontrol_planegroup's security audit among them, had nocargo test --features <F> --test <target>step in.github/workflows/ci.ymlorscripts/local-ci.sh. Each now has one in the job that already builds its feature graph;routingruns twice, undercontrol-planeand, filtered to its Redis KV index cases, in the liveredis-shared-statejob. A second audit intests/cargo_test_discovery_audit_test.rsfails the default suite when a gated target lacks a step in either file, andlocal-ci.shlists its control-plane steps literally instead of in a loop so both files share one shape. The CI graph gate now schedules a test file's#[path]loader in another target and derives the class of anytests/<class>/ortests/<class>_*/group directory. Also fixes a stale assertion inbedrock_converse_integration_testthat the missing step had hidden, the dead-port race that madeunhealthy_backend_is_never_selectedflaky (the port is now held by a bound, non-listening socket), the rate-limit metrics tests that asserted nothing, and 28 test path references #1620 left stale. -
Group most integration tests into domain test targets so
cargo test --testslinks far fewer binaries (#1620). Every top-leveltests/*.rsfile links the whole library, so the link step dominated the suite. 194 of the 285 top-level files now live as unchanged modules under 18 group targets (tests/<group>/main.rs:server,auth,admin,models,anthropic,appproxy,routing,providers,tracing_capture,cache,responses,control_plane,fallback,systemone,files_media,guardrails,streaming,web_search), andtests/tool_calling/became its own target, so the suite builds 109 integration binaries instead of 285. Files that CI or a script selects by name, that carry a runnable--testselector, or that read process-global state (the metrics registry, control-plane trackers, the config history store, the prompt file cache, the candidate-probe limit, the timeout boot pin) stay top-level. A grouped test is selected withcargo test --test <group> <old_stem>::, a substring filter that also selects any sibling whose name ends with the same stem (cargo test --test <group> -- --exact <old_stem>::<test>selects exactly one test);CLAUDE.mdlists the groups and how to choose one. The CI feature-graph gate still maps a moved file to its graph by name, and treats a group'smain.rschange as a change to its members. -
Gate the hosted CI feature-graph jobs on the files a change touches (#1621). A new
Detect Changesstep,scripts/ci_graph_gates.py, decidesci-isolated-graphs,ci-release-graph, andredis-shared-stateseparately from path classes, everyfeature = "..."string in the base and the head version of each changed file, the gates of the feature-gated modules that contain it, the files that include a changed data file or read it at runtime from a feature-gated test, and the--testtargets each job names, so a pull request confined to ungated code such assrc/services/smart_routing/runs onlyci-default. The step widens to every job whenever it cannot decide (a deleted or renamed file, a shared test helper, an unclassified feature, a tracked symlink under a code root, a diff it cannot compute, or a file the paths filter reported that git did not), a failed, skipped, or timed-out (5 minutes) gate step counts as "run", andworkflow_dispatchstill runs the full matrix.ci-defaultnow always runs the gate's tests (scripts/test_ci_graph_gates.py),cargo check --features "control-plane appproxy-router hot-reload redis-cache otel dynamo" --lib, and the bare-graph clippy pair moved fromci-isolated-graphs, and when the gate skips the release graph on a pull request it runs that job's all-features clippy minus the integration test targets the change did not touch, so a core change still cannot stopbedrock-sigv4,s3-cache, orappproxy-legacycode, a feature-gated unit test, an example, or a bench from compiling. The gate runs on pull requests only;redis-shared-statestays ungated on a push. Therustfilter now also covers non-Rust compile inputs (src/**,tests/**,examples/**,benches/**,docs/shared/llm-skills/**,config.yaml.example) and the CI scripts.make ci-localapplies no gating and still runs every step, with the gate test, the union check, and the bare-graph pair now in its default section. -
Run the hosted CI feature-graph jobs only on pull requests and manual dispatches, and cancel superseded pull request runs (#1619).
.github/workflows/ci.ymlgains a workflow-levelconcurrencygroup keyed by event and by pull request number or ref: a newer push to a pull request cancels that pull request's in-progress run, whilepushandworkflow_dispatchruns never cancel a running one and a newer queued run replaces an older pending one. Apushtomainordevelopnow runsci-defaultand theubuntu-latestjobs only, withci-isolated-graphsandci-release-graphskipped, because the pull request run builds the feature graphs for the PR's merge ref and the post-merge push no longer rebuilds them; a break that only appears after merge (a pull request merged before its run finished, or two individually green pull requests interacting after a squash onto a movedmain) surfaces on the next pull request run that builds the graphs, on aworkflow_dispatch, or inmake ci-local. A newworkflow_dispatchtrigger runs the full matrix on demand, withDetect Changesreporting every path group as changed.make ci-localstill mirrors the full pull request matrix, anddependency-float.ymlis unchanged. -
Replace the synthesized
kv_metricsfixture with two messages captured from a live Dynamo 1.4.2dynamo.vllmworker and record a live-fleet compatibility table (#1749, epic #1703).src/dynamo/load/fixtures/kv_metrics_frame_captured.bin(sequence 5,kv_used_blocks4) andkv_metrics_frame_captured_idle.bin(sequence 6,kv_used_blocks0) are one worker's busy and idle messages, read-only through its own ZMQ PUB socket, and their provenance note records the fleet, versions, trigger, and decoded values. The decoder reads exactly what the worker sent, and the test encoder reproduces both messages byte for byte, so the real envelope's key order and integer widths match the encoder's. The capture shows a divergence the synthesized fixture hid: a real 1.4.2 worker sends bothactive_decode_blocksandactive_prefill_tokensas nil and reports load only throughkv_used_blocks; the decode cost's existingkv_used_blocksstand-in keeps decode selection load-aware on such a fleet (now pinned by a picker test fed the capture), while under the load-only picker a prefill pool rotates (the KV-aware picker orders it by overlap instead). The new "Live-fleet validation" section ofdocs/en/architecture/dynamo-integration.mdrecords what was tested (Dynamo 1.4.2,kubernetespod-mode discovery, vLLM 0.25.0 aggregated workers, a--router-mode direct --dyn-chat-processor vllmfrontend, thekubernetes_podssource), the divergences (the vLLM chat processor dropping the worker pin on unpatched 1.4.2, multi-pool namespaces in #1888, nilActiveLoadfields, OS-assigned publisher ports), and that adynamo.vllmprefill/decode pair with NIXL and SGLang's concurrent bootstrap path remain untested. -
Enforce the
dynamosection's field rules on the load and hot-reload path (#1869). Until nowvalidate_confignever ran the section'sValidate, so its documented rules were checked byconfig validateonly, and a file that broke them still started the router. They now apply at load, on every hot reload, and on an Admin API import, whether or notdynamo.enabledis set: the shape of eachdiscovery.etcd.endpointsentry (no embedded credentials, query, fragment, or path; a dialable scheme or a bare host:port), at most 16 entries with nohttp:///https://mixing and notlsbeside anhttp://entry, a non-emptydiscovery.etcd.namespacewith no/, whitespace, or control characters, a non-emptyauth.username, parseablewatch_reconnect_backoffdurations withmax >= initial, theloadandshimrules,endpointswhen the etcd source is enabled, and the newdiscovery.kubernetesblock whenever it is present. Upgrade note: runcontinuum-router config validateon the file before upgrading; adynamostub that fails it, enabled or not, now stops the router at startup with an error naming the field. In the same changecontinuum_dynamo_discovery_connectedgains asourcelabel (etcdorkubernetes_pods) and only the configured source is exported, at0from startup until its watcher connects; a hot source switch replaces the series. An alert oncontinuum_dynamo_discovery_connected == 0therefore keeps working unchanged, and a dashboard that selected the unlabelled series should addmax by ()or asourcematcher. The Pod source keeps the gauge at1across the server's routine 290s watch close and the relist that follows; it drops to0on a failed pass, a watch that ends with an error, or a stop. A watch silent for 320s (a half-open connection), or closed well before the 290s server timeout (a swallowed transport EOF), ends as an error, so the gauge drops and a relist follows, and the list is read in pages of 500 that stop at the retention ceiling. -
Record a refused input mask as the block the client received, under the new category
guardrail_mask_unappliable(#1837, epic #1796). Client-visible: a masking verdict the router cannot apply (a redaction across two strings, more changed strings than the re-check budget, a rewrite that does not land, a masked request a provider still wants to change, a provider failure in a masking pass, or a masked request the ingress cannot turn back into its typed form) now answers withx-guardrail-category: guardrail_mask_unappliableand that category in the block body, instead of the genericguardrail, which remains only on the terminal chunk of a stream the output gate cut. Alerts keyed onguardrailfor masking refusals must add the new value. The request-level decision is now recorded once the outcome is final, soguardrail_verdicts_totaland the audit decision record count such a request as oneblock(and a masking pass that outran its deadline as oneblockunderguardrail_timeout) rather than thetransformthe first pass used to record; the typed round trips of/anthropic/v1/messages(its thinking gate included) and/v1/responsesrun inside the masking core through the newgate_input_document_acceptingandgate_input_document_deferred_acceptingentry points, so a refused round trip is the decision recorded, or folded into the request's pending decision (#1836), underguardrail_mask_unappliable, and/v1/completionsfolds its write-back refusal into the request's aggregate, so each of these paths records exactly one decision. Monitor mode never masks, so it recordstransformfor traffic enforce mode refuses asguardrail_mask_unappliable. The masking core records its decision under the policy it read before its first pass, so a reload during the masking passes neither drops that decision nor changes its mode label; a pending decision (#1836) records under the policy its kept pass read before its first provider call too (#1847). The tool-definition gate folds its accumulated verdict into an uninspectable refusal, so a monitor-mode deny followed by a tool that overflows the leaf cap is reported with the deny. On a Continuum Hub router the heartbeat verdict counters move fromtransformtoblockfor these requests; the category itself never reaches the hub, which counts only provider blocks by category (bothguardrailand the new value fold intoother). -
Read built-in
piiphone candidates in context (#1837, epic #1796). The phone pattern had no word boundaries and only a 7 to 15 digit count, so it masked IPv4 addresses, epoch timestamps, compact dates, process ids, file sizes, and digit runs inside hex hashes and UUIDs, which agent traffic resends every turn and which, since #1827, refuse the whole request when they sit in a tool contract field. A candidate must now not touch a letter, digit,_,/, or a.that follows a digit, must not be followed by a.or a-and a digit, must not be a dotted quad, must use dots only in the phone form (at least two, groups of at most 4 digits, so decimals and coordinates do not match), must not have a digit group longer than 5 before its last one, must not be hyphen-joined to a token whose only letters are hex digits (the all-digit groups of a UUID or hash; a plain word likephone-or-extdoes not block it), and a bare digit run counts only in the trunk-prefix form (a leading0and 9 to 11 digits, such as01012345678); trailing spaces, dots, and hyphens are no longer part of the masked span, and a rejected match no longer hides a number that starts inside it. Trade-off: an unformatted number without a trunk prefix, such as4155550123, is no longer detected, while its formatted forms (415-555-0123,415.555.0123,(415) 555-0123,+1 415-555-0123), including one hyphen-joined to a word such asphone-415-555-0123orTel-010-1234-5678, are; configure an external recognizer when bare numbers matter. -
Re-vendor the protocol crate from a single Continuum Hub revision (#1794, Phase 2 of epic #1796).
crates/continuum-protocol/srcandtestsare now a byte-identical copy of hub rev92907b47403b325b2a7038ad1f9ecf4ba4d07fbf, named on one provenance line in itsCargo.tomlin place of the former local-ahead notes, and the router uses the hub's definitions everywhere:GuardrailPolicyModeandGuardrailBlockBehaviorreplace the router's statement enums, the smart routing, guardrail provider content, provider inventory,ttft_absent_reason,routing_enabled, andinvalid_model_budget_usage_watermarksurfaces come from the hub unchanged, and the wire fixtures are the hub's. Router-only helpers (the guardrail summary emission bounds and category folding, the threshold conversion) moved undersrc/control_plane/. The guardrail apply path now validates through the hub's acceptance entry point, so the receive bounds are unchanged (64 category entries, 256-byte category ids checked only for emptiness and length, 64 route entries, 256-byte route keys, and an unknown block behavior still tolerated), but four things every emitting hub already satisfies are now required: a guardrail statement without adigestis refused asdigest_mismatchinstead of being applied on the router's recomputation, and a policy without amode, with an empty stage selection, or with a control character in a route key is refused asinvalid_guardrail_policy. Each refusal is scoped to the guardrail track: the rest of the envelope applies and the held guardrail policy stays in force. On the same receive side,EquivalenceClass.membersis now required on the wire, as the hub declares it; every hub since v0.5.0 always emits it. The router leaves the hub-ahead usage fields it does not stamp (UsageRecord.project_idand the web-search metering fields) absent, and apply clears anyPolicyEnvelope.search_credentialsandsearch_credentials_digesta hub sends, since this router never advertisessearch_credentials_v1and holding them would leave plaintext per-user search secrets in the held snapshot; usage records now always carrycompressed, andPROTOCOL_VERSIONstays 0. -
Adopt Continuum Hub's definitions for the batch lifecycle, TTFT measurement source, model-budget, and optimization policy wire types in the vendored protocol crate (#1793, Phase 1 of epic #1796), copied verbatim from hub rev
e6aae11e. The router now names its per-transition batch reportBatchStatusTransition(state enumBatchJobStatus) and still posts that flat body to/api/agent/v1/batches. Usage records write the hub's canonicalttft_sourcekey instead ofttft_kind; the hub reads both, so older and newer hubs keep ingesting TTFT, andttft_absent_reasonis unchanged. Model-budget types move intopolicy.rsunder the hub's names (ModelBudgetTierPolicy,ModelBudgetEntrywith the hub'ssell_spend_microusd,ModelBudgetDimensionStatusWire), digests are unchanged, enforcement still reads only the hub's sell-spend verdict, and a statement whose tiers all have no pools is now an authoritative clear, matching the hub. The optimization block,semantic_similarity_bps, andcache_ttl_secsare optional as on the hub: an absent block or value keeps the router default, a TTL beyondu32seconds saturates, and the Admin policy view reportsoptimization: nullwhen the hub sent no block and omitssemantic_similarity_bpsandcache_ttl_secsinside the block when the hub did not state them (previously0); the router uses its default. Header constants take the hub's*_CONTROL_HEADERnames with unchanged values.PROTOCOL_VERSIONstays 0 and the wire is unchanged. -
Judge a non-streaming chat reply once when
fallback.quality_checkandusage.quality_judgmentshare a target (#1802). With the sametargetblock and equalmax_request_bytes/max_reply_bytes, and no tool results sent to the usage judgment, the quality check's call over the original reply also askslanguage_matchas a best-effort question that can never fail the check or change its verdict, and the usage record reuses the answers without a second decision call or amax_in_flightslot; a question the shared call left unanswered is asked alone and merged, and a merge across two resolved judge models is dropped asinvalid.UsageQualityPlan::finish_text_withis the new wire-neutral core behindfinishandfinish_with. The shared call counts undersystemone_requests_total{consumer="fallback_quality"}. When the check made no judgment, the usage judgment calls on its own as before; different targets or caps keep two calls. The/v1/decisionsendpoint's per-target client cache now uses the sharedClientCache(capacity 256, keyed by target name), and a failed target build still warns once. -
Answer an input guardrail block before a
503 smart_routing_rejectedload rejection and before a per-key403on the routed model, on a smart-routed/v1/chat/completionsrequest whose classifier leaves the router (#1816). The input stage now runs ahead of classification there, so a blocked prompt gets the block response even while load management or the key's model allow-list would refuse it. -
Answer the smart routing Admin endpoints as disabled whenever the live switch is off (#1711). On a router that started with smart routing enabled and had it disabled at runtime,
GETandPUT /admin/smart-routing/policies,POST /admin/smart-routing/classify,POST /admin/smart-routing/simulate, and/admin/smart-routing/cache/*now answer400(they used to answer200from the switched-off router), andGET /admin/smart-routing/statsreportsload_snapshot: nullwhile load management is off. -
Build the smart router and load monitor at startup even when smart routing is disabled (#1711). Both start idle, so enabling smart routing or
load_managementin a reload, or in a hub-composed configuration, now takes effect without a restart; before, a router started with smart routing off ignored a later enable. A disabled router costs a request one atomic load (the load monitor'senabledswitch is now anAtomicBool), and the load assessment task starts the first time load management is enabled. -
Answer a hub-governed Admin write that has no configuration channel with a typed
503(#1711).PATCH/PUTon the guardrail endpoints andPUT /admin/smart-routing/policies/model-profilesreturn{"error": {"code": "hub_governed_no_config_channel", "section": ...}}(the guardrail body keeps itsstatusandmessagefields); the smart routing endpoints used to answer200 updatedwhile the edit was dropped. -
Deprecate the per-component smart-routing fields of
UpdatableHandles(tier_registry,smart_router,load_monitor,extensions_source) in favor ofsmart_routing: Option<SmartRoutingRuntime>(#1708). The fields still work: whensmart_routingis unset,HotReloadService::newbuilds the runtime from them. -
Emit the action-safety adapter's
safeguard_resultsin the wire shape captured from a real Claude Code session (#1694). Conversion-backend responses and the streamingsafeguard_resultsevent now carry a top-level array with onedangerous_tool_usecheck whosestatus.tool_usesmap is keyed by tool-use id:allowreads{"type": "evaluated", "outcome": "not_flagged"}, identical to what a native backend returns, anddenyreadsoutcome: "flagged". When any proposed action resolvesindeterminate(timeout, unreachable evaluator, budget exhaustion, and the other fail-closed cases), or cannot be correlated by id, the whole check readsstatus: {"type": "unavailable"}with no map. The old{"results": [{"verdict", "reason"}]}object is gone, and the evaluator'sreasonno longer reaches the client; it is logged at debug level. Thedeny,unavailable, and streaming mappings are unobserved on a real client and documented as decisions in the safeguards design note. -
Reload the Dynamo etcd connection identity (
dynamo.discovery.etcd.endpoints,tls, andauth) live instead of rejecting a change as restart-required (#1756). The discovery watcher already built its etcd client on every pass, so the boot pin only turned a working change into an error: the discovery supervisor now replaces the watcher, empties the registry, and the replacement dials the new cluster with a full resync. Nothing in thedynamosection needs a restart any more.pin_boot_dynamo_connectionandassert_dynamo_connection_unchangedremain as deprecated no-ops. -
Mark
continuum_router::services::disaggregated::BackendInfo#[non_exhaustive](#1759). The struct gained fields in #1698 (in_flightbecameu64,waiting_requestsandkv_cache_usagewere added), which already broke struct-literal construction outside the crate; code outside the crate now builds one withBackendInfo::newand sets the public fields afterwards, so a later field no longer breaks it again. -
Relay a client error on the buffered disaggregated arm instead of falling back (#1759). A 4xx the routed backend (or a gate in front of it) answered, other than 408 and 429, is now the request's answer on the non-streaming path too, stamped with
x-continuum-routing-pathand counted once on the routed path, exactly as the streaming arm has done since #1752; it is no longer adecodeerror and is no longer re-sent to unified serving underfallback_to_unified. -
Count a Dynamo aggregated fallback as
continuum_dynamo_selection_total{role="prefill", reason="no_prefill_pool"}and keep the decode worker's own reason (#1759). The picker used to overwrite the decode reason withno_prefill_pool, losing which comparison placed the worker; the{role="decode", reason="no_prefill_pool"}series no longer moves. -
Tighten Dynamo discovery identity rules (#1759). A card
worker_typethis build does not recognize is now no claim beside aprefillordecodecomponent, so the component decides instead of the worker being excluded as a mismatch, which an upstream rename of the spelling would have done to a whole fleet. A legacy card (emptycomponentandendpoint) and a scoped base card for the same instance are refused together and reported askind="duplicate", instead of the scoped card silently displacing the legacy one.dynamo.discovery.etcd.namespacenow also rejects control characters. -
Spell served model ids verbatim in the per-model metric labels (#1879).
model_usage_total,model_tokens_processed,llm_tokens_total, and the smart routing families (source_modelandtarget_modelonsmart_routing_decisions_totalandsmart_routing_requests_total,modelonsmart_routing_model_availability) now spell a model id with the rule the fallback families already use: case is preserved,/and:are kept beside Unicode letters, digits,-,_, and., any other character becomes_, the value is truncated at 128 bytes on a character boundary, and an empty id becomesunknown. Previouslymeta-llama/Llama-3.1-8B-Instructexported asmeta-llamallama-3.1-8b-instructandQwen/Qwen3-8Bandqwen/qwen3-8bshared one series, while smart routing only truncated and let whitespace and control characters through. Series identity changes for every model whose id carries uppercase,/, or:(on the smart routing families also whitespace or other punctuation): the old series stop and new ones start, so dashboards and alerts that match a lowercased or slash-stripped spelling must be updated, and/admin/metrics/historykeeps samples recorded before the upgrade under the old spelling. The model cardinality guard now keys on the exported spelling, so distinct models that used to collapse into one tracked value count separately against its fixed limit of 1000 models. Non-model labels (api_key_id,backend, guardrailproviderandcategory,type, and the smart routingpolicy_name) keep their spelling.GET /admin/config/schemaalso stops serving apatternon thefallback.fallback_chainskey and entry nodes: its\p{...}escapes are valid in ECMA-262 only with theuflag, so Pythonjsonschemaand several Go and Java validators rejected or mis-parsed the schema. Both nodes keepminLength: 1andmaxLength: 128, the description still names the accepted characters, and the server-side rule is unchanged. -
Wire
metrics.max_model_labelsand deprecate the unread metrics limits (#1897).max_model_labelsnow sets the model cap ofmodel_usage_total,model_tokens_processed, andllm_tokens_total; it was parsed but never read, and the cap was a compiled 1000. Its default moves from 100 to 1000, so a deployment that never set it keeps the same effective cap, and a configured value takes effect at startup (restart to change). Lowering it evicts nothing: tracked models keep exporting and new ones are refused.metrics.max_backend_labelsand themetrics.cardinality_limitsblock remain ignored, still parse so older files load, and now draw aconfig validateand load warning when set to a non-default value. The cardinality guard also accepts an already tracked label value under a read lock instead of taking the write lock on every request. - Build portable binaries from a source checkout by default (#1799, PR #1810).
.cargo/config.tomlno longer sets-C target-cpu=nativefor x86_64 Linux and macOS, which also applied to host proc-macros and build scripts and could crash a build or cached test binary with SIGILL when artifacts moved between CPU generations. Opt back in locally withRUSTFLAGSor a user-level~/.cargo/config.toml. - Shrink the Dynamo load subscriber's resident buffers from about 48 KiB to 2,624 bytes per idle subscription (#1755, PR #1764). The ZMTP frame buffer starts at 512 bytes and grows by powers of two only after the declared length passes the
MAX_FRAME_BYTEScheck, and the socket buffers are 2 KiB read and 64 bytes write instead of tokio's 8 KiB defaults. - Make
cargo deny checkcover every optional feature graph and pin the protobuf compiler CI installs (#1757, PR #1760).deny.tomlsetsall-features = true, so a license or advisory problem in adynamo,control-plane,appproxy-router, oroteldependency fails the check, and everyarduino/setup-protocstep (CI, release, and the weekly dependency float, which now installs it) pinsversion: "36.2".
Fixed¶
- Keep routing-policy conditions the WebUI policy editor cannot edit when saving from the Smart Routing page (#1988, PR #1990). The editor rebuilt each
whenfromcomplexity,domain, andrequiresonly, so saving sent a policy narrowed bykey_tier,org,language,header, orinput_tokensback aswhen: {}, where it matched every request. Those conditions are now carried through unchanged and listed on the policy row. - Keep reserved model-experiment variants out of the smart-routing status and simulate, and keep
smart_routing_model_availabilityin step with the candidate pool (#1989). A variant model of ahide_variant_models: trueexperiment was filtered from the request path only, soGET /admin/smart-routing/statuslisted it undercandidate_models(orexcluded_unprofiled_models) andPOST /admin/smart-routing/simulatecould return it as the target although no request routes there; the candidate pool now drops reserved variants itself, and the request path, status, and simulate read that one pool. The availability gauge is rebuilt on every collection instead of being written one model at a time: a routable candidate is1, a model that is unhealthy or excluded as unprofiled underunprofiled_models: excludeis0(an excluded model used to read1), and the series of a model that left the pool (a backend turnedinternal: trueor disabled, a newly reserved variant, a model gone from the aggregation) or moved to another tier is removed rather than kept at its last value until restart. A failed model aggregation leaves the gauge unchanged.SmartRoutingMetrics::set_model_availabilityis replaced bySmartRoutingMetrics::refresh_model_availability, which takes the whole collection and its sequence number, so a collection that finishes after a newer one is not applied. - State the share of requests
hybridactually escalates, and make it observable (#1973). The docs promised that hybrid mode escalates only genuinely ambiguous requests, "typically 10-20% of traffic", but the rule confidence counts signals instead of estimating whether the label is right: on the labelled accuracy datasethybridescalates 53.7% of requests at the defaultrule.confidence_thresholdof 0.7, on public benchmark prompts 91% to 99% fell below the threshold, and the confidence ranks a correct complexity label above an incorrect one no better than chance (AUROC 0.46), so no threshold selects the ambiguous minority. The configuration docs now give the measured shares, say what the score is, and namerulewithshadow: trueorhybrid_system_oneas the alternatives where the share is high. A newsmart_routing_hybrid_gate_total{method, outcome}counter (methodhybridorhybrid_system_one,outcomeroutedorescalated) makes the share readable from/metrics, and the accuracy harness now reports the confidence of correct and incorrect labels and the escalation share per mode. The rule confidence itself and every default are unchanged. - Accept registry pull Secrets in the
continuum-event-brokerHelm chart in both list forms (#1984).imagePullSecretsentries may now be- name: <secret>or the bare- <secret>, both rendered as{name: ...}on every broker Pod, and the render fails on an entry of another shape, an invalid Secret name, or a Secret listed twice instead of producing a Pod spec the API server rejects. The chart README, the example values, the Kustomize bundle (as a patch), and the deployment guide now document pulling the broker image from a private registry or mirror; the chart still never renders the credential. - Classify a translation request as
multilingualwith the rule classifier (#1976).DomainTag::Multilingualwas documented as translation, and the LLM and System One classifiers answer it for a translation request, but the rule classifier set it only from the share of non-ASCII characters and suppressed it forprimary_language, so an English translation request readgeneral, as did a Korean one on a Korean deployment, andwhen.domain: [multilingual]never matched either. A newtranslation_intentdomain signal fires ontranslateas a word (so "translation lookaside buffer", CSStranslateX, and "untranslated" stay out),번역(not번역가,번역자,번역기,번역본, or번역서),翻訳,翻译, and翻譯, maps tomultilingual, and is not suppressed byprimary_language; at 0.72 it outranks a single creative or a two-marker analysis match and yields to fenced code,code_intent, and math. Like other intent signals it counts toward rule confidence, so a short translation request now clears the default 0.7 hybrid gate instead of escalating. The script check remains the fallback when no intent signal fires. The type doc and the domain and signal tables in the configuration docs now state this meaning, and the three translation cases in the accuracy dataset (and the two router-derived rows of the System One routing corpus) are relabelledmultilingual; domain accuracy stays at 100%. The calibrationderived.jsonfiles were re-derived offline from the committed raw runs (the rule domain accuracy on the routing corpus moves from 0.445 to 0.440 and rule alone from 0.477 to 0.475, because the committed runs recorded two router-derived translation rows asgeneral; against the corrected corpus labels both classifiers gain those rows), and every derived System One default is unchanged either way. - Read a Korean request at the same complexity as its English translation (#1972). The rule classifier's Korean simple keywords were bare question words where the English ones are request phrases:
무엇matched every "what" and "which" question,어떻게every "how",설명the noun and the descriptive verb, and정의the genitive of any noun ending in 정 (과정의), so a Korean request read a band lower than its English twin far more often than the reverse (212 of 251 disagreements on 809 benchmark pairs).설명now counts only in its request forms (설명해,설명하세요,설명 부탁),무엇only as "what is" (not…것은 무엇입니까,무엇이었는가,무엇을, or the pronoun무엇인가를),어떻게only as "how do I" (not어떻게 바꾸었는가,어떻게 되었나요, or어떻게 지내세요), and정의not across a genitive unless a verb ending follows. The noun증명no longer counts as a complex keyword on the score path, only its verb forms (the English entries,proveincluded, are unchanged). A Hangul syllable or CJK ideograph now weighs two and a half Latin characters in the length estimate instead of two: on the repository's 36 paired English/Korean fixtures the Korean members estimated 12.9% shorter before and 1.6% longer after. On the accuracy harness Korean complexity goes from 24/34 to 25/34 on the original cases with English unchanged at 27/34, 30 of 32 pairs now read the same band (27 before), and the complexity gate rises from 72% to 73%. The Korean greeting안녕하세요! 오늘 하루 어떻게 지내세요?now estimates 11 tokens and readssimple. The System One calibrationderived.jsonfiles were re-derived offline from the committed raw runs, since they score the rule classifier beside System One: the rule figures in the routing shadow table moved (rule alone 0.472 to 0.477 onall), every derived default is unchanged, and thenon_latinslice no longer has a floor that beats the rule classifier. - Keep models served only by internal backends out of the smart-routing candidate pool (#1975). Smart routing chose among every healthy aggregated model, so a model whose only backend was
internal: true(orenabled: false) could be picked for a user request. The pool now applies the model listings' visibility rule: such a model is never a target, and a model that also has a public backend is health-checked on its public backends only. An internal backend can still serve the LLM classifier. - Release the per-user web-search credential binding when a reload removes
web_search, and meter every search path (#1970). The binding that advertisessearch_credentials_v1and lets the store hold Hub-delivered search secrets was latched once and never released, so after a reload removed theweb_searchsection the router kept advertising the capability, acknowledgingsearch_credentials_digest, and holding personal secrets no request could use. It now follows the section on every configuration revision (startup, file reload, and Admin apply): releasing it withdraws the capability and drops the held secrets and the acknowledged digest at once. The Anthropicweb_search_20250305emulation (Claude Code's WebSearch against self-hosted backends) now records its query-extraction turn in the statistics and the control-plane usage record, with the search metering, and records its early failures; each search is reported in exactly one record, even across the attempts of a fallback chain; a chat completion or Anthropic tool loop whose later turn fails after searching now emits one usage record with zero tokens and the metered searches instead of dropping searches a member's key paid for. A failed request that made no search still emits nothing. The local statistics row of a failed chat completion now names the backend that returned the failure, where it used to sayunknown. - Keep the
multilingualscript signal out of the rule classifier's confidence (#1971). With noclassifier.rule.primary_language, a short trivial Korean question such as대한민국의 수도는 어디야?scored exactly the shippedrule.confidence_thresholdof 0.7, because the signal (strength 0.7) counted once as a signal and once as a strong one, sohybridandhybrid_system_onerouted on the rule result without asking the LLM or System One classifier, while the same question underprimary_language: koscored 0.5 and escalated. The signal still decides the domain when no intent signal fires; it no longer moves the confidence, so both configurations now score Korean text alike. The confidence sum is also snapped to a 1e-9 grid before clamping, so an exactly at-threshold combination (four signals with one strong is 0.8, not0.7999999999999999) passes a non-default threshold, since every combination is now the f64 nearest its decimal value.method: rulelabels are unchanged. - Report the effective smart-routing state from
GET /admin/smart-routing/status(#1974). Status readclassifier_method, the policy count, and the virtual model from the localsmart_routing:section, so under a Hub policy it could name a method the router was not running. The top-level fields now come from the live smart router and tier registry, which is a behavior change for any consumer that read them as local values; the local values move under a newconfiguredblock. New fieldssource(huborlocal),hub_governed,smart_routing_policy_digest(the executed Hub digest, equal to the one acknowledged to the Hub), anddeclined_hub_policyshow which policy is in force, and the WebUI status tab shows the source.POST /admin/smart-routing/simulatenow returns the sameclassificationobject asPOST /admin/smart-routing/classify(addinglanguage,required_capabilities,reasoning, andsignals), built by one serializer, and both responses are documented in the Admin API guide. - State one context window per model on
GET /v1/models/extended(#1479). The flattened upstream fields kept one backend'smax_model_len(or an aggregator'scontext_length) verbatim beside the router'smetadata.limits.context_window, so the object stated two different windows whenever they differed: when backends serving one id disagree and the router publishes their minimum (#1456), and whenmodel-metadata.yamlor amodel-metadata.d/drop-in supplieslimits. Either key, when present, now carriesmetadata.limits.context_window, the window the router honors; neither is added when the backend did not send it, neither is rewritten when the published window is0(not applicable), and a single-backend model whose window came from that key serializes exactly as before. Every other upstream key, includingpricingandtop_provider, still passes through verbatim, andmetadatais the authoritative source for anything both state.GET /v1/models,GET /v1/models/{model}, andGET /admin/modelsare unchanged. The API reference gains a section forGET /v1/models/extended. - Honor a declared
reasoning_effortsvocabulary on the streaming Gemini route (#1668). A streamed/v1/chat/completionsrequest to agemini-typed backend is served by the typedGeminiBackend, whose Anthropic-native gate pins the backend type and reads no declaration, so a request carryingthinkingoroutput_configwas fitted to Gemini's default vocabulary when streamed and to the declared one when not. The streaming dispatch now runs that gate against the configuration snapshot the request was routed against before the typed transform, so the backend'smodel_configsand the shared metadata layer (amodel-metadata.d/drop-in) resolve the same way on both routes. A regression test also pins the mid-stream fallback relay, which already resolves each hop's declaration from the routed snapshot. - Apply a runtime edit of the
smart_routingsection to the live smart router (#1715). A configuration publish whose only change wassmart_routing, such asPUT /admin/config/smart_routingswitchingclassifier.methodfromhybridtollm, was reported by the config API but never reached the running classifier, because the hot reload diff did not compare the section and so applied nothing. The section is now compared and a smart-routing-only edit rebuilds the classifier. Amodel_experiments-only edit, which was already read per request, is now also reported as a change. - Score the rule classifier on the current turn, so conversation history no longer moves a smart-routing decision (#1714). The classifier read the whole conversation: every message counted toward the length signal, a
conversation_depthsignal counted messages, and keywords and code markup from earlier turns fed the score, so a trivial question after two short exchanges readsimple, after one complex exchangemoderate, and the added signals lifted the rule confidence to the default 0.7rule.confidence_threshold.hybridthen stopped escalating to the LLM classifier and routed the follow-up to a larger tier than the identical question asked with no history. Complexity, domain, and confidence are now computed from thesystemanddevelopermessages plus the latest user message, the same text the LLM classifier judges, under every method (rule,hybrid, and the rule fallbacks ofllm,system_one, andhybrid_system_one). The publishedlanguageis now the latest user message's, so a Korean question after an English conversation matches awhen.language: [ko]policy; it falls back to the language of every user turn only when that message has no letters.required_capabilitiesstill reads the whole conversation, so an image in an earlier turn still requires a vision backend. Theconversation_depthsignal is removed. Single-turn requests classify exactly as before, and complexity accuracy on the labelled dataset is unchanged. - Attribute non-streaming usage served through the Dynamo shim, and every other buffered disaggregated reply served by the selected backend, to the presented hub key (#1953, epic #1703). The buffered disaggregated path recorded its usage without the request headers, so the control-plane usage tap saw no API key, stamped the record
key_id="unattributed", logged the fail-open warning, and charged no key: these requests did not count toward the key's TPM windows or monthly budget delta. The path now records with the client's headers and the same hub routing attribution and context-compression metadata the unified path records, so the record carries the matched key'skey_id, user, and team, and the usage now counts toward the key's limits exactly once. Streaming attribution and local-cache replay metering are unchanged. The fail-open warningcontrol-plane: usage recorded without hub key attributionnow carries acausefield (no_policy_store,policy_not_synced,no_presented_token,unmatched_token, orno_stream_grant) and names the cause in its message without logging the token; onlyno_policy_storekeeps the advice to setcontrol_plane.policy.enabled: true. Each cause is rate-limited on its own one-minute window. -
Record fallback-chain usage under the model that served the reply (#1934). When
fallback.fallback_chainsmoved a request off a failing primary, the chain target's tokens were recorded under the model the client asked for, beside the servedbackendandbackend_id, so/admin/statscredited the primary with tokens it never produced and the control-plane usage record paired the served provider with the wrong model, missing the Hub model-budget and cost-center pool that was actually consumed. The/admin/statsmodel row, the per-model and per-key token counters, model-experiment token attribution, and the usage record'smodelnow name the chain target's configured id on buffered/v1/chat/completions,/v1/completions,/v1/embeddings, rerank, and sparse embeddings, whatevernotify_on_fallbacksays, and on streaming chat for a hop taken at selection, before the first byte, or mid-stream. A plain chain hop stamps norequested_model; under a Hub substitution, arbitrage, or budget fallbackrequested_modelkeeps the policy's value. Model-budget admission still checks the requested offering's pool while settlement charges the served one. Library surface: the publicMidStreamFallbackContext::begin_provider_attempttakes the attempt's model as a second argument./anthropic/v1/messagesand/v1/responseschains are unchanged. -
Scope the Responses session store to the router instance instead of the process (#1941). Stored responses (
store: true) lived in one process-wide store, so two routers in one process (serve_embedded, the library builder, or the members of one grouped test binary) read and overwrote each other's sessions whenever their upstreams answered the same id: a passthrough reply is stored aspt_<upstream id>, and the second write replaced the first. EachAppStatenow gets its own store on its first Responses request, with its own background cleanup task and its own 10,000-session cap; a dropped state's store and cleanup task are released the next time another state registers one. A process with one router, which is every production deployment, behaves as before, and hot reload keeps the same store. -
Rate-limit repeated backend-refusal log lines per model (#1926, epic #1703). With a model's only backend unhealthy, each refused request used to write about four ERROR or WARN lines (
No healthy backend found for streaming model ...,Failed to select backend for model ...,Router error: All backends are unhealthy, and on the Dynamo shim pathDisaggregated fast decode streaming dispatch failed), so a client retrying 21,600 times a minute produced about five million log lines in an hour and buried the health-check transitions that explained the outage. These sites, plus the non-streamingNo healthy backends available from candidates, the retry loop's closingExhausted all retry attempts ...andNon-retryable error ...lines for these refusals, and the per-backend rate-limit refusals, now log the first refusal per site and model at WARN, keep repeats within a 60-second window at DEBUG, and write one WARN summary with the count, the model, and the reason (N requests refused for model 'X' in the last 60s: all backends unhealthy) when the next refusal arrives after the window. The table of tracked models is bounded. Refusal metrics are unchanged. These refusal lines are written at WARN, not ERROR: an alert that matches ERROR-levelRouter error: All backends are unhealthy,Exhausted all retry attempts, orNo healthy backend foundlines should match the WARN level or alert on the refusal metrics instead. -
Stop the CI test step from silently skipping tests (#1485).
cargo test --tests -- --skip integration_testnever excludedtests/integration_test.rs, because libtest's--skipis a substring match over test paths and a target name is not part of them; it instead dropped the four tests inconfig_integration_tests(tests/mid_stream_fallback_test.rs) andmetrics_integration_tests(tests/rate_limiting_test.rs). The filter is removed from.github/workflows/ci.ymlandscripts/local-ci.sh, so those tests now run in CI, and the comments and step label no longer claim an exclusion. -
Accept HuggingFace and Ollama model ids in
fallback.fallback_chains(#1585). A chain model name, as a primary or as an alternative, may now contain/and:next to the Unicode letters and digits,-,_, and.it always accepted, soggml-org/gpt-oss-20b-MXFP4: [meta-llama/Llama-3.1-8B-Instruct, llama3:8b]validates and falls back like any other chain; the 128-byte cap is unchanged, whitespace, control characters, and other punctuation are still refused, and the name is used as exact bytes. A refusal now names the character and its byte offset, and repeats the refused value escaped and capped at 128 characters, so a control character, an ANSI sequence, or a bidi override cannot reach logs or the validate and Admin responses raw.fallback.quality_check.modelsshares the same rule.decisions.targets[].nameand model experiment names and variant ids stay[A-Za-z0-9._-], since the operator chooses them, and a refused value that looks likeorg/modelnow says so. The fallback metrics (fallback_attempts_total,fallback_success_total, and the rest) now export/and:inoriginal_modelandfallback_modelinstead of mapping them to_, and truncate at 128 bytes on a character boundary instead of 64 bytes mid-character, which could panic on a long Unicode model name; a dashboard that matched the underscore spelling of a slash-bearing id sees a new series. The WebUI chain editor and the Admin configuration schema follow the new rule. -
Classify a short formal proof as
expertunder the rule classifier, and warn fromconfig validateabout a routing policy that depends onexpertwhere the rule classifier decides it (#1617). The rule complexity score is a weighted average bucketed at 0.75, so a request under 200 estimated tokens without math notation cannot score above 0.683, andexpertin practice meant long rather than hard. A request whose latest user message carries math notation and whole-wordprove/proof(or증명) now readsexpertat any length, unless its simple keywords outnumber its complex ones; notation in the system prompt or an earlier turn does not count. A short novel-architecture or research-level request still readscomplexor lower, because its signals are identical to a routine design request's; the Complexity Levels section of the advanced configuration guide gives the arithmetic, including the one other short route (51 to 200 estimated tokens with math notation and four or more distinct complex keywords). Under an operator-enabledreject_expertload state, short proof requests that used to readcomplexare now rejected.config validatewarns atsmart_routing.routing_policies[N].when.complexityabout a policy that matchesexpertwithoutcomplexundermethod: rule,hybrid, orhybrid_system_one(hybrid only whilerule.confidence_thresholdis at most 0.95). Rule-mode complexity accuracy on the labelled dataset rises from 69.5% to 76.3%, and the accuracy gate from 65% to 72%. - Tighten the rule classifier's formal-proof floor, and warn from
config validatewhen the LLM classifier has no backend (#1892, follow-up of #1617). The about-a-proof guard now compares simple and complex keywords on the latest user message, the same turn the math notation and proof wording come from, so a "what is" in an earlier turn no longer suppresses a proof request and an "implement" in an earlier turn no longer turns a question about a proof intoexpert. The Korean stem증명no longer matches after a syllable that closes another word ending in증(검증명령,인증명세,보증명세서), while unspaced proof compounds such as존재증명still do.method: llmorhybridwith nosmart_routing.classifier.llm.backendpin and every backendinternal: trueorenabled: falsenow draws a warning at that path, and theexpert-ceiling warning covers the same case. A blank pin is now read as unset at runtime, as the load check already read it, so it takes the first user-routable backend instead of silently dropping the classifier, and a pin padded with spaces resolves to the backend it names.config validatewarns about a blank pin when the method isllm,hybrid, orhybrid_system_one, since an empty value usually means an unset environment variable. Complexity accuracy on the labelled dataset (76.3%) and the committed System One calibration runs are unchanged. - Even out the rule classifier's formal-proof floor across languages, spacing, and conversation history (#1899, follow-up of #1892). The about-a-proof guard now counts the proof term once, and only as a request verb, in both languages, so "이 증명을 설명해줘 \(\sum ...\)" keeps its score like "Explain this proof of \(\sum ...\)" instead of reading
expert, while "증명하고 각 단계를 설명해줘" still takes the floor;improveno longer counts as a complex keyword inside the guard. The guard also reads the keyword table for the latest user message's own language, so a Korean question about a proof after a long English turn is no longer floored.증명after a medical word ending in증(합병증명단,후유증명칭,통증명칭) is no longer proof wording, and a verb ending after the stem overrides the preceding-syllable exclusion (보이고증명하시오now takes the floor).disprove/disproof, a sentence-opening "show that" (optionally after "please" or "now"), and the Korean nominalized claim before을 보이/을 보여(무리수임을 보여라,성립함을 보여라,무리수라는 것을 보이시오) are now proof wording; "demonstrate" is deliberately not. The Korean form reads only a nominalized predicate ending (임,함,됨,짐,음,움,큼) or a-는 것clause, so a noun that merely ends inㅁ(이 그림을 보여줘,교점을 보여줘,덧셈을 보여줘,관심을 보이는) does not take the floor. One over-read is accepted and pinned by a test: "Show that step again with \(\frac{a}{b}\)." takes the floor. The accuracy dataset gains nine proof-floor cases, one multi-turn through a new optionalcontextfield; complexity accuracy on the 68 cases is 75.0% (51/68, 67.6% before the fix), and the 59 earlier cases are unchanged at 45/59. - Mask the right text when an external Presidio-compatible recognizer reports a
piispan after a multibyte character (#1862, follow-up of #1857). Presidio reportsstart/endas Unicode code points (Pythonstrindices), and the provider read them as UTF-8 byte offsets, so any accented letter, Hangul, Han, or emoji before an entity shifted the mask:서버 주소는 10.20.30.40 입니다became서버 <REDACTED:IP_ADDRESS>0.20.30.40 입니다, and a span that no longer fell on a character boundary was dropped, soJohn Smithin담당자 홍길동 님, 연락은 John Smith에게went out whole. The spans are now mapped from code points to bytes before overlap resolution, so the whole entity is masked and the surrounding text is kept; ASCII-only text is unchanged. Offsets must be code points, so an analyzer that reports UTF-8 byte or UTF-16 offsets is not compatible. A reversed span, or one that runs past the text (what such an analyzer produces), is now a malformed analyzer answer that followson_error:fail_closedfails the check as an invalid response body does, andfail_openmasks the remaining valid spans, logs a warning with the count, and incrementsguardrail_degraded_total{kind="external_invalid_span"}; an empty span is still ignored. The built-in scanners do not detect IP addresses, person names, or IBANs, so this recognizer is the only path that masks them. - Mask phone numbers that sit next to another number (#1863, follow-up of #1860). A phone number followed by a 5- to 12-digit run no longer leaks when the pattern's last group takes the run's first digits: the number is masked without that group when it starts with
+,(, or0, or when no later candidate inside it takes the rest of the run, so415-555-2770 337543,+82-10-1234-5678 482913, and+81 3 8758 4450 082985mask the phone number and1588-8099 01099843848masks both numbers, whileorder 1234 030 12345678still masks030 12345678. A match that starts one group early now also moves to a number whose next group has 5 or more digits when the scanner accepts that number whole (room 1419 07700 900159masks the UK mobile, not1419 07700), its leading group may have 5 digits (ref 12345 010 1234 5678), and a 4- or 5-digit token starting with0before a whole number, such as a Korean zip code, no longer leaves that number's last group in clear (06236 010 1234 5678masks010 1234 5678,0163 020 7946 4552is masked whole). A country code the 15-digit cap refused because the pattern took the next number's first group is masked again, the group given back (a number that starts with its own+keeps its last group, soroom 0987 +86 138 0979 3831still masks the whole number):+44 20 7946 5121 0630masks+44 20 7946 5121, and+44 20 7946 0958 4111 1111 1111 1111masks the phone number and the card, closing the known gap pinned in #1857. On a 240,000-line mixed corpus the phone digits left in clear drop from 28,878 to 190 and the non-PII digits masked drop by 10%, and on that corpus, the issue's shapes, and a 200,000-line adversarial corpus no phone or card digit that was masked before is unmasked. - Mask a phone number written right after a card number whole (#1868, follow-up of #1863). The phone pattern could match from the card's last group, and the phone validator then read the card's digits as the number's leading groups, so the span was cut at the card's end and the number's last group went out in clear:
4681 3716 0697 9774 010 1841 1335became<credit_card> <P> 1335. When a phone span starts inside a card and ends after it, the phone scan now runs a second time starting the candidate right after the card, and the detections of both passes are kept, so the number is masked whole and no digit the first pass masked is unmasked; text without such a span is scanned once. A number after a 1- to 3-digit token and a 3-digit token is also masked with both tokens (2 118 07700 900907,8 681 020 7946 0402), where its last group used to stay in clear. A phone number whose first group the card scanner reads as a card's short last group or as the remainder of an overlapping card candidate keeps that group masked whencredit_card: allowremoves the card span (4545 9033 0689 8841 415 555 1780masks415 555 1780). On the 240,000-line mixed corpus of #1863 the phone digits left in clear drop from 190 in 40 lines to none and the non-PII digits masked grow by 0.21%. On that corpus, two 200,000-line adversarial corpora, two 60,000-line mixed corpora, and three shape corpora, no digit that was masked before is unmasked under the defaultmaskpolicy,phone: block,phone: allow, orcredit_card: block, and no phone digit is unmasked undercredit_card: allow. - Close the remaining phone leak shapes of the built-in
piiprovider (#1894, follow-up of #1868). A number whose match started one group early, on a group the left extension then moved back from onto the previous number's0-led last group, kept that early start and lost its last group:+44 20 7946 0527 3700 020 7946 7480became<P> <P> 7480, and020 7946 5629 4279 3413 0472 1495 020 7946 2290,page 0 09305 020 7946 1556,020 7946 0757 21087 07700 900578, and+82 10 1389 0551 5088 07700 900675leaked the same way. A number after two short tokens either of which starts with0lost its last group too (177 021 07700 900011,080 832 010 0689 4644,+44 20 7946 9625 071 962 020 7946 3640). When the first phone detection sees such a span, a second detection runs with a validator that retries the lead from the pattern's own start and accepts a0-led two-token lead, and its spans replace a first-detection span only when they cover every digit of it, so no digit masked before is revealed. A card span that starts with0is no card number, so a phone number that starts it and ends inside it is no longer dropped with it, unless a real card starts at one of the span's later groups (0310 3782 822463 10005stays allowed whole): withcredit_card: allow,07700 900700 79271 020 7946 7001masks both phone numbers, andphone: blockblocks it. A phone number inside a card-shaped false positive that does not start with0is still revealed undercredit_card: allow, since no rule separates it from a real card, and the guardrails guide lists it as a known leak. On a 300,000-line ground-truth corpus (phones in seven national forms, cards in every accepted grouping, dates, times, prices, ids, versions, IP addresses, and adversarial adjacencies), a second seed of it, and a 200,000-line corpus in the distribution of the issue's prototype, no digit that was masked before is unmasked under the defaultmaskpolicy,credit_card: allow,phone: block,phone: allow, orcredit_card: block, and the non-PII digits masked grow by at most 0.08% under the default policy and 0.84% undercredit_card: allow. On the prototype-style corpus the phone digits left in clear drop from 28,450 to 8 under the default policy and from 62,402 to 21,499 undercredit_card: allow. -
Stop reading a short token and a real card's first groups as one phone number in the built-in
piiprovider (#1937, follow-up of #1894). A phone span that started on a token before a space-grouped card and ended inside the card was kept, since it did not lie inside the card: withcredit_card: allow,0311 3782 822463 10005masked0311 3782as a phone number, andphone: blockblocked it although it holds no phone number;0003 4111 1111 1111 1111,0311 3056 930902 5904, and03 3782 822463 10005did the same, and0311 4111 1111 1111 1111, whose0-led span passes the Luhn checksum, masked the card's last two groups. Such a span is now cut just before the card and dropped when fewer than 7 digits are left, and a phone span wholly inside the real card after a stray0-led token is dropped, so these rows are allowed whole withcredit_card: allowand masked rather than blocked withphone: block, while02 1234 5675 4111 1111 1111 1111keeps02 1234 5675masked and blocking. Both rules act only on a card written as its network prints it that ends its run of digit groups and that no other group of the run could start, and never cut into digits that could end a whole phone number, since the same text is as often a phone number followed by short numbers (02 1234 5678 9012 3452);5678 4111 1111 1111 1111and(03) 4111 1111 1111 1111read that way too and keep their phone span. The second rule drops only a span whose digits card spans cover (a card glued to a letter, as in0647 6515 4450 8558 3827 006ID 2076, is no card span) and that starts past any whole phone number the token begins (016977 4050 6177 9526 5756 826keeps4050masked), and a number that starts without0or+may reach 15 digits, so a country code written without+(86 138 1234 4567 8901 2345 6783) keeps its digits masked. On 1.5 million ground-truth lines (the three #1894 corpora, 400,000 lines of real cards after0-led, other, and parenthesized tokens and after phone numbers, and 300,000 lines of phone numbers followed by short numbers) and an independent 500,000-line corpus, no digit masked before is unmasked under the defaultmaskpolicy,credit_card: allow,phone: block,phone: allow, orcredit_card: block, and no non-PII digit is newly masked. Undercredit_card: allowthe card digits masked as a phone number fall from 2,512,026 to 2,262,464 and from 1,018,884 to 971,688. Underphone: blockno line with a detected phone number stops blocking; 9 and 161 lines stop blocking whose phone number neither version detects (for example+33 1 74 53 03 18), where only the span over the token and the card's head blocked, a count that grows with the share of undetected phone formats. -
Detect phone numbers written in European, Russian, irregular national, slash-separated, and ungrouped formats in the built-in
piiprovider (#1947, follow-up of #1937). The phone pattern needs a 3-digit group followed by a 3- or 4-digit group, so numbers in 1- and 2-digit groups passed unmasked under the default policy and did not block underphone: block(+33 1 74 53 03 18,+7 495 301-77-50,8 (495) 301-77-50), a3-2-2tail left its last pair in clear (+7 495 301 77 50,044 668 18 00,0212 123 45 67), and01267-1-5398,1800-87-6141,030/12345678, andMobile: 13800138000were never detected. A second phone scanner now reads these forms, each with a strong anchor: a+or00country code of a country that writes its numbers in pairs, a trunk0, Russia's trunk8in its exact shape, the toll-free1800, or, for a French national number, a space-separated UK irregular number, and an unseparated Chinese mobile number, a phone word before it (Tél. : 01 42 68 53 00,Tel: 01267 1 5398,手机13800138000). A generic phone word (home,work,office,ext,contact,call) anchors only right before the number or ascall me at, and a strong one allows at most one other non-ASCII character between, so/home used 15393645540 bytesand手机销量 16836428018 台stay in clear. A national form after an identifier word (ref,lot,order,Kundennummer,주문,订单, and similar) or joined by a space to another number is not masked unless a phone word comes right before it, nor is a French-shaped sequence or lottery draw, a German zero-padded counter or file name without a phone word, or a pair-grouped data row whose code is no pair-writing country (+10 20 30 40 50). Known leaks stay pinned in tests and documented: a country code written without+or00(33 9 61 83 82 88), and a bare French national, space-separated UK irregular, or Chinese mobile number with no phone word before it (01 42 68 53 00,01267 1 5398,kim,13800138000,seoul), whose shapes zero-padded table rows, dotted build numbers, unrelated adjacent numbers, and order ids share; the guardrails guide also lists the non-phone shapes the forms still mask (Step 03630/100000,Δ +43 79 06 04 33,1800 12 2026,KW 05 123 45 67,home: 16235080111), whichphone: blockblocks. The card network table that gates the #1937 rules also gains Diners Club 16-digit and3095, Mir, Maestro, RuPay, Elo, Hipercard, Verve, Troy, and the 13-digit Visa4-4-4-1grouping, so0003 3056 9300 0902 0004and0311 3095 123456 7897pass whole withcredit_card: allow; a trunk-prefixed token before a 4-4-4-4 card (0312 4111 1111 1111 1111) stays over-masked by design and is now documented. On 500,000 ground-truth lines of the new formats in context and of false-positive risks, the 1.5 million lines of the #1937 corpora, and an independent reviewer's 830,000 lines, no phone or card digit masked before is unmasked under the defaultmaskpolicy,credit_card: allow,phone: allow, orcredit_card: block, and no detected phone number stops blocking underphone: block. Phone digits leaked in the new formats (the pinned leaks aside) fall from 1,901,810 to 83,135, and no line without a real phone number newly blocks on those 500,000 lines. The wider network table does makephone: blockstop blocking 69 of those lines and 21 of the reviewer's whose phone number is in a form neither version detects (0003 6362 3386 3784 5325 tel 33 2 67 00 22 73), since the old code blocked them only through its span over a stray token and a card's head. The new forms mask 271 and 5 non-PII digits the old code left in clear (0.045% and 0.0002% of the non-PII digits masked before), while the card table releases 10,142 and 553 token digits. Detection takes up to about twice as long on dense numeric text, and about 20% longer on the regression corpora. -
Keep prefill and decode workers out of the unified fallback of disaggregated serving (#1864, follow-up of #1855). With
fallback_to_unifiedon, a request disaggregated serving handed back (every cold request in a deployment with both prefill and decode workers, since the prefill-then-decode wire protocol is unavailable, and every failed fast-decode dispatch) was re-dispatched over every backend serving the model, so the default round-robin rotated onto prefill-only workers and back onto the decode worker that had just failed, contradicting the documented fallback to aunifiedbackend. Both arms, buffered and streaming, now run that fallback with an allow-list of the caller's user-routable non-role backends minus the failed backend, used only while one of them serves the model and could be selected right now (health, circuit, per-backend rate limit, hub credential binding); otherwise the whole reachable set minus the failed backend, and a lone failed backend stays eligible. The list only narrows the API key'sallowed_backendsand any hub or model-experiment pin, never widens it, and never comes back empty; anx-backendpreference naming a role worker is ignored on the fallback while aunifiedbackend serves. A Dynamo shim dispatch excludes no replica, since its allow-list is a pool. The narrowed list scopes only the routed model's own attempts: chain hops (pre-stream, mid-stream, and quality) run on the caller's own list, so a chain target served only by a role worker stays reachable. The request de-duplication key now includes the effective backend allow-list when it is non-empty, so two requests with the same body and headers but different effective lists (a hub policy update without a configuration publish, experiment arms pinned to different backends, or this narrowing) no longer share a result or a cached error; unrestricted callers keep their previous key. - Record one input decision per buffered chat request and per Anthropic retry (#1855, follow-up of #1847). A buffered
/v1/chat/completionsrequest, unified or served throughdisaggregated_serving, no longer runs a second input pass joined with the backend call: the pre-call gate already runs and awaits every input provider, so that pass hid no latency and only called each provider again and recorded a second decision inguardrail_verdicts_totaland the audit log (afallback_to_unifiedretry ran it once more). Each input provider is now called once per buffered chat request, and, as on streamed chat, the input stage no longer inspects the operator's global prompt, which is injected after the gate.guardrail::run_input_during_callandGuardrailService::run_input_during_callremain for library callers; no router path calls them. On/anthropic/v1/messages, every attempt (the primary and each fallback hop) folds its thinking pass into one request-scoped decision, also when a guardrail service became live after the main pass saw none, so a request gated on several conversion attempts records one decision, the most severe, instead of one per attempt. - Record every request-level input decision once, under the policy that judged the request (#1847, epic #1796). A pass folded into a pending decision now records under the policy it read before its first provider call rather than the one live when it was folded, and
/v1/completionsand the tool and schema definition gate read theirs once at the start, so a reload during a long request (guardrails turned off for the route, orenforceswitched tomonitor) neither drops the decision nor labels an enforced refusalmonitor. Aresponses_onlychat request gated before an external smart-routing classifier records one decision instead of two: the bridge now runs the chat input gate and reuses or folds into the classifier pass. An/anthropic/v1/messagesthinking pass gated before admission folds only when the admitted backend converts, so an attempt moved to a native backend no longer records atransformfor text it sent unmasked./anthropic/v1/messages/count_tokensno longer records a tool definition refusal (it still answers400), so it records no decision in either mode./v1/completionsrecords a decision only when a check judged the request, so a request only unmatched match lists saw records nothing, as on chat. A/v1/responses/compactinput the compact body cannot carry records the refusal it answers with. -
Inspect input text that still reached backends uninspected (#1836, follow-up of #1827).
/anthropic/v1/messages/count_tokensis now gated like/anthropic/v1/messageson the arms that send text upstream (native Anthropic and an engine/tokenize), masking the body before it is forwarded; a refusal is a400Anthropic error, and the local estimate stays ungated. count_tokens records no request-level decision, since it answers no model call. On the/anthropic/v1/messagesOpenAI-compatible conversion,thinkingtext forwarded asreasoning_contentis gated and masked for each attempt on the backend it is actually admitted to (a native arm reached through a lost admission race sends the original signed text). On chat, each vLLMdocumentsentry is read and masked in place,chat_template_kwargsstring values are masked in place with their argument names inspected but never rewritten, and llama.cpp'sresponse_format.schemais gated likeresponse_format.json_schema. A tool's identifier-shaped top-leveltypeandcache_controlvalues are now read into its contract text, less a trailing_YYYYMMDDversion date, so a deny term can no longer hide there. On/v1/responses, a fallback hop to a model under another policy scope (or after a policy reload) re-gates the client's tools and input under that model on every strategy, passthrough included. A request gated in several passes (a thinking block on a conversion attempt, a re-gating/v1/responseshop or continuation, or a smart-routed chat request gated before the classifier and again after routing) now records one request-level decision, the most severe, instead of one per pass; it is recorded when the request ends (after the upstream reply on a non-streaming request, when the handler returns on a streaming one). -
Inspect Anthropic
documentandsearch_resulttext at the input stage (#1848, follow-up of #1836). On/anthropic/v1/messagesand its count_tokens route, thetextblocks andtitleof asearch_resultblock are read and masked in place, and itssourceURL is inspected but never rewritten; they used to reach native Anthropic and Bedrock backends unread. Asearch_resultordocumentinside atool_resultis read on count_tokens, which forwards the raw body;/anthropic/v1/messagesstill refuses that placement at parse. A base64documentwhose media type istext/*is decoded and inspected but never rewritten: a mask on it refuses withguardrail_mask_unappliable, and one that decodes to more than 1 MiB, would take the request's decoded documents past 4 MiB in total, names acharsetother thanutf-8orus-ascii, is not valid base64, or is not NUL-free UTF-8 is uninspectable (uninspectable_inputin enforce, passed in monitor).file_idreferences are now resolved before the input gate, once, so atext/plain,text/csv, ortext/htmlfile is read like an inline document; a guardrail refusal now happens after the files are loaded. Client-visible:documentblocks with atextorcontentsource, which were rejected at parse, are accepted: their text,title, andcontextare masked in place, the native arms forwardtitle,context,citations, andcache_controlunchanged (a document's block-levelcache_controlused to be dropped, and now also suppresses automatic cache-control injection), and the OpenAI-compatible, Gemini, and Responses conversions send the text as text parts. count_tokens now picks its arm from the backend admitted for dispatch, so admission moving a native count to an estimating backend answers the local estimate instead of posting/v1/messages/count_tokensto it. On/v1/responses, Gemini parts markedthought: trueare no longer rendered asoutput_text, buffered or streamed. -
Inspect the remaining Anthropic text blocks and document types at the input stage (#1854, follow-up of #1848). On
/anthropic/v1/messagesand its count_tokens route, a base64documentwhose media type is textual but nottext/*(application/json,application/xml,application/javascript,application/yaml,application/toml,application/sql,application/graphql,application/x-sh, and any+json,+xml, or+yamltype) is decoded and inspected like atext/*one, and a base64 document of any other type exceptapplication/pdfis uninspectable (uninspectable_inputin enforce, passed in monitor), so an unknown type fails closed; before, both reached every backend arm unread. ASCII whitespace between base64 characters is skipped for inspection, so a clean MIME-wrapped text document is no longer refused as uninspectable; the forwardeddatais unchanged. Block types the router forwards verbatim to native Anthropic and Bedrock backends (server_tool_use,mcp_tool_use,mcp_tool_result,web_search_tool_result,web_fetch_tool_result, and newer ones) now have every string inspected but never rewritten, except identifiers, opaque tokens,cache_control,citations, andmedia_type; a nesteddocumentis read as a document. Client-visible: asearch_resultordocumentinside atool_result, which answered422, is accepted, read by the input stage, resolved from afile_idagainst the same reference and byte budgets, and rendered as text into the tool message of the OpenAI-compatible, Responses, and Gemini conversions; a nested document with abase64,url, orfile_idsource answers400 invalid_request_erroron those conversions instead of being dropped. Adocumentblock now keeps onlysource,title,context,citations, andcache_control: an unknown field, forwarded to native backends since #1848, is dropped again.AnthropicContentBlock::Documentreplaces itsextramap with typedcitationsandcache_controlfields, andAnthropicToolResultBlockgainsSearchResultandDocumentvariants. A raw tool-use block'sinputis read whole, whatever its keys.AnthropicCacheControlgains a publicttlfield, so a breakpoint'sttl(for example1h) is now kept on every block type and tool definition instead of being dropped, and a breakpoint on a block nested in atool_resultnow suppresses automatic cache-control injection. The action-safety andusage.quality_judgmentcontexts read nestedsearch_resultand textdocumentblocks as tool output. -
Let an oversized uncached tool set converge, and stop partial phone masks (#1850, follow-up of #1835 and #1837). When more tool-definition views are left to check than the view cap (128 at the default 4 KiB pack size), the gate now checks the first views up to the cap and remembers their clean tools before treating the request as uninspectable, instead of refusing it before checking anything; enforce still refuses that request with
uninspectable_inputand monitor still records oneblock, but a client resending the same clean tools is served after at most one refusal, with each request still bounded by the cap. Masked tools are never remembered, so a set with more masked views than the cap stays refused, and under a non-deterministic provider a packed verdict lives 60 seconds, so the resend must arrive within that window. The built-inpiiphone detector now masks the country code, area code, or trunk prefix its pattern could not take (+44 20 7946 0958,+82 10-1234-5678,(02) 1234 5678,10-1234-5678) instead of only the local part, masks each number of a run whose prefix the pattern split (02 1234 5678 02 9876 5432,415-555-0123 212-555-0199; two numbers that together form a Luhn-valid run are still claimed by the card scanner and only partly masked), and no longer masks the tail of a dotted or longer hyphenated number (3.415-555-0123,2024-01-1234-5678), a digit run after a version (1.0.0-rc.0123456789), a number hyphen-joined to an identifier (id-1234-5678-9012,order-1234-5678), a display resolution (3840-2160), or a year range (2019-2024); service numbers such as1588-1234and labelled numbers such asHP-010-1234-5678still mask. -
Stop the built-in
piiprovider leaking card and phone digits through overlapping spans and card-match gaps (#1857, follow-up of #1850). A span that starts inside an earlier one and ends past it now keeps its remainder under its own type instead of being dropped, so4111 1111 1111 1111-2222masks the2222too, and an entity set toallowis set aside before overlaps are resolved, so an allowed email no longer hides the card number inside it (kim.4111111111111111@x.io). An entity set toblocknow blocks even when an overlapping masked span covers it, because block is decided before overlaps are resolved. The card scanner now takes the leftmost, then longest, card-shaped run of whole digit groups that passes the Luhn check (one run of 13 to 19 digits, groups of 4 with a last group of 1 to 4,4-6-5,4-6-4, or two or three groups of 4 digits or more such as4111 111111111111), trying every group start so overlapping runs are masked together: a card next to a short number, after a date, or after a phone number is found (4111111111111111 010-1234-5678,010 4111111111111111,2026-09-29 4111 1111 1111 1111), while two phone numbers, a date and a phone number, or an ISBN are no longer one card (415-555-0123 415-555-0199,2026-10-05 415-555-0123,ISBN 978-89-01-12345-6); a card typed with a short group (2-4-4-4,3-4-4-4,1-4-4-4-3,4-4-4-3-1,3-3-3-3-4, eight pairs of digits) is not detected. Every built-in pattern now uses ASCII word boundaries, so a card, email, SSN, AWS key, or API key written against Hangul or Han text (4111-1111-1111-1111이고,메일kim@x.io입니다) is detected. A built-in phone span wholly inside a card span is dropped, sophone: blockno longer blocks a space-grouped card andcredit_card: allowno longer masks part of the card as a phone number; a phone-shaped string elsewhere still acts, sokim.01012345678@x.iounderphone: blocknow blocks where it used to be masked as an email. A phone match that starts one group early on the preceding text masks the number after it instead (order 1234 010 1234 5678keepsorder 1234), unless the move would change how the next number is masked (a group that continues a number, or a country calling code written without its+as in82 010 1234 5678 3, is kept and the next group taken too), and a phone number is no longer hidden by a card number written right after it (415-555-0123 378282246310005). -
Charge the token usage of a streamed
/v1/responsesrequest that an enforcing output guardrail blocks on the Chat Completions, Anthropic (including Bedrock), and Gemini strategies (#1833). The relay ended the stream with theguardrail_blockederror event and dropped its usage tap unfired, so the router recorded no per-key TPM or monthly charge, nollm_tokens_total, and no hub usage event for tokens the provider still billed. A block now charges exactly the usage the backend reported, once, the rule/v1/chat/completionsand the passthrough strategy already apply; on the Gemini strategy that is the latestusageMetadata, including the one on thefinishReasonchunk. On the Chat Completions strategy the end-of-stream check runs on the finish chunk, before a backend following the OpenAIinclude_usagecontract sends its trailing usage chunk, so a block raised there holds itserrorevent until that chunk,[DONE], the post-finish grace (timeouts.request.streaming.chunk_intervalclamped to 1 to 5 seconds), a read error, or the end of the stream, and charges what arrived;/v1/chat/completionscharges the same stream because its check runs on[DONE]. A block raised before the backend reported any usage (achunkedwindow cut mid-stream) charges nothing, since nothing is estimated. Achunkedblock tripped by text on the finish chunk itself is a block at the terminal, so it is held and charged the trailing usage, where/v1/chat/completionsstops reading and charges nothing. The client still receives a singleerrorevent. -
Charge the final usage on a blocked or completed streamed
/v1/responsesrequest instead of an earlier partial or zeroed report (#1849, follow-up of #1833). On the Chat Completions strategy, a block raised at the finish chunk used to hold for the trailinginclude_usagechunk only when no usage had been seen at all, so a backend that reports a partial usage object (for example prompt-only) before the finish chunk and the final one after it had its block emitted at once and charged that stale partial count instead of the trailing final one; every such block is now held regardless of what usage (if any) arrived earlier, and is charged the latest report received by then, so a trailing report supersedes an earlier partial one, never zero when a report was in fact seen. On the Gemini strategy, a completed (non-blocked) stream whose last chunk carried an empty or all-zerousageMetadatahad that report wholesale-replace an earlier non-zero one, charging the completed response zero tokens; a zero report is now ignored on both the blocked and completed paths, sharing oneResponseUsage::is_zerocheck. -
Run the output guardrail's end-of-stream check on the terminal chunk of a streamed
/v1/responsesrequest converted to Chat Completions or Gemini (#1834). Gemini routinely puts the last text fragment on itsfinishReasonchunk, and the relay forwarded that chunk after the check without inspecting it, so a deny-listed term or a PII value in the last fragment reached the client under everystreaming_mode, masking included; a Gemini-compatible gateway that sends"finishReason": nullon every delta had every delta forwarded that way. Only a stringfinishReasonnow counts as the terminal. On the Chat Completions conversion,streaming_mode: chunkedcompleted the response while its last window was still held, dropping the tail of the reply, and monitor mode never ran its end-of-stream check, so it recorded no verdict for a completion no one had inspected. The terminal chunk now joins the check with the rest of the text, text-less terminals are forwarded after the held text, and later frames such as the trailing usage chunk still reach the client. A frame the upstream sends after[DONE]is no longer translated afterresponse.completed. A Chat Completions backend that sends its finish chunk and then neither the usage chunk nor[DONE]no longer holdsresponse.completedfor the whole streaming total (600s by default): the router completes the reply with the usage reported so far oncetimeouts.request.streaming.chunk_interval, clamped to 1 to 5 seconds, has passed since the finish chunk; keepalive comments do not extend that wait. -
Inspect tool and schema definitions on every ingress (#1827, follow-up of #1805 and #1823). The input stage read only messages, so a deny-listed term, an injection payload, or PII in a tool description, a parameter schema, a legacy
functionsentry, or a response schema reached the model uninspected under enforce mode. Each tool and schema is now gated alone, before the messages, on/v1/chat/completions(streamed and buffered), theresponses_onlybridge,/v1/responses, and/anthropic/v1/messages:descriptionandtitlestrings and the strings insideexamplesare masked in place, while names, property names,required, enum-like values, and every other string are inspected but refuse the request on a mask, since rewriting them would change the tool contract. A tool every check allowed is remembered under the live policy generation (up to 65,536 per router), so a tool list resent every turn costs no provider call; a change to the guardrails configuration forgets it (a hot reload that changes only other sections no longer re-applies the guardrail policy), and blocks, masks, monitor-mode would-blocks, and fail-open passes are never remembered. More than 256 tools, or 16,384 strings over all of them, is uninspectable. The tool checks share a deadline of one provider timeout per round of eight plus three, checked only before a round is dispatched (a dispatched round always completes, so a found mask is never dropped); when every provider fails open on a whole round, the remaining tools skip the providers and see only the match lists, and a deadline refuses (guardrail_timeout) only when a fail-closed provider is selected, so an outage behindon_error: fail_opennever refuses a large tool set. Tool checks count per provider; the request-level verdict is still recorded once, by the message pass. -
Bound the provider calls the tool-definition gate spends per request (#1835, follow-up of #1827). Each tool that missed the verdict cache cost one call per input provider, so a keyed client sending 256 unique tools per request forced 256 paid provider calls per provider on every request, plus up to 64 remote re-checks for each masked tool. Tools whose text totals at most 4 KiB (counted as JSON-escaped text, less when a selected provider truncates its view: System One's
max_state_bytesminus 512 bytes) are now packed, up to 32 per view, into one check with every tool's contract fields at its head; a 256-tool name-only agent set costs 8 calls instead of 256, a clean pack is remembered per tool, and a mask is written back to each tool's own field (a mask on any contract field still refuses). The match lists run on each tool alone before packing, so an allow rule exempts only the tool it matches and an anchored rule reads each tool as if sent alone. A request whose tools still need more views than the view cap after the cache, the match lists, and packing is uninspectable (enforce refuses it, monitor records it); the cap is 128 at the default pack size and grows as a small provider view shrinks the pack (up to 256, one view per tool, at 2 KiB or less), so a System One target sized for a small context never refuses a tool set the 256-tool limit allows. All of a request's views share one budget of 64 re-checked strings (lifted when every masking provider re-checks locally, as for one document), so a request costs at most 128 first-pass calls per provider at the default pack size (256 under a small view) and, withTmasking providers, at most64 x T x Tremote re-checks plus64 x Tverification calls. A clean tool verdict from a non-deterministic provider (anything but the built-inpiiscanners without an external recognizer, and the match lists) now expires after 10 minutes instead of living until the next policy change, and after 60 seconds when it was judged in a pack of several tools, since a score-based classifier can pass a borderline tool on the pack as a whole. The tool verdicts are folded into the request's one decision on every ingress, so monitor mode recordsblockfor a would-block tool or an uninspectable tool list and enforce mode recordstransformwhen only a tool was masked; before,guardrail_verdicts_totalcounted neither. CustomGuardrailimplementations gain two defaulted hints,max_view_bytes()anddeterministic(). -
Run the input-stage guardrails on streamed
/v1/chat/completionsrequests (#1646). Astream: truerequest took the streaming split before the input gate, so input providers,denyandallowlists, and input PII masking never saw it and the prompt reached the backend as sent, while the same prompt withoutstreamwas blocked or masked. The pre-call gate now runs once for both arms, before the streaming split, the global prompt, every response-cache lookup, and disaggregated dispatch. A blocked streamed request gets the non-streamed block response (200with thechat.completionblock, or400with thecontent_filterenvelope underblock_behavior: error, plus thex-guardrail-*headers) before any SSE starts, and atransformrewrites the prompt the backend and the cache keys see. Non-streamed requests change too: an exact or prompt-prefix cache hit no longer bypasses an input block, and pays the input check like the/anthropic/v1/messagespath already did. Streamed requests run no during-call stage, since a late verdict has no in-flight response left to discard. -
Charge the token usage of a streamed
/v1/chat/completionsrequest that the router bridges to an upstream/v1/responses(#1809). The bridge serves everyresponses_onlymodel, every Codex/ChatGPT-subscription backend, and any request combiningtoolswith a non-nonereasoning_efforton a model flaggedchat_completions_tools_require_none_reasoning, and its streamed arm recorded only a zero-token connect-time row: no control-plane usage event reached the hub, cost centers, or model budgets, and a hub key'sinput_tpm,output_tpm, and monthly token limits were never charged, so a client could spend past them by adding a dummy tool andstream: true. The bridge now charges the upstream's terminalresponse.completed(orresponse.incomplete) usage once at stream end through the same stream-end tap and context builder as the regular chat stream, including the served backend'sbackend_id, the matched hubkey_id, the model-experiment arm, and TTFT; per-key stats count the request once with its tokens, and a stream that ends before any terminal usage records none. Both bridge arms now also report the upstream'sinput_tokens_details.cached_tokensasprompt_tokens_details.cached_tokens, on the non-streaming body and the streaminginclude_usagechunk, so cache reads are metered and billed as cache reads rather than as fresh input. The details object appears whenever the upstream sendsinput_tokens_details, which OpenAI always does, so an uncached response now carriesprompt_tokens_details: {"cached_tokens": 0}, matching the native Chat Completions shape. -
Charge a slow-reading streaming client for the whole reply on every streaming relay (#1815). Ten relays read the upstream only as fast as the client read the response: the
/v1/responsespassthrough and its Anthropic, Gemini, and Chat Completions conversions, the/anthropic/v1/messagesnative passthrough (HTTP, Unix socket, Bedrock mantle), Bedrock runtime, OpenAI-wire, andresponses_onlyarms, and the native Gemini and Bedrock runtime/Converse/v1/chat/completionsstreams. A client that read slowly and disconnected just before the end left the relay behind the upstream, so the terminal usage event was never observed and nothing was charged (no hub usage event, cost-center or model-budget charge,input_tpm/output_tpm/monthly charge, or per-key stats) although the upstream generated and billed the whole reply. Every relay now sends through one shared unbounded relay channel, like the regular chat stream, so it reads the upstream at the upstream's pace and charges the terminal usage whatever the client's read pace; the native Gemini and Bedrock chat pipeline now runs its tap chain in its own task instead of inside the response body, and that task also watches for the disconnect while it waits on a silent upstream, so a client that leaves during a long reasoning pause still cancels the generation at once instead of waiting for the next upstream event. A client disconnect still stops the relay at its next send and drops the upstream, so cancellation is unchanged. Per-stream buffering is now bounded by the upstream reply (its output-token cap andtimeouts.streaming.total) instead of 256 events, the same bound the regular chat stream has, and the slow-client backpressure warning on the/v1/responsesconversion arms is gone. A source audit (tests/streaming_relay_pacing_audit_test.rs) now fails the build on a bounded channel of SSE events or anSsebody that does not serve the shared relay channel. -
Charge the token usage of a streamed
/v1/responsesrequest that the router converts to Chat Completions (#1825). That conversion serves vLLM, SGLang, TensorRT-LLM, Ollama, llama.cpp, mlxcel, LM Studio,continuumrouter, andgenericbackends, and every such request was charged zero tokens: the router never sentstream_options, so an OpenAI-contract server reported no usage, and the relay builtresponse.completedfrom the finish chunk before reading that chunk'susageand stopped reading there, so a trailinginclude_usagechunk never arrived. The result was one hub usage event with zero prompt and completion tokens, zero charged to the key's token windows, monthly budget, cost center, and model budget, and zero usage in the client'sresponse.completed. The relay now sendsstream_options.include_usage: true(as/v1/chat/completionsalready does for every OpenAI-compatible stream) and completes the response on the first chunk at or after the finish chunk that carries a usage object, or on[DONE], so a trailing usage chunk, usage on the finish chunk, and usage on every chunk are each charged exactly once."usage": nulldeltas and all-zero or empty usage placeholders no longer reset the counts or end the stream early, and a stream with no usage still completes on[DONE]or at end of stream with zero usage, as before. The relay now reads past the finish chunk; an upstream read error while it waits still completes the response with the usage absorbed so far. -
Run the guardrail stages on
/v1/responsesand/v1/completions(#1805). Neither route ran the input stage,/v1/completionsran no output stage, and a non-streaming/v1/responsesreply was never gated, so a client (Codex and every other Responses-API client included) could skip input and output blocking and masking, under local policy and under an enforce-mode hubguardrail_policy_v1alike, by choosing the endpoint, with no metric, audit row, or hub counter recording the bypass./v1/responsesnow runs the input stage in both modes before the response cache, backend selection, and every fallback attempt, and the output stage on all four non-streaming strategies before thestore: truesession write, soGET /v1/responses/{id}and aprevious_response_idcontinuation carry the gated text./v1/completionsgates its prompt before dispatch and its reply after, including astream: truereply the funnel buffers. Each route renders a block in its own wire: aresponsewithstatus: incompleteandincomplete_details.reason: content_filter, or atext_completionwithfinish_reason: content_filter, and the OpenAIcontent_filtererror envelope (HTTP 400) underblock_behavior: error. A block is never cached and never triggers afallback_chainshop, even when400is listed intrigger_conditions.error_codes. Every Responses input item is inspected from its wire form, so a raw item the router forwards verbatim (afunction_call_outputwithstatus: incomplete, or any item carryingphase,agent, orcaller) no longer passes unread, and tool-callarguments(Responses items and chattool_calls) are read token by token from the raw string, keys, numbers, and duplicate keys included, and masked in place without re-encoding; anargumentsstring the JSON parser rejects is refused in enforce mode. An unknown item type is refused by the parser with422. On the conversion strategies aprevious_response_idcontinuation is re-checked over the rebuilt history under the dispatched model's policy. An input mask is applied where each string lives (instructions, every turn, tool outputs, each token of tool-callarguments) on/v1/chat/completions, theresponses_onlybridge,/v1/decisions,/v1/responses(stored history included), compact, and/v1/completions, and a mask that cannot be applied that way is refused rather than dispatched unmasked. Only the providers that returned the mask are re-run, within a deadline of three provider timeouts, a provider failure after a mask refuses even under fail-open, and the providers see one message per client message. Chat also readsreasoning_content,refusal, custom tool input, and tool and participant names,/v1/completionsjudges each prompt on its own and the suffix once (even with no prompt), reads nested prompt arrays, judges at most 128 prompts per request, and records one decision per request, and a request read as more than 16,384 strings is treated as uninspectable./v1/completionstoken-id andprompt_embedsprompts are refused in enforce mode when an input-stage check applies, and/v1/responses/compactinput is gated. A mask now also clearslogprobsandtop_logprobson the chat, Completions, and Responses wires, streaming included. An output block on/anthropic/v1/messagesand an input block on/v1/decisionsno longer hop to a fallback model, and a200block no longer carriesx-served-backend. Monitor mode is unchanged. The guardrail, architecture, and API docs now tabulate the real per-route, per-stage coverage, including the routes that stay ungated (/v1/batches). -
Fold the context-compression history summary into the request's leading
system(ordeveloper) message instead of inserting a secondsystementry after it (#1704). A hub compression policy withsummarizationturned a multi-turn[system, user, assistant, user]request into[system, system, ...], which a strict chat template such as Qwen3.8's rejects withSystem message must be at the beginning.. The merge is unconditional, reuses therequires_single_leading_systemmerge (blank-line boundary, parts arrays kept), and leaves a request with no leadingsystemmessage unchanged: its summary is still a syntheticsystementry at position 0. The per-backendrequires_single_leading_systemcollapse already runs on the compressed payload at every send site, so a mid-conversationsystementry that compression keeps is merged there as before. -
Count a Gemini thinking model's
thoughtsTokenCountin chat-completions usage (#1677). Native Gemini usage on/v1/chat/completions(buffered and streaming) and on the/anthropic/v1/messagesbridge now reportscompletion_tokens(and the bridge'soutput_tokens) ascandidatesTokenCount + thoughtsTokenCount, takes Gemini'stotalTokenCountas the total when it is non-zero, and addscompletion_tokens_details.reasoning_tokens, matching what/v1/responsesalready reported. This is billing-visible: a thinking model's metered output on the chat route and the bridge, locally and in the Hub usage record, rises by its thinking tokens. -
Answer a safety-blocked Gemini prompt with a
content_filterfinish instead of a raw Gemini object (#1677). A native response or stream event that carriespromptFeedback.blockReasonand no candidate now becomes an ordinarychat.completion(or a finish chunk ahead of the usage chunk when streaming) withfinish_reason: "content_filter"for every block reason, and its usage is still metered; before, the non-streaming body and a stream event withoutusageMetadatawere returned verbatim and a stream event with usage produced no finish reason. -
Resolve
requires_single_leading_systemfrom the compiled-in catalog on every OpenAI-wire backend type, not onlyopenai(#1705). The key now resolves field-wise through all three sources (the backend'smodel_configs, themodel-metadata.yamlcache, then the compiled-in catalog), and the last step is consulted forvllm,sglang,generic, and the other OpenAI-wire types; the wholesale compiled-in lookup staysopenai-only so a self-hosted server that names a model after an OpenAI one never inherits itsresponses_onlydispatch. The Qwen3.8 default itself lives in the shippedmodel-metadata.yaml, which already resolved on every backend type but is read only whenmodel_metadata_filenames it; the backend docs andconfig.yaml.examplenow say so, since a router started without the file forwards a strict-template model'ssystementries as sent. -
Report usage on a streamed
/v1/chat/completionsresponse that an enforcing output guardrail blocked (#1680). The block replaced everything the gate held with thecontent_filterterminal, and the usage report went with it, so the client saw nousagefor tokens the backend generated and billed. The gate now remembers the latest usage the source reported and, when the block dropped the payload that carried it, appends an OpenAI usage-only chunk (choices: []) after the terminal and before[DONE], under everyblock_behavior; a usage report that already reached the client is not repeated. On the native Anthropic source path the router also meters the blocked completion from those counts, where it previously recorded nothing because the block dropped themessage_stopits usage is built from. A chunked block raised mid-stream still carries no usage: the stream is cut before the backend reports it. -
Stream a typed native backend on a
unix://socket incrementally, under the router's streaming timeouts (#1679). The typed streaming path (execute_streaming_requeston Gemini, Bedrock mantle, Anthropic, OpenAI, vLLM, llama.cpp, and Continuum Router backends) used the executor's buffered Unix-socket request, which read the whole answer under a hardcoded 30s timeout before the first byte left the router: SSE arrived as one burst at the end,timeouts.streaming.first_bytemeasured nothing, and any answer longer than 30s failed.RequestExecutorgainsexecute_streaming/execute_streaming_with_client, which return anExecutorStreamResponseonce the response headers arrive and yield the body as the engine writes it, pluswith_unix_socket_config, which each typed backend sets to its HTTP streaming client's window (300s for Gemini, 600s for Anthropic and Bedrock mantle) so the socket is never stricter than HTTP. The HTTP path and the buffered non-streaming socket path are unchanged; a non-2xx streaming answer on the Anthropic and Bedrock mantle paths now reads the same bounded 64 KiB error-body prefix Gemini already did. -
Send the Dynamo direct-mode instance-id headers as the decimal lease id the frontend parses (#1748).
x-dynamo-prefill-instance-idandx-dynamo-worker-instance-idcarried the registry's lowercase-hex id, whichdynamo.frontendreads as absent, so every shim request on a real Dynamo 1.5 fleet ended in the direct-mode refusal. The Admin view and thex-continuum-*response headers keep the hex spelling. -
Keep a Dynamo 1.5 prefill worker's model card instead of discarding its typed fields (#1748). Dynamo 1.5 writes
runtime_config.disaggregated_endpointon a prefill card as an object holding the bootstrap dial address, and the strict string field reset the whole typed card, so the prefill worker lost its model name and capacity and the shim picker never chose it. Any non-string value is now read as absent, and the address is not carried toward the Admin view. -
Make the
deploy/examples/dynamo-direct-shim/workers start (#1748). The tokenizer cache volume was mounted at a path the image lacks, so Docker created it root-owned and the non-root mocker exited withPermission denied; it now mounts over/home/dynamo/.cache, which the image owns. The example, the harness, anddynamo-e2e.ymlno longer require an NGC login, since the pinneddynamo-planner:1.5.0tag pulls anonymously. -
Meter streaming
/anthropic/v1/messagesbridged from an OpenAI-compatible or OpenAI Responses backend the same as the non-streaming request when the upstream reports a cache read above the prompt (#1762). The stream transformers kept only the Anthropic exclusive split, which saturates on such a report, and re-summed it, so an upstreamprompt_tokens: 100withcached_tokens: 800was metered as an 800-token prompt with an 800-token cache read, a total of 810, and 800 cached input on the Hub, where the non-streaming path records 100, 100, 110, and 100. The transformers now keep the upstream's inclusive prompt beside the split, and both stream-end snapshots are built by one shared helper that hands that count to the existing cache-subset clamp inTokenUsage::from_json_value. The usage the client sees on the Anthropic wire is unchanged. -
Register the disaggregated
RoleFilterScoreras phase-only (#1759). On a deployment with disaggregated serving andprefix_routingbut no KV index it was the only registered scorer, so every keyed request ran the scorer pipeline, scored every candidate a neutral zero, and was recorded as afallbackrouting decision withscorer_fell_backset; a pass without an inference phase now leaves phase-only scorers out and reports the pipeline as not attempted. Routing is unchanged. -
Classify the Dynamo direct-mode refusal on the streaming disaggregated arm (#1759). The shim's refusal arrives as a 200 SSE stream carrying the upstream error, which the streaming arm treated as a served stream; the streaming pipeline now marks a relayed upstream failure, and the arm reads the head of a failed or marked shim response and answers the refusal as
503 disaggregated_prefill_decode_unavailablewithdisaggregated_errors_total{phase="orchestration"}, as the buffered arm does. Any other response is replayed to the client unchanged. -
Stop charging the upstream twice on a buffered disaggregated backend mismatch (#1759). With
fallback_to_unifiedon, a response served by a backend the selection did not permit is now returned as afallbackanswer instead of being discarded and re-issued; without it the request is still refused withdisaggregated_backend_mismatch. -
Refuse a post-handshake command flood on the Dynamo load subscriber (#1759). More than 256 command frames within one second after the ZMTP handshake is now a
transport_refusedrefusal, so a peer can no longer hold a subscription in its skip loop forever while the gauge readsconnected; libzmq heartbeats stay far below the bound.MAX_COMMAND_BYTES,GREETING_BYTES,ZMTP_VERSION, and the newMAX_COMMANDS_PER_WINDOWandCOMMAND_WINDOWare re-exported fromdynamo::load. -
Apply a configuration reload published while the router starts, between the boot capture and the Dynamo discovery supervisor's subscription (#1759). It used to wait for the next change; the supervisor now compares the channel's current value with the boot section on start.
-
Stop serving guardrail-bypassing replies from the buffered disaggregated path and the response cache (#1817). A non-streaming
/v1/chat/completionsreply served throughdisaggregated_serving(fast decode, prefill-then-decode, a Dynamo shim dispatch, or a backend-mismatch reply served underfallback_to_unified) skipped the during-call and output stages; it now runs both, exactly like a unified reply. A response-cache hit (exact, prompt-prefix, or semantic, on buffered and streamed chat,/anthropic/v1/messages, and/v1/responses) skips the output stage, and the cache key did not reflect guardrail policy, so an entry stored undermode: monitor, under a since-tightened policy, or by abypass_api_keyscaller in a shared namespace kept being served until its TTL. Every cache namespace now folds in a guardrail policy scope: a digest of the live composed policy (hubguardrail_policy_v1statements and governed patterns included, the bypass key list excluded), the matchedroutesentry, and whether the caller holds a bypass key. The digest also covers the router version, so a release that changes the built-in detectors never serves an older binary's entries, including from a Redis cache a mixed-version fleet shares. A reply is not stored when the guardrail policy changed while it was in flight (a key added tobypass_api_keys, or a switch to monitor, mid-request), or when an enforced output check passed it only because a provider failed underon_error: fail_open; while a provider is failing, the cache stops filling router-wide. A change outsideguardrails, such as a new classifier model behind the same endpoint or an edit to thebackends[]entry behind a provider, does not move the scope. Guardrail-enabled deployments lose their existing cache entries on every upgrade and after each policy change (one miss per distinct prompt); deployments without guardrails keep their cache keys byte for byte. When a failed buffered disaggregated dispatch falls back to unified serving, the during-call stage runs a second time on the unified attempt (one extra evaluation and duplicate audit rows). The publicResponseCacheKeystruct has a newstore_permitfield (StorePermit::default()for an unconditional store), so code that builds it with a struct literal must set it. The guardrails guide no longer claims the output stage runs before a cached copy is returned. -
Run the guardrail input stage before a smart-routing classifier that sends the prompt out of the router (#1816). On
/v1/chat/completionsthe LLM classifier, the System One classifier, and ashadow: trueSystem One classifier received the raw prompt before anydenyrule, input provider, or PII mask applied, including a prompt the gate then blocked. When the classifier can leave the process, the input stage now runs first, keyed onsmart_routing.virtual_modelrather than the name the client sent, so underintercept_alla client cannot pick a looserguardrails.routesentry for it: a block stops the request before the classifier is called, and a mask is applied by the per-message masking core (#1805) to every message where it lives,systemincluded, before the classifier reads it, or refuses the request when it cannot be applied that way. After routing the stage runs again only when the routed model resolves to a differentguardrails.routesentry, so a request is evaluated once unless a per-route override applies, and then it is counted twice in the guardrail metrics. Amethod: rulerouter without a shadow is gated once after routing, as before. -
Mask every segment of an
/anthropic/v1/messagesrequest in place (#1823, follow-up of #1805). A masking verdict (the built-inpiiprovider, AWS Bedrock Guardrails) replaced the last user message with the redacted form of the whole conversation, so PII insystem, an earlier turn, or atool_resultstill reached the backend unmasked while the verdict and metrics recorded a mask, the last user turn received a copy of every earlier turn, and itstool_resultblocks collapsed into one string. The route now uses the per-leaf masking core:system, every turn's text (including a mid-conversationsystementry and a bare string inside acontentarray),tool_resultcontent, and the string values oftool_use.inputare read and each is masked where it lives, on every backend arm, streamed or not. The keys, numbers, and booleans oftool_use.inputare never read. Thinking text is not inspected (itssignatureis verified by the native backends), including where the OpenAI-compatible conversion forwards it asreasoning_content;redacted_thinking, images, documents, and block types forwarded verbatim are never read or rewritten. A mask that cannot be applied (one spanning two strings, for example) is answered with the Anthropic block and nothing is dispatched. Block-only providers now also inspectsystem, tool results, and tool-use input on this route./anthropic/v1/messages/count_tokensis still not gated. The 64-string limit on masked strings per request no longer applies when every masking provider re-checks in process (the built-inpiiprovider without an external recognizer), so an agent session whose resent tool results carry many phone-shaped numbers is masked rather than refused on every turn; a provider that may call out keeps the limit. The built-in phone detector also matches IPv4 addresses and timestamps, and the guardrails guide now recommendsoptions.actions: {phone: allow}for agent traffic. -
Keep health checks and model catalog fetches off the proxy connection pool (#1924, epic #1703). The health checker and the
/v1/modelsfetcher sent their probes through clones of the proxyreqwestclient, which share its pool, so when proxied requests filled a backend's single HTTP/2 connection up to the server'sMAX_CONCURRENT_STREAMS(128 at an nginx ingress) the probes waited for a stream slot, timed out, and a busy but live backend was marked unhealthy; with its only backend unhealthy the router refused every request until the long streams ended. Probes now run on a dedicated client built by the same builder as the proxy client (same TLS roots, timeouts, andserver.http_clienttuning) with its own connection pool, used by the background health checker, model discovery, and the one-off candidate health probes of the admin backend probe and the control-plane activation preflight. Credentials and the per-requesthealth_checks.timeout(#1065) are attached per request as before, and connection pre-warm still warms the proxy pool. An embedder that injects a client withwith_http_clientkeeps probes on that client unless it also calls the newContinuumRouterBuilder::with_probe_http_client, since its TLS or proxy settings cannot be rebuilt from the configuration. -
Report an applied Hub backend revision as
activefromBackendSyncStore::statuswhen the serving pool matches it (#1936, follow-up of #1808). Since #1808 the public wrapper passed a hardcoded "pool not converged", so it could never reportactiveeven for an applied revision whose pool matched, and the four tests that assertedactive.is_none()through it could not fail. The method now evaluates the live pool itself, and the inventory heartbeat calls it instead of duplicating the convergence check, so both share one implementation. Library surface, forcontrol-planeusers ofcontinuum_router::control_plane::BackendSyncStore:statusis nowasyncand takes the live&BackendPoolas a new second parameter. -
Bound the token ids
continuum-kv-listenerretains per source (#1936, follow-up of #1806). A single-blockBlockStoredevent kept its wholetoken_idsas its own segment whatever its length, so the documented worst case wasmax_tracked_blocks x max_chain_tokens(about 6.1 GiB of ids per source at the defaults). A new per-sourcemax_retained_tokens(default 16777216, 64 MiB of 4-byte ids;0is a load error) retires the oldest tracked blocks the same waymax_tracked_blocksdoes, and purges only a prefix whose last tracked reference retired, so the ceiling is nowmin(max_retained_tokens, max_tracked_blocks x max_chain_tokens). A single-block event whose non-empty token span differs from a non-zeroblock_size(vLLM skips masked sliding-window blocks from the hash list while the span stays contiguous) is now tracked without tokens instead of retaining the whole span, which leaves its descendants unattributable; it is logged once per source. - Keep text-less chunks behind held text on an enforcing
chunkedoutput guardrail (#1643, PR #1959). Withstreaming_stream_first: false,finish_reasonand usage (or an Anthropiccontent_block_stopandmessage_delta) left before the held partial window, so a client that ends a completion on its finish chunk lost up tostreaming_chunk_size - 1trailing characters. Such chunks now queue behind held text and leave with it in order; with nothing held they still leave at once. Tool-call deltas that follow held text now arrive with the next window or at end of stream. - Fit
max_tokensbudgets to each fallback target's context window instead of sending the primary's budget unchanged (#1601, PRs #1807 and #1908). Compatibility is judged on a typical prompt estimate (ASCII at three characters per token, any other character as one token), the clamp keeps a byte upper bound, a target with under 16 tokens of room is skipped as incompatible, and budgets are only ever lowered. Limits come from the configuration snapshot the request was admitted under, with backend metadata over global metadata and raw engine limits kept when configured metadata omits them. A quality-check hop skips an incompatible target within the ordinary attempt bound. The mid-stream relay no longer awaits a full model aggregation before it needs one, and its 503 for an exhausted walk no longer names a chain model the caller's key may not see. - Warm the model catalog when the router is built, so the first fallback hop is clamped to an engine-reported window on the embedded
into_router()path and withmodel_aggregation.background_refreshdisabled (#1935, PR #1938).ContinuumRouter::build()spawns one background revalidation that never blocks the build. - Record each quality-fallback generation's tokens under the model that generated it, and keep the tokens of an unusable generation (PR #1828, follow-up of #1601). A
fallback.quality_checkhop's usage was recorded under the originally requested model, and a/v1/decisionsemulated reply that could not be parsed or validated dropped the usage the backend reported. Such a generation is now one failure row carrying its tokens, without a second zero-token failure row, and a skipped incompatible target records nothing. - Restore the Hub-assigned backend pool after a restart (#1558, PR #1808). Persisted Hub backend layers are reconciled against the live pool before the hot-reload loop starts, an identical revision is retried while the pool has not converged, and the heartbeat reports a revision
activeonly for the exact persisted revision and effective pool. A hot-reload-addedgenericbackend whose detected type differs fromgenericis no longer drained and rebuilt on every reload. - Keep
continuum-kv-listenerprefixes across long vLLM block chains (#1627, PR #1806). The listener copied cumulative tokens into every tracked block and refused a valid batch atmax_chain_tokens, so the next sequence looked like a gap and cleared every prefix for the backend. Blocks now hold their own token segments with generation-bound ancestry, an over-limit chain becomes unattributable without losing sequence progress, capacity pressure retires the oldest prefix instead of clearing unrelated state, a descendant of a missing or replaced ancestor no longer regains salt attribution, and an empty or short token segment is refused. - Back off when the Dynamo discovery watch keeps ending (#1754, PR #1770). Only a watch session as long as
watch_reconnect_backoff.maxreturns the delay to its floor; a proxy that closes every watch right after the range get is no longer redialled every 500ms. A backoff-only reload keeps the registry snapshot, a disable is no longer counted askind="restart", and a watcher whose cancel channel was dropped exits instead of spinning. - Refuse duplicate Dynamo instance and publisher ids instead of resolving them by etcd key order (#1753, PR #1766). Two base model cards for one
(component, endpoint, instance_id), twogenerateendpoints with one instance id, or twokv_metricschannels with one publisher id are all refused and counted once ascontinuum_dynamo_discovery_events_total{kind="duplicate"}. LoRA adapter cards no longer join the worker, which fixes an adapter card overwriting the worker's model name and capacity. - Cancel a silent
/v1/responsespassthrough stream when the client disconnects (#2003, PR #2004). The relay awaited only the next upstream event, so a client that left while the provider was silent kept the upstream request alive until the provider emitted again; the relay now watches the downstream close beside each upstream read and drops the upstream at once. Abackground: truestream is not cancelled, since that response belongs to the provider after dispatch and is fetched later.
Security¶
-
Close a crash window in which the opt-in key-table sidecar could keep a key the Hub revoked (#1969). With
control_plane.policy.persist_key_table: true, a security-relevant key-table change (a key revoked, removed, re-hashed, or re-tiered, or a tier limit changed) reached the sidecar only through a blocking-pool write that nothing awaited, after the change was already enforced and its cursor acknowledged to the Hub, so a process killed in between (or a SIGTERM that cancelled the queued write) restarted during a Hub outage into the previous table and re-admitted the key. Such a change now removes the previous file and fsyncs its directory on the applying thread before the new snapshot is swapped in and before the acknowledgment is published, and advances the writer's generation watermark so an older table still queued cannot recreate it; the new table is then written on the blocking pool as before. A crash in the window leaves no file, so the restart is a cold start. A removal failure is counted inpolicy_sync.key_table_persistence.write_failuresand warned once per failing streak, and never delays enforcement. A routine refresh still never removes the file. The restored table is also bounded against clock changes: a sidecar whose sync time is more than 5 seconds ahead of the restoring clock is refused as unusable instead of clamped, and a restored table also expires at a monotonic deadline taken at restore, so a wall clock stepped backwards cannot lengthen how long it serves. -
Encode a model id as exactly one path segment in every upstream URL built from it (#1878). The Nano Banana image routes (
/v1/images/generations,/v1/images/variations,/v1/images/edits) formatted the client'smodelinto the Google Gemini URL unencoded, so an id such asgemini-x-image/../../cachedContents?a=passed thegemini-*image*gate and the router sent the request, with the operator'sx-goog-api-key, to/v1beta/cachedContentsinstead ofgenerateContent. Every such builder now goes through one helper,encode_model_path_segment(/becomes%2F,?%3F,#%23,%%25): the image routes, the native Gemini Responses and multimodal embedding endpoints, and each backend'sget_model_details, which also refuses an empty,., or..id before sending anything. Ordinary ids produce byte-identical URLs. -
Select the backend for Nano Banana image requests by its configured type, never by its name or URL (#1896). The Nano Banana routes (
/v1/images/generations,/v1/images/edits,/v1/images/variationswith a Nano Banana model) send every request to Google's Gemini API carrying the selected backend's key asx-goog-api-key, and they picked that backend when its name containedgeminior its URL containedgenerativelanguage.googleapis.comanywhere, so anopenai,generic, orvllmbackend namedgemini-gateway, or one whose URL only embedded that string (https://gw.example.com/generativelanguage.googleapis.com/), had its own key sent to Google. Only atype: geminibackend is now a candidate, and the same rule no longer keeps atype: openaibackend whose name containsgeminiout ofdall-e-2variations. Migration: atype: openaiorgenericbackend pointed at Google's OpenAI-compatible endpoint (https://generativelanguage.googleapis.com/v1beta/openai) stops serving Nano Banana until it is changed totype: gemini. Atype: openaibackend at that endpoint is also no longer excluded from OpenAIdall-e-2variations, so in a pool that mixes it with OpenAI backends some variations requests may be routed to it and fail until it is retyped. -
Refuse
.and..as a Bedrock model id (#1896). The Bedrock runtime and converse paths refused only an empty id, and.or..percent-encodes to itself, so a configured id of that form resolved as a dot segment in/model/{id}/invokeor/model/{id}/converseand addressed another path. Both are now rejected with a validation error onmodelbefore any request is signed or sent; every other id encodes as before. -
Stop a client from minting
smart_routing_requests_totalseries with arbitrary model names (#1897). Withsmart_routing.intercept_allevery request is smart-routed, and itssource_modellabel carried the client's rawmodelwith no cardinality bound, so an authenticated client could grow router memory and every scrape by one never-evicted series per distinct name. The label now keeps a requested model verbatim only when it issmart_routing.virtual_modelor a model the router currently serves; a request withoutmodelexportsnone, and any other name, a model alias included, exportsother. A served model literally namedotherornoneshares that series. Thex-smart-route-sourcedebug header and the routing log lines keep the name the client sent. - Strip client-supplied
x-dynamo-*headers on every request in every build (#1750, PR #1765). They were stripped only whiledynamo.shim.backendswas configured, so an ordinary backend pointed at a Dynamo direct-mode frontend received the client'sx-dynamo-prefill-instance-idandx-dynamo-worker-instance-idand let the client choose the worker pair. - Send the backend-naming response headers of disaggregated serving only when the client sent an
x-backendrequest header (#1751, PR #1767). Client-visible:x-continuum-prefill-backend,x-continuum-decode-backend, andx-served-backend(which on a Dynamo shim dispatch carry worker instance ids) now follow the rulex-served-backendalready followed elsewhere; a blankx-backendopts in.x-continuum-routing-pathstays unconditional.
Dependencies¶
- Refresh every dependency to its newest release, including
dirs7,opentelemetry0.33, andtracing-opentelemetry0.34, plus the WebUI's Alpine.js 3.17.4, uPlot 1.6.32, and Tailwind CLI 4.3.3 (PR #1771). OTLP 0.33 retries transient collector failures inside the exporter timeout, sotracing.otlp.timeoutnow bounds one batch export including its retries. The regenerated stylesheet restores about 20 classes the committedtailwind.csswas missing.
v1.31.0 - 2026-09-25¶
Added¶
-
Add
make calibrate-systemoneand a recorded calibration of the three System One consumers (#1725, epic #1719). The run replays the labelled corpora exactly as each consumer sends them (its own state builder and question battery) against a/v1/systemoneendpoint, records accuracy, the constant-answer baseline, Brier, ECE, AUROC, confusion matrices, p50/p95 latency and tokens per call per slice (all,en,ko,non_latin) with the checkpoint revision, and derives thresholds with tested rules. Runs against stocklaya-serve(CPU), each pinned Laya checkpoint, and Kev-0.8B are committed undertests/fixtures/systemone/calibration/2026-09-24/; hosted Jev, Kev-4B and themethod: llmshadow arm were not measured and say why. The new operator pagedocs/en/decision-models.mdcovers the models, the wire, the three consumers, egress and adversarial caveats, the procedure, the results, and when to use a decision model. -
Add the
systemoneguardrail provider (aliasesjev,laya; #1722, epic #1719): one call to a System One decision model (hosted Jev, or a self-hosted Laya / Kev / Nimble server through the shared client from #1720) asks onenoulper router category plus a four-levelseverityscore, and the answered probability is the verdictscore.p >= category_thresholds[category](default0.70) blocks,p >= options.review_threshold(default0.35, per-categoryoptions.review_thresholds) flags, and a flag withseverity >= options.severity_block_level(default2.0) is upgraded to a block, which makes theFlagverdict and a real review band reachable from a model for the first time (the classifier providers emit a constant1.0). The target is the sharedsystemoneblock on the provider row. The state is an object, never joined text ({user_message, history}at input;{ai_response, user_message}at output and, throughcheck_streaming_chunk, perchunkedwindow), excludes system prompts and tool results unless opted in, and is fitted undermax_state_bytesbefore dispatch.options.task: injectionnarrows the battery tojailbreakplusindirect_injection;options.questionsreplaces wording verbatim. Every422/429/529/ timeout / malformed answer is a check error underon_error, and a529also recordsguardrail_degraded_total{kind="overloaded"}.config validaterefuses a missing target block, row-level target fields, a review threshold above the same category's action threshold, and an unknown question category. Ships inmode: monitorwith the newguardrails-systemonetemplate, since the models are a first-pass gate rather than adversarially robust. -
Add the System One decision-model classifier for smart routing (
smart_routing.classifier.method: system_oneandhybrid_system_one, #1723, epic #1719). One decision call per uncached request asks ascoreover the ordered complexity levels (the five built-ins pluscustom_complexity_levelsin rank order, at most ten) and achoiceover the domains (the six built-ins pluscustom_domains, a load-time warning past sixteen), rounds the score to the nearest level, and fills the sameClassificationResultthe other classifiers produce with a realconfidence(the minimum of the two answers'), so routing policies need no change; two optionalnoulquestions (signals.needs_tools,signals.is_sensitive) are recorded as classification signals only.min_confidence(default0.5) gates the answer:system_onefalls back to the rule result below it,hybrid_system_one(rule first, System One when the rule is unsure) escalates to the LLM classifier when one is configured and otherwise uses the rule result, and any error returns the rule result exactly as a failed LLM classification does. The state sent is the LLM classifier's egress as a JSON object (the last user request truncated bymax_input_tokens, the 200-character system excerpt,message_count,has_images,has_tools, the requestedmodel) and nothing else; the cache key is its SHA-256.shadow: truebesiderule,llm, orhybridruns the battery without changing any routing decision and reports agreement insmart_routing_shadow_agreement_total{field, agree}plus theX-Smart-Route-Shadowdebug header, which is how a fleet measures the decision model before switching to it. The block embeds the sharedsystemonetarget contract from #1720 (flattened, soclassifier.system_one.endpointor.backend), mirrors the LLM classifier's cache anddisable_under_load_statefields, hot-reloads with the rest ofsmart_routing(a reload frommethod: llmtosystem_oneand back rebuilds exactly the classifiers the new method needs), and validates at load, which also closes a gap for the LLM classifier:classifier.llm.backendnaming an unknown orenabled: falsebackend is now a load error under a method that dispatches to it, instead of a silent fallback to rule-based classification. New metrics:smart_routing_system_one_classifier_cache_hits_total,smart_routing_system_one_classifier_fallbacks_total{reason},smart_routing_system_one_classifier_input_tokens_total{counted}(server-reported usage recorded as router-internal,countedfollowingcount_toward_budgets), andclassifier_type="system_one"on the existing classification series. Asmart-routing-systemoneconfiguration template ships with the configuration assistant, andtests/smart_routing_accuracy_test.rsgains a System One mode replayed through the fixture server over the same labelled cases. -
Add a System One evaluator for
safeguards.action_safety(#1724, epic #1719).evaluator: systemonewith asystemoneblock (the shared System One target plusallow_threshold0.85,deny_threshold0.60,min_confidence0.6, andmode: advisory | enforce) asks one decision call per proposed tool action:actionasapprove/confirm/block, plusirreversibleandinjected_instruction, over{tool, arguments, user_request, recent_tool_results}with no system prompt. The mapping is fail-closed:allowonly for anapprovechoice that clears every threshold with no injected instruction,denyon ablockprobability or an injected instruction atdeny_threshold, andindeterminateforconfirmand every transport or parse failure.user_requestis the last user message that is not a tool round trip (tail-capped at 4 KiB) and Claude Code<system-reminder>blocks are skipped. The defaultadvisorymode emits the chat-model evaluator's verdict (orindeterminatewithout abackend) and records the would-be verdict in the diagnostics and the newsafeguards_action_safety_advisory_total{would_verdict, actual_verdict}metric;enforceemits the mapped verdict.evaluator: llmstays the default,backendbecomes optional withsystemone, and every existing bound applies unchanged. -
Add the Dynamo direct-mode frontend shim pool and instance-id header picker (#1699, part of epic #1703). With
disaggregated_serving.enabled: trueand adynamo.shim.backendspool of ordinary OpenAI-wirerole: unifiedbackends runningdynamo.frontend --router-mode direct, the router picks a prefill worker (active_prefill_tokens / max_num_batched_tokens) and a decode worker ((active_decode_blocks + ceil(estimated_prompt_tokens / block_size)) / total_kv_blocks, one candidate per data-parallel rank) from the discovery registry, preferring the lowest fresh cost and rotating round-robin among candidates with no fresh sample, and sends the client request unchanged through the pool withx-dynamo-prefill-instance-id,x-dynamo-prefill-dp-rank,x-dynamo-worker-instance-id, andx-dynamo-dp-rankset after stripping any client-suppliedx-dynamo-*header. That strip is mounted on the whole/v1surface whenever a pool is configured, so no route carries a client pin to a shim, not even one that selects a shim replica as an ordinary backend.dynamo.shim.model_mapdecides candidate matching only; the request body is forwarded unchanged, so the frontend must be able to resolve the client's own model id. A model no worker serves falls through to ordinary routing; with no prefill worker onlyx-dynamo-worker-instance-idis sent and the path reportsunified. Responses carryx-continuum-routing-path,x-continuum-prefill-backend/x-continuum-decode-backendasdynamo:<instance_id>[/dp<rank>], andx-served-backendnaming the shim. The frontend's direct-mode refusal is answered as503 disaggregated_prefill_decode_unavailableand counted asdisaggregated_errors_total{phase="orchestration"}without re-pairing. Newcontinuum_dynamo_selection_total{role, reason}; selection is pluggable through theDynamoWorkerPickertrait on the registry.dynamo.shimreloads immediately,dynamo.shim.model_maptranslates client model ids to Dynamo display names, and a newdynamo-direct-shim.yamltemplate is included. -
Add a GPU-free end-to-end harness, deployment example, and operator documentation for Dynamo prefill/decode fleets behind a direct-mode frontend shim (#1700, part of epic #1703).
deploy/examples/dynamo-direct-shim/brings up etcd, twodynamo.mockerworkers inprefillanddecodedisaggregation modes, onedynamo.frontend --router-mode direct, and the router, with every image tag pinned in a single.envso the compose stack and the new compatibility table cannot drift;--event-plane zmqis passed explicitly because the mocker defaults to NATS whenever discovery is etcd while the router's load subscriber reads ZMQ only.scripts/e2e-dynamo-shim.shwaits forGET /admin/dynamo/workersto show both roles, sends a streaming and a non-streaming completion, asserts thatx-continuum-prefill-backendandx-continuum-decode-backendname two distinct instances and that nox-dynamo-*header is echoed to the client, then stops the prefill worker and asserts the degradation to aggregated selection, tearing the stack down on exit including on failure..github/workflows/dynamo-e2e.ymlruns it on manual dispatch only, with the runner labels as an input because the repository's self-hosted pool is macOS while the Dynamo images are linux/amd64.docs/en/architecture/dynamo-integration.mdgains a topology diagram, a compatibility table, and an explicit list of what stays Dynamo-owned (tokenization, the prefill/decode handoff, KV transfer, worker liveness);docs/en/deployment.mdgains a "Dynamo prefill/decode fleets" section; Korean mirrors updated. Kubernetes equivalents ship asdeploy/helm/continuum-router/values-dynamo-direct-shim.yaml(with an opt-indynamoShimServiceExternalName alias, off by default) anddeploy/kubernetes/overlays/dynamo-direct-shim/, both pointing at an operator-managed etcd and an existing shim Service with no RBAC change. -
Add a bounded asynchronous process-log pipeline and an optional rotating file sink (#1676). Formatted log records now enter a bounded queue that one dedicated writer thread drains, so a slow or stalled stdout, stderr, or file destination no longer blocks a Tokio worker serving requests. The queue is bounded by record count (
logging.queue_capacity, default 8192) and by retained bytes (logging.queue_capacity_bytes, default 16 MiB), each record is capped at 64 KiB and cut on a UTF-8 boundary, and a full queue or a failing sink drops records instead of blocking or stopping the router.logging.filewrites plain-text records to a directory with size-based rotation, bounded retention,{date}/{time}/{pid}/{sequence}filename placeholders ({sequence}is required), and file mode0600. Both queue settings require a restart. New metrics:continuum_process_log_queue_depth,continuum_process_log_queue_bytes,continuum_process_log_dropped_total,continuum_process_log_truncated_total, andcontinuum_process_log_write_errors_total.
Changed¶
-
Ship measured System One defaults (#1725). Against the reference run (the compose file's
laya-serveon CPU), the guardrailreview_thresholddefault moves from0.35to0.40, theviolenceaction threshold from0.70to0.75(the other categories keep the vendor0.70, since none reached0.90block precision), andsmart_routing.classifier.system_one.min_confidencefrom0.5to0.15, where the System One chain beat the rule classifier (0.510 against 0.463; 0.448 at the old default). The action-safety thresholds keep their vendor values because theactionanswer scored below its constant baseline. The guardrail and action-safety consumers are documented as monitor-only on that endpoint, and routing as shadow-only for Korean traffic.tests/systemone_calibration_test.rsre-derives every default from the committed run and fails if the library or either language's defaults table disagrees. Inmode: enforcethis blocks less on the defaults: aviolenceprobability in [0.70, 0.75) now flags instead of blocking (still a block atseverity >= 2), and a category probability in [0.35, 0.40) no longer flags; setreview_thresholdandcategory_thresholdsexplicitly to keep the earlier bands. -
Accept GPT Image custom sizes and up to 10 images per request (#1740). Image generation refused every GPT Image request with
nabove 1 and every size outside1024x1024,1536x1024,1024x1536, andauto, and image edits refused the same sizes, although OpenAI acceptsnfrom 1 to 10 and, on GPT Image 2 and the GPT Image 2.5 models, anyWIDTHxHEIGHTwhose edges are multiples of 16, whose long edge is at most 3840, whose aspect ratio is within 1:3 to 3:1, and whose pixel count is within 655,360 to 8,294,400. Onevalidate_gpt_image_sizenow serves generation and edits, names the rule a rejected size broke, and keeps the preset-only list for the GPT Image 1 models.nis 1 to 10 for GPT Image models on generation (edits already allowed it), DALL-E 3 stays at 1, and the catalogmax_nfor every GPT Image model becomes 10.
Removed¶
- Drop
gpt-4-32k,gpt-3.5-turbo-16k, ando1-minifrom the OpenAI backend's advertised model list and from the built-in OpenAI catalog, and move theo1-minicatalog entry tomodel-metadata-retired.yaml(#1739). Each is shut down on both the OpenAI API and Azure OpenAI (gpt-4-32k2025-06-06,gpt-3.5-turbo-16k2024-09-13 and 2025-04-30,o1-mini2025-10-27 and 2025-11). Ids with a scheduled but future shutdown, such asgpt-4,gpt-4-turbo,gpt-3.5-turbo,o1,o3-mini, andgpt-image-1, stay listed until that date passes. Request handling for these ids is unchanged.
Fixed¶
- Fix
deploy/docker-compose.systemone.ymlso the service actually runslaya-serve(#1725).laya, which the inference extra installs, ships its ownlaya-serveconsole script that replaced the package's, so the container served/healthinstead of/healthz(the healthcheck never passed) and ignored theLAYA_SERVE_*settings. The compose file also turned an unsetLAYA_SERVE_API_KEYinto an empty string, whichlaya-servetreats as a configured key, so every request answered401. The service now startspython -m laya_serve.cliand unsets an empty key. - Report Claude's real context window and output cap from Anthropic and Bedrock model discovery (#1741). Both backends derived
ModelInfolimits from a substring match that put every Opus at 200K / 32K and every Sonnet at 200K / 64K, so a nativetype: anthropicortype: bedrockbackend, which never readsmodel-metadata.yaml, advertised 200K for Opus 4.6 through 5.5 and Sonnet 4.6 and 5, which accept 1M. A sharedclaude_model_limitsnow parses the family version (aliases, dated snapshots, and Bedrock inference-profile ids included), matches the catalog for every Claude entry under a test, and lets a later minor inherit its family's limits. Each backend'smax_tokens_per_requestcapability moves from 200K to 1M. - Bound the ZMQ frame length before allocation in the Dynamo
kv_metricssubscriber (#1733, part of epic #1703). The SUB socket is now the router's own minimal ZMTP 3.x client (src/dynamo/load/zmtp.rs) over a plaintokio::net::TcpStreaminstead ofzeromq, whoseZmqCodec::decodereads an 8-byte frame length off the wire and reserves that much capacity before a single payload byte arrives, exposes no maximum-message option, keeps its codec module private, and has no pluggable transport. Because the router dials endpoints that fleet-authored etcd records announce and[profile.release]setspanic = "abort", a hostile peer could end the whole router process from a 9-byte header without transmitting the bytes it declared, and could chainmore-flagged frames to grow one multipart message without bound; the router's own 16 KiB payload check ran on an already-received frame and never reached it. The new client refuses a declared message-frame length above 32 KiB (compared as au64before any narrowing, so a declaredu64::MAXcannot truncate into an acceptable number), a command frame above 4 KiB, amore-flagged chain past 8 frames against a wire message of exactly four, and a chain totalling more than 64 KiB — each checked before anything is sized from the wire value, with the read buffer allocated once from the compiled-in constant. It also refuses a peer that is not speaking ZMTP 3.x with the NULL mechanism to a PUB or XPUB socket. A refusal is counted once under the newcontinuum_dynamo_load_events_total{result="transport_refused"}label and drops the connection into the existing jittered reconnect, so a hostile or broken publisher can at most lose its own subscription, and the trust-boundary caveat that asked operators to treatdynamo.discovery.etcdwriters as router-equivalent for process integrity is removed from the module doc and both architecture documents. The subscription lifecycle, connect budget, decoder, and store are unchanged. The reconnect schedule now restarts from its 500ms floor only after a session that stayed up for at least 30 seconds, rather than whenever a connect succeeded: a peer that completes the handshake and then fails at once would otherwise have held the schedule at the floor, redialling, logging, and counting several times a second per publisher for as long as its etcd record existed, and no signal the peer produces can decide this because a well-formed sample is one such a peer can also send.zeromqmoved to[dev-dependencies], so no runtime graph resolves it and a release binary drops it, whiletests/dynamo_load_zmq_test.rsstill runs a realPubSocketas the publisher — the check that keeps the bounded client's greeting,READYexchange, and ZMTP 3.0 subscription form matching the crate Dynamo's event plane runs on.
Dependencies¶
- Float the dependency lock (47 packages, including rmcp 3.3.0 to 3.4.1) and name rmcp's
InitializeResultdirectly instead of theServerInfoalias that rmcp 3.4 deprecates (PR #1686). This supersedes the dependabot group update in PR #1683.
v1.30.0 - 2026-09-24¶
Added¶
- Add explicit endpoint/deployment UUID identities to Hub backend inventory and AppProxy ROUTER replicas without changing accounting attribution; expose them in backend configuration, Admin, probes, and schema (#1732).
-
Add the shared System One decision-model client (
src/services/systemone/, #1720, epic #1719): typednoul/choice/scorequestions over astate, decoded against the questions asked; onePOST /v1/systemoneper call with at most one retry on429,529, or a connection failure that honorsretry-afterinside the call's own timeout, and never a retry on422or a parsed response; a closed error enum so each consumer applies its own fail policy; error bodies capped at 200 bytes; the server's resolvedmodelandusagesurfaced for audit and internal usage accounting;systemone_requests_total{consumer, result}andsystemone_request_duration_seconds{consumer}with no state or question text in any log or metric. The target is described once by the always-compiledSystemOneTargetConfigblock (endpoint+api_key_env, orbackendunder the pinned-backend contract, plusmodel,timeout_ms,max_state_bytes,count_toward_budgets,max_concurrent), validated at load and documented in the configuration assistant; the consumer sub-issues embed it unchanged. -
Add the models released since the last catalog update to
model-metadata.yaml, with published context windows, output caps, knowledge cutoffs, and per-MTok or per-image pricing. OpenAI:gpt-6-solandgpt-6-luna(September 22) and the two GPT Image 2.5 models,gpt-image-2.5-sunburstandgpt-image-2.5-flare(September 8). Anthropic:claude-opus-5-5(September 22). Meta:muse-spark-1.1,muse-spark-1.2, andmuse-spark-1.3with the lower-priced-contributorids for 1.2 and 1.3,muse-image-1.0, andmuse-voice-transcribe-1.0. Google:gemini-3.8-live,gemini-3.8-live-extended-thinking,gemini-3.5-transcribe,gemini-3.5-transcribe-live,gemini-robotics-er-2-preview, andgemini-3.1-flash-lite-image. Open-weight and API models from Chinese labs:deepseek-v4.1-flash(API iddeepseek-flash),glm-5.3,glm-5.3-flash,glm-5.3-flashx,mimo-v2.6-pro,mimo-v2.6-flash, Tencenthy4-previewandhy3, Meituanlongcat-2.0, Ant Groupling-3.0-flash, Shanghai AI Laboratoryintern-s2-397b, StepFunstep-3.7-flash,qwen-image-2.1,qwen-image-3.0-pro, and ByteDancedola-seedream-5-0-pro-260628anddola-seedream-5-0-flash-260915. Existing entries record what changed upstream:deepseek-v4-procarries the 0813 GA snapshot and the peak rate DeepSeek has charged since 2026-08-16,deepseek-v4-flashnotes that its API id now routes to V4.1 Flash,gemini-3-pro-image-previewbecomes the GAgemini-3-pro-image(the preview id stays as an alias) with pricing corrected to Google's token rate,muse-glimmer-30bdrops the video input that neither its model card nor Meta's docs list,text-embedding-004notes that the Gemini API shut it down while Vertex AI serves it until 2027-04-01,gpt-image-1,gpt-image-1.5, andgpt-image-1-minigive their scheduled shutdown dates, and the Gemini 2.5 models note that since 2026-09-18 Google serves them only to projects that already used them. -
Register
gpt-6-solandgpt-6-lunain the built-in OpenAI catalog as well, since a nativetype: openaibackend's/v1/modelsdiscovery never reads the YAML. Unlikegpt-6-astrathey acceptreasoning_effort: "none"(nonethroughmax, defaultmedium), and OpenAI documents that Chat Completions takes function tools only atnone, so both carry the same tools-plus-reasoning bridge flags as the gpt-5.6 family: a tool request atnonestays on/v1/chat/completions, and one at any other effort, or with the effort omitted, is bridged to/v1/responses. The two GPT Image 2.5 models are registered in the built-in image catalog, and image generation accepts their newxhighandmaxqualities, which the router still refuses for earlier GPT Image models. -
Support Claude Opus 5.5's request surface. Opus 5.5 keeps thinking always on and returns HTTP 400 for an explicit disabled-thinking config at every effort, so it now takes the Mythos-class omission path (
model_rejects_disabled_thinking) instead of the Opus 5 rule that accepts the config up to efforthigh; the two gates still never fire for the same id. It also removed forced tool use, somodel_rejects_forced_tool_choicenow covers Opus>= 5.5alongside Mythos-class>= 5.1, and the router answers a forcedtool_choicefor it with its own 400 naming the documented remedies on all three ingresses. Both gates are parsed from the family version, so Opus 5 keeps its current behavior and later Opus minors inherit the new rules. -
Stamp what each usage record's
ttft_msmeasured: every control-plane usage producer now names the span with an additivettft_kindprotocol field (TtftKind:streamed_first_token,response_headers,cache_replay) carried throughUsageEventandinto_usage_record(#1641). The field is set only whenttft_msis set, and a record whosettft_msis absent never carries one. This is an additive local protocol change ahead of the upstream re-sync; the hub-side analytics fix is continuum-hub#1164.
Changed¶
- Move every default that pointed at a shut-down or restricted model to a model a new project can call. An image generation request without
modelnow defaults togpt-image-2instead ofdall-e-3, an image edit togpt-image-2instead ofgpt-image-1(which OpenAI shuts down on 2026-10-23), and an image variation tonano-bananainstead ofdall-e-2, OpenAI's only variations model;dall-e-2anddall-e-3were shut down on 2026-05-12, so the old defaults sent every such request to a retired model. GPT Image models rejectresponse_format, so a request that omitsmodelbut sendsresponse_formatnow gets the router's usual 400 for that combination.nano-banananow maps togemini-3.1-flash-imageinstead ofgemini-2.5-flash-image, which Google limited to projects that already used it on 2026-09-18, and the catalog alias moves with it; naminggemini-2.5-flash-imagedirectly still passes through. The Gemini backend's fallback model list, used when discovery fails, becomesgemini-3.1-pro-preview,gemini-3.8-flash,gemini-3.5-flash, andgemini-3.5-flash-lite, and its advertised model list dropsgemini-2.0-flash,gemini-2.0-flash-exp,gemini-2.0-flash-lite,text-embedding-004, andimagen-3.0-generate-002in favor ofgemini-3.1-flash-imageandgemini-3-pro-image. The OpenAI backend's advertised list dropsdall-e-2anddall-e-3and addsgpt-image-2and the two GPT Image 2.5 models. The defaulttimeouts.limits.extended_modelslist addsgpt-6-astra,gpt-6-sol,gpt-6-luna,claude-fable-5-1, andclaude-opus-5-5and dropsgemini-3-pro, so a per-model streaming override abovemax_streaming_timeoutis accepted for the new frontier tiers and refused for the retired one; an operator list still replaces the default wholesale.
Removed¶
- Move the metadata of models shut down on every serving surface out of
model-metadata.yamlinto a newmodel-metadata-retired.yaml, which the router does not load:dall-e-2,dall-e-3,gemini-3-pro(with itsgemini-3-pro-previewalias),gemini-2.0-flash,gemini-2.0-flash-lite,imagen-4, andimagen-3.0-generate-002. Each was checked against the provider's own API, Azure OpenAI, and Vertex AI on 2026-09-23, and each entry records where and when it was shut down;text-embedding-004stays because Vertex AI still serves it. The file keeps the base catalog's schema, so a deployment that still serves one of these ids restores its metadata by copying the entries into amodel-metadata.d/drop-in.dall-e-2anddall-e-3also leave the built-in OpenAI catalog, following the rule that a retired model is absent from both catalogs; request handling for DALL-E ids is unchanged. A new test fails the build if the retired file stops parsing or validating, or if an id or alias appears in both files, and the image metadata tests read both files so the DALL-E-onlyper_imageandlimitsshapes stay covered.
Fixed¶
-
Accept
gpt-image-2,gpt-image-2.5-sunburst, andgpt-image-2.5-flareon/v1/images/edits. The edit endpoint's model allowlist stopped at the GPT Image 1 generation, so the router answered every edit for a current GPT Image model with a 400 before it reached a backend. -
Map
nano-banana-proto Google's GAgemini-3-pro-imageid instead ofgemini-3-pro-image-preview, which Google shut down on 2026-06-25. Everynano-banana-proimage generation or variation request was reaching a retired model. -
Repair the disaggregated prefill/decode orchestrator scaffold so real workers can land on it (#1698, part of epic #1703). The fast-decode lookup now keys the KV cache index the way the KV listener does, with the 64-hex SHA256 prefix key from the shared
prefix_key_hexencoder, gated onprefix_routing.enabledlike the KV overlap scorer; it used to hash the request withDefaultHasherinto 16 hex characters, a keyspace no real event ever populated. Astream: truerequest now consults the orchestrator: the decision is taken ahead of the streaming split, the selected backend goes into the streaming pipeline as a one-element allow-list, and thex-continuum-routing-pathand backend headers are stamped on the streaming response, withfallback_to_unifiedre-dispatching a failed routed attempt through unified streaming and the 503 envelope preserved when it is off.BackendInfo.healthyis the health checker's verdict andin_flightthe pool's live gauge instead of the hard-codedtrueand0, and the freshest engine-statswaiting_requestsandkv_cache_usageride along for the picker. Role filtering goes through the pool's registeredRoleFilterScorer(BackendPool::phase_eligible_candidates), which reads roles from a new per-pool configuration identity map;BackendPool::update_from_configdiffs that identity instead of(name, url)only, so aroleflip is re-read in place with no drain (rolealso joins the hot-reload backend change detection, which is what lets that reconcile run at all for a role-only edit), amodels,weight, orrequest_extensionschange drains and rebuilds the backend while keeping its engine-stats snapshot, and a URL change keeps its existing resets. Thedisaggregated_*metric family is registered in the global registry and written by the orchestration arms:disaggregated_requests_total{routing_path},disaggregated_fallback_total, anddisaggregated_errors_total{phase}, with the duration histograms registered but reserved for the picker. The architecture page now describes the KV transfer as engine-owned (NIXL between workers) with the router brokering selection only, and documentsexternal_storageas a legacy field. The publiccontinuum_router::services::disaggregated::BackendInfostruct changed:in_flightis nowu64,waiting_requestsandkv_cache_usageare new fields, and aBackendInfo::newconstructor was added; a downstream caller buildingBackendInfovalues directly should review its call sites. -
Clamp
TokenUsage::from_json_value's cache-subset fields to the inclusive prompt count instead of storing a provider-reported value verbatim (#1678). A cached count aboveprompt_tokens, such as OpenAIprompt_tokens_details.cached_tokens, Responsesinput_tokens_details.cached_tokens, a top-levelcache_read_input_tokens, or Gemini'scachedContentTokenCount, was stored verbatim, so the Hub record'scached_input_tokenscould exceed its inclusive prompt. A newclamp_cache_subsetsstep clampscache_read_input_tokenstoprompt_tokensfirst andcache_creation_input_tokensto the remainder, leavingNoneasNoneand logging nothing. Router-local billable input and the Hub record'sinput_tokensare unchanged for every shape; only an over-reported cached bucket shrinks. The streaming/v1/responsestap now builds its usage by parsing throughfrom_json_valueinstead of constructingTokenUsageby hand, so it reaches the same clamp.
v1.29.0 - 2026-09-21¶
Added¶
- Add
searxngas aweb_search.provider, so the router-managedweb_searchtool can run against a self-hosted SearXNG instance instead of a hosted search API (#1664). The three existing providers (Serper, Exa, Brave) each need a commercial API key and a route to the public internet, which an air-gapped or policy-restricted deployment may not have;base_url(#803) could point them at a mirror but not at a different engine. The newSearxngProviderimplements the sameSearchProvidertrait behindbuild_provider, so the injection loop, the Anthropicweb_search_20250305emulation, metrics (provider="searxng"), and result sanitization are unchanged. It callsGET <base_url>?q=<query>&format=jsonwith no credential and maps each entry of the JSONresultsarray (url,title,content, any of which SearXNG may serialize asnull) to a result, truncated client-side tomax_resultsbecause SearXNG has no per-request count parameter. Two things differ from the hosted providers and are enforced at load, hot reload, andconfig validate: SearXNG has no public endpoint, sobase_urlis required and names the instance's/searchendpoint with no query string or fragment (the same rule Brave already had, since both append their own query string), and it has no API key concept, soapi_keyis ignored and never forwarded. Two instance-side settings are the usual failure modes and are documented as such: an instance whosesettings.ymldoes not listjsonundersearch.formatsanswers every search with HTTP 403, andserver.limiter: trueclassifies the router's non-browser requests as bot traffic and answers 429 unless the router's address is inbotdetection.ip_lists.pass_ip; both surface as the upstream status (429 maps to the Anthropictoo_many_requestscode). A200with no results whose body also namesunresponsive_enginesis logged atwarnwith the engine names, since an instance whose engines cannot reach the internet answers every query that way. A configuration that names one of the existing providers loads, validates, and dispatches exactly as before. The configuration-assistant skill document moves to schema 1.56.0. -
Let an operator declare the
reasoning_effortvocabulary a served model accepts (#1545). Nothing on an OpenAI-compatible wire says which levels a self-hosted engine takes, so the router fitted the level it derived onto one hard-coded spelling per backend type (low,medium, andhigh, with bothhighandxhighbecominghighfor every self-hosted engine type), and forwarded it unchanged. A backend whose vocabulary excludeshightherefore answered400for a client that asked forhigh, and there was no way to tell the router what that engine takes:BackendConfighad no effort field,request_paramsis a closed set of seven typed numeric sampling parameters, and no serving engine documents a per-model accepted-effort set. A new optionalmetadata.reasoning_effortsarray on a model entry now names the spellings that model accepts (minimal,low,medium,high,xhigh,max, in that order), and the closest declared level is used instead, by the same nearest-level search and tie-break the built-in OpenAI model table already applies.maxandminimalare spellings the router never requests, so a declaration is the only way they become reachable;noneis not a level and is refused at load with a message namingrejects_reasoning_effort_none; and a declaration that answers nothing for the requested level (empty, or holding an entry the fitter cannot use) is treated as no declaration rather than as a vocabulary that accepts nothing, so a hot-reloaded configuration carrying one cannot silently strip an effort from a hop the operator only meant to narrow. The declaration is resolved from the selected backend's ownmodel_configsfirst and the shared metadata cache second, so amodel-metadata.d/drop-in can declare one model's vocabulary for every backend that serves it and can override a built-in OpenAI table entry without a release; the declaration is read from the snapshot the request was routed against at every configured dispatch site, including each mid-stream fallback hop; the typed Gemini transform reads no declaration at all, because it pins its backend type and holds no configuration. Fitting runs once per attempt against the selected backend, on the Chat Completions wire, on the Responses wire, and in the dispatch-side conversion that serves a client sending Anthropic-nativethinkingto/v1/chat/completions; because it is per attempt, a fallback hop fits against its own target and never against the previous one's declaration. On the Anthropic Messages ingress the value being fitted is the router's own:AnthropicMessagesRequesthas no#[serde(flatten)]catch-all andCanonicalParamhas no reasoning variant, so a client cannot put the field there at all, which is why rewriting it leaves the documented invariant that a client-supplied effort is never overwritten intact. A client that sendsreasoning_effortdirectly to/v1/chat/completionstherefore keeps exactly what it sent: fitting the OpenAI-wire direct path would overwrite a genuinely client-supplied value and is a separate decision this change does not make. A declared vocabulary is intersected with Gemini's ownVALID_REASONING_EFFORTS, because that backend's transform runs after the fit and would otherwise remove the field silently and leave the request at Gemini's default effort. A declaration onbackends[].model_configs[]is a load error when it names an unknown or duplicate entry, matching every other operator-authored backend field, while the shared metadata layer stays warn-only, because a downloaded catalog must not be able to brick a reload. An unset declaration changes nothing: every payload is byte-identical to before. -
Add per-backend
request_extensionsso an operator can set an OpenAI-compatible aggregator's routing preferences and attribution once in configuration instead of in every client request (#1631). A client could already send OpenRouter'sprovider,models, andtransformsfields and itsHTTP-Referer/X-Titleheaders, but there was no per-backend place to set a house policy, and a client that cannot be modified could not have one:request_paramsis a closed set of seven typed sampling parameters by design, andBackendConfighad no extra-headers field.request_extensions.body.defaultsfills a key only when the client left it unset (client wins) andrequest_extensions.body.overridesreplaces the client's value (config wins), both as a deep merge of JSON objects in which arrays, scalars, and type conflicts are decided wholesale at the key, sooverrides.provider.data_collection: denyenforces one leaf while keeping the client'sprovider.sort.request_extensions.headersadds headers that replace a forwarded client header of the same name. The fragment is merged last into the fully built wire payload of the selected backend at every send site, on every retry and fallback hop (a hop to another backend carries that backend's extensions and never the previous one's), for chat completions, completions, responses, and embeddings, including the Anthropic Messages and Responses ingresses converted for an OpenAI-wire backend; headers also ride on model discovery, health probes, pre-warm, control-plane probes, and the realtime handshake. Extended in #1652 to one rule: every request that carries the backend's credential carries its extension headers. The guardrailbackend:and smart-routing classifier calls through the typed backend executor, provider batch lifecycle calls, engine-statistics scrapes, backend type detection, Gemini context-cache management, the model auto-discovery a typed backend runs at construction, llama.cpp/propscapability detection, and the typed Anthropic and Gemini connection pre-warm now carry them too, over both HTTP and Unix socket where the site has both. A consumer that outlives one configuration generation reads the block per send, so an Admin backend edit or a config-sync change reaches its next call, and a headers-only edit restarts an engine-statistics poll task so the next scrape carries the new values. The Admin backend-create type probe runs before the candidate is validated, so it attaches the candidate's headers only when they already pass header validation and otherwise probes without them, which keeps an unvalidatedAuthorizationextension from replacing the key on the wire. The Unix-socket health probe still sends neither the credential nor the headers. Validation runs at load on every ingestion path with a named path in the error:bodyis a load error onanthropic,gemini, andbedrock, andheaderson a Bedrockruntime/conversebackend, whose SigV4-signed dispatch cannot carry them; request identity,stream, therequest_paramskeys and theirmax_completion_tokens/max_output_tokensaliases, tool and output-shape fields, the response-shape fieldstext,logprobs,top_logprobs,encoding_format, anddimensions, router-managed fields, and any key the dispatch strips for that backend (includingstopon a Codex OAuth backend) are reserved, whilestop,seed, andlogit_biasotherwise stay settable; auth-owned and router-owned header names are refused case-insensitively and with_read as-; and the fragment (16 KiB, depth 8), header count (16), and header name and value sizes are bounded, with control characters refused. Tightened in #1650:background,include,include_reasoning,prompt_cache_key, andsafety_identifier(Responses),modalities,audio, andprediction(Chat Completions), the legacy Completionsecho, vLLM'sprompt_logprobs, and vLLM's structured-output fieldsguided_json,guided_regex,guided_choice,guided_grammar, and their vLLM 0.12 replacementstructured_outputsare now reserved for the same reason as the fields above, since each would change request content or the response shape a client parses, whileservice_tier,prompt_cache_retention, andprompt_cache_optionsstay settable alongsidestop,seed, andlogit_bias. Header values must also be visible ASCII (SP through~): a value with a byte outside that range, such ascaféor Hangul, is now refused at load, because the wire client otherwise sends it as RFC 9110 obs-text that upstreams and intermediaries handle inconsistently.request_extensionshas not shipped in a release yet, so this is a same-cycle tightening rather than a migration: a configuration written against the block as #1631 first added it, using one of these now-reserved body keys or a header value that is not visible ASCII, fails to load under this and later builds; drop the key or percent-encode the header value. Header values support${ENV_VAR}and are treated as secrets whatever the header is called: masked on the Admin config and backend reads, export, history, and the WebUI with names left visible, preserved across a masked GET-then-PUT round trip, redacted byconfig diffand the MCP tools, never logged, and reported to Continuum Hub only as therequest_extensionsunsupported-field code on a local backend export. A change applies to the next request without a restart, a backend with a body fragment keys its response-cache entries by a fingerprint of the fragment so an edited fragment is never answered from entries stored under the previous one, and a configuration without the field serializes and dispatches exactly as before. -
Add
model_experiments, so one client-facing model name resolves per request to one of several weighted variants, each naming an upstream model and optionally pinning backends (#1634). Resolution runs insidedispatch_model_for_request, so every ingress that already dispatches aliases gets the same ordering, assignment is sticky and deterministic across replicas, attribution is recorded per variant, and one arm never answers from another arm's cache. Up to 32 experiments each carry up to 16 variants;sticky_keyselects the assignment key (api_keyby default, oruser,none, orheader:<name>),sticky_ttlbounds an assignment's lifetime between 60s and 365d, andhide_variant_modelsreserves the variant models for experiment traffic by hiding them from listings and answering a direct request for one with the ordinary 404. -
Add
GET /admin/control-plane/policy, a read-only view of the Continuum Hub policy the router is executing rather than the one the Hub last sent (#1451). It reports tier limits with per-tier key counts, substitution rules, equivalence classes, routing windows, the optimization policy, model-budget pool state, and the status reported back to the Hub, built field by field from one policy snapshot so key hashes and provider credentials never reach it and keys appear only as counts. It is mounted beside/admin/control-plane/statusunder the samecontrol-planegate, with a Hub policy card on the Integrations page covering the not-yet-synced, hub-unreachable, and rejected-revision states.
Fixed¶
-
Enforce
rate_limiting.limits.per_backendas a selection-time candidate filter, and stop shipping it enabled by default (#1682). The dimension was evaluated by the identity admission mount, which read aRateLimitBackendNamerequest extension nothing ever inserted, so every request took the "no backend pinned" branch and the limit never charged; meanwhile the shipped default carriedper_backend: 100 req/s, burst 200on every deployment that never configuredrate_limiting.limits, so simply making it work would have started refusing traffic on upgrade. A per-backend limit is a capacity constraint on that backend, which makes it a routing input rather than a refusal reason: the shared selection seam now drops a backend whose bucket is empty from the candidate set, beside the health and circuit-breaker filters, so the request goes to another backend serving the same model, and the bucket is charged once at the dispatch boundary, so a backend the filter dropped, a backend the strategy did not pick, a backend a fallback hop moved away from, a cache hit, and a lost half-open admission race are all uncharged. Only when every candidate is exhausted is the request refused, with a429carryingerror.details.limit_type: "backend"and aretry-afterthe bucket can honor, distinct from the503 service_unavailablean outage produces; with a fallback chain, the refusal is the429trigger, so the chain advances to a model whose backends have room, and a chain that exhausts on it answers the429rather than the generic exhaustion500. This applies on every dispatch path (chat, streaming and its mid-stream relay, Responses, Anthropic Messages and count_tokens, images, realtime, ACP), a source audit pins that no path opens circuit admission without charging, and the admission-time path (RateLimitBackendName, its read, the per-mount flag, and the skip accounting) is removed rather than left beside the filter. The shipped default becomesper_backend: None, matchingper_api_key, so no deployment's live enforcement changes: the dimension never charged before, and it now enforces only where an operator configures it. Understorage: redisthe bucket is shared across replicas and degrades to the replica's in-memory bucket when Redis is unreachable, as the other dimensions do;whitelistandbypass_keysexempt callers from the admission mounts and do not exempt a backend from its own capacity. -
Report streamed Anthropic usage to
/v1/chat/completionsclients and to the router's own accounting in OpenAI shape (#1640). The stream transformer copiedmessage_delta.usageverbatim onto the finish chunk, so a client received{"output_tokens": N}with noprompt_tokens,completion_tokens, ortotal_tokens, and the input and cache counts, which Anthropic reports only onmessage_start, were dropped entirely. The usage parser then found no prompt count and recordedprompt_tokens: 0for Prometheus, admin statistics, hub TPM, and the control-plane usage tap, so a Backend.AI GO agent run on an Anthropic provider metered no streamed input at all. The transformer now accumulates the counts frommessage_startand everymessage_delta(Anthropic reports cumulative values, so a field present on a later event replaces the earlier one, keeping only the four known counters and only when they are unsigned integers, so a garbage value on a later event cannot erase a count an earlier one reported) and emits one usage-only chunk atmessage_stopwithchoices: []and ausagemapped by the same helper the non-streaming transform uses, which is the shape OpenAI sends understream_options.include_usageand that Bedrock Converse already emitted. The chunk is emitted whatever the client asked for instream_options, because the router's own taps read it. The finish chunk no longer carries usage. This covers every caller of the shared transformer: native Anthropic over HTTP and over a Unix socket,type: anthropicbackends, and Bedrockendpoint_type: runtimeandmantle. On a cache hit the streamedprompt_tokensis now the inclusive count (non-cached input plus cache reads plus cache writes) withprompt_tokens_details.cached_tokensalongside it, matching what #1642 made the non-streaming path report. -
Serialize the
/v1/responsesusage breakdown under the OpenAI Responses API field namesinput_tokens_detailsandoutput_tokens_details(#1660). The router emitted the singularinput_token_detailsandoutput_token_details, which no client following the OpenAI schema looks for, so the cached-token count was invisible to every such consumer and any of them that prices cached input separately billed the whole prompt at the full input rate. This is a client-visible rename on/v1/responses: the non-streaming body, every response-carrying streaming event (response.created,response.completed, and the rest), and a stored session now all carry the plural keys, and the singular ones are gone from every body this router serializes itself. The two bodies it does not serialize are the exception: a/v1/responsescache hit replays the stored bytes verbatim, so an entry written before the upgrade keeps the singular keys until it expires, and a pass-through body is forwarded exactly as the upstream sent it, so an upstream still on an older build supplies the singular keys. Metering is unaffected in both cases because the parser accepts either spelling. The member names inside the two objects are unchanged, becausecached_tokensandreasoning_tokensalready match the OpenAI schema and the remaining members (cache_write_tokens,text_tokens,image_tokens,audio_tokens) are router extensions that schema does not define. Both spellings still decode on input, so a mixed-version pair of routers keeps metering correctly in both directions: a newer router reading an older peer's singular body, an older router reading a newer peer's plural body (the reads added in #1645 already preferred the plural spelling), and a cached or stored Responses body written before the upgrade all keep the cache read as the Hub record'scached_input_tokensinstead of billing it at the input rate. A downstream consumer that parses this router's/v1/responsesusage and looks for the singular key has to be updated; one that follows the OpenAI schema needs no change and sees the breakdown for the first time. -
Stop charging cached prompt tokens twice on Continuum Hub usage records (#1642). The Hub prices
input_tokensandcached_input_tokensas disjoint buckets, but the record sent the backend'sprompt_tokensasinput_tokens, and for OpenAI-shape responses that count already includesprompt_tokens_details.cached_tokens, so every cached token was billed at the input rate and again at the cached rate; local cache replays were billed the same way.prompt_tokensis now the inclusive prompt count for every producer (non-cached input plus cache reads plus cache writes) and the record carriesinput_tokens = prompt_tokens - cache reads, the same billable input router-localinput_tpmand monthly enforcement already charged. Shapes that report an exclusive count are converted where they enter: the usage parser addscache_read_input_tokensandcache_creation_input_tokensback onto an Anthropic-nativeinput_tokens, the native/v1/messagesstream tracker now also captures cache writes, and the Anthropic and Bedrock Converse transforms to/v1/chat/completionsemit the inclusiveprompt_tokenswithprompt_tokens_details.cached_tokensand keep the top-level Anthropic cache keys even without a thinking block. Two visible consequences:prompt_tokensreturned to/v1/chat/completionsclients on an Anthropic or Bedrock cache hit is larger than before, and on those cache hits router-local input TPM and monthly token counters now charge the non-cached input plus cache writes (before, the cache read was subtracted from Anthropic's already-exclusive count, which could zero the charge), while Prometheus input-token counters, admin statistics, model experiment token totals, and capacity observations now count Anthropic and Bedrock cache reads and writes as prompt tokens. Anthropic streaming on/v1/chat/completionsreports its prompt usage from #1640, in the same release. The reverse bridges follow the Anthropic convention: when/anthropic/v1/messagesis served by an OpenAI-compatible or Responses API backend, the non-streaming body and the streamingmessage_start/message_deltausage now reportinput_tokensas the upstream prompt minus cache reads and writes (readingprompt_tokens_details.cached_tokensandinput_tokens_details.cached_tokensas well as the top-level keys), so a client, or this same router re-parsing the bridged body (a non-streaming response, its own local cache replay, or a bridged stream it reconstructs itself), no longer counts the cached prompt twice; on a cache hit those clients see a smallerinput_tokens. The native/v1/messagesstreaming tracker now also readsinput_tokens,cache_read_input_tokens, andcache_creation_input_tokensfrommessage_delta(the Anthropic Messages API repeats those cumulative counts there, and a bridged stream defers them there because itsmessage_startcarriesinput_tokens: 0), so a router metering another router's bridged stream records the real prompt instead of 0./v1/responsesserved by an Anthropic backend now reports the Responses-convention inclusiveinput_tokenswithinput_tokens_details.cached_tokensandcache_write_tokenson both the non-streaming and streaming paths (streaming previously recorded Anthropic's exclusive count, or 0, and dropped both cache buckets),/v1/responsesserved by an OpenAI-compatible chat backend keepsprompt_tokens_details.cached_tokensasinput_tokens_details.cached_tokenson the streaming path, and the usage parser reads that spelling as well as the singularinput_token_detailsthis router emitted before #1660, so a router metering another router's/v1/responsesbody, or replaying its own cached one, keeps the cached bucket instead of billing it at the input rate. -
Decode an OpenAI-compatible
/v1/modelscatalog that omits the decorativeobjectandowned_byfields, instead of failing the whole catalog withModelFetchError::ParseError(#1629).ModelsResponse.object,Model.object, andModel.owned_byhad noserde(default), so an aggregator such as OpenRouter, whose catalog carries none of the three, was classified as a permanent parse failure and leftGET /v1/modelsempty for that backend forever, even though the same backend served chat traffic normally.objectnow defaults to"list"/"model"and an absentowned_bydecodes as an empty string, which now reaches theowned_byrepair inprocess_models_response(anopenaibackend fills it with"openai", except that avendor/modelid takes its vendor namespace, see #1630) instead of being rejected before that repair ever runs. An explicit JSONnullonobject,owned_by, orcreated, which servers that serialize unset optional fields asnullemit, now decodes the same as an absent field instead of failing the catalog with a type error.idstays required, so an entry with a missing ornullidstill fails to decode. Both strict decode sites (handle_successful_responseover HTTP andhandle_unix_socket_responseover a Unix socket) now share onedecode_models_responsehelper, so a future change to this logic cannot fix one transport while missing the other. Making those fields optional also shrank the smallest accepted catalog entry from 36 bytes to 10 ({"id":""}), so a catalog inside the default 10 MiBmax_response_sizecould decode to roughly a million entries beforemax_models_per_backendtruncated it, a measured 657 MB peak per fetch against 175 MB for the densest catalog accepted before, and several such backends stack becausefetch_all_modelsholds every result until the whole fetch pass finishes. Both decode sites now cap entries during decode itself instead of truncating afterward, keeping the firstmax_models_per_backendentries and still failing the catalog on a malformed entry past the cap, which brings the same body down to a 3 MB peak. -
Report an aggregator backend's model vendor and its upstream pricing, context window, and output cap in the model catalog (#1630). An OpenAI-compatible aggregator such as OpenRouter sends no
owned_byon any catalog entry and namespaces every id by vendor, so the backend-type substitution inprocess_models_responsestamped every model it listed, fromanthropic/claude-haiku-4.5todeepseek/deepseek-v4-flash, asowned_by: "openai", and the router ignored thecontext_length,top_provider.max_completion_tokens, andpricingthe upstream sent, so each of those models read as unpriced with no known context window. When an upstream sends noowned_byfor avendor/modelid, the first path segment is now the vendor. A real upstream value is still kept, and an explicit placeholder such as OpenAI's"system"still becomes the backend-type owner, as does a missing value on an id without a namespace; a per-model metadataowned_bystill wins, andresponse_defaults.owned_bystill applies only to a value that remains a placeholder.GET /v1/modelskeeps its OpenAI field set and changes only in that vendor. OnGET /v1/models/extendedandGET /v1/models/{model},context_lengthjoins vLLM'smax_model_lenas a context-window spelling (max_model_lenwins when both are present),top_provider.max_completion_tokenssetslimits.max_output, and thereforemax_tokens, when it is within the entry's context length, andpricing.prompt/pricing.completionare converted from USD per token topricing.input_tokens/pricing.output_tokensin USD per 1M tokens, withinput_cache_readbecomingcached_input_discount; the raw upstream fields keep passing through on the extended listing. Upstream values are treated as untrusted: a price must parse to a finite, non-negative number no larger than 100,000 USD per 1M tokens, so OpenRouter's"-1"for a variable price and an unparseable value publish no price rather than zero. Configured metadata frommodel-metadata.yaml, amodel-metadata.d/drop-in, backendmodel_configs, or the built-in OpenAI catalog still wins forlimitsand every other field, but not for the price, which is specific to the provider serving the model: an upstream price wins over pricing from the sharedmodel-metadata.yaml, its drop-ins, and the built-in catalog however the entry was matched, so a shipped0/0self-hosting entry reached throughvendor/prefix stripping no longer makes a model such asminimax/minimax-m2read as free, and only a price set in the serving backend's ownbackends[].model_configswins over the upstream one. When several backends serve one id, the context window and output cap are the minimum across them, and the price folds one candidate per backend (itsmodel_configsprice if it pins one, otherwise its upstream price) into the component-wise maximum with the smallest discount. Both are collected per backend during deduplication the way #1456 collectsmax_model_len, so they do not depend on pool order; the published price is the highest any backend states, and a backend whose price is missing or"-1"is left out of the fold rather than bounded by it. A model kept listed because its backend's discovery failed now has its metadata recomputed from current configuration and the retained per-backend reports on every refresh, instead of republishing the folded context window, output cap, and price cached from the pass before, which could still reflect a backend no longer serving the id (this also applies to the #1456 context window). Two pre-existing passthrough hazards on the extended listing are closed as well. An upstream entry carrying a key the router writes itself (backends,tier,domains, ormetadata; atype: continuumrouterupstream's own listing emitstieranddomains) put that key into one JSON object twice, and such keys are now dropped from the passthrough. An upstreammetadataobject was decoded as router-trusted metadata, which let an upstream publish unvalidated pricing and failed the whole catalog on a shape mismatch; it is no longer decoded. -
Accept model ids containing
/onGET /v1/models/{model}andGET /admin/smart-routing/model-profiles/{model}(#1647). Both routes were mounted as a single-segment axum capture, so a namespaced id such as OpenRouter'santhropic/claude-haiku-4.5or vLLM's HF-styleQwen/Qwen3-32B404'd in raw form and only resolved percent-encoded (anthropic%2Fclaude-haiku-4.5), which PR #1644 had to document as a workaround. Both are now mounted as an axum catch-all ({*model}), so the raw and percent-encoded forms return identical bodies; the reserved static segmentsextended,proxy, andrefreshon/v1/modelskeep routing to their own handlers ahead of the catch-all, unchanged. A newmodel_id_from_models_pathhelper insrc/http/middleware/model_extractor.rsparses the same decoded id that axum hands the single-model handler, andEnhancedRateLimitMiddleware::extract_modelnow uses it (as does the unmountedextract_model_middleware); the rate limiter previously readpath.split('/')[3]off the raw, undecoded path and so keyed the per-model rate-limit dimension onanthropicrather thananthropic/claude-haiku-4.5for a namespaced id, and on the literalextendedfor/v1/models/extended. -
Scope request de-duplication to the configuration snapshot each request runs under (#1648). The de-duplication key hashed only the endpoint, the request body, and the inbound headers, and a hit returns the earlier result before any backend is selected or dispatched to, so a byte-identical non-streaming request repeating within
retry.timeoutof an earlier one replayed that earlier answer across a configuration publish: a hot reload, an Admin API write, a control-plane config-sync install, or an AppProxy reconcile that changedbackends[].request_extensions, backend membership, a backend'smodelslist, URL, or credential, orselection_strategytook effect for everything except such a repeat. Each key now folds in a per-process random salt and the address of theArc<Config>snapshot the request executes under, and every entry keeps aWeakhandle to that allocation, which reserves the address while an entry is keyed on it and still lets the superseded configuration's contents drop with its last request. Within one published snapshot nothing changes: identical requests still coalesce onto one upstream call, and a request waiting on an in-flight one still receives its result. Any publish, including one that changes nothing routing-relevant, now starts a fresh window, costing at most one extra upstream call per distinct request per publish. Streaming is unaffected, because it is not de-duplicated. Expired entries are now reclaimed by a periodic sweep started with the server, because an entry keyed on a superseded snapshot is never looked up again and so never reaches the lazy expiry check that a lookup performs; without it, eviction atmax_entrieswould be the only reclaim path. In AppProxy worker and ROUTER modes the effective window is shorter thanretry.timeout, because every reconcile tick republishes the configuration whether or not anything changed, making itappproxy.reconcile_interval(default 15s). Library surface, for anyone using the publiccontinuum_router::services::deduplicationmodule directly rather than the documentedserve_embeddedentry point:DeduplicationEntrygains a publicconfig_snapshot: Weak<Config>field, andDeduplicationManager::{generate_request_hash, mark_in_flight, cache_success_with_backend, cache_error}andEnhancedRetryHandler::execute_with_deduplication_attributedeach take the snapshot as a new leading parameter. -
Re-validate the composed configuration when runtime backends join a hot reload (#1649). A reload validated the file on its own and never the result of merging in the backends created through
POST /admin/backendsand kept in thebackends_persistence_filesidecar, so the router could publish an effective configuration that the next startup refuses: a reloadedtracing.headersname that a runtime backend already uses inrequest_extensions.headers, or a renamed file backend that duplicates a runtime backend'sbackend_id. The reload worker now composes a candidate and runs the shared validation gate on it before anything is published, and a failure takes the existing retry-and-retain path, so the previously published revision stays live. The ledger write that records which runtime entries a file shadows is deferred until publication, so a rejected reload cannot make a live runtime backend reportconfiguredand lose the next Admin update to it on restart. -
Escape upstream-authored text before it reaches a model-discovery log line (#1651). Every configured backend's
/v1/modelsis untrusted input, andtracing-subscriberescapes ANSI/ESC in a log message but not a raw\r/\n, so an id orowned_byvalue carrying one could forge a second log record;process_models_responseand eighteen sites acrossmodels::aggregationinterpolatedmodel.id/model.owned_bydirectly intodebug!/trace!messages, reachable today from any backend whose catalog entries a client or operator does not fully control. Two new helpers incore::text_utils,escape_upstream_text(bound and escape control characters plus U+2028/U+2029) anddescribe_upstream_json_error(apply that to aserde_json::Error's message while keeping its line/column), are now used at every such site and at the three/v1/models//propsparse-error paths, closing both the forging vector and the unbounded log growth an oversized upstream value could cause on a parse failure. -
Surface Gemini's context-cache read on both native-shape usage conversions (#1659). Gemini's
promptTokenCountis inclusive ofusageMetadata.cachedContentTokenCount, exactly like OpenAI Chat Completions'prompt_tokens, but neither native-shape conversion surfaced the cached count, so every cached prompt token was billed at the full input rate and Hubcached_input_tokensstayed 0, the same under-reporting #1642 fixed for the OpenAI and Responses bridges.transform_native_gemini_response, what the/v1/chat/completionsHTTP and Unix-socket relays apply when a Gemini-compatible backend answers in the nativecandidatesshape, now emitsprompt_tokens_details.cached_tokens.GeminiConverter::extract_usage, the/v1/responsesingress and the only one that calls:generateContentand:streamGenerateContentnatively, streaming and non-streaming alike, now emitsinput_token_details.cached_tokens, and the non-streaming stats and control-plane tap carries it through asprompt_tokens_details.cached_tokens. So a Gemini response withpromptTokenCount: 100000andcachedContentTokenCount: 90000records 10000 billable input tokens and 90000 cached instead of 100000 billable, and bridges to Anthropicinput_tokens: 10000withcache_read_input_tokens: 90000while the router keeps charging the inclusive 100000 minus the cache read. The still-unwired direct Anthropic-to-Gemini bridge (gemini_transform::transform_gemini_usage, prepared for a future native routing path and not on any live request path today) gained the same fix and now reusesbridged_input_usagerather than a second implementation, and its two truncatingpromptTokenCount/candidatesTokenCountas u32casts, along with the ones inGeminiConverter::extract_usage, are now the saturating conversion every other usage path from #1642 uses. Gemini has no cache-write concept, socache_creation_input_tokensstays absent on every path. Atype: geminibackend answering/anthropic/v1/messagesor/v1/chat/completionsis normally reached through Gemini's own OpenAI-compatible surface, which already reportedusage.prompt_tokens_details.cached_tokensand already split correctly; that path is unchanged. -
Merge a request's
systemmessages into one leading message for models whose chat template accepts nothing else, so Claude Code no longer fails from its second turn against a Qwen3.8 backend (#1663)./anthropic/v1/messagesserved by an OpenAI-compatible backend forwards a mid-conversationsystemmessage at thesystemrole, in place, so the instruction keeps system priority, and the OpenAI ingress passes a client's own mid-arraysystementry through unchanged; everyQwen/Qwen3.8-*chat template raisesSystem message must be at the beginning.for anysystementry that is not the first message, which vLLM answers with HTTP 400 and the router relayed as400 Invalid request sent to backend, so Claude Code, which sends such a message from its second turn on, failed every turn after the first against that backend. The check is the model's template, not the engine, and it also rejects a secondsystementry placed directly after the first, so the entries have to merge rather than move. Because the constraint ships with the model, it is model metadata: a newrequires_single_leading_systemkey, set in the built-in catalog for the Qwen3.8 family (qwen3.8-27b, whose provisional entry is confirmed against the published weights, plus newqwen3.8-2.4t-a95bandqwen3.8-flash-nextentries), which an HF-style served id such asQwen/Qwen3.8-27B-FP8reaches through the usual namespace-prefix stripping and FP-format peeling, so a deployment that serves the model under its Hugging Face name needs no configuration. A deployment that names the model itself declares the key in the backend'smodel_configsentry, the first source the metadata lookup consults; the key resolves field-wise across the sources, so an entry that leaves it unset inherits the catalog value and only an explicitfalseclears a catalog default for an engine started with its own chat template. An unlisted model is forwarded as sent. When the key resolves, everysystementry of a/v1/chat/completionspayload for that model is merged, in order and joined with a blank line, into one leadingsystemmessage, and the other entries keep their relative order; a payload with nosystementry, or exactly one that already leads, is not touched, a part the router cannot flatten, such as an image, is kept with the merged content becoming a parts array, and an entry whose content is neither a string nor a parts array leaves the request as sent. The merge runs at every/v1/chat/completionssend site that carries a client conversation, on the OpenAI, Anthropic Messages (including the web_search query-extraction rounds), Responses, and ACP ingresses and on every fallback hop, resolved for the hop's own backend and model, so the mid-stream relay's next hop starts from the original array; it reads the published configuration and metadata on every dispatch, so a hot reload, an Admin backend edit, a config-sync change, or a metadata drop-in applies to the next request; and it is inert onanthropic,gemini, andbedrockbackend types, whose dispatch does not send the array through these sites. It is a property of the model rather than the default because the merge keeps the instruction's priority but not its position, so one meant to apply from turn N on applies from the start; a configuration and catalog without the key dispatch exactly as before. -
Emit a usage chunk on native-shape Gemini chat streams (#1671).
transform_native_gemini_eventnever readusageMetadata, and an event carrying onlyusageMetadatanever reached it at all, because the stream dispatch routed onlycandidatesevents to the native path and an event withoutchoiceswas forwarded verbatim. A client streaming from a Gemini-compatible backend that answers/v1/chat/completionsin the nativecandidatesshape therefore received a raw Gemini object, and the router recorded the request with zero prompt and completion tokens, so the cached-input split #1659 added had nothing to act on there. The native path now appends one OpenAI usage chunk on the stream's terminal event,choices: []beside ausageobject built by the same converter the non-streaming transform uses, so a Gemini context-cache hit arrives asprompt_tokens_details.cached_tokensand streaming and non-streaming report identical counts. The chunk is emitted once per stream rather than once per event carryingusageMetadata: Gemini repeats cumulative totals on every native event, and the tap that writes the usage record keeps the first chunk that parses as usage, so a chunk per event would have metered the first event's partial completion count as the whole completion. A stream whose events carry nousageMetadatais unchanged. -
Key the
rate_limiting.limits.per_modelbucket on a model name the router derives from the request instead of on the client-suppliedX-Modelheader, and apply the dimension to inference requests for the first time (#1670). The limiter readX-Modelahead of everything else and the value it returned chose the bucket. The header was undocumented, nothing in the router set it, and no trust boundary filtered it, so any caller (an anonymous one, in permissive auth mode) could drain a bucket the whole fleet shares for a model they were not calling (GET /v1/modelscosts 0.1, so ten requests carrying a victim's name emptied a token of that model's bucket), name an unlimited model to skip a configured limit outright, or read a shared bucket's level out of the refusal'sx-ratelimit-*headers. At the same time the dimension never saw a real inference request's model, because nothing read the request body: a chat completion was charged only when the client volunteered the header. A new middleware, mounted inner to authentication and outer to the limiter, now resolves the name from the request path onGET /v1/models/{model}and from the top-levelmodelmember of the JSON body on/v1/chat/completions,/v1/completions,/v1/embeddings,/v1/rerank,/embed_sparse,/v1/responses,/v1/responses/compact,/v1/images/generations, and/anthropic/v1/messages, reading the body once within the 10 MB limit those routes already carry and handing it on unchanged. The bucket keys on the client-facing name exactly as the request spelled it, matched exactly, so a limit on a model experiment's client-facing model bounds the experiment as a whole rather than either variant's upstream capacity, and an upstream model reachable under two client-facing names throughmodel_aliasesneeds an entry for each name. The multipart image edit and variation routes and/anthropic/v1/messages/count_tokensstay uncovered and are documented as such. A body with nomodel, a body that is not JSON or is malformed, and a body past the peek cap each leave the request untouched for the handler to validate and reject in its own error shape, and each is counted onrate_limit_skipped_total{dimension="model"}, which this dimension previously left silent. An operator who was relying onX-Modelto select a bucket must send the model in the request body or path instead. -
Stop writing a prefix of the caller's raw credential into the model-listing rate-limit logs (#1669).
extract_client_idderived the bucket key from the first 16 characters afterBearer, and both list handlers interpolated that value into the rejectionwarn!, so at the shippedlogging.level: infoa default deployment recorded a stable, vendor-marked prefix of every caller's key, and a key shorter than 16 characters went in whole. The bucket key is now derived from the API key id. No route in this repository mounts the two handlers, so the leak was latent in a public library handler reachable by anembedconsumer or a future route edit rather than active. -
Let
--no-default-features --features redis-cachecompile again (PR #1623).src/infrastructure/cache/redis_store.rsrecords operation latency throughRouterMetrics::global().kv_cache.record_redis_latency, and the no-opKvCacheMetricsstand-in compiled withoutmetricshad no such method, so that graph failed with three E0599 errors. No shipped graph buildsredis-cachewithoutmetrics, which is why nothing caught it.
CI¶
- Clear the seven rustc warnings that had accumulated in the no-feature graph, and deny warnings there in CI (PR #1622).
crates/continuum-kv-listenerdepends on this crate withdefault-features = false, so the release workflow's KV listener build compiles the library with no features and those warnings surfaced in the Windows release log. Each was a value computed only for a#[cfg(feature = "metrics")]consumer, or a helper only such a consumer called.
Dependencies¶
- Bump rustls to 0.23.45 for RUSTSEC-2026-0285 (PR #1638). rustls 0.23.44 accepted TLS 1.3 handshake messages across encryption level boundaries (GHSA-2mjx-qc3c-rqvc). The root
Cargo.lockandcrates/continuum-kv-listener/Cargo.lockmove; theperfandcrates/continuum-protocollockfiles do not contain rustls. No source change. - Bump the Cargo minor-and-patch group with two updates,
tomlandrmcp(PR #1625). - Refresh the root
Cargo.lockagainst the current semver-compatible dependency set (9a389356).
v1.28.0 - 2026-09-11¶
Forty-six commits since v1.27.0 completed KV-cache event ingestion end to end, put engine load and caller identity into routing decisions, and closed a run of provider-compatibility defects. A new continuum-kv-listener bridge carries vLLM, SGLang, and native TensorRT-LLM events into the router's cache index, a router-issued cache_salt takes detokenization out of that path, and Continuum Hub can manage and roll back KV routing as a synced configuration section. Smart routing gained an EngineLoad selection strategy, engine statistics in its load assessment, and when conditions that match on key tier, organization, language, and client headers, while its rule classifier learned code intent from wording and reached 100.0% domain accuracy on a labelled dataset grown to 59 cases. Fallback chains now run on the Anthropic Messages and Responses ingresses with a per-hop timeout multiplier, the semantic response cache has a serving path behind the hub gate, and the current Anthropic, OpenAI, and Google flagship models are in the catalog, with Fable 5.1 and Mythos 5.1 refusing forced tool use.
Added¶
-
Run
fallback.fallback_chainson/anthropic/v1/messagesand/v1/responses(#1609). Both provider-shaped ingresses dispatched exactly once, so a chain keyed on the model a client sent to either of them never fired, the primary's failure was the client's answer, and noX-Fallback-*header could appear; PR #1598 had to record itsX-Original-Modelcriterion as vacuous there. The two ingresses now run their own per-attempt selection, admission, and native dispatch throughFallbackService::execute_with_fallback_snapshot, the executor the chat funnel uses, so the trigger conditions,max_fallback_attempts, and the per-backend hop dial bound are the ones chat applies. The primary attempt keeps its native wire, a hop is the same typed request with only the model name swapped and converted at dispatch by the selected backend'sbackend_type, and on/v1/responsesthis covers the passthrough and every convert strategy. Each attempt is judged from its shaped response: a 2xx commits it, a refused connect, timeout, unknown model, or no admissible backend hops under its own trigger class (carried by a marker the ingress error mappers store in the response extensions, never on the wire), and a provider status hops only when its code is intrigger_conditions.error_codes; on exhaustion the client receives the last attempt's own error in the ingress dialect. The streaming arms hop before the first byte only: a failure before the provider handshake restarts the native stream on the next chain entry, a committed handshake never hops, and mid-stream recovery stays on the OpenAI-compatible wire. The fiveX-Fallback-*headers are emitted on both ingresses honoringnotify_on_fallback, withX-Original-Modelnaming the client's requested name after an alias rewrite (#1588), and a fallback-served request is counted in the supply telemetry at the same seam as chat.docs/en/architecture/model-fallback.mdanddocs/en/error-handling.md(and theirdocs/komirrors) now state the coverage per ingress and arm. -
Match
smart_routing.routing_policies[].whenon the caller's identity as well as on the content of the request (#1569). Four optional fields joincomplexity,domain, andrequiresunder the existing semantics (AND across fields, OR within one, first match wins, evaluation order unchanged):key_tiermatches the hub tier id,orgthe API key'sorganization_id,languagea short BCP-47 primary subtag, andheadera map of header name to accepted values. Oneautoalias can now send a paying tier to flagship models, pin a sandbox organization to the cheap tier, route Korean requests to a model that handles them, and honor an explicit client hint. Every field isserde(default), so an existing configuration keeps its exact meaning, andis_catch_allcounts them, so a condition that sets only an identity field no longer satisfies the catch-all warning. Absence fails closed: a build without thecontrol-planefeature resolves no key tier and neither does a non-hub key, so awhen.key_tierpolicy never matches there rather than matching everything; an unauthenticated request carries no organization; and a letterless request carries no language and matches nolanguageclause, while examined but unattributable text carriesund.when.headerreads client-controlled input, so only names a loaded policy references are captured, values are capped at 256 bytes and dropped rather than truncated (a longer value that merely starts with the configured one must not match), and no value reaches a metric label or a log line; gate anything privileged onkey_tierororginstead. The tier reaches the handler through a new always-compiledKeyTierContextrequest extension that only the control-plane enforcement middleware populates, following theOptimizationDecisionpattern, and the newRequestIdentityholds no HTTP types so the engine precomputes the header names any loaded policy reads and copies nothing else per request.POST /admin/smart-routing/simulateacceptskey_tier,org, andheadersnext topayload, so a tier-gated policy is checkable without holding a key on that tier, andGET /admin/smart-routing/policiesreports the new fields.config validaterejects an invalidwhen.headername or a blankwhen.languagetag, and warns when a policy useswhen.key_tieron a build that can never resolve one. -
Read code intent from wording rather than only from literal markup (#1604). The code domain was keyed entirely on fenced or inline code markup, so the classifier recognized a pasted snippet but not a request to write one: "Implement the rate limiting logic for this REST API." carries no backticks, fired no code signal, and fell through to
generalormultilingual, so every policy keyed ondomain: [code]missed the unmarked half of its traffic. The same sentence in Korean had the identical gap, because the signal was keyed on markup rather than on language. Acodekeyword category joins the existing four onKeywordTableand itssmart_routing.classifier.rule.keywordsconfig mirror, withEN_CODEandKO_CODEshipped built in and merged the same additive way, so a Korean request embedding English technical vocabulary reaches the signal from either side of the mix. The signal needs two distinct matches rather than one, which is the existing house pattern (detect_analysis_markersalready requires two) and is what lets the lists carry ordinary words likefunctionand수정at all, since reaching the threshold takes a second independent piece of evidence from the same request. Requiring a bare action verb instead was tried and rejected:writefires on "Write a poem about autumn leaves" and flips every creative case todomain = code, so the lists mix actions with objects because each fails alone, bare actions taking creative writing and bare objects taking "What is a REST API?". Two entries are absent on purpose with a test pinning their absence, because a single-word keyword is a plain substring match:logicsits insidebiologicalandtechnological, andapiinsidecapitalandrapid, so either would have reached the threshold beside an innocent second match and classified "Explain the biological function of mitochondria" as code.rest apicarries the API case instead, and a multi-word entry is matched token-wise. -
Add
continuum-kv-listener, a standalone bridge that turns engine KV-cache events into the per-backend SSE streamskv_cache_index.event_sources[]already consumes (#1561). Tier 4 of KV-aware routing has read one SSE stream per backend since the index was introduced, but vLLM and SGLang publish their KV events as msgpack over ZMQ rather than as SSE, so every deployment had to supply that adapter itself. The listener ships as a second binary with its own man page (continuum-kv-listener(1)) and Debian packaging. It subscribes to one ZMQ endpoint per backend, tracks bounded block chains, resolves each chain's text through the engine's own/detokenizeendpoint, and computes the prefix hash through the sameextract_prefix_keyseam the router routes on, so the index and the router agree on the key by construction rather than by convention. One listener serves a fleet:/events/<backend_name>fans out to every subscribed router and replays retained history on reconnect throughLast-Event-ID, so a router restart does not lose the blocks the engine reported while it was away. A sequence gap resets that source's tracked state instead of attributing later blocks to a chain the listener can no longer reconstruct. The publisher allowlist, the ZMQ bind or connect mode, the chain and tracked-block ceilings, and the prefix length are all configuration;config.kv-listener.yaml.examplecarries the shipped shape. The router side changes only in whatevent_sources[].endpointpoints at: the engine container no longer needs an extra HTTP producer port, and the documented endpoint moves fromhttp://vllm-1:8000/v1/kv_eventstohttp://kv-listener.internal:7817/events/vllm-1. -
Add a native TensorRT-LLM source to
continuum-kv-listener(#1563). A source declaredengine: trtllmpollstrtllm-serve'sPOST /kv_cache_eventsinstead of subscribing to ZMQ, and normalizes itscreated,stored, andremovedrecords through the same processor and SSE hub the ZMQ sources use, so the router consumes one stream shape regardless of engine. The poll interval, the request timeout, and the response body ceiling are each bounded and configurable, and the event ID rejects a duplicated response and clears stale state after a gap.trtllm-serveexposes no public detokenize endpoint, so this source resolves prefixes only through the router-issued cache salt: the first usable stored block must carry a salt matching the source's backend, later blocks inherit that resolved chain identity, and a chain whose salt is missing, malformed, or issued for another backend is discarded rather than attributed to a prefix.cache_level: 0is read as GPU and higher levels as storage, an omitted stored level defaults to GPU, and a removal retains the tier it was tracked at. The URL must be the exact/kv_cache_eventspath on an explicitly allowlisted host, with no credentials, query, or fragment; redirects are never followed, and response bodies, token IDs, and cache salts stay out of logs and errors. Dynamo deployments that already publish the vLLM-compatible ZMQ event format should keep using the ZMQ source rather than configuring a second native HTTP source. -
Add
prefix_routing.salt_echo, a default-off router-issuedcache_saltthat lets a listener attribute KV blocks without detokenizing them (#1562). With it enabled, a prefix-routed chat request to a vLLM, SGLang, or TensorRT-LLM backend carries a salt of the formcr1:<backend>:<prefix-hash>, and the engine echoes that salt back on the blocks it stores: vLLM inextra_keys, SGLang inmetadata, TensorRT-LLM inblocks. A listener source configuredidentity: salt_echothen reads the prefix key straight off the event, which takes the/detokenizeround trip out of the ingestion path and is the only way to attribute TensorRT-LLM blocks at all. A client-suppliedcache_saltis preserved and never overwritten, a router-issued salt is bound to the selected backend name so it cannot be replayed against a different backend, and the default offalseleaves request bodies byte-identical to previous releases. -
Advertise
kv_routingas a second Hub-manageable configuration-sync section oncontrol-planebuilds (#1564). Alongside the existingrequest_paramssection, a Hub may now deliverselection_strategy, theprefix_routing.*leaves, and a bounded set ofkv_cache_index.*routing leaves, so a fleet can turn KV-aware routing on, retune its scoring, and roll it back centrally instead of by editing every router's file. The section carries its own schema version and structured capabilities, its snapshot entries report presence and digest without values, andcontrol_plane.config_sync.kv_routing_immutable: truepins the whole section locally the wayrequest_params_immutabledoes. Delivered values go through the durable config-sync store beneath explicit local pins, and an unknown path, an out-of-range value, or an event-source endpoint carrying a secret is rejected rather than applied. A successful apply publishes through the existing hot-reload watch channel, so the effective snapshot reaches live reload consumers instead of waiting for a restart. -
Bridge Chat Completions requests that combine function
toolswith a reasoning effort through/v1/responseson the OpenAI models that refuse the combination (#1547). OpenAI answers/v1/chat/completionswith400 invalid_request_errorandparam: reasoning_effortwhen a request carries function tools and the effectivereasoning_effortis notnone, and the router forwarded that unchanged, so a client that pins an effort per model family and pins the Chat Completions endpoint for any custom base URL (n8n does both) could not use these models with tools at all. Two new model-metadata keys next toresponses_onlydescribe the upstream restriction as data on the model entry:chat_completions_tools_require_none_reasoningmarks a model whose Chat Completions route refuses the combination, and the optionalchat_completions_default_reasoning_effortrecords the effort upstream applies when the request omits the field, which is what separates the two rows of the restriction (the gpt-5.6 family defaults tomediumupstream, so omitting the effort is refused there and accepted on gpt-5.4 and gpt-5.5). When the resolved model carries the flag, the request has a non-emptytoolsarray, and the effective effort would be refused, that one request is dispatched through the existingresponses_onlybridge to/v1/responses, which accepts the combination, and the reply is translated back into the Chat Completions shape, streaming included. Everything else keeps going straight to/v1/chat/completions,reasoning_effort: "none"with tools and every request without tools included, so the cost and latency of the paths that already worked do not change. The requested effort rides through asreasoning.effortand an omitted effort stays omitted: the router does not rewrite the effort tonone, which would silently change model behavior (#510). The keys ship set ongpt-5.6-sol(aliasgpt-5.6),gpt-5.6-terra,gpt-5.6-luna,gpt-5.5,gpt-5.4,gpt-5.4-mini, andgpt-5.4-nano, in the built-in OpenAI registry and inmodel-metadata.yaml, with every row measured against the live API on 2026-09-03; the gpt-5.4 mini and nano tiers are included because live probing found them refused too, while gpt-5.2 and earlier accept the combination and are untouched, which is why the rule is per model rather than a family prefix check. Because these are ordinary metadata keys, amodel-metadata.d/drop-in clears them the day OpenAI lifts the restriction, without waiting for a release. Reasoning validation still runs before the bridge decision, so an effort outside the model's supported set is the router's own 400 and never reaches upstream. The dispatch log line and the newresponses_bridge_totalcounter both carry areasonlabel separatingtools_with_reasoningfrom an unconditionally bridgedresponses_only_model, andcontinuum-router config validatenow warns when a backendmodel_configsentry setsresponses_only: trueon one of these models: that override is the pre-bridge workaround and remains the mitigation on released versions, at the cost of routing plain tool-free chat through the Responses API as well. Built-in OpenAI metadata now also resolves through a model's declared aliases, sogpt-5.6inherits thegpt-5.6-solentry's capability flags instead of resolving to no built-in metadata at all. -
Add catalog entries for the current Anthropic, OpenAI, and Google flagship releases:
claude-fable-5-1andclaude-mythos-5-1,gpt-6-astra, and the Gemini Flash line from 3.5 through 3.8 (gemini-3.5-flash,gemini-3.5-flash-lite,gemini-3.6-flash,gemini-3.7-flash,gemini-3.8-flash). Each entry carries the published context window, max output, knowledge cutoff, and per-MTok pricing, so/v1/modelsmetadata, smart-routing profile inference, and cost reporting stop falling back to name heuristics for them. Two of the additions are more than data.gpt-6-astrais also registered in the built-in OpenAI catalog (a newgpt6_familymodule) so a nativetype: openaibackend's/v1/modelsdiscovery reads its real 1.05M context window and $10 / $50 price instead of the parser's fabricated defaults, and it shipschat_completions_tools_require_none_reasoningtogether withchat_completions_default_reasoning_effort: "medium": OpenAI refuses function tools on/v1/chat/completionsfor this model and, unlike the gpt-5.6 family, rejectsreasoning_effort: "none"outright, so the "set reasoning_effort to none" remedy named in its own error text does not exist here and every tool-carrying request is bridged through/v1/responses. Its accepted effort set (lowthroughxhigh, plusmaxon the Responses API) is registered insupported_reasoning_efforts, which turns anonerequest into the router's own 400 rather than an upstream one.claude-fable-5-1andclaude-mythos-5-1are advertised by the Anthropic backend's default model list, and every existing capability gate already covers them because those gates key on the Mythos-class name and the parsed family version. Prompt cache reads are recorded at 2.5% of base input on both, against 10% elsewhere in the Claude family. The Gemini 3.6, 3.7, and 3.8 Flash entries record the promotional $0.75 / $3.75 per MTok rate that runs through 2026-12-31 rather than the $1.50 / $7.50 that replaces it on 2027-01-01, and they are deliberately absent from the Geminisupports_thinking_disablelist, soreasoning_effort: "none"for them is refused by the router with a named-model error instead of being forwarded to a model documented to reject theminimalthinking level. -
Reject a forced
tool_choiceon Claude Fable 5.1 and Claude Mythos 5.1 before dispatch, instead of forwarding a request the provider answers with a 400. Both models removed forced tool use:{"type": "any"}and{"type": "tool", "name": ...}returntool_choice: type "tool" and "any" are not supported for this model., whileautoandnoneare unaffected anddisable_parallel_tool_usestill works alongsideauto. Claude Fable 5 and Claude Mythos 5 accept forced tool use, so the newmodel_rejects_forced_tool_choicegate parses the family version (>= 5.1) rather than keying on the Mythos-class name the way the disabled-thinking gate does, and every later Mythos-class minor inherits it automatically along with aliases, dated snapshots, and Bedrock-prefixed ids. The check runs on all three ingresses that can express a forced call:/v1/chat/completionsstreaming and non-streaming (where OpenAI's"required"and{"type": "function", ...}become Anthropic'sanyandtool),/anthropic/v1/messages, and the Responses-to-Anthropic converter, which unlike the disabled-thinking guard is reachable by an ordinary request because a Responses client can ask for a forced call today./anthropic/v1/messages/count_tokensand Bedrock Converse passthrough stay ungated for the same reasons they are exempt from the disabled-thinking guard. The 400 names the model, echoes the spelling the client actually sent rather than the Anthropic translation of it, and lists the documented remedies (autoplus an instruction naming the tool,strict: trueon the tool, or structured output); the router applies none of them, because downgrading a forced choice toautolets the model answer with plain text while the caller still believes a tool call was guaranteed, and that failure surfaces far from the request that caused it. -
Add
selection_strategy: EngineLoad, which ranks the whole eligible candidate set on engine-reported queue depth instead of only the holders of a prefix key (#1581, follow-up to #1447). Therouting.engine_loadscoring term reaches selection only through the KV overlap scorer, so a fleet with prefix routing off, or a cold prefix, collected engine statistics that never influenced a routing decision. The strategy ranks on engine truth only when every eligible candidate has a freshwaiting_requestsstatement and the spread clears the balance dead band; otherwise it defers to the new hot-reloadingrouting.engine_load.base_strategyand records why (strategy_stale_fallback,strategy_hysteresis_hold). Requiring a statement from every candidate is deliberate, because ranking a subset would send every request during an imbalance to whichever backends publish statistics and starve the ones that do not. Naming the strategy is the opt-in, so it does not readrouting.engine_load.enabled;config validatewarns instead whenengine_stats.enabledis false, andbase_strategy: EngineLoadis a load error with a second guard at the point of use so a programmatically built pool cannot recurse either. One shared comparison now serves both the strategy and the scorer term, which also makes the winner of an exact tie deterministic instead of dependent onHashMapiteration order. -
Feed engine-side
kv_cache_usageandwaiting_requestsinto smart-routing load assessment, so tier degradation reacts to GPU saturation (#1578). Router-local counters cannot see it: a backend whose KV cache is nearly full still accepts connections and still passes its health probe, and router latency only moves once the queue has already built.smart_routing.load_management.thresholds.{warning,critical}and anytier_thresholdsoverride gain both fields, each opt-in per field, withkv_cache_usagerange-checked to 0.0 through 1.0. Values are folded by maximum across backends with a fresh snapshot rather than averaged, because averaging would let three idle backends hide the one saturated backend the thresholds exist for. Freshness reuses the existingEngineStatsStoredefinition, so a deployment withoutengine_statsnever fires the new thresholds and a stalled poller degrades them to silence rather than to a stale verdict.smart_routing_load_transitions_totalgains areasonlabel drawn from a closed four-value set, andGET /admin/smart-routing/load-state, thestatusendpoint that embeds the same snapshot, and the WebUI panel now show the three engine numbers a degradation decision was made on, rendering absence as null rather than as a false zero. -
Serve semantic response-cache hits behind the Continuum Hub optimization gate (#1580). The hub decision has computed
semantic_cachesince #887 with nothing consuming it; the serving path adds a bounded in-process vector index, one embedding call per eligible request to an operator-pinned backend, and a third/v1/chat/completionslookup after the exact and prefix paths miss and after the guardrail input gate. Partitioning is the security boundary: the digest folds in the caller's identity, the model, the embedding model, every response-shaping field, and every message before the final user turn, so two API keys, system prompts, or tool sets never share an entry.response_cache.semanticis always compiled and validated whenever present, withbackendrequired when enabled because vectors from two embedding implementations are not comparable. The lookup runs after the guardrail input gate so a blocked prompt is never sent to the embedder, the embedder call is bounded byembedding_timeout_msand degrades to a miss on any failure, and the whole path is skipped before any embedding call when neither the operator threshold nor the hub similarity names one. Hits answerX-Cache: HITwithX-Cache-Mode: semanticand count undercontinuum_response_cache_semantic_total{result}andcontinuum_response_cache_semantic_entries. No new dependency, and theembedfeature graph stays green.
Changed¶
-
Behavior: Resolve the
/v1/realtimemodel alias through the same exact-alias rule the chat, Anthropic Messages, and Responses ingresses use (proxy::alias_dispatch) instead of realtime's own fuzzy pipeline (#1589).resolve_audio_modelused to runfind_matching_config_slice's full six-phase matching (date-suffix stripping, quantization and format peeling, HuggingFace prefix stripping, wildcard aliases) and checked the metadata cache before backendmodel_configs, the opposite of the documented metadata priority; a realtime request for a fuzzy spelling such as a date- or quantization-suffixed id could therefore resolve to a different model than the identical spelling reached on the chat path, or resolve at all when the chat path would reject it. Only an exact id or exact alias, or amodels/-prefixed spelling of one, now resolves, and capability lookup follows the same backend-model_configs-before-metadata-cache order ascanonical_catalog_id. The per-request retry that only fired when literal candidate lookup came back empty is replaced byproxy::alias_dispatch's catalog-backed rewrite (canonical_catalog_idplusdecide_dispatch_model), which reads the live catalog rather than the configuredmodels:allowlist; this also fixes a configured-but-unserved alias, where a backend lists the alias inmodels:alongside the canonical id but live discovery answers only the canonical, a case the old retry could never reach because the configured alias always produced a candidate on its own. -
Behavior: Apply the alias-to-canonical dispatch rule on the remaining ingresses that pick a backend from a client-supplied model name (#1590).
/v1/embeddingsand its native Gemini multimodal sub-path,/v1/images/generations,/v1/images/edits,/v1/images/variations,/v1/responses/compact, and/anthropic/v1/messages/count_tokenseach looked the raw name up and forwarded it verbatim, sogemini-embedding-2-preview(an alias ofgemini-embedding-2) ornano-banana-2(an alias ofgemini-3.1-flash-image) reached Google as a404while the same alias worked on chat. Each now runs the sharedproxy::alias_dispatch::dispatch_model_for_requestafter its own per-key gate and before backend selection, and carries the requested and dispatched names as a pair so a404or403raised after the rewrite still quotes what the client sent. A name any backend serves literally is still never touched, and no rule about which aliases resolve changed. Two consequences are worth knowing:/v1/embeddingsnow runs its per-key model check at the ingress rather than leaving it to the dispatch funnel, so the canonical id can be added to a non-emptyallowed_modelslist only after the requested name was admitted; and the image edit and variations endpoints check their fixed list of accepted model names before the rewrite, so an alias reaches them only when it is itself one of those names. ACP stays literal-only, as its usage and architecture documents promise. -
Behavior: Answer a string or
nullinerror.codeon every OpenAI-shaped error body and SSE error event instead of repeating the HTTP status as an integer (#1610). The OpenAIErrorschema typescodeasanyOf [string, null], and openai-python builds the field withconstruct_type, whose loose coercion hands anintstraight through under anOptional[str]annotation, soerr.code == "insufficient_quota"silently never matched anderr.code.startswith(...)raised on a body the router considered valid.ErrorDetail.codeis nowOption<String>, filled from one table insrc/errors.rsthat maps everyRouterErrorvariant to a stable snake_case code (model_not_found,insufficient_permissions,rate_limit_exceeded,invalid_api_key,invalid_request,request_too_large,upstream_error,service_unavailable,timeout,internal_error), and a provider's own string code wins over the table whenever the upstream body carried one, which is howmalformed_function_call(#1592) now reaches the client aserror.codeon both the non-streaming body and the streaming error event rather than only inside the message text. The status was and remains on the HTTP status line. Every route that used to format its own body (the admission503, both rate limiters, the Responses handler, the auth refusals, the Chat Completions SSE error event) now builds it through one shared envelope, somessage,type,param, andcodeare all present on every/v1error withparamandcodeserialized asnullrather than omitted; the Responseserrorstream event takes the flatResponseErrorEventshape the spec describes, withcode,message,param, andsequence_numberbesidetypeinstead of nested under anerrorobject. A client that parsederror.codeas a number has to read the HTTP status line instead. -
Breaking (behavior): Accept
xhighas anoutput_config.effortvalue on the/anthropic/v1/messagesingress instead of treating it as unknown.xhighis one of the five levels the Anthropic Messages API defines and the one Claude Code sends by default, but neither ingress transform listed it:transform_thinking_with_effortinsrc/http/handlers/anthropic/transform.rsandmap_effort_stringinsrc/http/handlers/anthropic/responses_transform.rsboth matchedmax,high,medium, andlowand sent everything else down the_arm, which logsUnknown output_config.effort valueand substituteshigh. A client that asked forxhighhad its request downgraded one level, the only trace a warning in the router's own log, and on a backend whose vocabulary excludeshighthe downgrade turned a serviceable request into a400. Observed against avllmbackend serving a Qwen3.8 FP8 template that acceptsxhigh,medium, andlow:
POST /anthropic/v1/messages {"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}
DEBUG Unknown output_config.effort value effort="xhigh"
DEBUG Mapped output_config.effort to reasoning_effort anthropic_effort="xhigh" reasoning_effort="high"
400 Invalid request sent to backend
With the arm added the same request maps xhigh to xhigh and returns 200. Both transforms now map xhigh to xhigh, the spelling every other level already uses. The _ arm and its warning are unchanged, so a genuinely unrecognized string still falls back to high, and the max to xhigh promotion is unchanged. Migration: a backend whose vocabulary lacks xhigh now receives xhigh where it used to receive high, and rejects it if it validates the field: vLLM chat templates that accept only low/high/max, for example, answer 400. This surfaces a mismatch the substitution used to hide, and the fix is to send the level that backend accepts, or to give it a model entry whose vocabulary the router already fits against. Targets that are fitted per model are unaffected: Gemini still downgrades xhigh to high through validate_reasoning_effort, and a cross-provider fallback hop still fits the level to the selected target as #1530 left it.
-
Bound the model-list fan-out tail so one unreachable backend no longer costs every refresh its full retry budget (#1553, the follow-up that #1548 and #1551 deferred).
ModelFetcher::fetch_all_modelsawaits one future per backend, so a refresh costs as much as its slowest backend, and a backend that never answers/v1/modelsspent(max_retries + 1) × request_timeout + max_retries × retry_delayon every refresh: 16 s at the defaults, the unit every convergence bound in #1548 and #1551 was denominated in. The fetcher now keeps a small per-backend failure memory across refreshes, shared through anArcbecause the fetcher is cloned into every spawned revalidation, and a backend whose previous refresh failed with a transient error (a timeout, a connection failure, a 5xx) is probed with a single attempt atrequest_timeouton the next one. Measured with three fast backends and one that accepts connections and never answers: the first refresh still costs 16.02 s, every refresh after it 5.00 s. The per-attempt timeout is deliberately unchanged, so a backend that answers again is restored to the full budget on its first success, within one refresh of its recovery, and a slow-but-working backend cannot be locked out by a timeout it can never meet. A backend with no history always gets the full budget, so the first refresh of a process and the first refresh of a newly added backend are never cut short. When every backend fails in one refresh, the backends probed once get the rest of their budget before the result is stored, so a fleet-wide blip is not published as an empty list and such a refresh costs the same 16 s as before. A cut-short backend is an ordinary transient failure: it lands infailed_backends, counts inmodel_transient_errors_total, keeps its last-known models through the #1422 retention, and is not marked unhealthy. Unix-socket backends honor the same budget, the Admin discovery entry points keep the full one, and theSlow model fetch from backendline now also carriesbudget_attempts. There is no new configuration field; the threshold is one failed refresh. -
Tighten the ordering contract between a pool membership change and its cache invalidation, and bound what a superseded model refresh costs (#1552, follow-up to #1548). The store-time membership compare was not serialized against a pool mutation the way the epoch compare is, so a backend added between that compare and the insert would have published the pre-add list as
Freshfor a full soft TTL; the store now re-reads the membership generation after the insert and expires the entry in place through the newModelCache::expire_entry, which neither re-runs the store nor bumps the invalidation epoch. Hot reload invalidates the model cache directly after mutating the pool and before its immediate health-check round trip, so both stamps move before any await and one reload reaches an in-flight fan-out as one supersession instead of two, which saves a full fan-out per reload; the reorder is safe because the fan-out filters on the configenabledset and never on health state, and/v1/modelsavailability is computed per request. The in-place retry of a superseded revalidation releases the aggregation lock between passes and backs off 200 ms doubling per retry with jitter, so each contiguous hold is exactly one fan-out (aPOST /v1/models/refreshor a reader past the stale-serve bound queued behind a pass gets the lock next rather than after the whole chain), and it re-checks the cache on re-acquire so a list another task published in the gap is not aggregated again. A chain superseded on all three passes leaves the entry expired and holds the next revalidation for a 2 s re-arm interval, which turns sustained pool churn into bounded bursts of fan-outs rather than an unbroken chain; the interval never applies to a first supersession, whose follow-up still starts at once, and aFreshpublish from any path lifts it.model_background_refreshes_totalis incremented per fan-out again, including in-place retries, so dashboards that read it as a fan-out count are right under supersession;model_background_refresh_successes_totaland_failures_totalstay one per revalidation and both metrics pages say so.
Fixed¶
-
Apply
fallback.fallback_policy.fallback_timeout_multiplierto every fallback hop's own timeout (#1615). The knob was parsed, range-validated to 1.0-5.0, exposed in the Admin schema, stored onFallbackPolicy, shipped at1.5in three configuration templates andconfig.yaml.example, and documented as scaling an attempt's own timeout, but no request path read it:calculate_timeouthad no non-test caller, so every hop ran with the primary attempt's window and an operator who raised the value got nothing. The attempt number the fallback executor already keeps is now threaded to the four drivers (the executor closure gains it as a third argument,IngressAttemptcarries it, and the pre-stream connection loop derives it from its own hop counter), and each scales its base through one shared helper: the primary is attempt 1 and unchanged, hopnruns withbase * multiplier^(n-1), clamped to the boot-pinnedtimeouts.limitsceiling for that timeout class so a chain cannot reach past a value the configuration itself could not have set. The base is whatever the attempt would have used without a chain: the per-modeltimeouts.request.standard.total(or the image-generation total) on the OpenAI-compatible non-streaming path, the per-attempt streaming window on the pre-stream connection phase, the per-model profile total plus the first-SSE-byte deadline on/anthropic/v1/messages, and the flat standard or streaming total on/v1/responses.timeouts.connection, the wait for a fallback dial permit, a within-hop retry, and a mid-stream hop taken after the client already holds the stream are deliberately not scaled. The scaling composes withtimeouts.streaming_fallback_budget_multiplierrather than multiplying with it, because the streaming connection phase scales the hop's window first and then caps it at what is left of the shared chain budget. Deployments that left the knob at its1.5default now give a first hop half again its previous window, which is the documented behavior they were already configured for. -
Fold the hub key id into the response-cache caller identity so two hub keys never share an exact or prefix cache entry (#1579). Under
api_keys.mode: permissivea hub-keyed request passes local auth without anAuthContext, so the local cache identity was the empty string for every hub key on a router and the exact and prefix caches shared one namespace across all of them: key B was served key A's cached deterministic completion, and an entry stored by key B answered key A. The semantic cache added in #1571 already folded the hub key id into its partition; the exact and prefix paths on/v1/chat/completions(streaming and not),/v1/responses, and/anthropic/v1/messagesdid not. The caller half of a cache namespace is now one type,proxy::cache_identity::ResponseCacheCaller, which every ingress must build from the presented token and the hub decision before it can mint a cache key, so the fold cannot be skipped by a handler added later, and the semantic cache consumes the same identity instead of resolving its own. A hub-keyed request whose token no longer resolves to a hub key is not cached at all rather than falling into the shared namespace. A local key and an anonymous permissive caller keep their identity byte for byte, so single-tenant deployments see the same cache keys as before; a hub key that local auth also recognizes (blocking mode) gets the id folded on top, which costs it one cold cache after the upgrade. -
Refuse to forward Gemini's
MALFORMED_FUNCTION_CALLturn as a success, and pin every Geminifinish_reasonto the OpenAI set (#1592). Google intermittently answers HTTP 200 with an assistant message carrying neithercontentnortool_calls,completion_tokens: 0, and a finish reason of its own, measured on 2026-09-10 as one call in two and one in five ongemini-3.1-pro-previewwith a single tool. The client received{"finish_reason": "function_call_filter: MALFORMED_FUNCTION_CALL", "message": {"role": "assistant"}}, which breaks an SDK switching on the five documented values, loops an agent on a tool turn with nothing in it, and violates the Chat Completions shape by omittingcontent. Both spellings (bare on the native API, behind afunction_call_filter:label on the OpenAI-compatible endpoint) are now recognized by one table shared bymap_gemini_finish_reason, the two streaming paths, and the non-streaming response transform, and the outcome becomes a retryable upstream failure throughCoreError::upstream_status(502,type: upstream_error, a message namingmalformed_function_call), so the retry loop and any configuredfallback.fallback_chainshop engage; a retry succeeded in every observed case, while answering a cleanstopwould hide a turn that produced nothing and still cost prompt tokens. On a streaming request the failure is detected after the response is committed, so it arrives as an SSE error event rather than a status. Two shape guarantees now hold on every Gemini route:finish_reasonis always one ofstop,length,tool_calls,content_filter, andfunction_call, because the shared table reduces anything unrecognized tostoprather than passing it through, and an assistant message always carries acontentkey,nullonly alongsidetool_callsand empty otherwise. -
Run the Gemini chat-completions transform on the non-streaming route, not only when
stream: true(#1591). The request transform lived on the streaming dispatch alone, so the same/v1/chat/completionsrequest reached Google withextra_body.google.thinking_config.include_thoughts: trueandmax_completion_tokens: 16384when it asked for a stream and byte-identical to the client body when it did not: a non-streaming client got noreasoning_contentfrom a thinking model, ran into Gemini's low implicit output cap, and hadreasoning_effort: "xhigh"forwarded instead of downgraded tohigh, while an unsupported effort was a router400only on the streaming half. The proxy path now runstransform_request_geminifollowed bystrip_non_openai_fields, the pair and the orderGeminiBackend::transform_requestuses, before the context-cache and cache-salt injections whoseextra_bodyoutput the top-level strip never touches. Enabling the injection alone would have leaked markup, because Google returns the thought summary inline inmessage.content, wrapped in<thought>...</thought>and markedextra_content.google.thought: true, and only the streaming transformer unwrapped that; the response side now moves that text intomessage.reasoning_content, leavescontentholding the answer alone (empty rather than absent when the model produced nothing but thought), drops the consumed marker while preserving a siblingthought_signature, and shares one tag-stripping helper with the stream transformer instead of a second copy of the literals. Which models receive the injections is now a family table with a reduction step rather than sixcontainschecks: the substring list matchedgemini-3.1-pro-previewbut notgemini-3.1-pro, so after the alias rewrite in #1583 the canonical name a caller is most likely to send was the one that got nothing. Both transports are covered, and the newer Flash families are deliberately not in the table, because adding one changes what those deployments already receive. -
Fit
reasoning_effort: "none"on a fallback hop whose target model always reasons, instead of forwarding it into a provider error the chain then counts as a failed hop. The ingress gate added withrejects_reasoning_effort_nonejudges the model the client asked for; a hop lands on a different model chosen by the operator's chain, whose metadata can carry the flag when the original did not. Reproduced with a chain from a dead backend ontogemini-3.6-flash: the hop forwardednone, Gemini answered400 Request contains an invalid argument., the chain treated that as a failed hop, and the client received500 An internal server error occurred.naming neither the model nor the field. The hop now drops the effort and logs it, and the same request is served. The effort is fitted rather than the hop refused because a hop has already substituted the model, so a client that accepted a different model over a failure is better served by that model reasoning than by an error. The primary attempt is never fitted, since the ingress gate has already answered for it, and no other effort level is touched. One consequence is worth knowing: the substitute reasons, so a smallmax_tokensthat fit a non-reasoning answer can now truncate one. -
Answer the client in the model name it sent when an alias was dispatched under its canonical id (#1588). PR #1583 rewrites an exact metadata alias to the id its backend serves before backend selection, and each ingress overwrote the only copy of the requested name, so two client-facing surfaces started naming a model the client never asked for: the
X-Original-Modelfallback header reportedgemini-3.1-pro-previewfor a request that sentgemini-3.1-pro, and a404or allow-list403raised after the rewrite quoted the canonical id too. The three rewriting ingresses (/v1/chat/completions,/anthropic/v1/messages,/v1/responses) now carry both names as aDispatchModelpair: selection, the fallback chain lookup, the cache key, and the payload keep using the dispatched id, while the fallback attribution and any model-not-found or not-permitted error are built from the requested name, on the streaming arms as well as the non-streaming ones.X-Fallback-Modelstill names the model that served, and the responsemodelfield is still whatever the provider answered. Nothing in the alias resolution rules changed. -
Turn the Gemini
reasoning_effort: "none"gate from an allowlist compiled into the router into a deny list carried in model metadata, and apply it on the/v1/chat/completionsingress. The predecessor refusednonefor any model its table had not heard of, which reliably meant "released after the last router build": Google shipped four Gemini Flash minors between May and September 2026, and each one lostnonesupport until someone measured it and cut a release. The newrejects_reasoning_effort_nonemetadata key inverts that. An unlisted model is forwarded and the provider decides, and the flag ships set only on the ids measured to refuse:gemini-3.6-flash,gemini-3.5-flash-lite, and the Gemini Pro tiers. Because it is ordinary metadata, amodel-metadata.d/drop-in can set or clear a row the day a provider changes its mind. The check now runs at the chat-completions ingress before backend selection, so it covers streaming and non-streaming and the previously ungated proxy path, and it answers with a400naming the model and the field: for the two Flash tiers the upstream body is onlyRequest contains an invalid argument., which names neither.validate_reasoning_effortkeeps vocabulary validation and thexhighandautonormalizations but no longer decides model support, soGeminiBackendcarries no model table at all. -
Key the Gemini thinking-disable gate on the exact model family instead of a name substring, and correct the set it holds.
supports_thinking_disabledecidedreasoning_effort: "none"with fivecontainschecks, which was wrong in both directions:gemini-3.5-flash-litematched thegemini-3.5-flashsubstring and was forwarded to a provider that refuses it, whilegemini-3.7-flashandgemini-3.8-flashmatched nothing and were refused locally though the provider accepts them. Each id in the shipped catalog was measured against the OpenAI-compatible endpoint on 2026-09-10 with onereasoning_effort: "none"request: 2.5 Flash, 2.5 Flash-Lite, 3 Flash, 3.1 Flash-Lite, 3.5 Flash, 3.7 Flash, and 3.8 Flash answer200, while 3.5 Flash-Lite and 3.6 Flash answer400 INVALID_ARGUMENT, and the 2.0 family answers404because it is retired upstream and is carried unverified so a proxy still serving those names behaves as before. Support follows neither the version order nor the Flash to Flash-Lite relationship, so the gate is now a measured table keyed by a family reduction that keeps a-litesegment and drops a version, date, or preview suffix. The router's own400for an unsupportednonenow names the families that accept it. The gate is consulted by the typed Gemini backend paths (execute_chat_completion,execute_streaming_request, and the control-plane probe) and by the Anthropic-ingress payload transform, which drops an invalid effort with a warning rather than answering400. A request that reaches Gemini's OpenAI-compatible endpoint through the proxy path still carries the effort through untouched and is answered by Gemini itself, which was measured on the same date. The nativethinkingConfig.thinkingLevel: "MINIMAL"field is a separate axis with the opposite answer on the two models that differ (3.8 Flash refuses it, 3.6 Flash accepts it), which is recorded next to the table so a documentation statement aboutminimalis not used to extend it again. -
Assert the offending field from the message rather than the finding path in the
routing.engine_load.base_strategyself-reference test (#1581). A loader validation error reachesconfig validatethroughloader_validate_config, which files every such error under the genericconfigpath, so the path assertion pinned a bucket name rather than the diagnostic. The test was added against a base that predated the change which routes this error that way, and it has been failing onmainsince, blocking every pull request that touchessrc/. -
Match a multi-word classifier keyword when its words appear in order within one sentence with at most two tokens between them, instead of only when they sit adjacent exactly as the table lists them (#1603).
count_matcheswas a plain substring test, so natural phrasing that inserts a modifier in English ("write a short story") or a particle in Korean ("시를 하나 써줘") missed the marker, the request fell through tomultilingualorgeneral, and every policy keyed on that domain missed it too. Lengthening the keyword lists cannot fix this, because enumerating every modifier and every particle placement does not terminate, so the rule sits in the matcher and applies to every table at once. The adjacent spelling is still tried first through the same substring test, so no keyword that matched before stops matching, and a single-word keyword keeps exactly its previous behavior. A sentence terminator (a period, an exclamation mark, a question mark, the ellipsis character, a newline, or the fullwidth CJK forms) closes the window, so words on opposite sides of one never form a match however close together they sit. Each word must line up with a whole token, punctuation at the token edges ignored, with one exception: the last word may be followed by more characters in the same token when it ends in a non-Latin letter, which is써matching써줘. Confining that exception to the last word is what keeps the시in시 한 편from matching시스템, the collisionKO_CREATIVEwas written to avoid, and Latin script never takes the exception at all, orhow do iwould fire on "how do you implement". Because the rule sits incount_matches, thesimple,complex,creative, andanalysistables, both built-in languages, operator keywords undersmart_routing.classifier.rule.keywords, and any language table added later all inherit it, which makes it operator-visible semantics and it is documented as such. Measured on the labelled dataset, domain accuracy moves from 94.6% to 97.3% overall and from 88.9% to 94.4% on Korean, with complexity unchanged. -
Raise rule-classifier complexity accuracy from 62.2% to 69.5% by correcting the scoring mechanisms behind its misses (#1605). Complexity measured 62.2% while domain sat at 94.6%, and since
routing_policies[].when.complexityselects a model tier, that signal was choosing tiers correctly about three times in five. The simple-keyword term was inverted: it contributed(1 - strength) * 0.1against a weight of 0.2, an effective ratio of 0.15 to 0.30 that sits at or above the trivial boundary, so adding a keyword that means "this is easy" raised the score, and an identical greeting readtrivialin English andsimplein Korean purely because the Korean one carried a simple keyword. It now has the sameratio * weightshape as every other term, with a ratio that falls as the signal strengthens. The labelled dataset grows from 37 to 59 cases andCOMPLEXITY_ACCURACY_GATErises from 0.55 to 0.65 against the measured 69.5%; per language, English moves from 68.4% to 73.3% and Korean from 55.6% to 65.5%. Domain reaches 100.0% on the larger dataset, which is the combined effect of #1603 and #1604 landing first rather than of this change. The remaining misses all under-read, so the residual bias is one-directional and recorded rather than papered over. -
Dispatch a model alias as the canonical id its backend serves on
/v1/chat/completions,/anthropic/v1/messages, and/v1/responses, so a request forgemini-3.1-proreaches Google asgemini-3.1-pro-previewinstead of failing (PR #1583).model-metadata.yamlhas declaredgemini-3.1-proas an alias ofgemini-3.1-pro-previewsince #594 because Google serves only the-previewid, and the sampleconfig.yamllists both names under the Gemini backend; but backend selection matched the requested name literally against the configuredmodels:list and dispatched that same name, so a client asking forgemini-3.1-prowas selected onto the Gemini backend through the configured-name fast path and forwarded verbatim, which Google answers with404 models/gemini-3.1-pro is not found for API version v1mainand the router surfaced as a400. Verified against the live API on 2026-09-10: Google's catalog still lists onlygemini-3.1-pro-previewandgemini-3.1-pro-preview-customtoolsfor the 3.1 Pro family, and the same request through the router now returns200. The newproxy::alias_dispatchmodule applies one rule from one place, and the rule is deliberately narrow: the requested name must be an exact alias of a different canonical id (the fuzzy date-suffix, quantization, prefix, and wildcard phases of the metadata pipeline are not consulted, so a pinned snapshot such asgpt-5-2025-08-07is never silently served bygpt-5), every enabled backend that lists the requested name inmodels:must have been enumerated into the aggregated live catalog (a backend whose discovery is cold or failing contributes nothing, and its silence is not evidence), no backend may serve the requested name in that catalog, and a user-routable backend must serve the canonical id in it. The catalog is consulted rather than the configuredmodels:lists because a configured name is an operator allow-list entry, and in the motivating case it is exactly the entry that is wrong. A name any backend serves literally is never rewritten, so a local engine that listsunsloth/Qwen3.6-35B-A3B-GGUFkeeps receiving that exact id; a request nothing can serve keeps its original name and fails as it did before. The rewrite runs after each ingress's per-keyallowed_modelsgate andrequest_paramspolicy snapshot, both of which keep judging the name the client asked for, and the allow-list handed to the dispatch funnel is extended with the canonical id so its re-check cannot turn the admission into a403; it is skipped for Hub-attributed requests, requests under a Hub exact-model budget guard, and AppProxy ingress-pinned requests, which already carry a model decision. Afallback.fallback_chainsentry keyed on the alias no longer fires once the alias is dispatched as its canonical id; the router logs a warning at request time when it meets one, and the fix is to key the chain on the canonical id. The upstream answers with the id it served, so an alias request now shows the canonical id in the responsemodelfield, which is how OpenAI itself answers a floating name with the pinned snapshot it resolved to. The embeddings, image, Responses compaction, ACP, and realtime paths are unchanged.ModelAggregationService::catalog_snapshotis the new read-only catalog accessor behind the decision: it answers from cache, serving stale data with a background revalidation once it has aged, and pays a blocking aggregation only in a process that has never aggregated.tests/model_alias_dispatch_test.rspins each clause on the chat path (non-streaming and streaming), the Anthropic Messages path, and the Responses path by reading themodela mock upstream received, together with a key scoped to the alias, a failing discovery endpoint, and a date-suffixed spelling. -
Report a
routingvalidation error under its field path incontinuum-router config validateand the MCPvalidatetool, sorouting.engine_load.base_strategy: EngineLoadis filed as an error onrouting.engine_load.base_strategyrather than under the genericconfigpath (PR #1583). The loader validates the section with itsValidateimpl and fails fast with onerouting: <errors>string; the report now renders the identical text with the flattened field paths attached, so the fail-fast gate deduplicates it and the operator sees which field to fix. This also makes thea_self_referential_base_strategy_is_an_errorunit test that #1581 added pass, which it did not onmain. -
Make Hub-managed
kv_routingreach request routing without a restart, and stop KV ingestion from losing or misattributing cache transitions (#1560). The section shipped as immediately reloadable, but the live index, the event consumers, and the KV scorer were each fixed at process startup, so an apply changed the stored configuration and nothing else: a rollout, a source change, a scoring change, and a rollback all needed a restart to take effect. The index and its consumers are now materialized or torn down live, the named scorer is replaced atomically, and startup seeds itself from a restored Hub snapshot. Four ingestion defects go with it: an empty-token offload lost its identity, a prefix held by several chains was evicted by whichever chain ended first rather than refcounted, a reused block hash was not evicted before its reuse, and a source sequence was committed before the records it covered had been processed, so a failure mid-processing skipped them. Event delivery now applies backpressure instead of dropping cache transitions, and a consumer whose listener fanout has fallen behind closes the SSE stream and reconnects withLast-Event-IDso the retained history is replayed rather than lost. Asalt_echoidentity that is malformed, missing, or issued for another backend is treated as unattributable instead of falling back to detokenization, and event-source endpoints are redacted in runtime error logs. -
Enforce non-empty API key
scopeson OpenAI-compatible and Anthropic API routes inapi_keys.mode: blocking, so ascopes: [read]key can list/read models and stored outputs but can no longer drive chat, completion, embedding, rerank, image, Responses create/delete, model refresh, batch mutation, or realtime inference routes (#1557). Empty or absentscopesremains unrestricted for backward compatibility,writeimpliesread,adminimplies both,filescontrols only the separate Files API group, andpermissivemode still never rejects a request solely because a presented key has narrow scopes. The route-class table is now source-audited against thebuild_api_routesinventory so newly added/v1routes must choose a scope class instead of silently inheriting all-or-nothing authentication. -
Publish a backend added by hot reload while a model-list fan-out is already in flight within the remaining fan-out plus one fresh fan-out, instead of hiding it for soft TTL plus one fan-out (#1548, reported from backend.ai-go#4812).
ModelFetcher::fetch_all_modelssnapshots the pool at fan-out start, and a hot reload that landed during the 16 s that one slow backend costs every refresh mutated the pool and expired the cache without touching the running fan-out, so that fan-out finished with its pre-add membership and stored it as aFreshentry. The healthy-eventrevalidate_nowand every request-pathspawn_revalidationin that window coalesced into the in-flight refresh and were dropped,GET /v1/models/{id}answered 404 from the stale list, and nothing re-aggregated until the soft TTL expired: 79.7 s measured against 1.24.0 with the defaults and one 8 s backend, against 16.5 s when the add lands between fan-outs. Each aggregation now records two stamps immediately before it snapshots the pool.BackendPool::membership_generationis bumped inside the write critical section ofadd_backend,remove_backend, anddrain_backendon their success paths only, which covers hot reload, the Admin API, the AppProxy registry, and control-plane backend sync because all of them funnel through those three.ModelCache::invalidation_epochis bumped byclear_cache()andModelCache::clear, which also catches amodels:allowlist edit, an auth change, or anenabledtoggle that invalidates the cache without changing membership. At store time a result whose stamps moved is written through the newModelCache::set_expired, or refused byModelCache::set_if_epoch_unchanged, whose epoch compare and insert share one lock with the invalidation so nothing can slip between them; either way the result is published as an already-expired entry, so readers inside the stale-serve bound from #1324 still get the previous list and the next reader revalidates instead of coalescing into aFreshhit. The follow-up does not wait for that reader: a background revalidation re-aggregates in place, bounded at three passes so a burst of Backend.AI GO model registrations cannot pin the task, and the request path andPOST /v1/models/refreshrelease the aggregation lock and spawn one background revalidation before answering with the list they produced. The stamps are read before the snapshot on purpose, since reading them afterwards would reintroduce the race, and the code says so. An unchanged pool costs nothing new: the stamps differ only when something changed under the fan-out, and the #1324 suites still count exactly one fan-out per refresh. A superseded refresh is logged atinfowith the reason and both stamp values, on a line separate from theModel refresh: N models in Dpair that #1326 made scrapeable, and counted in the newmodel_superseded_aggregations_total. The regression suite gates the mock backend so the pool mutation provably lands inside the fan-out on every run rather than on the runs where a sleep happened to line up. The fan-out tail itself, where one unreachable backend costs every refresh 16 s, is unchanged and left for a follow-up. -
Emit
tool_calls[].indexas the 0-based ordinal of the tool call within the response on streaming/v1/chat/completions, instead of the upstream content-block or parts position (#1546). The Anthropic converter keyed its per-call state by the Anthropic content block index and reused that same number on the wire, so anythinkingortextblock before the firsttool_useshifted the numbering: a turn that began with one sentence streamed its two tool calls asindex1 and 2 rather than 0 and 1. OpenAI defines the field as a slot in thetool_callsarray and clients treat it literally, so@ai-sdk/provider-utilsbuilt a sparse array and every tool-using turn died at end of stream withTypeError: Cannot read properties of undefined (reading 'hasFinished'), which looked intermittent because turns without a tool call were unaffected. The transformer still keys its state by the block index, which is the only identifiercontent_block_deltaandcontent_block_stopcarry, but assigns eachtool_useblock a sequential ordinal at its start event and emits only that, in the opening chunk and in everyinput_json_deltachunk, so interleaved argument deltas for parallel calls stay on their own index. The Gemini native converter had the same defect in two forms: it used the position of the part inside one event'spartsarray, so a leadingtextpart shifted the first call, and it kept no state across events, so two calls delivered in two events both reported0and the client merged the second into the first. It now counts tool calls with a per-stream counter cleared byreset(). Both fixes match the rule the Bedrock Converse converter has followed since #909. The Anthropic change reaches the native Anthropic backend, the HTTP and Unix-socket streaming paths, and the Bedrock InvokeModel-style paths (bedrock-mantleandbedrock-runtime), which reuse the same transformer. Non-streaming responses are unchanged, since theirtool_callsarray carries noindex. -
Classify Korean requests by intent instead of collapsing them into
domain = multilingual(#1567). The rule classifier's keyword lists were English only, so no intent signal ever fired for Korean text and the non-ASCII ratio check was left as the strongest domain signal on anything without a fenced code block. Length compounded it: the token estimate divided the UTF-8 byte count by four and a Hangul syllable is three bytes, so a short Korean greeting measured over the 10-token trivial boundary while its English translation measured under it. A new language module holds one detector, a script-weighted token estimate, and per-language keyword tables, with English and Korean built in, every table including the English keywords, and an unrecognized language falling back to English.determine_domainnow takes the strongest intent signal and consultsmultilingualonly when none fired, andClassificationResultcarries the detected language. Two optional hot-reloading fields undersmart_routing.classifier.rule,primary_languageand per-languagekeywords, complete parity for a request that carries no intent signal at all, such as a greeting. Leaving both unset preserves every pre-existing domain assignment, and the admin classify diagnostic and the WebUI playground now report the detected language.
CI¶
-
Add a labelled Korean and English classification dataset with a runner that reports rule-classifier accuracy and confusion matrices per mode, gated in CI (#1568).
tests/data/smart_routing/dataset.jsonlabels requests across everyDomainTagandComplexityLevelcombination, labelled independently from the doc-comment definitions insrc/services/smart_routing/types.rsrather than copied from classifier output, so the measured accuracy is a real regression signal rather than a tautology.tests/smart_routing_accuracy_test.rsruns every case throughclassify_onlyfor therule_defaultandrule_primary_kodeployment modes, prints accuracy and a domain and complexity confusion matrix per mode and per detected language, and asserts a gate per mode. LLM and hybrid modes are out of scope, since they need a live or mocked backend round trip rather than a fast deterministic check.docs/en/development.mdand its Korean mirror document how to extend the dataset. No CI configuration changed:.github/workflows/ci.ymlandscripts/local-ci.shalready runcargo test --tests -- --skip integration_test, which compiles and runs every top-leveltests/*.rstarget under default features. -
Exempt the realtime metadata lookup from the backend candidate filter audit (#1602).
every_backend_candidate_set_is_filtered_or_explicitly_exempthad been failing onmainsincemodel_config_for_canonical_idwas introduced insrc/proxy/realtime.rs. That function is not a dispatch path: it walks enabled backends'model_configsto find theModelConfigdeclaring an already-resolved canonical id so the realtime handshake can read that model'saudiocapability, and it returnsOption<&ModelConfig>rather than a backend set, which is structurally identical to the already-exemptalias_dispatch::canonical_catalog_id. No production code changed, and the visibility guarantee the audit protects is still enforced where a backend is actually chosen: the realtime handshake builds its dispatch candidates separately throughfind_backends_for_modeland appliesfilter_user_routing_candidates, so a model served only by aninternal: trueorenabled: falsebackend is still refused.
Documentation¶
- Describe the semantic response cache as implemented (#1616). The Scope notes in
docs/en/architecture.mdand its Korean mirror still said the semantic cache path was a deliberate no-op with no embeddings client and no tenant-isolated vector-similarity index, and thatCacheHitType::Semanticwas reserved with no serving path. #1571 built that path, so the text had been wrong since it merged, and the usage-record paragraph repeated the same claim about thesemanticvalue ofcache_hit_type. Both paragraphs now describe what ships, drawn fromdocs/en/architecture/kv-cache.mdandconfig.yaml.examplerather than restated from memory: the opt-in third path consulted only after the exact and prefix lookups miss, its three gates, cosine similarity againstthresholdor the hub policy'ssemantic_similarity_bps, theX-Cache-Mode: semanticheader, and the bounded per-router index with oldest-first eviction and no external vector database.src/services/smart_routing/load_monitor.rsis also split into a directory module (assessment.rs,state.rs,tests.rs) with no behavior change.
Dependencies¶
-
Refresh the Cargo dependency graph, moving roughly three dozen crates to their newest semver-compatible versions and pulling in
core_detect,multiversion,multiversion-macros, andmultiversion_no_opas new transitive dependencies of the language-detection path. The repository diff is limited toCargo.lock. -
Bump the Cargo minor-and-patch dependency group with five updates (PR #1556):
tower-http0.7.0 to 0.7.1,lru0.18.3 to 0.18.4,toml1.1.4 to 1.1.5,aws-smithy-types1.6.2 to 1.6.3, andrmcp3.1.4 to 3.2.0. The resulting repository diff is limited toCargo.lock.
v1.27.0 - 2026-09-03¶
Twenty-three commits since v1.26.0 closed credential-forwarding gaps, added Basic authentication for self-hosted engines, and carried cross-provider reasoning intent and answered upstream status through fallback. They also rejected backend endpoint URL forms deprecated in v1.26.0 and bounded fallback dials per backend.
Added¶
-
Convert an Anthropic thinking config into
reasoning_efforton a hop toward an OpenAI-wire target instead of only stripping it (#1518, the residual #1517 left behind). Since #1517 every OpenAI-wire send site removesspeed,thinking, andoutput_configkeyed on the selected backend's configuredbackend_type, which is fail-safe but lossy: a request that asked for extended thinking hopped onto a rescue backend running with that target's default reasoning behavior, and the client's intent was discarded without a trace. The same dispatch-side gate now translates immediately before it strips, on exactly the requests that strip, so no send site can strip without converting and no call site changed.output_config.effortmaps level for level, withmaxbecomingxhighwhere the target's vocabulary has it andhighwhere it does not;thinking: {"type": "enabled", "budget_tokens": N}maps back through the existing band table (<= 4096low,<= 10240medium, anything larger high), which is the inverse ofReasoningEffort::to_budget_tokensrather than a second vocabulary;{"type": "enabled"}with no budget maps tomedium; adaptive thinking with no effort and{"type": "disabled"}both emit nothing, disabled winning even when anoutput_configeffort is present; and anything malformed degrades to a plain strip, never to a failed hop. A client-suppliedreasoning_effort, flat or nested asreasoning.effort, always wins and is never overwritten. Because the conversion runs at the send site, after the reasoning validation that guards a client-supplied value, the emitted level is fitted to what the selected target documents:openaiandazureconsult the OpenAI model table and receive nothing at all when the model supports noreasoning_effort(gpt-4o, the chat and instant tiers, an Azure deployment name the table does not recognize), and otherwise take the closest allowed level on thelow < medium < high < xhighladder, preferring the next higher level on a tie, solowtowardgpt-5-probecomeshighandlowtowardgpt-5.2-probecomesmedium; every other OpenAI-wire target, the self-hosted engines and Gemini included, receiveslow,medium, orhighand neverxhigh. Local engines are included deliberately:reasoning_effortis the spelling the router already keeps out of every strip because every target either consumes or ignores it, the inbound/v1/messageshandler has fabricated it toward these same engines since it shipped, and cloud Gemini's own transform maps it tothinking_level.none,minimal,auto, andmaxare never emitted. The guard is an end-to-end suite that asserts on the outbound body a mock backend actually received, non-streaming and streaming, and five of its cases were verified to fail with the conversion removed. -
Add
auth.type: basicfor self-hosted engine backends (#1476). The credential is theusername:passwordpair inapi_key, split on the first colon per RFC 7617, and the router sendsAuthorization: Basic base64(username:password). This is the supported destination for a credential that used to be embedded inbackends[].url: the bytes on the wire are identical, becausereqwestproduced exactly this header by stripping URL userinfo inRequestBuilder::new, so a reverse proxy sees no change. What changes is that the credential moves out ofbackends[].url, which the configuration maskers deliberately leave readable and the WebUI renders, intoapi_key, which they mask. Accepted onvllm,sglang,ollama,lmstudio,llamacpp, andmlxcelonly, and a load error on every other type includinggeneric, whose factory arm can fall back to a backend that carries no credential at all: accepting it there would drop the credential silently.api_keyis required and must contain a non-empty username before the colon, so a malformed credential is refused at load rather than encoded into a header that authenticates as a bare username and fails against the proxy on every request. An unresolved${VAR}is accepted and re-checked at startup after interpolation.
Changed¶
-
Breaking (behavior): Strip client-supplied credential headers on every chat-completions path (#1523, from the security review of #1519). The OpenAI-wire transports forwarded every client header they did not recognize as a connection header, and suppressed the client's
authorizationonly when the selected backend happened to carry a credential of its own, sox-api-key,api-key,cookie,set-cookie,proxy-authorization,x-auth-token, andx-access-tokenalways reached the backend, andauthorizationreached it whenever the backend was configured without a key. Because the router's own auth middleware acceptsx-api-keyas a bearer-equivalent credential, the very header a client uses to authenticate to the router was replayed upstream to a third-party backend operator; and because the mid-stream fallback loop resolves the backend secret once per hop precisely so a hop can switch providers, provider A's client credential rode along to provider B by construction. All four forwarding loops now drop credential headers unconditionally through one sharedis_credential_headerpredicate insrc/infrastructure/common/secure_header.rs: the streaming builderbuild_openai_chat_request_core(which serves the initial dispatch, the mid-stream per-hop loop, and auto backend selection), the streaming Unix socket builder, the non-streamingmake_http_request, and the non-streaming Unix socket builder. The predicate reads the same list log redaction reads, so stripping and redaction cannot drift apart, andx-goog-api-keyjoins that list. A backend's credential now comes only from its ownapi_key, itsauthblock, or the hub-delivered provider secret, and the strip is unconditional, so nothing is re-evaluated per hop. Migration: a backend configured without any credential now sends noAuthorizationheader at all rather than relaying the caller's, which used to work by accident of the old condition; setapi_keyon that backend entry instead, and the symptom of an unmigrated one is a401from the upstream on every request to it. Non-credential headers such asuser-agent,x-request-id, and tracing headers still forward unchanged, so this is a deny list and not a whitelist; the native Anthropic, Gemini, and Bedrock dispatch paths and the Responses and Realtime paths already behaved this way; and the two Responses header filters now take the credential half of their deny lists from the same predicate without losing a single name. The guard is an end-to-end suite that reads the headers a wiremock backend actually received and asserts by value as well as by name, sincereqwestappends rather than replaces and a name-only assertion would pass while the client's value rode along as a second entry; six of its seven tests were verified to fail when the predicate is disabled. -
Breaking (behavior): Extend the client-credential-header strip to the Anthropic Messages, count_tokens, image, and multimodal embedding paths (#1527, from the security review of #1524). Issue #1523 fixed the four chat-completions forwarding loops, but five sibling loops kept the pre-#1523 shape: the Anthropic Messages HTTP builder
build_request_with_authand Unix socket builderbuild_unix_socket_headersinsrc/http/handlers/anthropic/handler.rs,build_count_tokens_requestinsrc/http/handlers/anthropic/count_tokens.rs, and the two duplicated loops inhandle_streaming_image_generationinsrc/proxy/image_gen.rs. On those paths a clientauthorizationorx-api-keywas suppressed only when the selected backend happened to carry a credential of its own, andcookie,set-cookie,api-key,proxy-authorization,x-auth-token, andx-access-tokenwere forwarded whatever the backend's configuration. That mattered most on the Anthropic surface, because the Anthropic SDK authenticates withx-api-keyand the router's own auth middleware accepts that header as a router credential, so the very header a caller uses to reach the router was replayed to a backend configured without a key of its own. All five loops now call the same unconditionalis_credential_headerpredicate the chat paths use, and two more join them. The image variations loop: itsopenai-*/x-*allow list matchedx-auth-tokenandx-access-tokenby prefix, so both reached the provider, and its narrower localcontains("api-key")heuristic is replaced by the shared deny list. The native Gemini multimodal embedding dispatch behind/v1/embeddings(handle_multimodal_embeddinginsrc/proxy/handlers.rs): it namedauthorizationandx-goog-api-keyin the samematches!as the connection headers and forwarded every other credential name to Google, including thex-api-keya caller authenticates to the router with. The two image-generation loops are now oneforward_client_headershelper shared by the initial dispatch and the OAuth 401 retry builder, so the copies cannot drift.backend_has_config_authhad no caller left after this and is removed. Migration: the same note as #1523, now on these paths too. A backend configured without any credential receives no client credential at all on/anthropic/v1/messages(HTTP and Unix socket),/anthropic/v1/messages/count_tokens,/v1/images/generations,/v1/images/variations, and a multimodal/v1/embeddingsrequest to a Gemini backend; setapi_keyon that backend entry, and the symptom of an unmigrated one is a401from the upstream.user-agent,x-request-id,anthropic-version,anthropic-beta, and every other non-credential header still forward unchanged. The guard is a new end-to-end suite that drives the real handlers against wiremock backends and asserts by value as well as by name, plus a keyless Unix-socket variant; every assertion was verified to fail when the predicate call is removed from its loop. -
Breaking (configuration): Reject backend endpoint URLs that embed credentials, carry a query string or fragment, or name a transport the router cannot dial (#1476, the phase-2 promotion of #1457). These forms warned for one minor release, v1.26.0, and are now refused at configuration load by startup, hot reload, the Admin backend API, and hub config sync, and reported as errors with a non-zero exit by
continuum-router config validate. The message names the backend and the offending property and echoes no part of the URL, because the credentials case is exactly the one where echoing would leak a password into every surface that renders aConfigError. The same promotion applies to the base rule onengine_stats.metrics_url, whose scheme, same-host, and no-downgrade constraints were already hard errors. The transient probe and the hub-managed paths are unchanged: they always rejected these forms, and the one asymmetry the deprecation window left behind is now gone, so a passing candidate probe is a truthful preview of what saving the backend does. Migration for a credentialed URL isauth.type: basicabove; for atype: genericbackend that relied on one, name the specific engine type instead. -
Unify the
budget_tokenstoreasoning_effortbands between the/anthropic/v1/messagesingress and the cross-provider fallback hop mapper (#1532, the follow-up #1530 asked for). Three observable changes for clients on this endpoint whose request lands on an OpenAI-compatible backend: abudget_tokensin2049..=4096now maps tolowwhere it used to map tomedium, one in8193..=10240maps tomediumwhere it used to map tohigh, and athinking: {"type": "enabled"}with nobudget_tokensmaps tomediumwhere it used to map tohigh. The ingress carried its own older band table and the Responses API transform used forresponses_onlymodels carried a second copy of it, so the same request could receive a different reasoning level depending on which door it entered through and whether it was rescued onto a fallback backend. Both ingress transforms now resolve a budget throughReasoningEffort::from_budget_tokens, the one band table shared with the hop mapper from #1518 and #1530, which reads its thresholds fromReasoningEffort::to_budget_tokensand is therefore its exact inverse, so the forward and reverse directions cannot drift apart again. The hop mapper's own bands are unchanged, and anoutput_config.effortstill takes precedence over the budget on every path. -
Breaking (API):
SecureHeaderValue::beareris now module-internal (pub(in crate::infrastructure::common)), andSecureHeaderValue::bearer_for_provider_fixed_schemeis the public escape hatch that replaces it (#1529).SecureHeaderValueis re-exported from a public module, so this is a visible change for an embedder. A caller that built the header for a backend credential switches toSecureHeaderValue::for_backend_auth(auth_type, secret)or holds aBackendCredentialand calls itsauthorization_value(), either of which honorsauth.type: basic; a caller that carried a credential whose scheme the provider or the protocol fixes (an OAuth access token, a cloud provider key, a guardrail endpoint key) switches to the new name, whose rustdoc states that contract and whose every in-tree call site is listed intests/authorization_scheme_audit_test.rswith a written reason. Nothing on the wire changes: the new constructor produces the sameBearer <token>value the old one did, andSecureHeaderValue::basicandfor_backend_authstay public.
Fixed¶
-
Carry answered upstream HTTP statuses through typed Gemini and Bedrock chat-completion dispatches instead of flattening them into connection failures (#1539). Native streaming now treats non-429 4xx responses as terminal provider answers, lets 429 and 5xx responses enter the same configured
fallback.fallback_policy.trigger_conditions.error_codesgate as OpenAI-wire handshakes, preserves parsed provider error detail and answering-backend attribution, and keeps refused connections on the existingconnection_errorpath. Non-streaming Bedrock Runtime and Converse requests use the same classifier, so an answered 400 remains HTTP 400 and does not count as a circuit failure instead of becoming a retryable 502. Generic typed HTTP dispatches now produce the same dependency-free status carrier, while transport-only executor errors remain unchanged. -
Restart an engine-statistics poll task when a hot reload changes the backend's effective authentication scheme (#1521). The task fingerprint now derives the secret and resolved scheme from the fused backend credential, so switching only
auth.typereplaces the task and the next scrape uses the new header while an absentauthblock and explicitauth.type: api_keyremain equivalent. -
Normalize configured backend base URLs by removing redundant trailing slashes before health checks, model availability, and routing build their shared identity (#1492). This fixes backends that logged healthy yet remained unavailable and returned 503 after a trailing-slash URL was pasted into the WebUI or supplied through file, hot-reload, Admin API, or programmatic configuration; intentional path prefixes are preserved.
-
Route a native-typed primary's pre-stream failure through the streaming fallback chain (#1531, the observation the PR #1525 body recorded). A
stream: truerequest whose selected backend istype: anthropic,type: gemini, or atype: bedrockruntime endpoint and which fails before the provider answers (a refused connect, a transport error, a handshake timeout, or a circuit rejection) answered HTTP 502service_unavailableeven withfallback.enabled: trueand a chain pointing at a healthy rescue backend, becausestream_chat_completions_innerreturned the native dispatch result before the fallback dispatch decision was computed; the identical fixture with atype: genericprimary fell back and served a 200 SSE stream. The native arms now report a failure with no provider answer as a value instead of a response, and the pre-stream connection phase dials whatever wire the current backend speaks on every attempt, so a native primary enters the same walk an OpenAI-wire primary enters, in bothfallback.mid_stream_enabledmodes, under the sametrigger_conditionsandmax_fallback_attempts, with the sameX-Fallback-*headers (x-fallback-reason: connection_errorandx-fallback-attempts: 2for a one-entry chain), the same per-backend dial bound, and the same cross-attempt budget: the Anthropic arm takes the capped attempt window on its request timeout, and the Bedrock runtime and Gemini arms, whose trait seam has no timeout parameter, bound the handshake wait with the same window and report an elapsed one as atimeouttrigger. The same change ends the terminal native hop #1522 documented: a chain entry resolving to a native backend now advances to the next entry when it fails before answering, so[dead native entry, live entry]serves from the live entry withx-fallback-attempts: 3, and a saturated dial bound on a native hop advances onbackend_unhealthylike any other hop. What does not change: with no chain a dead native primary still answers 502service_unavailablewith no SSE shell, a dead primary and a dead rescue exhaust into identical 502 bodies in both modes, an answered non-2xx from an Anthropic-shape native backend never advances the chain (that pipeline reads the upstream status itself and still relays an upstream 5xx as a 200 SSE stream), a native stream still has no mid-stream recovery, a native primary over a Unix socket stays outside the walk, and aValidationError,ConfigError, orAuthErrorfrom a native primary returns unchanged without consuming an attempt. The observability context now survives a failed native attempt, so the stream that eventually serves still records its token usage. The guard is eight end-to-end cases that drive the real handler withstream: trueagainst deadanthropic,gemini, and Bedrock runtime primaries and mock rescues on both wires; every one was verified to fail on the previousmainwith the 502service_unavailablesignature. -
Give fallback dials a real concurrency bound (#1522). The guard against a thundering herd of fallback dials onto one rescue backend was a hardcoded, router-wide
tokio::sync::Semaphoreof 50 permits with a 5 second acquire timeout, acquired on exactly one of the four fallback dispatch paths (the mid-stream relay's post-commit hops) and held across the whole rescued stream rather than the dial, so its real meaning was "at most 50 concurrent fallback-served streams router-wide", an availability cliff during the exact outage it was meant to smooth, while the pre-stream connection phase, the pre-stream-only mode, the native-protocol hop, and the non-streaming funnel had no fallback-specific ceiling at all, and the only test that named it constructed a local semaphore and never touched router code. It is replaced byfallback.fallback_policy.max_concurrent_dials_per_backend(default50,0= unlimited, validated range0..=10000), a per-backend bound owned by the fallback service and shared by all four paths. The permit covers the dial only: it is taken immediately before a hop's outbound request is sent, before the circuit-breaker admission so a saturation timeout never consumes a half-open probe slot, and released as soon as the provider handshake returns, never across the body. Only hops are bounded; the primary attempt and a selection-time walk onto a fallback model are not, because there the rescue backend is effectively the primary. A saturated backend queues the dial FIFO for at most the effectivetimeouts.connection(and what is left of the streaming chain budget), then fails the hop for that backend asbackend_unhealthyso the chain advances or exhausts with the preserved upstream failure; the non-streaming Unix-socket and Bedrock runtime transports hold the permit for the whole call because they expose no handshake seam, and a saturated native-protocol hop is served as that hop's failure because the native dispatch is terminal (since #1531, a saturated native-protocol hop instead advances onbackend_unhealthylike any other hop). The limit reloads immediately with a documented transitional window, and saturation timeouts are counted infallback_dial_bound_saturated_total{backend}and asdial_bound_saturatedinstreaming_fallback_total. The guard is a concurrency suite that drives the real handler with six concurrent clients against a mock rescue backend that withholds its handshake until released, on every path, plus a two-chain no-starvation test and an unbounded positive control; with the acquire removed the bounded tests observe six dials in flight and fail. The vacuoustest_semaphore_limits_concurrent_fallbacksis deleted. -
Honor the backend's configured
auth.typeat every remaining credential-carrying site, and make the next hardcoded Bearer a build failure (#1529).auth.type: basic(#1476) reached the executor, the health probe, model discovery, the non-streaming HTTP and Unix socket paths (#1505 family), the streaming builders and the Anthropic inbound transform (#1528), and the OAuth strategy on the streaming builders (#1534), and each of those rounds found its sites by reading code rather than by a failing test. Ten sites remained, and a basic-auth engine receivedAuthorization: Bearer user:passwordon every one of them: the/v1/responsesconversion path for OpenAI-wire backends (ResponsesApiStrategy::ConvertToChatCompletions), non-streaming and streaming alike,/anthropic/v1/messages/count_tokens(which wroteformat!("Bearer {..}")straight into the header and so bypassed log redaction as well), the ACPsession/promptdispatch, the startup connection pre-warm, the hub-directed synthetic probe, the realtime WebSocket upgrade, and the streaming image generation, image variations, and image edit routes. All ten now build the value frombackends[].auth, so a backend with noauthblock or withauth.type: api_keysends exactly what it sent before, a backend withauth.type: basicsends RFC 7617 Basic, and the Anthropicx-api-keybranches and the OAuth gates are untouched. The image variations and image edit routes are included because their backend filter accepts a backend whose name or URL merely containsopenai, so agenericbackend namedopenai-proxyreaches them today andgenericacceptsauth.type: basic; the filter itself is unchanged. Each of the ten is pinned by a test that reads theAuthorizationoff a real listener with a Bearer control beside it, and each was verified to fail when its wiring is reverted. Two guards replace the manual enumeration:SecureHeaderValue::beareris module-internal, so a new site outside the seam's own module is a compile error, andtests/authorization_scheme_audit_test.rsfails the build on any remaining spelling, including theformat!("Bearer ..")one no visibility change can reach, unless the site is listed with a written reason. -
Decide whether the Anthropic-native fields
speed,thinking, andoutput_configmay reach the wire at dispatch, keyed on the selected backend's configuredbackend_type, rather than at the fallback hop, keyed on the providers inferred from the two model names (#1517). The hop-side table added in #1512 got the answer wrong in both directions. When both model names were operator-chosen aliases, each inferredProvider::Unknown, the table read that as a same-provider hop needing no hygiene, and all three fields rode through to an OpenAI-wire backend, where a strict cloud endpoint answers HTTP 400 and kills the very hop that exists to rescue the request. In the other direction aclaude-*primary hopping onto an Anthropic-typed backend behind a custom alias looked like a provider change, sothinkingwas dropped by the one layer with no way to know the target would have honored it. Model-name inference cannot distinguish an aliased Anthropic backend from an aliased vLLM backend, which is the same class of defect #1512 removed elsewhere; only dispatch knows which backend serves a request.field_filter::strip_anthropic_native_fields_for_backend_typeis now the single enforcement point and runs at every OpenAI-wire send site: the non-streaming HTTP and Unix-socket passthrough branches, the streaming request core (covering direct streaming, the mid-stream per-hop loop, and auto backend selection), the streaming Unix-socket path, and the typed Gemini transform. Every OpenAI-wire type strips (generic,openai,azure,gemini,vllm,ollama,llamacpp,mlxcel,lmstudio,sglang), matched exhaustively so a new backend type forces a decision;anthropicandbedrockkeep the fields because their transform is what reads them, andspeedthere stays governed by the unchangedanthropic_fast_modeopt-in;continuumrouterkeeps them because a downstream Continuum Router understands the same canonical extension fields and applies this rule against its own backends; and a backend with no configured type keeps full passthrough rather than being guessed at.reasoning_effortandextra_bodyare never touched, so a client's reasoning intent still crosses every hop. The hop-sidePROVIDER_ONLY_PARAMETERStable is removed along withTranslationResult::removed_parameters, leaving the hop with one job, the model-name swap, andTranslationResult::translatednow reportstrueonly when that swap actually happened instead of unconditionally. The guard is an end-to-end suite that asserts on the outbound body a mock backend received, streaming and non-streaming, and it was verified to fail on currentmainwith the three fields present on the vLLM-typed target. A self-hosted engine configured astype: generic(the default whentype:is omitted) that previously read a top-levelthinking,speed, oroutput_configfield stops receiving them after this change;extra_bodyremains the escape hatch for engine-specific settings. -
Route a streaming fallback hop through the typed per-backend dispatch instead of forcing it onto the OpenAI SSE relay (#1513, field report #1493).
ensure_openai_streaming_relay_targetwas applied as a blanket pre-dispatch guard, so a native Anthropic, Gemini, or Bedrock backend was rejected withbackend type '<type>' is unsupported by the OpenAI SSE fallback relaywhenever a fallback chain existed for the requested model. Three failures followed from one guard placement: a streaming request whose primary refused the connection could not hop onto a healthy Anthropic backend, a selection-time chain walk that had already resolved onto one was rejected before reaching the native branch that would have served it, and merely configuring a chain for a model whose own primary is native answered HTTP 400 to every streaming request to that model, healthy direct routing included. A hop now re-selects a backend and re-enters the same typed dispatch direct routing uses, keyed on the configuredbackend_type, so a native target is served through its own pipeline (for Anthropic, the Messages API at/v1/messageswithx-api-keyandanthropic-version) while the client keeps receiving OpenAI-format SSE; the guard survives insidebuild_openai_streaming_attempt_payload, the one place the relay wire is actually about to be used. In the default mid-stream mode the SSE response is now committed at the first successful provider handshake rather than before any backend is contacted, which is what lets a pre-stream hop reach any backend type and carry theX-Fallback-*headers pernotify_on_fallback. Three consequences ride along there: pre-stream exhaustion returns a proper HTTP error instead of a 200 stream carrying an error event, pre-stream hops are governed byfallback.fallback_policy.trigger_conditionsandfallback.fallback_policy.max_fallback_attemptsrather than the relay's own retryable-error check, and no SSE keep-alive comments flow while the router is still connecting, matching what the pre-stream-only and no-chain paths always did. That silent connection window is bounded by the same cross-attempt budget the relay enforces (timeouts.request.streaming.totalscaled bytimeouts.streaming_fallback_budget_multiplier, 20 minutes on stock defaults): each handshake attempt is capped at what is left of it and an exhausted budget answers with the preserved upstream failure, so the phase can never hold the client socket formax_fallback_attempts + 1full per-attempt timeouts. The relay never discards a handshake the phase obtained, and the stream it takes over is measured from the moment of handover rather than from the start of the connection phase. Once SSE output has begun the relay speaks only the OpenAI wire, so a chain entry resolving to a native-protocol or Unix-socket backend is skipped without consuming a fallback attempt and the next entry is tried, never a request-wide validation error. The guard is an end-to-end suite that drives the real handler withstream: trueagainst a mock Anthropic Messages backend and asserts both sides of the wire; it was verified to fail with the historicalunsupported by the OpenAI SSE fallback relaysignature when the old guard is restored. -
Keep a cross-provider fallback hop's payload in canonical OpenAI chat-completions form instead of pre-translating it toward the target's wire format (#1512).
ParameterTranslatorran on every hop whose model differed from the original, guessed the target provider from the model-name prefix, and converted the payload toward that provider's native shapes; dispatch then performed the real conversion keyed on the backend's configuredbackend_type, assuming canonical input. Two format deciders in one path produced a hybrid neither understood, and an OpenAI to Anthropic hop was damaged three ways: pre-converted tools lost theirtype: "function"wrapper, so dispatch's second conversion skipped every entry and senttools: []; a pre-convertedtool_choicebecame{"type":"auto"}, which dispatch rejects, so any client that senttool_choicegot a failed hop instead of a fallback; andstoprenamed tostop_sequenceswas never read by the Anthropic transform, which readsstop. The hop now swaps the model name and, on a cross-provider hop, drops only provider-only fields the target does not own, currently the Anthropic-onlyspeed,thinking, andoutput_configfields, which a non-Anthropic dispatch path would otherwise forward verbatim to the wire; none of the three belongs to the OpenAI chat-completions schema or is covered by the cloud field filter, and a strict cloud endpoint answers HTTP 400 for an unrecognized top-level key. Message, tool,tool_choice, and field-name translation, the Gemini-native renames (maxOutputTokens,topP,stopSequences) that were dead keys against the router's OpenAI-compatible Gemini endpoint, thetop_ktotop_logprobsandtopKtotop_logprobsmappings that turned a sampling parameter into a logprob-reporting knob, and the redundantmax_tokensdefault are all removed. Removals toward Anthropic that dispatch already made on its own (frequency_penalty,presence_penalty,logprobs,logit_bias,n,seed,response_format) are gone too, because the hop's provider guess is wrong forclaude-*models on Bedrock, Gemini behind its OpenAI-compatible endpoint, andgpt-prefixed open models on vLLM, and dropping a parameter there loses it for a backend that would have accepted it. The guard is an end-to-end test that drives the real handler from a failing OpenAI-compatible mock to an Anthropic-typed mock and asserts the outbound/v1/messagesbody; it was verified to fail on all three defects when the fix is reverted. Streaming relay behavior is unchanged and tracked separately in #1513. -
Apply the configured
auth.typeon every credential-carrying request path, not only the executor path (#1505).auth.type: basicwas added in #1476 and wired intoHeaderBuilder, which servesRequestExecutor, but proxied inference, Unix socket dispatch, health checks, model discovery, and the engine-statistics scrape each build theAuthorizationheader inline and hardcoded Bearer. A backend configured withbasictherefore sentBearer username:passwordeverywhere that mattered, and becausehealth_checks.enableddefaults to true it failed its own health probe, was marked Unhealthy, and never received traffic at all. The five paths now resolve the header through one seam,SecureHeaderValue::for_backend_auth, which takes the configured scheme as a required argument so a new call site cannot silently inherit the wrong one; the auth scheme travels beside the credential throughBackendHealthCheckInfo, the model-discovery credential map, and the engine-stats target. Backend-type auto-detection is deliberately left on Bearer and carries a comment saying why: it runs only fortype: genericand the hot-reload detection round, andbasicis a load error ongeneric, so no basic credential can reach it until #1504 gives that type a credential path. A backend with noauthblock or withauth.type: api_keyis byte-identical to before on every path. The guard is a behavior test that reads the header off a real listener and was verified to fail when the fix is reverted; the previous unit test pinned the encoder, which was never the broken part. -
Accept
auth.type: basicon a transient Admin backend probe candidate (#1503).validate_candidate_authrefused the scheme alongsideservice_accountandsigv4, soPOST /admin/backends/probecould not check the configuration #1476 tells operators to migrate to: an operator whose config was refused at load had no way to verify the rewrite short of applying it. The refusal was a conservative placement made while #1476 was in flight rather than a capability gap, because both probe paths already apply the scheme, the health probe throughBackendHealthCheckInfo.auth_typeand the catalog probe throughfetch_models_from_backend, both wired in #1505.service_accountandsigv4stay refused, since each needs lifecycle-managed material a transient candidate cannot load. Candidate validation still runs the whole-configuration validator over a single-backend config, so the backend-type restriction and theusername:passwordshape rule are inherited rather than duplicated, andcredential_statusalready reportedbasictruthfully becausecandidate_static_key_was_appliedexcludes only the three lifecycle schemes. -
Give a
type: genericbackend a credential path, and acceptauth.type: basicon it (#1504). The generic factory arm attempts auto-detection and, when nothing matches, falls back to a backend built from aBackendInfo, which carriesname,url,weight,supported_models,metadata, andenabledand no credential of any kind;HttpBackendcontained noAuthorizationconstruction at all. A configuredapi_keyon a generic backend was therefore accepted and never sent. The type worked against an authenticating endpoint only becausereqwestturnedbackends[].urluserinfo into a Basic header inside the HTTP client, and #1476 made that URL form a load error, which lefttype: genericwith no way to authenticate at all.HttpBackendnow holds the resolvedAuthorizationvalue, produced once at construction through the sharedSecureHeaderValue::for_backend_authseam (#1505), and applies it on all four of its request paths ahead of caller-supplied headers so an explicit header still wins. Backend-type auto-detection is routed through the same seam, over both HTTP and Unix socket transports, which closes the one site #1505 deliberately left on Bearer. A generic backend with no credential sends noAuthorizationheader, unchanged. -
Honor the configured
auth.typeon the streaming OpenAI-wire request builders and on the/anthropic/v1/messagestransform path (#1528). The #1505 family moved the health probe, model discovery, the executor chat path, and the non-streaming HTTP and Unix socket paths onto the scheme-awareSecureHeaderValue::for_backend_authseam, but six credential sites were left behind: four insrc/http/streaming/handler.rsand two insrc/http/handlers/anthropic/handler.rs, all hardcodingSecureHeaderValue::bearer. A backend configured withauth.type: basicandapi_key: user:passwordtherefore receivedAuthorization: Bearer user:passwordon every streaming chat completion, which is the main path for chat clients, and on every Anthropic-API request routed to an OpenAI-compatible engine, while the health probe fixed in #1505 kept reporting the backend Healthy, so the reverse proxy answered 401 exactly where most traffic goes. All six now build the header through one newpub(crate)helper,crate::proxy::oauth_helper::backend_authorization_value, which is the same callsrc/proxy/backend.rsmakes, so no site in either file names a scheme of its own: the streaming request core (serving the initial dispatch, every mid-stream fallback hop, and auto backend selection), the streaming Unix socket builder, the two Bedrock-mantle streaming branches, and the Anthropic Messages HTTP and Unix socket transform builders. Because the core reads the hop's ownbackend_config, a mid-stream hop re-evaluates the scheme per hop rather than inheriting the primary's. No signature changed, the hub-secret substitution from #888 is untouched, and a backend with noauthblock or withauth.type: api_keyis byte-identical on the wire before and after. The Bedrock-mantle branches always resolve to Bearer, sincebasicis a load error onbedrock, and go through the helper only so the scheme is never named at a call site. The guard is a new end-to-end suite that drives the real handlers and asserts on the bytes a wiremock backend or a Unix socket listener actually received, reading each SSE body to the end so a header assertion cannot pass on a request that never completed; each Basic assertion was verified to fail when its site is reverted toSecureHeaderValue::bearer. Separately verified and not fixed here: these streaming builders apply no registered OAuth strategy, so a backend declaringauth.type: oauthis dispatched with its static key or with nothing at all, unlike the non-streaming path, which is tracked in #1534. -
Apply the registered OAuth strategy on the four streaming request builders, and stop sending the static key when
auth.type: oauthis declared (#1534, the gap #1528 verified and left open).build_openai_chat_request_core(serving the initial dispatch, every mid-stream fallback hop, and auto backend selection) andstream_via_unix_socketlooked up no strategy at all: a backend declaringauth.type: oauthwas dispatched with whatever staticapi_keyit carried as a plain Bearer, or with noAuthorizationwhen it carried none, the token was never refreshed, and the strategy's Codex extra headers (originatorand the CodexUser-Agent, which the ChatGPT-subscription front requires on every request rather than only on the OAuth endpoints) never left the router. All four now hold the contractmake_http_requestalready held: with a strategy registered the token is refreshed, its whole header map is applied after the client-header forwarding loop so the two paths cannot drift on ordering, and the static key is suppressed; withauth.type: oauthdeclared but no strategy loaded, from a missing or malformed token store, noAuthorizationis sent at all rather than the static key standing in for the absent token; and otherwise the static path is byte-identical to before, which a Bearer control with an empty registry pins. The core becameasyncand takes the caller-resolved strategy, so its twobuild_streaming_requestcall sites gained an.awaitand nothing else. The mid-stream relay resolves the strategy PER HOP from a registry handle moved into its spawned task, next to the effective-secret resolver and for the same reason: the relay outlives any one token, so a pre-spawn snapshot would pin the whole stream to the credential of the backend selected first and would be the wrong backend's token entirely after a cross-provider hop. The Unix socket transport builds a headerVecrather than areqwest::RequestBuilder, sooauth_helpergainedstrategy_headers, the refresh-then-snapshot half thatapply_strategy_headersnow calls too, which is what keeps the two transports on one refresh rule. The guard is a new end-to-end suite that drives the real handlers and asserts on the bytes a wiremock backend or a Unix socket listener actually received: every OAuth backend carries a decoy staticapi_key, so asserting the singleauthorizationvalue equals the seeded token also proves suppression, andoriginatoris asserted alongside the bearer so a bearer-only shortcut cannot pass. Each of the seven wired sites was reverted in turn and the matching assertion observed to fail. Two decisions are recorded rather than deferred silently.stream_with_auto_backend_selectiondoes not gain thenative_stream_kindguard here: it is a library-only entry point bound to no route, the only correct guard is delegation to the typed dispatch #1531 is restructuring concurrently, and the credential fix itself does land on that site. Andauth.type: service_accounton a type other thangemini, orsigv4on a type other thanbedrock, is now a warning in both the startup log andcontinuum-router config validaterather than a load error, because those combinations load today and send the static key as Bearer, so rejecting them in a fix release would break a running router; this follows the #1457 then #1476 path of warning for one release and rejecting afterwards. No rule foroauth, which is a working configuration on every OpenAI-wire path once this lands. Still open and tracked for a follow-up: the streaming counterpart of the non-streaming 401 force-refresh retry, which would have to live inconnect_with_pre_stream_fallbackwhile #1531 is restructuring it.
Documentation¶
- Correct the phase-1 claim that embedded backend URL credentials "are never forwarded" (#1476). Measured against a local listener with the router's own call shape, a backend URL of the form
http://svc:pw@host/v1with noapi_keyproducesauthorization: Basic ...on the wire, through both the shared executor and the hot proxy path, becausereqwest::RequestBuilder::newcallsextract_authority. With anapi_keyset, theBearerheader replaces it and the URL credential is silently dropped. The v1.26.0 entry below is corrected in place, and the endpoint rule sections ofdocs/en/configuration/backends.mdand its Korean mirror now state the real reason the form is refused, which is thatbackends[].urlis deliberately unmasked in Admin API responses whileapi_keyis masked.
v1.26.0 - 2026-09-01¶
Twenty-three commits since v1.25.0. Six of them close a bundle of credential-disclosure defects that one redaction review opened up: a backend URL embedding inline credentials was written verbatim to the log on the ordinary success path at 67 sites, echoed by unsupported-scheme transport errors and by invalid-URL configuration errors, and returned by the Admin backend probe, while a Redis connection URL's password was returned by GET /admin/config/full and reachable through Debug. Epic #1448 finishes with engine-native statistics collection, engine-load-aware backend selection, and type: sglang as a first-class backend type, and five defects in KV-cache-aware selection are repaired alongside it. Backend endpoint URLs that embed credentials, carry a query string or fragment, or name a transport the router cannot dial are deprecated and will be rejected at configuration load in v1.27.0. Admin configuration mutations that refuse a candidate now answer 400 instead of 200.
Added¶
-
Add engine-load-aware backend selection behind a new
routing.engine_loadsection (#1447, the last sub-issue of epic #1448). Withrouting.engine_load.enabled: true, the KV overlap scorer (#1444) gains an additive term that prefers, among a request's prefix holders, the backend whose engine reports fewerwaiting_requests(normalized bytotal_slotswhen every fresh candidate states one, otherwise by the request candidate set's maximumwaiting_requests, so mixing a bounded and an unbounded basis cannot invert the ranking on a mixed llama.cpp/vLLM fleet) and lowerkv_cache_usage, sourced from the engine-stats snapshots collected by #1446. A staleness guard (max_staleness_intervals, default 3 polling intervals, re-applied to an already-held snapshot on every hot reload and every poller task restart, not only on the next successful scrape) drops a candidate to its base score once its snapshot ages out, and matches the base scoring exactly when every candidate is stale (stale_fallback). A two-threshold hysteresis dead band (balance_abs_threshold64,balance_rel_threshold1.5, mirroring the SGLang Model Gateway's balance gates) holds the term still unless the freshwaiting_requestsspread exceeds both (hysteresis_hold), so two backends whose queues oscillate around each other cannot flap selection. An optional saturation admission hint (routing.engine_load.admission, default off) refuses selection with a retryable 503 that participates infallback.fallback_chainswhen every healthy candidate reports a freshkv_cache_usageabovekv_usage_threshold(default 0.98), throttled to at most one WARN log line per 60 seconds per pool so a genuine saturation event stays readable instead of flooding the log. Decisions are counted in the newrouting_engine_load_decisions_total{backend,reason}series (engine_load,stale_fallback,hysteresis_hold,admission_reject), with a Grafana panel and anEngineLoadAdmissionRejectingPrometheus alert added tomonitoring/. The whole section defaults to off, so selection is byte-identical to a build without it; every field's range is enforced on every path that can change a running router (startup, hot reload, Admin API PUT/PATCH), not only the whole-Configvalidation chain no production caller invokes, closing a path where an out-of-range admission threshold could have refused traffic fleet-wide; a low admission threshold that would shed ordinary traffic and an enabled term with no data source or no scorer to run in are flagged asconfig validateadvisories. The engine-load term's influence is bounded byengine_load_weight(default 0.3, additive on top of the base weights that sum to 1.0), the prefix-holder-only scope the base scorer already enforces, and the hysteresis dead band; the admission hint can only refuse a request for which every healthy candidate is saturated. Documented indocs/en/load-balancing.mdanddocs/ko/load-balancing.md, including the trust model for self-reported engine figures. -
Collect engine-native statistics from self-hosted serving backends (#1446). A new always-compiled engine-stats layer polls each engine's own load or metrics endpoint on a jittered per-backend interval: vLLM
GET /metrics(accepting both bare and_totalcounter names and the hidden V0gpu_cache_usage_percfallback), SGLangGET /v1/loads?include=core(an object wrapper with one entry per DP rank, whoseomit_defaultswire format makes a missing number a truthful zero) with/get_loadand/metricsfallbacks, llama.cppGET /slotsandGET /metricsselected from theendpoint_slots/endpoint_metricscapability booleans in/props(501 and 404 both degrade silently, per-slotn_ctxis the context figure, andmlxcelrides the same adapter), OllamaGET /api/ps, and LM StudioGET /api/v0/models. Snapshots live in a per-pool TTL-bounded store next to the in-flight tracker and surface asbackend_engine_*{backend}Prometheus series (absent-not-zero, scrape failures emit onlybackend_engine_stats_scrape_success 0, counters mirrored with reset detection, hot-reload-removed backends vanish on the next scrape),GET /admin/backends/{name}/engine-stats, anengine_statsobject inGET /admin/stats/backends, WebUI dashboard badges, andBackend::get_current_load()for vLLM-family and llama.cpp backends while a fresh snapshot exists. Configured by a new hot-reloadableengine_statssection, opt-in withenableddefaulting tofalseso no poller task runs and no series are emitted until an operator turns it on, plus a per-backendengine_statsblock (enabled,source,metrics_url,interval);metrics_urlmust resolve to the same host as the backend URL unless the section'sallow_external_metrics_url(defaultfalse) opts in, and anhttpsbackend'smetrics_urlcan never downgrade to plaintexthttpeven with that opt-in, because the backend's API key rides on the metrics fetch. Grafana panels and Prometheus alert examples ship undermonitoring/. -
Add
type: sglangas a first-class backend type for SGLang servers (#1445). The newBackendTypeConfig::Sglang(aliasesSGLang,sg-lang,sg_lang) defaults the URL tohttp://localhost:30000, probes/healthwith a/v1/modelsfallback (a 503 while the engine starts or exits is treated as warming up), routes through the vLLM-family OpenAI-compatible path with full passthrough of SGLang request extensions andreasoning_content, converts/v1/responsesto chat completions, and tokenizes/v1/messages/count_tokensthrough the engine at/v1/tokenize. The Admin API, WebUI backend list and dashboard, backend probing, control-plane usage provider (sglang), configuration assistant skill and templates (config generate --template sglang), and documentation all name the type, and the stale Admin config-schema backend-type enum now lists every variant.
Changed¶
-
Breaking (API): Answer
400 Bad Requestinstead of200 OKwhen an Admin configuration mutation refuses the submitted candidate (#1466).PUT/PATCH /admin/config/{section},POST /admin/config/importwithapply: trueanddry_run: false, andPOST /admin/config/applywith aconfigcandidate andhot_reload: trueused to answer200with the refusal carried only in a"success": falsebody field, so any caller keying on the status code, which is the default forcurl -fand for most HTTP client libraries, read a refused configuration as an applied one and continued while the router kept serving the old configuration. The dividing line is whether the request asked to change running state, not which endpoint received it:POST /admin/config/validateis a verdict endpoint and keeps200for every parseable input, an import withdry_run: trueorapply: falsepublishes nothing and keeps200, and an apply with no candidate, an unchanged candidate, orhot_reload: falsekeeps200. Response bodies are unchanged in every case,"success": falseand the descriptiveerror/validation.errorsincluded, so a client that already inspects the body needs no change; the WebUI routes the refusal through the same field-level error rendering it used before. Server-capability refusals ("hot reload not available") and the import size and nesting guards deliberately keep200, since neither is a client error. Automation that raises on a 4xx now raises where it previously continued silently, which is the intended correction. -
Feed the engine-reported
max_model_lenfrom/v1/modelsentries into the model metadata context window (#1445). This applies family-wide to any backend whose model entries carry the field (vLLM and SGLang report it), so discovered models now expose a reallimits.context_windowwithout operator work, withlimits.max_outputset to the same value:max_model_lenis the engine's supremum for generated tokens, while the0default is the reserved marker for "not applicable" and would otherwise be published as"max_tokens": 0on/v1/models/{model}. Explicitmodel-metadata.yaml/model-metadata.d/limits always take precedence, including a deliberatecontext_window: 0; only models with no configured limits pick up the engine value. Because the field is engine-controlled rather than operator config, it is accepted only up to the same 10,000,000-token ceiling the config validator enforces on operator-supplied limits, so a backend cannot publish a number the router would reject from its own config file. -
Publish the minimum engine-reported
max_model_lenacross every backend serving a model id, instead of whichever backend's report survived deduplication (#1456, a Phase 4 unit of epic #1448). Bothlimits.context_windowandlimits.max_outputnow fold every reporting backend's value withmin; previously the published number came from the first backend in pool order under the defaultMergeBackendsstrategy (the last underLastWins), and pool order moves on hot reload, on Admin API backend mutations, and on AppProxy reconcile, so removing and re-adding a backend could change the advertised window with nothing operator-visible to explain it. The minimum is the only figure the router can promise: selection may send a request to any backend in the model'sbackendslist, and an engine-side context rejection is an HTTP 400, which appears in neitherretry.retryable_status_codesnor the default fallbackerror_codes, so over-reporting produces a replica-dependent failure that neither the retry handler norfallback.fallback_chainscan recover from. A backend that reports nothing contributes no candidate rather than a zero, and a report of zero or above the shared 10,000,000-token ceiling is discarded on its own instead of narrowing or suppressing the others; when no candidate survives, no limits are synthesized, unchanged from before. Explicitmodel-metadata.yaml/model-metadata.d/limits still win unconditionally, which is the supported way to advertise a larger window on a heterogeneous fleet. The/v1/modelsJSON body gains no field, and backend disagreement is logged at debug level with the model id, the chosen minimum, and the full candidate list. The operator-visible consequence: on a fleet where one replica was launched with a larger--max-model-len, the advertised window drops to the smallest replica's value, and that replica's extra capacity is not reachable through the shared model id because routing does not consider the context window, so an operator who wants the larger window advertised must either give that backend a distinct model id or set explicit metadata limits.
Deprecated¶
- Backend endpoint URLs that embed credentials, carry a query string or fragment (a bare trailing
?or#counts), or use a scheme other thanhttp,https, orunixare deprecated inconfig.yaml, the Admin backend API, and every other registered-backend path, and will be rejected at configuration load starting with release v1.27.0 (#1457; the promotion to an error is scheduled as #1476). These forms have never worked correctly: request URLs are built by concatenating the request path onto the backend URL, so a query or fragment swallows the path and the backend answers 404 on every request; embedded credentials are forwarded, as anAuthorization: Basicheaderreqwestbuilds from the URL userinfo, but only when the backend sets noapi_key, and they sit in a field the Admin API deliberately renders in the clear (corrected in #1476, which also addsauth.type: basicas their supported home); and an unsupported scheme fails per request inside the HTTP client instead of at load with an error naming the field. During the deprecation window such a configuration still loads and serves, with a warning on the startup and hot-reload log and in thecontinuum-router config validateJSON report (pathbackends.{name}.url), each naming the backend and the offending property while echoing no part of the URL. Behind the deprecation, the endpoint rule is now a single shared predicate (validate_endpoint_urlin the transport layer) used by the Admin transient backend probe, hub-authored candidate tasks, hub backend sync, hub backend-export representability, and registered-backend validation, replacing three byte-equivalent private copies whose duplication is what let the configuration path drift loose in the first place; the probe and hub error contracts are pinned unchanged by table-driven verdict tests. The same deprecation phase applies to the base rule onengine_stats.metrics_url, whose http(s)-scheme, same-host, and no-downgrade constraints remain hard errors as before.
Fixed¶
-
Scope engine-load hysteresis, normalization, decision labels, and the scorer's short-lived cache to the exact live candidate set for each request. Previously, any backend tracked by the pool could open the balance gate or set the preferred-backend metric even when model visibility, health, circuit/retry state, or per-key permissions had excluded it, and a cached result for one candidate set could steer another request with the same prefix during the 100 ms cache window. Candidate names are now canonicalized into the scoring context and cache identity, so non-candidates cannot affect selection.
-
Report the heartbeat inventory
BackendInfo.providerfrom the operator's typed backend configuration instead of the constructed backend object's runtime type, the same sourceUsageRecord.provideralready resolves from, keeping the runtime type only as a fallback for a pool backend with no live config entry during a hot-reload rename window (#1454, the first defect of epic #1448's Phase 4). This is a hub-facing behavior change for existing deployments running withcontrol_plane.enabled: true: backends configured asollama,lmstudio, orsglangpreviously reported providervllm(all three are served by the shared vLLM backend struct);mlxcelreportedllamacpp;bedrockreportedanthropic(mantle/runtime endpoints) orbedrock-converse(converse endpoint);continuumrouterreported the hyphenatedcontinuum-router;azurereportedopenaiwhenever its endpoint URL did not contain the literal substringazure, which covered every private-endpoint or custom-DNS Azure OpenAI deployment; and agenericbackend auto-detected as llama.cpp reportedllamacpp. Each now reports its configured type verbatim, so inventory and usage agree for every backend. Migration note for operators of the affected backend types: the wire field is a plain string andPROTOCOL_VERSIONdoes not move, andBackendInfo.stable_idremains the accounting identity, so cost-center attribution cannot shift; but any hub-side grouping, dashboard, or policy keyed on the inventory provider string must accept the widened vocabulary (ollama,lmstudio,sglang,mlxcel,bedrock,continuumrouter,generic) before routers with this fix are deployed against it. -
Type an AppProxy replica from the coordinator's
runtime_variantthrough the shared probe token vocabulary instead of matching the single literalvllm(#1455, the last unit of epic #1448's Phase 4). Reconcile previously accepted onlyvllm(ASCII case-insensitively) and mapped every other token,sglangincluded, togeneric, so a replica served by any engine other than vLLM lost every behavior the router derives from a backend type. Resolution now runs throughparse_probe_backend_typeand accepts the six locally served engine typesvllm,sglang,llamacpp,mlxcel,ollama, andlmstudio, case-insensitively and including the punctuation spellingssg-lang,sg_lang,llama-cpp,llama_cpp,llama.cpp,mlx-cel,mlx_cel,lm-studio, andlm_studio. An absent variant, the empty string, an unknown token, the explicitgenerictoken, and every cloud-provider token (openai,anthropic,gemini,azure,bedrock,continuumrouter) all still resolve togeneric, deliberately: an AppProxy replica is a locally served OpenAI-compatible HTTP server reached overhttp://kernel_host:kernel_port, and a cloud type would select the wrong auth resolver and endpoint defaults. The same change collapses two byte-identical private copies of the mapping (src/appproxy/common/reconcile.rsandsrc/appproxy/router/reconcile.rs) into one shared resolver, so the legacy worker path and the ROUTER frontend mode path run the same implementation. Migration note for operators running AppProxy circuits on non-vLLM engines: backend names are unchanged (appproxy-<circuit_id>-r<route_key>), so reconcile stays idempotent and the backend set does not churn on upgrade, only the type field differs. What a newly typed replica gains is the engine's own default health-check contract in place of the generic one (sglangprobes/healthwith a/v1/modelsfallback, reading a 503 while the engine starts or exits as warming up;llamacppandmlxcelprobe/healthwith a/v1/modelsfallback;ollamaprobes/api/tagswith a/fallback;lmstudioprobes/v1/modelswith an/api/v1/modelsfallback), a backend construction that skips the per-replica/v1/modelsauto-detection probe thegenericfactory arm spends and with it the hot-reload type-detection round that could write a detected type back into the live configuration and propagate a second configuration change,/v1/messages/count_tokensanswered through the engine's/v1/tokenizeonsglangreplicas as vLLM-family replicas already were instead of falling back to estimation, and participation in the top-levelengine_statspoller (#1446) wheneverengine_stats.enabledis true, which the global switch alone governs because AppProxy synthesizes no per-backendengine_statsoverride block. -
Repair five defects in KV-cache-aware backend selection and record the routing decisions the documentation already described (#1444, PR #1449). The
kv_cache_index.scoringblock was never wired into the scorer, which ran on hardcoded default weights; the scorer read backend load from aBackendStatsfield dead since #971 rather than from the pool's in-flight tracker; a backend with no entry in the index scored on load and health alone, so it could win scored selection when the index held data only for ineligible backends; andmin_overlap_thresholdwas compared against a constant that made the check inert, so aStorageWarm-only holder passed the gate. Selection now recordskv_aware, KV indexfallback, and theprefix_hash/overflow/model-hash-fallback family once per selection through a single seam, dropping non-finite overlap samples so one NaN cannot permanently corrupt the histogram_sum. Two configurations that silently do less than they appear to are flagged at startup and byconfig validate:selection_strategy: PrefixAwareHashwithprefix_routing.enabled: false, which degrades to uncapped model-name consistent hashing, and akv_cache_index.scoringblock whosegpu_tier_weightsits belowmin_overlap_threshold, which leaves scored selection registered but permanently inert.docs/en/architecture/kv-cache.mdand its Korean mirror are rewritten against the post-fix behavior. -
Validate the KV overlap scorer's cached pass on the prefix key rather than on the TTL alone (PR #1474).
cached_result_for()checked both, whilescore()read the cache slot directly and checked only the 100 ms TTL, so within that window a request could be scored against another request's prefix overlap and a backend holding nothing for the requested prefix could win scored selection outright, measured at 0.9 against the shipped 0.3 threshold and thereby overriding the configured selection strategy.CachedResultnow stores the raw 32-byte prefix key instead of its hex form and both readers validate on it, which makes the check an allocation-free compare and leaves one predicate where there were two. The split dates to the original prefix overlap scoring implementation in PR #473 and was found during the security review of PR #1462. -
Reject non-finite
kv_cache_index.scoringweights at configuration load (#1460, PR #1461). Every range check in the section compared with<orRangeInclusive::contains, and every comparison againstNaNis false, so a YAML.nanpassed on all five weights and.infon both tier weights. ANaNweight makes every holder scoreNaN, which never wins adoption, so KV-aware selection reported itself enabled while falling through to the configured strategy on every request; an infinite tier weight makes the first such holder win permanently and stops load balancing across equally cached holders. The finiteness gate is added to the section validator and, more importantly, tovalidate_config_with_limits, the path that startup,config validate, hot reload, the config watcher, control-plane config sync, and the Admin configPUT/PATCHall funnel through and that had never touchedkv_cache_indexat all. Reaching the rejected state requires literally typing.nanor.inf, so no working deployment regresses. -
Serialize the
src/metrics/kv_cache.rstests that assert an exact metric delta (PR #1459).KvCacheMetrics::newregisters clones of the module'slazy_staticmetrics into a test-localRegistry, but prometheus metric clones share oneArccore, so a fresh registry never isolates the values and each of two test pairs was its own concurrent writer, measured at 6 failures in 12 runs for one pair. The module's single-counter lock is replaced by oneGLOBAL_METRIC_WINDOW_LOCKcovering every process-global metric in the module, held across the whole before-record-after window by all seven delta tests, so there is no per-test decision about which lock to take.
Security¶
-
Redact inline credentials in backend URLs written to the log on the ordinary success path (#1469, PR #1472). A backend URL of the form
http://svc:pw@host/v1was written verbatim at INFO once per backend at every configuration load, once per backend on every health-status transition with no traffic at all, and again on every proxied request.reqwestlifts that userinfo into anAuthorization: Basicheader, so this is a working deployment shape, typically a self-hosted engine behind a basic-auth reverse proxy, rather than a misconfiguration, and the shipped defaultlogging.levelisinfo, so the disclosure needed no operator error. All 67 sites across 27 files now wrap the logged value inredact_endpoint; the value handed toreqwestis unchanged at every one. Redaction stays at the call site because at each of them the composed URL is simultaneously the outbound request target and the logged value, so it cannot move intocompose_backend_urland its six siblings. A source-scan audit test guards site 68. -
Redact backend URLs echoed by unsupported-scheme transport errors (#1467, PR #1471). A live reproduction with an
ftp://backend URL carrying a password produced nine log lines containing that password from one boot plus one non-streaming and one streaming request, across seven modules; the client-facing body was already a static string, so the disclosure was to the log only.TransportError::UnsupportedSchemenow stores the redacted value at construction rather than redacting inside itsDisplayattribute, because the type also derivesDebugand aDisplay-only redaction still prints the password through any{:?}. Nine call sites that interpolated the operator-supplied URL into a log macro are wrapped as well. Separately, the scheme named in the diagnostic was derived withsplit("://").next(), which yields the whole string when the separator is absent, so a scheme-less value named itself as the scheme; the replacement requires a syntactically valid RFC 3986 scheme and reportsunknownotherwise. -
Mask Redis connection URL credentials in both configuration maskers (#1468, PR #1470). A Redis URL carries its password inline and neither masker hid it, so on a default build, where the Admin API is mounted unconditionally,
admin.authdefaults toNoneand is treated as allow-and-warn, and the bind address is0.0.0.0:8080,GET /admin/config/fullreturned the password to anyone who could reach the port. AddingurltoSENSITIVE_FIELDSwould have blindedbackends[].url, which two in-tree tests exist to prevent, so each masker instead treatsurlas a secret only directly under arediskey and the name-only predicates stay byte-identical. The masked value is the existing sentinel rather than a partial redaction, because a partially redacted URL is neither a current sentinel nor a legacy shape, soPOST /admin/config/importof an unedited masked export would write that literal string in as the live Redis URL. -
Redact the backend URL echoed by invalid-URL configuration errors (#1463, PR #1464).
validate_backends_configinterpolated the rawbackend.urlinto its parse-failure message, so a malformed credentialed URL put the password into the startup stderr line, the hot-reload error log, theconfig validateJSON report, the MCP tool responses, and six Admin API response bodies together with thewarn!lines those handlers emit. The message now names the backend, renders the URL throughredact_endpoint, and retains theurl::ParseError, which is what keeps a value that collapses to***diagnosable. Control flow and status codes are unchanged. -
Redact the Redis URL password in
Debugoutput (#1465, PR #1499).RedisCacheBackendConfigandRedisConfigderived a plainDebugthat printed their credential-bearingurlverbatim, unlike five sibling config types in the same file that hand-writeDebugto elide secrets, and both are reachable throughConfig's own derive. No production call site formats either type withDebugtoday, so this closes a latent hole rather than an active leak: the risk was the nextdebug!(?config)or a new#[derive(Debug)]wrapper printing a Redis password in full with nothing in the code or CI stopping it. -
Redact backend URL credentials in the Admin backend probe response, and teach the log-redaction audit scan to see inline format captures (#1498, PR #1501).
POST /admin/backends/{name}/checkechoed the backend URL inchecked_urland, through the health-check error string, inerror; both are now redacted where each value is composed, which also covers the background monitor'sdebug!of the same error, and aunix:<path>address is reported in full rather than collapsing to***. The durable half is the audit scan added by #1469: it blanked string literals before looking forurl-suffixed identifiers, soinfo!("target: {backend_url}"), the idiomatic Rust 2021 spelling, was invisible to it, verified by an unredacted probe site passing with exit 0 under the pre-fix scan and failing with exit 101 under the widened one. The widened scan surfaced one genuine leak that #1469 could not see, the OAuth-backend health-check skip line, which fires on every health round for an OAuth backend; it is redacted rather than allow-listed.
CI¶
-
Build the dependency versions
Cargo.lockpins instead of runningcargo updatefirst (PR #1483). Every CI run resolved whatever was newest at that moment, so it verified a dependency set nobody installs and made a red result ambiguous between a real defect and a dependency that moved overnight. The float is still tested, throughmake update-depswhile preparing a release and through the new weeklydependency-float.ymlworkflow, andscripts/local-ci.shnever ran the update step, so the change also closes a divergence documented in three places. -
Split the
cijob across three self-hosted runners (#1484, PR #1487). Thirteen steps ran serially on one machine in about 40 minutes, of which 83% was rustc recompiling the workspace under a feature set the previous step did not share, because Cargo keys build artifacts on the exact resolved feature set; one step spent 789 seconds to execute 8.1 seconds of tests. Three machines answer the runner label and two sat idle for the whole run. The job becomesci-default,ci-isolated-graphs, andci-release-graph, grouped so that steps building the same feature graph stay together, measured at 723s, 942s, and 1037s, so the wall clock is now the longest of the three rather than their sum. Every step's command line is unchanged. A duplicatedcargo test --librun is removed, since--testsalready selects the library's unittest target. -
Keep the Cargo build directory outside the checkout (#1494, PR #1496).
actions/checkoutrunsgit clean -ffdxbefore every job andtarget/is gitignored, so the build directory was deleted at the start of every job and every CI build was cold; the checkout log namestarget/in its removal list. A local composite action now points the build directory at a persistent per-runner path outside the workspace and clears it once it passes 80 GB, so the worst case of a stale cache is one slow run rather than a red one.
Dependencies¶
- Refresh
Cargo.lockto the latest semver-compatible versions (PR #1497). Thirteen crates move by a patch or minor release (aws-sdk-sts,chacha20,combine,cpufeatures,deadpool,deadpool-redis,h2,hyper,indexmap,libredox,lru,rand,uuid); nothing is added or removed and the resolved graph stays at 477 packages. No security advisory motivates the change, andcargo deny checkpasses on both the previous and the updated lock file against an advisory database refreshed to 2026-08-29.
v1.25.0 - 2026-08-26¶
Twenty-four commits since v1.24.1. Four defects reported from a Backend.AI GO deployment running one vLLM backend under load are fixed together: a warmup timeout that never latched, a cancelled streaming request that leaked a half-open circuit probe, a single-backend retry exhaustion that hid the upstream error behind a router-authored 503, and a model that vanished from the catalog while its fallback chain still served it. The root crate and the performance harness move to Rust 2024 and declare rust-version = "1.95". Three additions extend Hub integration and runtime backend management: per-model budget fallback, a durable sidecar for backends created through the Admin API or WebUI, and enrollment from the router itself.
Added¶
- Add Hub-directed per-model budget fallback on
/v1/chat/completionsand/v1/completionsbehind themodel_budget_fallback_v1capability (#1410, PR #1430). When a Hub-authored monthly budget for the effective provider and model is exhausted and the tier permits fallback, the router dispatches to the first ordered eligible target instead of returning429 insufficient_quota. Candidates are intersected with tier allowlists, per-key backend permissions, visibility, provider identity, health, and circuit state, and retries and streaming failover are held to the admitted offering. Usage records keep both the served identity and the pre-budget requested identity, and the fallback is reported on bounded metrics and on streaming and non-streaming response headers. The router side of continuum-hub#909; the capability is advertised only once the durable usage-sequence state and the enforcement path are attached. - Persist backends created through the Admin API or the WebUI in an opt-in owner-only YAML sidecar (#1423, PR #1436). Runtime backend registrations existed only in memory, so they were lost on restart and on any config-file reload, while runtime API keys had
api_keys.persistence_filefor exactly this. Local create, update, and delete mutations are written atomically and restored at startup, file-defined and Hub-managed backends stay out of the sidecar, and the Admin API and WebUI report whether a backend is configured, memory-only, or durable. Continuum Huboverlayandauthoritativeownership behavior is defined and tested against it. - Enroll a router into a Continuum Hub from the router itself (#1424, PR #1437). Enrollment previously required editing
control_plane.enrollment_tokenin the YAML and restarting, and the Admin path that looked like it should work could not take effect. A feature-gated Admin endpoint exchanges a write-only token and persists only the Hub-issued owner-only credential, refuses accidental replacement, exposes rejected and pending-restart states, serializes startup and Admin enrollment, and lets a rejected credential recover without editing the state file. The Integrations page gains the matching form. The rest ofcontrol_planestays startup-owned, with an explicit error on a runtime write.
Changed¶
- Activate Rust 2024 for the root crate and the
perf/harness, and declarerust-version = "1.95"(#1417, #1418, PRs #1420, #1421). The source preparation landed first as a separate pass: every machine-applicablerust-2024-compatibilityrewrite, explicit matches wherever the compiler flagged a potentially non-trivial destructor in anif lettemporary, and an audit of 215 tail-drop warnings across 209 sites, of which four needed explicit bindings to preserve observable ordering. The repository toolchain stays pinned at 1.97.1,rustfmt.tomlkeepsstyle_edition = "2021"so the switch is not a repository-wide reformat, and the vendoredcrates/continuum-protocolstays on edition 2021 and Rust 1.88 to match the upstream Continuum Hub workspace. - Resolve the health checker's settings from the authoritative
health_checkssection on both startup and hot reload (#1395, PR #1429). Startup readtimeouts.health_check.*while hot reload readhealth_checks.*, so the same file produced 30s probes at boot and 5s probes after any reload, and--disable-health-checks,--health-check-interval, and--health-check-timeoutwrote fields that startup never read. One fallible resolver now serves both paths,health_checks.enabledgains real lifecycle semantics instead of being simulated with a 24-hour interval, andenabled,block_startup, andprewarm_timeoutare restart-required while cadence, thresholds, endpoint, and warmup settings stay gradual reloads. - Replace both control-plane outbox acknowledgment
Result<(), ()>returns with a public typedAcknowledgeErrorthat separates a stale result, unavailable state, and a durable persistence failure (#1404, PR #1435). Repeated acknowledgments stay idempotent, and the two agent drain sites now log an accurate reason and mark only durable write failures for retry.
Deprecated¶
timeouts.health_checkis parsed and then ignored (#1395, PR #1429). A value that differs from thehealth_checkssection produces a conflict warning at load naming the field inert. The Admin API, MCP tool surface, documentation, templates, example config, and generated IDE rules are synchronized to the same contract.
Fixed¶
- Latch the backend warmup timeout so a wedged backend settles in Unhealthy (#1396, PR #1415). The transition out of WarmingUp called
record_failure(), which clearedwarmup_started_at, so the next HTTP 503 probe started a fresh warmup timer: a backend serving 503 indefinitely alternated between WarmingUp and Unhealthy on every probe cycle andmax_warmup_durationwas effectively unbounded. An explicit not-started, active, and timed-out state machine replaces the optional timestamp, the latch survives ordinary failures until a successful health signal clears it, and it is preserved across same-URL backend renames. - Probe only the backends that need acceleration (#1397, PR #1419). One WarmingUp, Unknown, or recently-added Unhealthy backend put the whole fleet on the 1s warmup interval instead of the 30s normal interval, multiplying probe traffic to every healthy backend by about 30x. The scheduler now tracks a monotonic last-dispatch reservation per backend and selects accelerated and normal targets independently each cycle, reconciles results by stable URL identity, holds no health-state lock across a network await, and wakes when hot-reloaded cadence settings change.
- Stop validating self-hosted
reasoning_effortagainst Gemini's vocabulary on the generic streaming path (#1393, PR #1416). The payload helper shared by initial and fallback streaming attempts applied Gemini normalization to every backend, so a vLLM request carryingreasoning_effort: "max"or"none"was rewritten. Gemini normalization moves back inside the Gemini backend path, and OpenAI-family normalization andstream_options.include_usageare unchanged. - Release the half-open circuit probe when a streaming request is cancelled before a backend outcome (#1409, PR #1427). The chat streaming paths called
proxy::circuit::admitdirectly and recorded the outcome by hand after the handshake, so a client disconnect in between ran none ofrecord_success,record_failure, orrecord_ignored. In HalfOpen that leaked one ofhalf_open_max_requestsslots, and because the state machine refuses to leave HalfOpen while any probe is outstanding, three leaks meant every request to that backend was rejected with 503 until a restart or an Admin circuit reset. Every handshake and the non-streaming retry dispatch now go through the existingAdmissionguard, with first-wins settlement across the standard, fallback, native-provider, and Unix-socket paths. - Keep configuration hot reload attached across atomic-replace saves (#1398, PR #1428). The watcher handled only
EventKind::Modify(_)and watched the config file path itself, so a rename-into-place save (vim, VS Code atomic save,sed -i, provisioning tools, Kubernetes ConfigMap symlink swaps) did not trigger a reload, and the inotify watch stayed attached to the deleted inode so every later change was missed until restart. The watch moves to the logical target's parent directory, and parsing and validation move out of the notify callback into a bounded debounced worker that preserves the last known good revision. - Return the real upstream failure when retry exhaustion leaves no untried candidate (#1408, PRs #1441, #1442, #1443). With one backend serving a model, a retryable first attempt (429, 502, 503, 504, or a
first_bytetimeout) left the second attempt with no candidate and the request failed with the router-authored 503AllBackendsUnhealthy, so a transient vLLM 429 from queue pressure was indistinguishable from a dead backend. The last dispatched backend failure is now preserved with its upstream HTTP status, provider message,Retry-After, and provable backend attribution across the non-streaming, pre-stream, and mid-stream paths; a first-attempt unhealthy selection stays router-authored, and first-byte exhaustion is reported as a backend-specific 504. Two integration contracts that asserted the old generic error were corrected in the same series. - Keep a model in the catalog while its only serving backend is unhealthy (#1422, PRs #1432, #1438). Discovery dropped the backend's entries, so a model that inference still routed correctly through its configured fallback chain disappeared from
GET /admin/models,GET /v1/models, and the WebUI Models page, which made the fallback path unreachable from any client that builds its model picker from/v1/models. Force refresh now fans out synchronously without deleting the prior aggregate first, and only failed-backend memberships are carried forward, bounded by the catalog size limit, intersected with the backend's current configured allowlist, and never resurrected for a successful, removed, disabled, or newly narrowed backend. - Limit the strict smart-routing classifier schema to its required fields so OpenAI accepts it (#1406, PR #1434). The shared schema declared
reasoningwithout requiring it, which strict structured output rejects, and the field was unused on the routing hot path. Custom complexity and domain enum extensions are unchanged, and tolerantreasoningparsing is retained for non-strict responses. - Apply the GPT-5 token-field compatibility transform on every OpenAI dispatch path (#1407, PR #1440). The smart-routing classifier built an OpenAI-shaped request with
max_tokensand dispatched it throughBackend::execute_chat_completion, which forwarded the body without themax_tokenstomax_completion_tokensrewrite the proxy path applies, so classification against a GPT-5.x model failed. One idempotent backend-boundary helper now normalizes GPT-5, o1, and o3 token-limit fields across the selected OpenAI and Azure proxy, streaming, typed chat, typed stream, probe, transform, and Anthropic compatibility paths, preserving canonical field precedence, sampling controls,extra_body, and unrelated fields. Gemini and self-hosted providers are untouched. - Add MLxcel to the WebUI backend type selector (#1414, PR #1431). The router already supported the backend type, but the Add and Edit Backend form did not offer it, so an operator adding an MLxcel backend through the WebUI had to misclassify it.
- Restore the AppProxy registered-but-down circuit fallback tests (#1412, PR #1426). Fallback eligibility moved to the request-time
Configsnapshot and the fixture attached itsFallbackConfigonly to theFallbackService, which is not what production reads.
CI¶
- Preserve crash evidence when the
perf/unit tests hit the intermittent SIGILL on native x86_64 (#1403, PR #1433). Kernel, Rust, Cargo, CPU model, and CPU flags are recorded before the suite, and on failure the core, the exact matching test executable, their hashes, the test log, and full debugger output are retained, with bounded compression time and CPU and the raw core kept if compression cannot finish. Twenty formal runs on a GitHub native x86_64 runner forced to Rust 1.98.0, covering 200 perf suites, 10,000 tests, and 20 smoke pipelines, did not reproduce the crash, so the failure-only diagnostic path is what a future occurrence will leave behind. - Promote the mirrored all-features all-targets build check to
cargo clippy --all-features --all-targets --keep-going -- -D warningsin both.github/workflows/ci.ymlandscripts/local-ci.sh(PR #1435). The widened gate found one test-only health-config initializer warning, and later one test environment-lock scope finding (PR #1439). - Run
appproxy_ingress_testunder the legacyappproxyfeature in hosted CI and in the local mirror (PR #1426).
Documentation¶
- Update the Korean Extension Points cache example to the current two-step
CacheStoreandResponseCacheStoreform, document the shippedRedisCacheStoreand itsredis-cachefeature gate, and drop the deleted service types (#1382, PR #1425).
Dependencies¶
None. Cargo.lock is unchanged since v1.24.1 apart from the version bump.
v1.24.1 - 2026-08-24¶
Four commits since v1.24.0, two of which exist because the last release ran. v1.24.0 published without its two musl archives and without a container image, because the release workflow installed the cross-compilation targets on a different toolchain than the one that built them. macOS notarization moves off one person's Apple ID onto an organization-issued App Store Connect API key. The other two changes are a timeout fix on the Anthropic ingress paths and a direct-dependency major refresh.
Fixed¶
- Read the Anthropic-ingress backend deadlines from configuration on every dispatch path (#18, #1029, #1399, PR #1400). The handlers behind the Anthropic Messages API carried fixed backend deadlines, so
timeouts.requestand anymodel_overridesentry were ignored once a request entered that ingress: a vLLM request was cut at 300.027 seconds, and under the configured streaming deadline the same request completed after 952 seconds. Buffered calls now usetimeouts.request.standard.total, and true SSE calls usestreaming.first_byte,chunk_interval, andtotalas three independently armed deadlines spanning both response opening and body consumption. Coverage is native HTTP, Unix sockets, the OpenAI and Responses transforms, and Bedrock Runtime, with per-model overrides applied on all five. The response mode is passed through the request builders as a typed value rather than inferred frompayload.stream, which is what keeps the Responses bridge correct when it streams upstream but buffers for a non-streaming client, and every phase resolves from the immutableCoreConfigsnapshot taken at request entry rather than from the startup cache, which is retained only as a parse-error fallback. A timeout is classified separately from a transport error in the SSE stream, the Unix and typed streaming paths fill in request-failure and circuit-breaker accounting they had been skipping, andstandard.totalapplies as one absolute outer deadline to buffered Unix dispatches, including the request write, which the transport's own response-read timeout could not bound.
CI¶
- Activate Rust edition 2024 for the root router crate and the standalone
perf/harness while declaringrust-version = "1.95"in both manifests (#1418). The explicit MSRV matches the Debian Build-Depends floor and the English/Korean source-install guidance, while the repository toolchain remains pinned to Rust 1.97.1 for reproducible linting. A rootrustfmt.tomlpinsstyle_edition = "2021"so this language-edition change does not carry the unrelated Rustfmt 2024 mechanical rewrite, and the vendoredcrates/continuum-protocolcrate remains intentionally mixed-edition at edition 2021 / Rust 1.88 until the upstream Continuum Hub workspace moves. - Install the release build's cross-compilation targets on the pinned toolchain (PR #1405). A rustup target is installed per toolchain, and rustup resolves a directory's
rust-toolchain.tomlahead of the rustup default, but.github/workflows/release.ymlinstalled${{ matrix.target }}throughdtolnay/rust-toolchain@stable, whose composite action runsrustup toolchain install stable --target <target>. The musl std therefore landed on 1.98.0 whilecargo build --targetran under the pinned 1.97.1 and failed with "can't find crate forcore", so v1.24.0 published without itslinux-x86_64-muslandlinux-aarch64-muslarchives. The four host-target jobs passed throughout, because a toolchain always carries std for its own host, and nothing inci.ymlorperf.ymlcross-compiles, so no gate exercised the broken path until a release ran. The workflow now readschannelout ofrust-toolchain.tomland installs the target on that toolchain, and a check between install and build fails immediately when the target is missing from the active toolchain rather than several minutes into a compile. - Run the Rust CI jobs when the toolchain pin or the clippy configuration changes (PR #1405). The
rustpaths filter in.github/workflows/ci.ymllisteddeny.tomlandrustfmt.tomlbut neitherrust-toolchain.tomlnorclippy.toml, both of which arrived after that list was written. Raising the pinned channel is the change most likely to break a build or surface new lints, and it would have skipped the entire Rust matrix. - Notarize the macOS release binaries with an App Store Connect API key (PR #1411).
notarytoolauthenticated with--apple-idand an app-specific password, a credential that belongs to one person's Apple ID: it stops working when they leave the team, rotate their password, or reset 2FA, and the release stops with it. An App Store Connect API key is issued and revoked by the organization instead. The name misleads, so worth stating plainly: the key is not App Store only, because notarization exists for distribution outside the App Store, App Store submissions are not notarized by the developer at all, andnotarytooldocuments--key,--key-id, and--issueras a first-class credential alongside--apple-id.--issueris passed only when set, since a Team key requires it and an Individual key rejects it; the .p8 is accepted base64-encoded or raw; and a key that does not parse as PKCS#8 is reported at that point rather than surfacing later as notarytool's opaqueinvalidAsn1. Thepackagingenvironment needsAC_API_KEY_ID,AC_API_ISSUER_ID(omitted for an Individual key), andAC_API_PRIVATE_KEY_P8holding the base64 of the .p8, or notarization fails with a named error.APPLE_ID,APPLE_TEAM_ID, andAPPLE_PASSWORDbecome unreferenced and can be removed once a release has gone out on this path.
Dependencies¶
- Update five direct dependencies to their current released majors (PR #1413):
base640.22.1 to 0.23.1,jsonwebtoken10 to 11,rmcp2.2 to 3.1,serial_test3.5 to 4.0, andvalidator0.20 to 0.21, with the resolver-compatible transitive set refreshed alongside them. The one API change that reached router code is rmcp 3.1's multiple-response-type handler surface, so the configuration-assistant MCP server converts its existing complete tool and resource results into the newCallToolResponseandReadResourceResponseenums.generic-arraystays at 0.14.7 andmatchitat 0.8.4, becausecrypto-commonandaxumexact-pin those versions.
v1.24.0 - 2026-08-22¶
Make the timeout subsystem configurable and coherent. The DoS ceilings that cap the whole timeouts: section become operator-tunable and boot-pinned, first_byte starts doing something on the one path where it means something and stops pretending on the paths where it does not, and the remaining hardcoded timeouts get a configuration surface. The toolchain is pinned in the same release, so the lint gate stops changing with the calendar.
Added¶
- Expose the timeout validation ceilings through a new
timeouts.limitsblock, and raise the defaultmax_standard_timeoutfrom 180s to 540s (#1401, PR #1402).TimeoutLimitsis the guard that caps every value in thetimeouts:section, and it had no configuration surface at all: an operator who needed a longer budget than the compiled-in cap had to patch the source and rebuild, and two of the caps sat at exactly the shipped default value, leaving zero adjustment room. The shippedtimeouts.request.standard.totalstays 180s, so raising the cap changes what a configuration may ask for rather than what any deployment does; nothing changes until an operator raises the value, and at that point a hung upstream holds a request slot for the larger budget, whichconnection, health checks plus the circuit breaker, andserver.max_concurrent_requestsare what bound. The block is resolved once from the boot configuration and pinned for the process lifetime, so hot reload, the Admin API, and control-plane config sync all reject a candidate that changes it rather than letting a runtime or hub-authored path widen a router's own ceiling;continuum-router config validate <file>runs with no pin and therefore resolves from the file under inspection, which is the correct behavior for validating an arbitrary file. Each field also carries a compiled-in absolute ceiling (max_standard_timeout1080s,max_retry_timeout120s,max_streaming_timeout3600s,max_connection_timeout60s,min_chunk_interval10s,max_chunk_interval300s,max_image_generation_timeout1800s,max_first_byte_timeout1200s,large_model_bonus1200s), and a value above its ceiling is a load error rather than a silent clamp.extended_modelsreplaces the hardcoded large-model list, which granted a streaming bonus togpt-4andclaude-3-opuswhile matching none of the modelsconfig.yaml.exampleactually configures. The shipped default is now the current frontier tiers (gpt-5.6-soland its Terra and Luna siblings,gpt-5-pro,gpt-5.5-pro,claude-fable-5,claude-opus-5,claude-opus-4-8,gemini-3.1-proandgemini-3-pro) rather than the retired one, so this is a deliberate behavior change: a per-model streaming override abovemax_streaming_timeoutnow succeeds for a current frontier model and is rejected for a retired one. An operator list replaces the default wholesale, which is the escape hatch in either direction. In the same passretry.timeoutis documented as what the retry loop implements, one budget spanning every attempt that is consulted only between attempts and never interrupts one in flight, and the "total possible retry time" warning was dropped rather than carried forward, because both of its terms assumed per-attempt semantics and a correctly computed value could never reach its own 180s threshold under a 120s absolute cap. - Give five hardcoded timeouts a configuration surface, each keeping its previous value as the default (#1401, PR #1402).
server.http_clientexposes the shared outbound client'spool_idle_timeout(300s),pool_max_idle_per_host(64),tcp_keepalive(30s),http2_keep_alive_interval(15s),http2_keep_alive_timeout(5s), andhttp2_keep_alive_while_idle(true); the client is built once at startup, so likeserver.connection_pool_sizethese are restart-required.health_checks.prewarm_timeout(10s) replaces the fixed cap on the startup pre-warm probes, read by both the Anthropic branch and the generic branch. The two Unix-socket streaming paths now take their connect deadline fromtimeouts.connectioninstead of a separate hardcoded 10s, so the socket and TCP transports agree.timeouts.streaming_fallback_budget_multiplier(2.0, accepted range 1.0 to 10.0) exposes the factor that turnsrequest.streaming.totalinto the single wall-clock budget shared by every attempt in a streaming chain; it is deliberately a new knob rather than a reuse offallback.fallback_policy.fallback_timeout_multiplier, which scales one attempt's own timeout rather than the shared chain budget. An absent block, and every absent field inside one, reproduces the previous client and the previous budgets exactly.
Changed¶
- Enforce
timeouts.request.streaming.first_byteand raise its default from 60s to 120s (#1401, PR #1402). The field was parsed, validated, cached, and then read by no request path, so a documented knob silently did nothing; on the mid-stream fallback path it was worse than inert, because that read loop appliedchunk_intervalfrom the very first poll, making the effective first-chunk deadline 30s rather than the configured 60s. It is now the deadline for the first chunk on all three streaming paths, withchunk_intervaltaking over from the first chunk onward, and an expiry before the first chunk is reported as a distinctfirst_bytetimeout so logs and metrics can tell a slow first token from a mid-stream stall. On the mid-stream fallback path this is a relaxation, since 120s replaces an effective 30s; on the two SSE pipeline paths it is a tightening, since nothing bounded the first chunk there at all. The default moves to 120s because reasoning models routinely exceed 60s to their first token, which is exactly whyconfig.yaml.examplealready prescribedfirst_byte: 120sfor the Gemini thinking models. Two new load-time checks come with it:streaming.first_bytemust not exceedtimeouts.limits.max_first_byte_timeout(480s by default), and it must not exceedstreaming.total, without which a configured deadline could never fire and nothing would say so. A per-modelstreaming.first_byteoverride is bounded by the same cap, so a model override is not a bypass. - Apply
timeouts.request.streaming.chunk_intervalon the Bedrock and Gemini streaming paths (#1401, PR #1402). The per-chunk deadline was wired only on the mid-stream fallback path: both SSE pipeline constructions left it unset, so a stalled Bedrock or Gemini stream was bounded by nothing except the total budget, and with the per-chunk deadline unset even that was only re-evaluated when an outer layer happened to wake the task, which means at keep-alive granularity rather than promptly. Both paths now pass the per-model resolvedchunk_interval, so stall detection is uniform across all three streaming paths instead of correct on one of them. - Breaking: cap
timeouts.request.image_generation.totalat 600s (#1401, PR #1402). The image-generation block was validated against nothing at all, so an operator-supplied budget of an hour passed while a 200s standard budget was rejected, andimage_generation.first_bytewas never compared against its owntotaleither. A deployment that setsimage_generation.totalabove 600s will now be rejected at load. Raisetimeouts.limits.max_image_generation_timeout(absolute ceiling 1800s) and restart to keep the larger value; the 600s default was chosen well above the 180s effective default so that realistic existing configurations keep loading unchanged. - Route the Gemini native multimodal embedding request through the configured standard timeout (#1401, PR #1402). It was pinned to a hardcoded 60s that bypassed the
timeouts:section entirely, ignoringrequest.standard.total, ignoring anymodel_overridesentry for the embedding model, and unchangeable without a rebuild. It now reads the per-model resolved standard total like every other non-streaming call, which also makes it hot-reloadable. This raises the effective default on that one path from 60s to 180s; a deployment that wants the old ceiling setstimeouts.request.model_overrides.<model>.standard.total: 60s.
Deprecated¶
timeouts.request.standard.first_byteandtimeouts.request.image_generation.first_byteare inert and scheduled for removal in the next major release (#1401, PR #1402). An OpenAI-compatible non-streaming completion is a buffered,Content-Length-delimited body: the upstream generates the entire completion and only then writes headers and body together, so time to first byte on that path IS the full generation time rather than a separable early signal. A deadline there would not be a distinct guard at all, it would be a second and shortertotal, and enforcing the shipped 30s default would have turned it into a 30s ceiling on every non-streaming completion. Both fields stay parsed and stay bounded byfirst_byte <= totalfor backward compatibility, a non-default value now produces a load-time warning naming the field inert, andcontinuum-router config validatereports the same thing. The operative budgets arestandard.totalandimage_generation.total, and the advisory for reasoning models moved with them: it now points atstandard.totalon the non-streaming path and atstreaming.first_byteon the streaming path.
CI¶
- Pin the toolchain to Rust 1.97.1 through a new
rust-toolchain.toml. The repository pinned nothing, so CI linted against whateverstableresolved to on the day it ran. That is not a theoretical exposure: when Rust 1.98 tightenedclippy::result_large_err,cargo clippy -- -D warningsbegan failing onmainitself with no change to the code, which a re-run of main's last green CI run at the unmodified commit6b0f6e0fconfirmed. CI installsstablethroughdtolnay/rust-toolchain@stable, which sets it as the rustup default, and rustup resolves a directory'srust-toolchain.tomlahead of that default, so this file is what everycargoinvocation in the repository actually uses, locally and on a runner alike.componentslists rustfmt and clippy, so the pin is self-sufficient rather than dependent on the workflow installing them separately. Note what it masks: on 1.97.1 the widercargo clippy --all-targets --all-featuresreports zero warnings where 1.98 reports theclippy::result_unit_errpair tracked in #1404, so that issue is still real but no gate surfaces it at the pinned toolchain. Raising the pin is deliberate work: bump the channel, runmake ci-local, and fix whatever the newer lints surface in the same change.
Dependencies¶
- Refresh
Cargo.lockagainst the current semver-compatible set: 36 crates updated,itertools0.14.0 added to the graph, none removed. The direct dependencies that moved areredis1.5.0 to 1.6.0,uuid1.24.0 to 1.24.1, andaws-config1.10.1 to 1.11.0. The rest are transitive, the largest groups being theicu_*family 2.2.0 to 2.3.x and the AWS Smithy runtime crates.
v1.23.0 - 2026-08-20¶
Ship OpenTelemetry trace export and shared Redis state in official release binaries, and make a forgotten backend visibility filter fail to compile instead of misrouting. The hidden-backend bug class this repository had fixed nine times is closed by a type rather than a tenth patch.
Added¶
- Export spans over OTLP behind a new off-by-default
otelCargo feature (#1248, PR #1391). Export is opt-in twice: the build must carryotel, which official release binaries do, andtracing.otlp.enabledmust be true, which defaults to false. Thetracing.otlpconfiguration type is always compiled, so oneconfig.yamlvalidates identically under any feature set and a default binary warns that export is inactive rather than refusing to start. Four spans and two events make up the whole taxonomy:router.requestwith the matched route template rather than the raw path,router.select_backend,router.backend_call, and therouter.retryandrouter.circuit_breakerevents, each emitted from the seam that already counts the same thing so a trace and/metricscannot disagree. The taxonomy is enforced twice, at the constructors and again at a span processor on the export boundary that drops any span, attribute, or span event outside it, which is what keeps ordinary log lines and provider error bodies out of the collector. Auth header values resolve${ENV_VAR}only at exporter construction, so a config dump never holds the secret, and header names must be RFC 7230 tokens with no control characters in values. With export off, every span constructor returnsSpan::none()after one relaxed atomic load. - Ship
redis-cachein official release binaries and define the shared-state deployment contract (#1249, PR #1390). The shared-state code existed but no official artifact contained it, so the deployment profiles had to present per-replica rate limits as an unavoidable boundary. Compiling the feature activates nothing:rate_limiting.storage: redisandresponse_cache.backend: redisremain the runtime opt-in. The router drives Redis through onedeadpool-redispool overConnectionManager, which performs no Sentinel master discovery and no cluster slot routing, so the supported topology is exactly one managed endpoint (standalone, a provider-managed HA endpoint, a proxy fronting a cluster, or a unix socket).redis+sentinel://,redis+cluster://, comma-separated multi-host URLs, and a#master-namefragment are rejected at config load rather than implied and then failing at connect time. An unresolved${VAR}is accepted and re-checked once resolved, so a rendered manifest validates on a CI runner holding no production Secret. Outage semantics are documented and tested per consumer. The Helm chart and the Kustomize bundle reference an operator-created Secret and never ship a Redis server or invent a credential. - Add a buffered
MaybeReasoningstate to the thinking stream transformer (#216, PR #1389).assume_reasoning_firstdecided what the leading tokens of anunterminated_startmodel were before any evidence arrived, so a model that skipped its thinking phase and answered directly sent the whole answer on the reasoning channel and clients rendering onlycontentshowed an empty response. The new state withholds those tokens and resolves the classification from what arrives: the end marker flushes the prefix asreasoning_content, while a decision timeout, the buffer ceiling, or end of stream flushes it ascontent. Opt-in per model throughbuffered,max_buffer_size(50KB default, 8MB cap), andreasoning_timeout(10s default), all defaulting to today's behavior. Measured against a 400ms thinking phase, unbuffered relays the first delta in about 0.14ms and buffered in about 401ms; streams that never enter the decision window arm no timer and hold no extra buffer. - Automate progressive delivery with an optional Argo Rollouts integration in the Helm chart (#1251, PR #1392). Weighted canary steps, automated analysis against the router's own Prometheus series, automatic rollback, and an operator approval pause before production promotion, all values-gated and off by default. Every existing profile renders byte-identical output to what it rendered before, verified by diffing renders from
mainagainst the branch. Both analysis metrics select onrollouts_pod_template_hashso a bad canary cannot hide inside good stable traffic, and both queries carry aminRequestRateguard that makes a thin overnight sample Inconclusive rather than rolling back a good release on one failure out of three requests. The Argo Rollouts CRD schemas are vendored underdeploy/schemas/sokubeconformvalidates the custom resources offline instead of reporting them as Skipped. - Add
perf/, a standalone load harness and a weekly capacity regression gate (#1250, PR #1388). Reproducible non-streaming and SSE-streaming profiles run against hermetic mock backends, record results as versioned JSON, and are gated against checked-in reviewed thresholds. The crate declares an empty[workspace], socargo build,cargo check --all-targets, andmake ci-localat the repository root never compile it, and the gate is guarded byif: github.event_name != 'pull_request'so it never touches a per-commit build. The driver is a minimal HTTP/1.1 client overTcpStreamrather thanreqwest, because a driver sharing its pool implementation with the system under test cannot separate client-side from router-side queueing.benches/stays Criterion-only. - Report
x-served-backendon a failure a named backend actually returned (#1362, PR #1378). The header was suppressed on every non-2xx response, which is a blanket safety measure rather than a fact about the problem: an upstream 429 that a real backend genuinely returned came back unattributed, and that is the case where the answer matters most. Attribution cannot ride in the error value, becauseRouterError::RateLimitedis byte-identical whether it came from an upstream 429 or from the router's own limiter, so provenance is recorded at the four dispatch sites that hold a real HTTP response from a backend and carried out of the retry seam through a per-request slot every attempt overwrites. After a 429 from one backend and a connect failure against the next, the slot holds nothing rather than a stale name. Router-authored failures stay unattributed.
Changed¶
- Make a forgotten backend visibility filter fail to compile (#1359, PRs #1369 and #1373). Nine separate fixes (#868, #1336, #1339, #1348, #1349, #1354, #1355, #1356, #1357) were all the same defect: a path selected a backend for a user request without applying
filter_user_routing_candidates, so a backend markedinternal: trueorenabled: falsestayed an ordinary candidate. It recurred for a structural reason rather than a careless one. The filter cannot move into the shared selection seam, because it has to run before the per-key allow-list so a model served only by hidden backends reports model-not-found rather than a misleading forbidden, and the seam never receives that allow-list. So the filter stayed with the caller, every candidate list was a plainVec<String>, a filtered list and an unfiltered one had the same type, and omitting the filter compiled, passed every test, and misbehaved only on a deployment that actually hid a backend. The newproxy::selection::UserRoutableCandidatesnewtype wraps a privateVec<String>with a constructor set that carries the proof, so a seam-based path that skips the filter no longer compiles. Alongside it, the two visibility test suites are now driven by the mounted route table read out of the route-composition functions, and a source-scanning audit demands a decision for each candidate-building site, so both stop being hand-written lists that recorded history. - Rewrite the Dependency Injection section of both architecture documents (#1367, PR #1381). It described a container-resolution design that was scaffolded and abandoned before it was wired. Four names in the samples did not resolve and two never did:
setup_serviceshas no commit in the history ofsrc/, andHttpClient::new(&config.http_client)?could not have compiled against any version of the code. The section is rewritten around the pattern the router actually uses, theBackendServicetrait seam withBackendManageras its only production implementation and the two construction sites insrc/server/state.rs.
Fixed¶
- Read vLLM reasoning output on the Anthropic Messages ingress (#1386, PR #1387). The Chat Completions arm of
/anthropic/v1/messagesread reasoning only fromreasoning_content, so current vLLM responses usingreasoningsilently lost billed thinking output. The Responses arm of the same endpoint already converted that field. Both non-streaming blocks and streamingthinking_deltaevents now readreasoning_contentfirst and fall back toreasoning, and output guardrails still inspect the converted result, so the alias does not bypass configured reasoning inspection. - Filter hidden backends out of four control-plane candidate sets (#1355, PR #1361).
handle_disaggregated_request,compute_arbitrage_decision,target_provider_backends, andprovider_for_modeleach built candidates fromconfig.backendsorfind_backends_for_modelwith no visibility filter. None could dispatch to a hidden backend, because the shared seam applies the filter before the per-key allow-list and a hidden name arriving as an allow-list entry empties the intersection. What the caller got instead was a wrong answer: a 403 blaming a key that was never the problem, a 404 when the hidden backend was the only one serving the model, a 503 on the disaggregated path, or a silently mis-decided substitution rule with no error anywhere. All four were safe only because every current consumer treatsallowed_backendsas a filter applied after the visibility filter, and nothing in the type system said so. - Stop the smart-routing LLM classifier sending prompts to a hidden backend (#1356, PR #1363). With
classifier.methodset tollmorhybridand no explicitclassifier.llm.backend, resolution fell back toconfig.backends.first()with no visibility check, so every classifiable request's prompt text went to whatever sorted first, which may be aninternal: trueguard backend or a powered-offenabled: falsestandby. Unlike the other instances of this class, what leaks is prompt content rather than routing. Resolution happens once per config generation, so the exposure was steady rather than intermittent. The implicit fallback now takes the first user-routable backend and logs atinfowhich one it picked, aninternal: truepin stays honored because that is what the flag is for, and anenabled: falsepin is refused with awarnwhile the router continues on rule-based classification. - Filter hidden backends out of the
web_searchpolicy scan (#1357, PR #1364).find_backend_for_modeldecided the tool-injection policy by scanningconfig.backendsunfiltered. No hidden backend is dispatched to on this path; a hidden backend claiming the model could decide whetherweb_searchwas injected into a request a different, visible backend served, whichweb_search.per_backendoverride applied, and what backend-type value the metric label recorded. - Refuse a disabled backend for the guardrail guard model reference (#1371, PR #1377). The
backend:reference resolved purely by name, so a backend an operator had switched off withenabled: falsekept receiving guard-model prompts on the request hot path.internal: truestays exempt, since reserving a backend ordinary routing will not reach is the usual way to serve a guard model. A refused reference is neither a hard allow nor a hard block: it produces the same ordinary check error an unresolvable guard backend already produced, so the provider's existingon_errorpolicy decides, and the operator'sfail_openorfail_closedchoice is not overridden in either direction. - Take one configuration snapshot per image-edit backend resolution (#1372, PR #1376).
image_edit::find_openai_backendreadstate.current_config()three times while resolving one/v1/images/editsrequest, so a hot reload landing between two reads could decide one resolution against two config generations and select a backend no single generation would have produced. It now takes one snapshot, matching the shapeimage_gen::find_openai_backendalready had. - Make
response_cache.redis.fallback_to_memory: falsefail closed (#1249, PR #1390). The store still read and wrote the in-memory tier when the flag was off, so the fail-closed option did not exist. It now reports the outage, which the response cache logs and treats as a miss. In the same pass,rate_limiting.redisturned out never to have been validated on the real load path at all; its endpoint is now checked there, scoped to the URL rather than the wholeRateLimitConfig::validateimpl, whose numeric ranges have also never run at load and would refuse to start routers that work today. - Harden three paths after the merge of #1388, #1389, and #1390 (PR #1394). The
MaybeReasoningbuffer enforced its byte ceiling only after buffering the incoming chunk, so a single large chunk could be buffered whole before the check; the ceiling is now enforced before buffering, marker precedence is preserved exactly at the ceiling, and the full-buffer clone and rescan that made the scan quadratic is gone. Redis fallback state and its exported gauge could drift apart,fallback_to_memory: falsecould still activate the memory tier through the recovery path, and the health, clear, and statistics operations carried no command timeout. The performance harness read time to first token from the first content delta even when that delta was empty, so a backend whose first SSE frame carried an empty content field reported a wrong TTFT. - Restore the safe
continuum_router::streamingcompatibility exports (PR #1385). PR #1380 deleted the module along with the unfiltered selectors it held, which also removed the documented public compatibility namespace on a crate still on the 1.x line. The safe parser, transformer, and streaming helper re-exports are restored; every unfiltered backend selector stays deleted, and both source-audit and compiled-path coverage now prevent one from returning. - Cover
SelectionStrategy::WeightedRoundRobinand retiretests/integration/(#1366, PR #1374). A shipping selection strategy had no behavioral coverage at all. The gap was hidden bytests/integration/, seven files and 63 test functions that belonged to no Cargo test target: Cargo discoverstests/*.rsandtests/<dir>/main.rs, this directory offeredmod.rs, and no top-level target declared it. It had been dead for about twelve months, hand-edited across twenty-one commits by people who reasonably assumed it ran, and produced 57 compile errors when finally built. Four tests now cover proportional distribution, a zero-weight backend, the single-unit-of-weight branch, and the all-zero fallback, and a discovery guard fails the build on any test file placed where Cargo will not compile it. - Remove two test flakes (#1331, PR #1375 and #1383, PR #1384). The token bucket refill test asserted against a fixed window after a 1.1s sleep, but
tokio::time::sleepguarantees only a lower bound, so a loaded runner could legitimately overshoot it; the expected value is now derived from measured elapsed time. A Gemini context cache test used a 3 second wall-clock expiry window that had to cover manager construction, a background create round-trip, and a polling wait, and its windows are now wide enough that scheduler delay alone cannot fail it. Two health-probe and model-timing tests that depended on HTTP connection reuse and on sibling-test execution order were also made self-sufficient (PR #1385).
Removed¶
- Delete the unreachable three-function
select_backendchain (#1358, PR #1365). It selected a backend on health alone and bypassed the visibility filter, the per-key allow-list, the circuit breaker, and the configured selection strategy. Nothing had a production caller, but two of its three types werepub use-exported, which made it exactly the ready-made unfiltered selector someone finds when they grep for how to select a backend.ProxyServiceImpl,BackendServiceImpl,BackendManager::select_backend, and theselect_backendrequirement on theBackendServicetrait are gone. - Delete
ModelServiceImpl(#1370, PR #1379).get_backends_for_modelenumerated the whole pool with no visibility filter and no per-key allow-list. Nothing constructed it outside its own unit tests and itsServiceRegistryregistration was commented out. - Delete
BackendPool::get_backend_async(#1322, PR #1380), the unfilteredcounter % lenselector that applied no model, health, or circuit filter. #1316 already showed the cost of leaving one in reach: the ACP path picked it up and shipped an (N-1)/N mis-routing rate. With it gone, the contract stated onselect_admissible_backend, that every dispatch path not already inside the retry loop comes through there, is enforced by the compiler on this axis rather than by convention.
v1.22.0 - 2026-08-16¶
Make x-backend work on every path where the router picks a backend, let a client read back which backend actually answered, and close six paths where a backend hidden with internal: true or enabled: false could still take user traffic. One of those six is an authorization bypass present in v1.21.2 and earlier.
Added¶
- Honor
x-backendon every path where the router selects a backend (#1332, PR #1335). v1.21.2 shipped the header on chat completions, streaming, and Responses, and documented the coverage as a table of endpoints. That table was wrong in both directions: it omitted/v1/completions,/v1/embeddings,/v1/rerank, and/v1/sparse_embeddings, which honored the header through the shared retry seam, and it claimed/v1/responseshonored it on the streaming path, which never read the header at all. Coverage now extends to the Anthropic Messages API and its token counter, image generation, image edits, image variations, streaming/v1/responses,/v1/responses/compact, the Gemini-native image sub-paths, and the multimodal/v1/embeddingssub-path. The documentation states the mechanism, that the header applies wherever the router selects a backend for a request, and names the three exceptions (/v1/realtimesessions, ACP prompts, and the Batch API) instead of listing endpoints that go stale. - Report the backend that answered, through a new
x-served-backendresponse header (#1333, PR #1342). A client that composed a backend name from its own state had no way to tell an honored preference from a silently dropped one: a single wrong character produced a system that kept returning 200 while ignoring the choice. The header is returned only on a request that carriedx-backend, so a deployment whose clients never send the preference is unaffected, and it names the backend that actually produced the response, including after a retry or a fallback-chain hop. Where the answering backend cannot be proven, the header is omitted rather than guessed: streaming chat under mid-stream fallback commits its response headers before any backend has answered and can switch backends afterwards, so that arm reports nothing rather than naming the first attempt. Non-2xx responses, cache replays, guardrail blocks on non-streaming responses, and theweb_searchtool loop are omitted for the same reason and are listed in the documentation. - Advertise
backend_preference_header_v1atGET /admin/capabilities(#1334, PR #1343). A client had to compare against the literal1.21.2to decide whether the header was worth sending, which is what that endpoint exists to avoid. One token covers the whole feature rather than a per-surface list, which is honest now that coverage is uniform. A client holding only an inference key cannot read the admin endpoint and does not need to: sendingx-backendand checking forx-served-backendproves support, though its absence is inconclusive.
Fixed¶
- Enforce the per-key backend allow-list on
/v1/realtime(#1337, PR #1345). The WebSocket upgrade was authenticated, but the handler never extracted the auth context, soallowed_backendswas never applied to the session's backend. An API key restricted to one backend could open a realtime session served by any other backend serving the model, which is the restriction that setting exists to impose. Present in v1.21.2 and earlier. An empty intersection now answers 403 with the samepermission_errorshape the Anthropic and Responses paths use, kept distinct from "no healthy backend". - Stop image variations from routing to a hidden backend (#1336, PR #1344).
find_openai_backendon the variations path enumerated the pool and applied the per-key allow-list and an OpenAI capability filter, but never the visibility filter, so a backend markedinternal: trueorenabled: falsestayed an ordinary round-robin candidate. The asymmetry was backwards: naming that backend explicitly withx-backendwas correctly refused, while the implicit no-header pick could still reach it. - Stop multimodal embeddings from spending a hidden backend's credential (#1339, PR #1347). The Gemini candidate set for a multimodal
/v1/embeddingsrequest was built without the visibility filter, and the handler attaches the selected backend's provider credential before dispatching, so a hidden backend's secret was spent on user traffic. With the filter in place, that path now also reportsx-served-backend, which #1333 had deliberately withheld there. - Stop no-model streaming chat from selecting a hidden backend (#1348, PR #1352). A streaming
POST /v1/chat/completionscarrying nomodelfield resolved its backend through a helper that applied only the allow-list. The non-streaming twin of that exact case has filtered since #868; this is the half that was missed. - Stop mid-stream fallback from hopping onto a hidden backend (#1349, PR #1353). The relay resolved a fallback target from unfiltered configuration while the pre-stream sibling filtered. A hidden backend claiming the target model could become the hop destination. A target the filter empties is now treated exactly like any unreachable hop, so a mid-stream response gains no new failure mode.
- Stop batch dispatch from selecting a hidden backend (#1354, PR #1360).
resolve_batch_backendgated only on the hub credential dispatch gate, which is not a visibility check and admits every name when no gate is installed, so aninternal: trueOpenAI-style backend listed first took every batch job and spent its credential. The implicit first-match fallback now filters. An explicitcontrol_plane.batch.backendpin still resolves aninternal: truebackend, which is what that flag is for, but no longer resolves anenabled: falseone. Requires thecontrol-planefeature, which official release binaries compile. - Resolve image backend credentials from live configuration (#1341, PR #1350). Six credential lookups across image generation, variations, and edits read the startup configuration snapshot, so a hot reload that rotated an API key left those paths sending the old key until the process restarted, and the failure looked like an upstream authentication problem. Five backend-type classification reads in the same functions were converted with them, because leaving one read live and another stale inside a single function is its own defect.
- Cap the client-supplied
x-backendvalue before logging it (#1340, PR #1351). Fourteen log sites wrote the header value verbatim with no length bound. There was no injection risk, since header parsing rejects control characters, but nothing capped the size, and because an unusable value falls through rather than failing, the request still returned 200 and nothing discouraged repetition.
Removed¶
- Remove the unused non-attributed deduplication entry point (#1338, PR #1346). It shared one cache with the attributed variant, so a live caller would have let a coalesced follower read an entry with no backend attribution, which flows into request stats, control-plane usage, and now the
x-served-backendheader. Nothing called it; removing it keeps it that way.
v1.21.2 - 2026-08-15¶
Cut the /v1/models tail latency a polling client sees from about 16 seconds to under 150ms, route ACP prompts and x-backend requests to the backend that actually serves the requested model, and sign macOS release binaries with a certificate Gatekeeper accepts.
Added¶
- Honor the
x-backendrequest header as a routing preference on chat completions, streaming, and the Responses API (#1317, PR #1320). Several backends can serve the same model id and/v1/modelsalready reports all of them in the aggregated entry'sbackendsarray, but a client could not address either copy: the router load-balanced across them regardless of which row the caller picked. The header can only narrow a choice the router was already free to make. It is a first-attempt bias, honored when the named backend is visible, allow-listed for the caller's key, among the candidates serving the requested model, and admissible; anything else logs a warning and falls through to normal selection. A request never fails because of this header, and a request without it behaves exactly as before. Fallback-chain hops keep normal selection, and/metricsattribution still refuses to trust the client-supplied value. The semantics are the onesproxy::image_edithas used since #674, now shared rather than duplicated. - Add a Classifier tab to the smart-routing Web UI page (#1314, PR #1319). The admin API already read and wrote
classifier.method,classifier.rule.confidence_threshold, and theclassifier.llm.*fields with immediate hot reload, but the page could only display classifier state. The tab exposes the seven core controls with a backend-name datalist populated from/admin/backends, a save confirmation, and a validation panel, and the Status tab now bindsclassifier_methodandhas_llm_classifier. Advanced LLM classifier fields (timeout, temperature, token limits, structured output strategy, prompts) stay on the generic Configuration page, linked from the new tab. - Export model aggregation timing from the production metrics registry (#1326, PR #1329). Diagnosing a slow model list previously meant reading source code.
model_backend_fetch_duration_secondsis a new histogram labeled bybackend,model_refresh_duration_secondsis now registered in the production registry rather than only in the legacy collector list, and 13 aggregation counters that were written on every refresh and read nowhere are exported (attempts, empty responses, unavailable backends, rate limits, transient and permanent errors, stale-while-revalidate serves, coalesced requests, background refresh outcomes, and singleflight acquisitions). A backend that exceeds its per-attemptrequest_timeoutalso produces one WARN line naming the backend, its duration, and its attempt count, so the finding is available without Prometheus.http_request_duration_secondskeeps its 10s ceiling; both new duration histograms bucket to 60s instead, so a 16 second refresh is legible on the metric that names the responsible backend rather than merged into+Infon a shared histogram.
Changed¶
- Serve the stale model list while revalidating in the background instead of blocking (#1324, PR #1327). An expired or invalidated cache turned the next
/v1/modelsrequest into a full concurrent fan-out across every pooled backend, about 16 seconds worst case at default settings, with concurrent list requests queued behind the singleflight mutex. The cache layer already tracked an expired-but-present body; the aggregation entry point read it through a getter that threw that body away. A request now blocks only when the cache genuinely has nothing to give. The staleness bound iscache_ttl * 3.0(180 seconds at the default 60 second TTL) as an internal constant rather than a new YAML field, so the public config schema is untouched and operators tune it throughcache_ttl; past the bound the request blocks as before, so a wedged refresher cannot serve arbitrarily old lists. Invalidation retains bodies and restarts the age clock from the invalidation instant, which removes the race in the common "register backends, then immediately list models" sequence. Per-request response filters (per-key backend and model allow-lists, internal-backend stripping) still run against the current configuration, so a briefly stale membership list cannot widen what a caller is allowed to see.
Fixed¶
- Make the background refresher actually refresh stale cache entries (#1323, PR #1330). Its
StaleButUsablebranch called an aggregation that reads through the cache and treats a stale-but-usable entry as a hit, so it returned the stale body without contacting any backend. The soft TTL window did nothing, entries were refreshed only after reaching hard expiry, and every TTL cycle left a window in which an inbound/v1/modelsrequest performed the whole fan-out inline. For a deployment whose main traffic is model-list polling, that was the dominant latency. The branch now performs a real coalesced re-aggregation, so entries refresh between soft and hard expiry and in steady state never reach expiry at all. The healthy-event listener took the same no-op path and additionally hand-incremented counters the shared path already records, which double counted; it now shares the same slot, so at most one aggregation is in flight regardless of whether the request path, the periodic loop, or the listener started it. Measured with a 300ms backend, a 2 second TTL, and a 250ms refresher cadence, request latency stays under 150ms across more than three cycles. - Stop probing backends that cannot serve a model list (#1325, PR #1328). The refresh probed every pooled backend on every cycle and retried every non-2xx except
401and403. Because the fan-out is concurrent, the slowest pointless probe set the latency of the whole aggregation: a Bedrock backend spent three attempts at 5 seconds plus two 500ms waits against a/v1/modelsthat does not exist, roughly 16 seconds, every refresh. Anthropic and Bedrock backends are now served from their configuredmodels:with no HTTP request, matching what the admin discovery path already did for them. Disabled backends are dropped from the fetch set by set membership, so pool-only backends such as AppProxy dynamic replicas are unaffected.404and405join401and403as permanent failures that fail fast, while5xxand connection errors keep the full retry budget. One intentional response change: Bedrock models now appear in the aggregated list, since the fetch previously failed outright and dropped them. Internal backends are deliberately still fetched, because the admin catalog, control-plane inventory, andfind_backends_for_modelall read past the user-facing internal filter. - Route ACP
session/promptthrough model-aware backend selection (#1316, PR #1321). Selection used a bare counter modulo the pool length that ignored the requested model, backend health, and circuit state, so on a router whose backends serve disjoint models it mis-routed (N-1)/N of all prompts into an upstream 404 surfaced as-32603. In the same handleracp.default_modelwas declared, documented, and displayed but never read, so every model-less prompt sent the literal string"default"upstream and failed. Candidates now come from the model lookup, pass the user-routing visibility filter, and go through the shared admission seam that every other dispatch path uses, which brings health filtering, circuit admission, and the configuredselection_strategy; ACP traffic also settles its outcome into the circuit breaker. Model resolution is the request override, thenacp.default_model, with no literal below that: a prompt naming no model is rejected with-32602rather than answered from an arbitrary backend. Errors carry structured data (data.field,data.value,data.sourcefor an unknown model;data.backendanddata.statusfor a backend HTTP error), and a blankacp.default_modelis now rejected at config validation. ACP stays a thin dispatch path by design and does not inherit the HTTP retry loop, fallback chains, or the per-key backend allow-list; that decision is recorded in the module docs and the architecture page. - Sign macOS release binaries with a Developer ID Application certificate and notarize them (PR #1313). Every macOS release up to v1.21.0 was refused by Gatekeeper on download for two independent reasons. The signing leaf was an
Apple Distributioncertificate, an App Store and TestFlight submission identity whose leaf lacks the1.2.840.113635.100.6.1.13extension Gatekeeper requires for a download from outside the App Store, so the signature verified locally and was rejected on every user's machine;codesign --sign "Distribution"matches identity names by substring, which is how it picked that certificate out of the keychain. The release also never callednotarytool, and a notarization ticket is a separate requirement, so even a correct certificate would have produced the "Apple could not verify this app is free of malware" refusal on a quarantined download. Signing now runs through two composite actions that assert theDeveloper ID Applicationauthority, the hardened runtime flag, and a pinned code signature identifier before packaging, then submit tonotarytool --waitand gate onstatus: Accepted. Nothing in the old workflow inspected the resulting authority, which is why this shipped unnoticed for eight months; a wrong certificate now fails the release instead of shipping quietly. - Enable the Configuration review dialog's "Confirm & save" button (#1315, PR #1318). Its
:disabledexpression ended in two bare.lengthoperands, so with no blockers the whole chain evaluated to the number0. Alpine removes a boolean attribute only fornull,undefined, orfalse, sodisabledstayed set and the button was permanently unclickable with no console error. Both operands are now compared with> 0. An audit of every:disabled,:checked,:readonly,:required, and:selectedbinding undersrc/webui/assets/partials/confirms this was the only unsafe expression.
v1.21.1 - 2026-08-12¶
Advertise backend_tasks_v1 from the running task executor so Continuum Hub can dispatch backend candidate probes to a released Router, resolve 16 quantization and container model-id forms that previously matched nothing, and make Web UI charts readable in dark mode.
Added¶
- Add four NVIDIA Nemotron 3/3.5 catalog entries (#1306), taking
model-metadata.yamlfrom 230 to 234:nemotron-3.5-lightning-30b-a3b(30B total, 3B active, hybrid Mamba-2 and attention MoE),nemotron-3-ultra-550b-a55b(550B total, 55B active LatentMoE with MTP layers),nemotron-3-nano-omni-30b-a3b-reasoning(31B omni-modal with a CRADIO v4-H vision encoder and a 0.6B Parakeet speech encoder), andnemotron-3.5-content-safety(4B Gemma-3-4B-it base with a LoRA safety adapter). The alias audit classifies every new alias as LOAD-BEARING with zero broken baselines.
Changed¶
- Name
hot-reloadexplicitly in the release build feature list, and keep the CI and local CI release-union steps in sync with it (#1307). This is a pin, not a fix:release.ymlbuilds without--no-default-features, sodefault = ["full"]already suppliedhot-reloadand shipped binaries have always had config hot reload compiled in. What was missing is the guarantee, which rested entirely on inheritance.
Fixed¶
- Advertise
backend_tasks_v1in heartbeat inventory whenever the outbound backend task channel is attached (#1305, PR #1310). Public v1.20.0 and v1.21.0 binaries attached the executor but never reported the capability, so Hub refused every candidate-probe dispatch with HTTP 409 and the backend-profile workflow could not be validated against a released Router. Three defects contributed.build_inventory_heartbeatreplaced the whole reported policy status with the policy store's view, discarding the capability appended moments earlier, so enrollment advertised it and every heartbeat dropped it; the heartbeat inventory is the one Hub retains, which is why a released binary looked capable at enrollment and unsupported from then on. The capability was also appended only when a policy store existed, so a router running withcontrol_plane.policydisabled never advertised it at all. Attachment was additionally re-derived ininventory.rsfrom a hot-reloadable configuration snapshot instead of being observed from the executor the agent actually attached, which let the two answers disagree. Attachment is now a runtime latch, set only after the identity-boundBackendTaskStoreand its loop are running, cleared when that loop exits, reported independently of policy-store availability, and surfaced with a disabling reason in structured logs and onGET /admin/control-plane/status. - Resolve 16 model-id forms that previously matched no catalog entry (#1297, PR #1309).
layered_format_strippeeled trailing hyphen-delimited tokens right to left and stopped at the first unrecognized one, so every recognized token to its left was unreachable:Trinity-Large-Thinking-FP8-Blockstopped onblockand never peeledfp8. Now recognized are the compressed-tensors scaling qualifiers-block,-dynamic, and-static, peeled only when the token to their left is itself a quantization token so an ordinary trailing word is never stripped; the weight and activation forms-W4A16,-W8A8, and-W4A8, including thequantized.prefixed spelling RedHatAI publishes; the-ONNXcontainer token; the bare-unslothre-publisher marker; and-mxfp8. Date-then-flavor names such asMinistral-3-14B-Instruct-2512resolve through a date-stripped form fed back into the peel loop. Behavior-changing derivatives stay unresolved by design, including-abliterated,-REAP-<params>, and-DFlash, since those denote a different model rather than a different serialization. - Make uPlot chart axis labels and grid lines legible in dark mode on the Usage and Dashboard pages (#1304, PR #1308). uPlot resolves axis colors at construction time and paints them onto
<canvas>, so its built-in#000labels andrgba(0, 0, 0, 0.07)grid survived every.darkCSS override and left the plotted series as the only readable part of the chart. Axis, grid, tick, and border strokes now come from a shared theme helper, and mounted charts re-color on the Light and Dark toggle and on an OS theme change in Auto mode without a page reload. The drag-select rectangle and the crosshair cursor lines, which are DOM rather than canvas, are corrected in CSS.
v1.21.0 - 2026-08-11¶
Serve /v1/realtime so speech backends whose only interface is a bidirectional WebSocket become routable, gate every upgrade on Origin before anything else happens, and grow the model catalog from 189 to 230 entries from first-party sources.
Added¶
- Add
GET /v1/realtime?model=<id>and the/realtimeSDK compatibility alias, an OpenAI Realtime-compatible WebSocket proxy (#1299, PR #1301). Text and binary frames are relayed byte-identical in both directions with no protocol adaptation; the router routes, authenticates, bounds, and relays. This makes backends such as the NVIDIA NemotronLabs VoiceChat inference container reachable, since that container serves nothing but this WebSocket. Pre-upgrade handling resolves the model through the same 6-phase metadata matching pipeline as HTTP (so aliases and suffix normalization work), requires theaudiocapability, computes candidates throughfind_backends_for_modelandfilter_user_routing_candidates, and picks a backend throughselect_admissible_backend, the same selection and circuit-admission seam HTTP dispatch uses. When the caller's raw model string resolves only through an alias, the backend is asked for the canonical catalog id while the client-facing 404 still echoes what the caller sent. - Add the
realtimecargo feature, enablingaxum/wsfor the inbound upgrade and the already-optionaltokio-tungstenitefor the outbound dial (#1299). It is part offulland deliberately unreachable fromembed, whose negative contract excludestokio-tungstenite;scripts/assert-embed-feature-graph.shstill passes andcargo check --no-default-features --features realtime --libproves the feature stands alone. - Add the always-parsed optional
realtimeconfig section (#1299):enabled(defaultfalse),max_sessions(256),handshake_timeout(10s),idle_timeout(60s,"0"disables),max_session_duration("0"unlimited), andbackend_path(/v1/realtime), all on the sharedparse_durationcontract. Validation is wired into bothimpl Validate for Configand the real load-path funnel, so a malformed section fails startup, hot reload,config validate, and the admin config API alike. Disabling both timers warns rather than failing, because that combination leaves a session with no deadline and lets a half-open client hold amax_sessionspermit until the OS TCP keepalive expires. The section is snapshotted when routes are built, so changes need a restart. - Refuse cross-site WebSocket upgrades before any observable side effect (#1299). A WebSocket handshake bypasses both CORS and the same-origin policy, and unlike
fetchit hands the initiating page full bidirectional read access, so theOrigincheck runs immediately after the endpoint gate and ahead of model parsing, model resolution, candidate computation, backend selection, and the session permit. A refused upgrade dials no backend, records no circuit event, and consumes no session slot. An absentOriginis allowed, since every non-browser SDK sends none and a browser cannot forge it; a present one is allowed when it is same-authority with the request or whenserver.cors.enabledis set andserver.cors.allow_originsmatches it through the same matcher the CORS layer uses. Everything else, including the opaque literalnulleven under a*pattern, gets HTTP 403 with error codeorigin_not_allowed. Browser origins are declared once inserver.corsrather than in a second realtime-specific list. - Bound realtime sessions on every axis (#1299): a
max_sessionssemaphore, 4 MiB message and frame ceilings on both legs, the dial underhandshake_timeout, an idle timer refreshed by every frame, and an optional absolute session ceiling. HTTPtimeoutsnever apply to a session. Relay runs one pump future per direction in a singleselect!, each owning the read half of its leg and the write half of the other, so close frames propagate with their originating code and reason; an orderly close on either side closes the other with 1000, and a peer vanishing without a close handshake closes the survivor with 1011. The send await is the only backpressure. - Add four realtime metrics under
metricsplusrealtime(#1299):realtime_active_sessions,realtime_sessions_totalover a closed six-outcome vocabulary,realtime_frames_relayed_totalper direction, andrealtime_session_duration_seconds. Call sites are always compiled and go through cfg-gated no-op seams. - Add
Admission::settle_success(#1299). A WebSocket upgrade answers 101, whichsettle_statuswould have mis-recorded, while a dial failure settles as a connection failure on the circuit HTTP failover already shares. - Add 41 model metadata entries and fill in nine families that had none (#1298): Liquid AI LFM2.5 (11 entries), Ai2 Molmo 2, Microsoft Florence-2, Arcee AI Trinity and AFM, Xiaomi MiMo V2.5, the MiniMax back catalog through M3, Jina VLM, NVIDIA LocateAnything 3B, Pokee Isaac, Alibaba
qwen3.8-27b, and the two Korean sovereign-AI foundation modelsa.x-k2(SK Telecom) andk-exaone-2.0-750b-a37b(LG AI Research). Every value comes from a first-party source: HuggingFaceconfig.jsonfor context windows and expert counts, the vendor model card for parameter splits and licenses, and the vendor rate card for pricing. Where a vendor publishes nothing the field stays at the documented zero and an inline comment records why. Resolution was verified against 60 real HuggingFace repo ids covering quantization and container suffix chains. - Add
nvidia-nemotronlabs-voicechat-11b(#1300), NVIDIA's 11B full-duplex speech-to-speech model under OpenMDW-1.1, with thenemotron-voicechat-11bandnemotronlabs-voicechat-11baliases registered explicitly because the vendor and lab segments are not peelable suffix tokens.context_windowis the documented0, because the repository ships a NeMo training config rather than a transformers config and the model card states no token limit; the backbone's own 128K window is deliberately not asserted for a checkpoint that never claimed it. - Add
muse-glimmer-30b(#1302), Meta's 30B dense multimodal agentic model under Apache-2.0, withcontext_window: 131072taken frommax_position_embeddingsin the publishedconfig.jsonand thecodecapability grounded on its SWE-Bench scores rather than assumed. No aliases are needed: the HuggingFace repo form and the official GGUF quant tails all resolve through repo-prefix stripping and the existing peels, confirmed by seven id-form probes.
Changed¶
- Promote
qwen3.8-max-previewtoqwen3.8-max(#1298). Alibaba shipped it GA on 2026-08-03 with published rates and confirmed image and video input, while the entry still described a credit-only preview with zero pricing and no vision capability. The preview id is retained as an alias so existing callers keep resolving. - Flag
minimax-m2.5pricing asUNVERIFIEDin the catalog (#1298). MiniMax moved the model to its legacy catalog and stopped publishing a rate card, so the inherited rates are unverifiable. The comment warns against relying on them for cost routing rather than substituting an unsourced number. - Redact
Sec-WebSocket-Protocolandproxy-authorizationin request logging (#1299). OpenAI's browser Realtime clients cannot setAuthorizationon aWebSocket, so they smuggle the API key through a subprotocol token that would otherwise land in the trace-level log line.
Fixed¶
- Correct
minimax-m2.7-highspeedpricing (#1298). It was recorded at parity with standard M2.7; the highspeed tier bills at 2x.
Removed¶
- Remove
mimo-v2-proandmimo-v2-omni(#1298). Both were hosted-only endpoints with no weight release, and Xiaomi switched them off on 2026-06-30. The MiMo V2.5 entries supersede them.mimo-v2-flashis kept, because its MIT weights remain published and self-hostable.
Security¶
- Never log realtime frame payloads at any level, including error text (#1299). Both WebSocket libraries embed a lossy copy of a malformed frame inside their UTF-8 decode error, so read errors are reported through bounded classifiers instead of interpolating the error, and unit tests prove frame bytes cannot reach a log line.
Documentation¶
- Add the
realtimeoperator pagesdocs/en/configuration/realtime.mdanddocs/ko/configuration/realtime.md, both in the Zensical navs, covering the browser-origin table and per-session memory sizing (max_sessions * 2 * 4 MiBworst case), plus session-lifecycle sections in the English and Korean architecture guides and a commentedconfig.yaml.exampleblock (#1299). - Bump the configuration assistant to schema 1.36.0 with a
realtimesection entry, regenerate the IDE rule files including the "browser realtime client gets 403" row, and update the drift-test inventory in the same change (#1299). The section count is 36: 34 always-on plus 2 feature-gated.
CI¶
- Add a per-crate
cargo-denylicense exception forwebpki-roots(#1299). The crate is the Mozilla CA bundle under CDLA-Permissive-2.0, a permissive data license, and it reaches the default graph through therustls-tls-webpki-rootsfeature thatrealtimebrought intofull. Official release binaries already shipped it throughcontrol-plane, butcargo-denyresolves default features only, so this is the first build where it saw the crate. The exception is scoped to the one crate rather than added to the global allow list, so a future code crate under that license still fails loudly. - Bump
Azure/setup-helmfrom v4.3.1 to v5.0.1 (#1295),Azure/setup-kubectlfrom v4.0.1 to v5.1.0 (#1293), andactions/attest-build-provenancefrom v3.0.0 to v4.2.2 (#1294).
Dependencies¶
- Bump
clapfrom 4.6.5 to 4.6.6 andlrufrom 0.18.1 to 0.18.2 (#1296), and refreshCargo.lockagainst the current semver-compatible set.
Known Issues¶
- The
dorny/paths-filterrustfilter in.github/workflows/ci.ymldoes not listmodel-metadata.yaml, so a metadata-only pull request skips every Rust job even though several integration tests parse and assert on that file. The three metadata changes in this release were gated locally instead. Addingmodel-metadata.yamlandmodel-metadata.d/**to the filter closes it.
v1.20.0 - 2026-08-09¶
Hand backend topology and backend credentials to a Continuum Hub over an outbound-only channel, close the last gaps in TTFT telemetry, serve the image pricing and image limits keys the metadata loader had been dropping, and move the smart-routing LLM classifier onto the same backend implementations that serve production traffic.
Added¶
- Add
control_plane.config_sync.backends, the Router-sidebackend_config_v1executor for hub-managed backend snapshots (#1264). It carries an opt-inmode(disabled,overlay,authoritative), a durable identity-bound last-known-good snapshot store with an apply-id-keyed result outbox, atomic validation and hot reload through the single existing pool writer, graceful-drain evidence, and metadata-only convergence status on the heartbeat. Hub-supplied stable ids are used verbatim, uninterpretable tokens reject instead of being guessed, and pre-activation health checks run only for enabled backends that are new or materially changed. The coordinatedcontinuum-protocolbackend-config contract is vendored with its provenance recorded, and the hub's nine fixtures plus both canonical digest vectors are pinned by tests, so a canonicalization divergence between the peers fails a test rather than silently disagreeing. - Add
backends[].enabled, defaulting totrue(#1264). A disabled backend is excluded from routing candidates and from every model listing surface (OpenAI, extended, single-model, Anthropic) while staying constructed in the pool, health-checked, candidate-probeable, and model-discoverable. Exclusion reads as model-not-found, never forbidden. Absent stays off the wire, so existing configurations are unchanged, and flipping the flag participates in hot-reload change detection. - Add backend-scoped Hub credential bundles indexed by
credential_id(#1269), advertised asbackend_credentials_v1. This removes the one-credential-per-provider last-entry-wins limitation and closes the resolver seam that had rejected every credentialed snapshot ascredential_unavailable. A hub-owned backend with acredential_refresolves exactly that credential by stable backend id, works with no localapi_key, and never falls back to a provider-wide or local secret; a hub-owned backend without a ref sends no Hub credential; local backends keep their existing precedence. The acknowledgment digest covers ids, providers, and versions only, never secret bytes, and the capability is advertised only when the complete executor is live. - Add the outbound Hub backend task channel (#1270), advertised as
backend_tasks_v1. The Router executes hub-authored backend candidate probes and redacted local backend exports over its own outbound connection, so the Hub can reach a candidate inside a customer network and read what a Router has configured without any inbound Hub-facing endpoint. The probe is the existing Admin primitive rather than a second implementation: its domain moved to the always-compiledsrc/backend_probe/(becausecontrol-planedoes not enableadmin),src/admin_config/backend_probe_support.rsbecame a thin axum adapter, and Admin request and response shapes are unchanged. Both callers share one process-wide concurrency and rate ceiling. - Report
ttft_mson both/v1/responsesstreaming usage taps (#1268). Previously both pushed usage records with TTFT absent, so a fleet whose traffic runs on the Responses API rendered empty prefill and decode panels. The router performs the upstream dispatch and forwards every streamed event, so both instants existed and were simply never taken. - Record
ttft_msfor non-streaming completions on/v1/chat/completions,/v1/completions,/v1/responses, and/anthropic/v1/messages(#1271). The span from provider dispatch to upstream response headers is a genuine router-observed first byte, not a fabrication. It is threaded through a request-scopedtokio::task_local!installed once for the whole API route group, so there are no signature changes. - Stamp
ttft_mson local cache replays and pin the provider-batch exemption with a test (#1272). A replay writes its whole body in one shot, so first byte and last byte genuinely coincide and the derivation lives once in the single producer behind all seven replay sites. Replays reportcompletion_tokens: 0, so decode and TPOT derivations are unaffected. - Add
per_image,image_input_tokens, andcached_image_input_tokensas optionalPricingInfofields (#1279).PricingInfohad exactly three fields and nodeny_unknown_fields, so the shipped catalog's image prices were silently dropped at deserialization: nine paid image models advertisedpricing: {input_tokens: 0, output_tokens: 0}andgpt-image-2served only its text rates.per_imageis an untagged flat-or-by-quality map in USD per generated image, a different unit from the per-1M token rates. - Add the eight image
limitskeysModelMetadatawas dropping (#1284):max_prompt_length,supported_sizes,max_n,supported_qualities,supported_output_formats,supports_streaming, and dall-e-3'squalities/styles, normalized tosupported_qualities/supported_styles. All are informational for API consumers; image request enforcement is unchanged. Unknown metadata keys now produce one load-time warning listing dotted paths labeled with model ids instead of vanishing, and are still never rejected, becausemetadata downloadmust let an older binary fetch a newer catalog.
Changed¶
- Dispatch the smart-routing LLM classifier through
Backend::execute_chat_completioninstead of a privatereqwestclient with a hardcoded endpoint path and auth header (#1290, PR #1292). URL construction, protocol transformation, and effective authentication are now owned by the same backend implementations that serve production traffic, which removes the transport-divergence class rather than its known instances. The backend is resolved by name from the live pool on every classification call, so a hot reload cannot leave a stale transport alive. Aclassifier.llm.backendname that resolves in configuration but not in the live pool fails per call with the backend named and falls back to the rule classifier; no working deployment becomes a boot failure. The structured-output clamp toprompt_onlynow coversbedrockalongsideanthropic. - Pin
ModelMetadata.pricingto USD per 1M tokens (#1276). The field was documented as per 1K while every shipped value was per 1M, and three independent facts in the code already read it as per 1M. Nothing in the catalog is rescaled; the doc comments, user-facing docs, catalog header, and WebUI label now agree. - Reject non-finite and out-of-range
smart_routingfloats at config load rather than at use (#1286, #1288). Profilecost_per_1k_input_tokensandcost_per_1k_output_tokens,load_management.recovery.hysteresis_factor,load_management.thresholds[].error_rate, and twoclassifierfloats are now validated on the real config path, which covers startup, the config watcher, control-plane config sync, and the admin config API. See Breaking Changes. - Align every runtime retry duration with the config validator's contract (#1282).
parse_durationaccepts a positive unitless integer as seconds, soretry.initial_delay: "2"passes validation, butRetryHandlercarried a private suffix-only parser and silently fell back to 100ms and 2s, with a third 10s fallback for theRetry-Aftercap. A validated 2s/8s policy could execute as 100ms/2s. All runtime paths now read the same contract, defensive fallbacks are one consistent constant per field, and each fallback logs a warning naming the field, the configured value, and the substitution.
Fixed¶
- Stop addressing a Gemini classifier backend with a doubled version segment (#1290, PR #1292). The router's own default
GEMINI_API_BASE_URLends in/v1beta/openai, to which the classifier's private client appended/v1/chat/completions. - Dispatch a Bedrock classifier backend through its real transport (#1290, PR #1292). Every Bedrock classifier configuration was previously non-functional. On a build without
bedrock-sigv4the backend now returns its operator-actionable error naming the backend and itsendpoint_type, which the classifier surfaces before falling back, so the failure is no longer anonymous. - Bound provider error text on the classifier hot path (#1290, PR #1292). Backends in the Anthropic Messages protocol family embed the entire upstream body in the error they return, and that string was logged twice per failed classification, so one misbehaving classifier backend could write an unbounded amount of upstream-controlled text to the log on every request reaching the classifier (reproduced at 12,037 bytes per occurrence). The body is now truncated to
MAX_BACKEND_ERROR_BYTESon a character boundary. - Omit
temperaturefrom the classifier payload for Claude models that forbid sampling parameters (#1289, PR #1291). Anthropic rejects the parameter for Claude Opus 4.7 and later by its presence, not its value, so a Claude 4.7+ classifier model returned HTTP 400 on every classification request, including at the shipped defaulttemperature: 0.0. No misconfiguration was required to hit it, and the failure was silent: the router burned a full round trip againsttimeout_msand degraded to the rule classifier, so the operator saw smart routing working and never using the LLM they configured. - Meter a
/v1/responsescache replay exactly once (#1273, PR #1283). The replay pushed two control-plane usage events, and the second was markedserved_from_local_cache: false, so the hub charged the matched key's TPM windows and monthly delta for a request that consumed no provider tokens. The other three inbound families were already correct. - Correct auto-inferred smart-routing profile costs, which were 1000x too large (#1276).
ModelTierRegistry::infer_from_metadatacopied per-1Mpricingstraight into the per-1Kcost_per_1k_*fields without the division its sibling performs. That reached cost-savings estimates that mixed inferred and configured profiles, model selection ranking, and the valuesGET /admin/smart-routing/model-profilesand the WebUI report. Explicitly configured and glob-pattern profiles are unaffected. See Breaking Changes. - Label the admin WebUI model catalog price column
per 1Minstead ofper 1K(#1276). The numbers were always correct; only the label was wrong, and it was the same mislabel that trapped a downstream consumer. - Wire the previously dead
PricingInfoandModelLimitsrange validation into the real load paths (#1284). Violations warn once per model at runtime load and are a hard error fromcontinuum-router metadata download, so a bad download can never replace a working file. Thecontext_windowandmax_outputbounds are reconciled to[0, 10000000]with0meaning not applicable, because the shipped catalog carriescontext_window: 0on 17 models andmax_outputabove the old ceiling on 43. - Name the reason when
metadata downloadcannot diff against a pre-existing local file (#1284), instead of silently treating the local file as if it never existed.
Breaking Changes¶
- A
config.yamlcarrying a negative, NaN, or infinitesmart_routingmodel-profile cost now fails at startup, and a hot reload keeps the previous configuration (#1286). Previously a cost of exactly-1.0produced an infinite model score, so that model won every candidate comparison in its tier and silently absorbed traffic intended for its siblings, with neither a config error nor a log line. An adminPUT /admin/smart-routing/model-profilescarrying such a cost now returns 400. The same validation descent also makes a pre-existing 200-character limit onmodelandmodel_patternreachable from config load for the first time. smart_routing.load_management.recovery.hysteresis_factorandload_management.thresholds[].error_rateare now bounded to[0.0, 1.0]at config load, along with twoclassifierfloats (#1288). Both were documented as(0.0 - 1.0)and enforced nowhere. A negativehysteresis_factor, an ordinary sign typo, made every stay-elevated comparison unconditionally true, so once the router escalated to Warning or Critical it never left and degradation stayed permanently applied.- Auto-inferred smart-routing profile costs drop by 1000x to their correct figures (#1276).
GET /admin/smart-routing/model-profilesrows and the WebUI smart-routing table change accordingly (an auto-inferred GPT-4o row that read2.5now reads0.0025), and candidate ordering between an inferred and a configured profile can change, in the direction of the two being comparable at all.
Documentation¶
- Document the hub backend-sync contract across the assistant and IDE surfaces (#1264): the configuration assistant at schema 1.35.0 with the
config_sync.backends.*andenabledrows, regenerated IDE rule files, the MCPfleet_backend_sync_contractresource and theexplain_effectiveenabledprojection, aconfig.yaml.exampledisabled-backend section, and new sections in the English and Korean architecture and backend-configuration guides. - Document the metadata unknown-key warning, the range-violation warning, and the dall-e-3 key normalization together in the English and Korean advanced-configuration guides (#1284).
- Correct every remaining per-1K pricing sample in the English and Korean docs and in the shipped catalog header (#1276), including the
/v1/modelsresponse example that contradicted the table 450 lines above it on the same page. - Add bilingual technical reports for the TTFT telemetry family (#1268, #1271, #1272) and for the image pricing keys (#1279).
CI¶
- Scan each release image on its own platform (#1260). Each build step pushes by digest, so its digest names an index holding a single platform; the runner is amd64 and Trivy defaults to
linux/amd64, so the arm64 scan aborted withno child with platform linux/amd64 in indexbefore reading a single package. The v1.19.0 release failed on exactly this.TRIVY_PLATFORMis now set on both steps, so neither depends on the runner architecture. - Pin
tracinginterest for integration capture harnesses (#1274, PR #1285).audit_decision_action_is_allow_in_monitor_modefailed roughly once in four full-suite runs and never reproduced in isolation, becausetracingcaches interest per callsite in a process-global table and resolves that cache through the rebuilding thread's own subscriber when at most one dispatcher is registered, so a sibling test with no subscriber latched the callsite off for the whole binary. The guard the crate-internal harness already had is now installed in the integration-test binaries, with a regression test that fails deterministically without it.
v1.19.0 - 2026-08-06¶
Changed¶
- Publish one container image instead of two, built on
gcr.io/distroless/static-debian12(#1256). The image carries the statically linked musl binary, a passwd file, and timezone data, and runs as uid 65532. The-alpinetag family is no longer published::<version>-alpine,:<major>.<minor>-alpine, and:latest-alpinestop receiving new tags, and the Debian base is gone from:<version>,:<major>.<minor>, and:latest. Deployments that pinned an-alpinetag must move to the unsuffixed tag. The image has no shell and no package manager, sodocker exec ... shandkubectl exec ... -- shno longer work; the deployment guide documents the ephemeral-container recipes that replace them, and the router's ownconfigsubcommands and--health-checkrun as the image entrypoint without a shell. The Debian base carried 22 CRITICAL and HIGH findings that no rebuild could clear, because every one of them was an unfixed Debian package the router never used. - Remove
Dockerfile.alpineandDockerfile.alpine.ci.DockerfileandDockerfile.cibuild the distroless image and consume the musl release archives.
Fixed¶
- Make the container
HEALTHCHECKwork (#1256).perform_container_health_checkparsed the target as aSocketAddr, which only accepts a numeric address, so every hostname was rejected, including thelocalhostin the--health-check-urldefault. The declared health check had therefore failed on every published image since it was introduced in #198. The target is now resolved rather than parsed, and each resolved address is tried before the check reports a failure.
CI¶
- Install a published Trivy release in the release image scan steps (#1255).
aquasecurity/trivy-actionv0.33.1 defaults to Trivy v0.65.0, whose release assets upstream deleted, so the installer resolved the surviving git tag, loggedfound version: 0.65.0, and then failed on the asset download. The scan step died about 0.3 seconds in and no re-run could pass. The action is now v0.36.0 and all four scan steps pin Trivy v0.73.0 explicitly. - Run the self-hosted macOS CI jobs on the
self-hosted-macos-15-x64runner (#1259).
v1.18.0 - 2026-08-05¶
Add two admin-authenticated discovery endpoints, one for a backend candidate that has not been registered yet and one for a single configured backend, and ship maintained Kubernetes deployment profiles with a supply-chain gate on release images.
Added¶
- Add
POST /admin/backends/probe, a non-mutating probe for a backend candidate before registration (#1252). It accepts the same candidate fields as backend creation plusoperations: ["health", "models"], is admin-authenticated on every Admin transport including Unix sockets, and is advertised byGET /admin/capabilitiesastransient_backend_probe_v1. The candidate stays request-local: nothing is written to active configuration, config history, backend pools, health state, circuit breakers, aggregation caches, environment files, or token stores. The response separateshealth.credential_status(valid,invalid,unknown,not_required) fromcatalog.source(live,curated,configured,unsupported), so a healthy unauthenticated endpoint cannot be read as proof of a valid credential. Model-list-capable candidates reuse the URL composition, authentication headers, parser, normalization, response-size cap, and per-backend model-count limit that registered discovery uses. Request bodies, concurrent probes, request rate, upstream time, response bytes, and returned model counts are bounded, credential-bearing URLs are rejected, and validation, health, authentication, upstream, timeout, parse, and response-size errors are sanitized. - Add
POST /admin/backends/{name}/models/discover, which returns one configured backend's live model catalog before thebackends[].modelsallowlist is applied (#1245). It resolves exactly one backend by name and reuses the model fetcher normal aggregation uses, preserving timeouts, the response-size cap, the per-backend model-count limit, normalization, Codex account-plan filtering, and centralized OAuth refresh. It writes nothing: configuration, the aggregated model cache, health state, and circuit-breaker state are unchanged, and normal aggregated model visibility is unaffected. Native Anthropic, Bedrock, and non-Codex OAuth backends answer501withmodel_discovery_unsupportedrather than falling back to configured model names, an unknown name answers404withbackend_not_found, and upstream failures are machine-readable asbackend_authentication_failed,backend_discovery_timeout,backend_discovery_network_error,backend_discovery_parse_error,backend_discovery_http_error, orbackend_discovery_response_too_largewithout echoing access tokens, refresh tokens, token-store paths, or auth headers. - Add maintained Kubernetes deployment profiles under
deploy/(#13). A Kustomize base with a staging overlay and a Helm chart with development, staging, canary, and production values cover authenticated configuration, hardened pods, TLS ingress, autoscaling, disruption budgets, and network isolation.monitoring/prometheus/carries a standalone Prometheus bundle with a 10 GiB persistent volume claim and 15-day retention for clusters without the Prometheus Operator, and the Helm chart can render aServiceMonitorwhere the operator CRD is installed.
Changed¶
- Share one live-discovery path between registered and transient backend snapshots (#1253, PR #1254). Candidate and registered discovery had duplicated provider and authentication branching; a single shared function now serves both from the validated backend snapshot, while token-store access stays registered-only.
Fixed¶
- Stop a transient OAuth candidate from reporting a synthetic healthy state (#1252, PR #1254). Probe health inherited the registered-backend active-check bypass and answered
healthywith no network I/O, even though the lifecycle-managed authentication strategy a registered OAuth backend depends on does not exist before registration. Transient OAuth health now returnshealth.status: "unknown",credential_status: "unknown", andbackend_probe_health_unsupported. - Return the stable authentication error for health-only probes (#1252, PR #1254). A 401 or 403 on a health-only probe was collapsed into a generic health error, so a wrong key was indistinguishable from an unreachable endpoint. It now maps to
backend_authentication_failedwithout exposing the upstream body. - Bound configured candidate catalogs (#1252, PR #1254). A configured catalog read only
modelsand applied no per-backend ceiling, so a probe response could exceedmax_models_per_backend.modelsis now honored first withmodel_configsas the enhanced fallback, and every configured and curated catalog is truncated to the per-backend limit. Request-controlled configured Anthropic model ids no longer reach the existing info-level catalog logging. - Reject unsupported transports and invalid enhanced model configuration before dispatch (#1252, PR #1254). An unsupported URL scheme reached lower fetch code instead of failing validation, and the probe-specific validation path accepted
model_configsthat normal backend validation rejects. Both now fail with typed, sanitized candidate errors.
Documentation¶
- Document both discovery endpoints in English and Korean, in the Admin API reference and the backend configuration guide, including the error codes, the non-mutation boundary, and when to use each one.
- Expand the metrics guide with the Helm
ServiceMonitorobject and the standalone Prometheus bundle, including its storage sizing, cluster-wide discovery RBAC, and NetworkPolicy caveats. - Add deployment documentation for rolling, blue-green, canary, and configuration rollouts alongside the new profiles.
CI¶
- Add a
Deployment Asset Validationjob that renders every Helm profile and Kustomize overlay, validates the manifests with kubeconform, and validates each rendered router configuration withcontinuum-router config validate(#13). It runs only whendeploy/,monitoring/prometheus/, the validation script, or the affected workflows change, andscripts/local-ci.shruns the same script locally. - Gate release container images (#13). Trivy scans the Debian amd64 and arm64 images and fails the release on CRITICAL or HIGH findings, buildx emits an SBOM with
provenance: mode=max, and the multi-arch manifest digest is verified and attested. Workflow-levelcontents: writeandpackages: writewere narrowed to per-job grants. - Add
deploy-staging.yml, a release-to-staging workflow behind a protected environment.
v1.17.0 - 2026-08-03¶
Execute hub-governed guardrail policy for the first time, enforce Hub cost-center V2 monthly limits locally, bound file resolution and Hub envelope decoding, make the per-key and per-client rate-limit dimensions fire on live traffic, and stream Files API transfers instead of buffering them.
Security¶
- Bind Responses file and session authorization to the authenticated API-key principal instead of the client-supplied
X-User-IDheader (#1244). File resolution, stored-session creation,previous_response_idreplay,GET, andDELETEall derive the requester fromAuthContext::user_id(),X-User-IDis filtered before upstream dispatch, and the spoofable cache and session fallback namespace is gone. Owned files and sessions on both the Responses and Anthropic paths now fail closed when no authenticated requester is present, and a ResponsesAccessDeniedon file resolution answers403instead of forwarding the rawfile_id. Deployments that partitioned Responses state byX-User-IDmust move to per-key partitioning; the English and Korean guides carry the migration steps. - Redact backend URLs and other reqwest-derived transport detail from client-visible error bodies (#1235). A shared
client_safe_reqwest_errorhelper consumesreqwest::Error::without_url()at the response boundary, covering the Responses, Messages,count_tokens, image edit, multimodal embedding, and responses-only passthrough builders that previously interpolated the raw display text; the responses-only passthrough mapper now matches the Responses mapper and returns a static backend connection message. HTTP status, error type, error code, circuit-breaker accounting, and the full operator logs are unchanged. - Escape and cap the untrusted log values #1161 left out of scope (#1179). Client-supplied multipart field names on
POST /v1/files, the image edit and variation parser, and the admin Files API upload reachedmessagethrough format-string interpolation, whereEscapeGuardpasses\nand\rthrough and nothing bounded the length. Backend-supplied values in the Anthropic subtree that #1161 did not touch, including the/v1/responsesparse-failure body head and the full upstream error body during streaming, get the same escape-and-cap treatment. - Cap the client-supplied values echoed into the disabled-thinking rejection body (#1183).
disabled_thinking_effort_error_messageinterpolated the rawefforttwice and the raw model id once with no length bound, and all three ingresses turn that message into a 400 body. Neither gate bounds its input first:effort_exceeds_highcompares a trimmed, lowercased copy while interpolating the original, andclaude_family_versioninspects only the leading tokens. The cap now lives in the shared builder, so every ingress inherits it. - Bound Responses
file_idresolution per request (#1180, closes #1174), the same defect class #1169 fixed on the Anthropic path. The resolver enforced only the per-file 10 MiBMAX_INJECTION_SIZE, so any number of individually small references resolved with no aggregate ceiling, and each one holds its own base64 copy resident until dispatch. A count pre-pass now rejects above 100 references before the first metadata lookup, an aggregate byte budget is charged per reference frommetadata.bytesbefore the content read (so a repeated reference is charged again), and the whole resolution phase carries the 30 second timeout the other two paths already had. A newsrc/core/files/limits.rsholds the bounds all three paths agree on, replacing two duplicated declarations and the comment asking that they be kept in step. - Bound chat-completions local
file_idresolution with the same 32 MiB aggregate raw-byte budget used by Anthropic Messages and Responses (#1240). The chat path now charges every reference frommetadata.bytesbefore reading content, including repeated references to the same file, and returns a 400invalid_request_errorwhen one request exceeds the budget instead of forwarding partially resolved payloads. Chat-completions also now applies the 10 MiB per-file inline ceiling to PDFs as well as images; larger PDFs remain uploadable underfiles.max_file_sizebut are no longer expanded into a chat-completions JSON body. - Bound the Hub policy envelope decode, which previously had no size cap (#1193).
sync_policynow decodes through the same bounded reader as config sync under a derived ~152.6 MiB ceiling (MAX_POLICY_SYNC_RESPONSE_BYTES), written down term by term against the Hub's own distribution budgets (a documented 100,000-entry key-table allowance for the one member the Hub does not cap per org, 1,024 tiers with the 4 MiB aggregaterequest_paramsbudget, the 256 KiB guardrail canonical budget, 5,000 cost-center assignments plus 1,024 capped centers, and explicit allowances for the members the Hub does not bound), sized so a legitimate tenant at every ceiling at once still decodes, which a new ceiling-scale round-trip test locks in. An over-limit refusal is loud and typed rather than a silent parse-class failure: a distinctHubError::ResponseTooLargecarrying the limit and the observed size, anerror!log naming both (never body content), and a newcontrol_plane_policy_sync_failure_total{reason="envelope_too_large"|"other"}counter separating "envelope too large" from an ordinary sync failure, because a persistent refusal leaves a restarted router permanently fail-open while an ordinary failure heals on its own. Both reason series are instantiated at zero at registration so a fleet alert can see them before any refusal, and the refusal count with its last-occurrence time is surfaced aspolicy_sync.oversize_refusals/last_oversize_refusal_msonGET /admin/control-plane/status, where a nonzero count beside a nulllast_sync_msis the cold-start fail-open signature. The policy store preserves the last-known-good snapshot across a refusal and never widens enforcement; a cold-start refusal leaves the store exactly fail-open. The WebSocket policy stream now sets its message and frame ceilings to the same constant, replacing tungstenite's 64 MiB default, which sat below the poll bound and would have refused on one path a legitimate large envelope the other path accepts. The remaining unbounded Hub response decodes (enroll,push_usage,push_probes) each get their own derived bound with the same typed refusal, the usage ack bound derived from the 10,000-recordMAX_USAGE_BATCH_MAX_RECORDSclamp rather than the 500-record default, and an unreadable oversize usage ack is terminal for its already-processed batch rather than retried forever;heartbeat,report_config_apply_result, andpush_batch_statusdecode no response body and are unchanged.
Added¶
- Add local enforcement of Hub cost-center monthly limits under the corrected V2 convergence contract (#1162; hub issues #688, #735, #736, epic #646). The vendored
continuum-protocolcrate syncs the complete cost-center surface from the hub at rev98bb8ac, byte-for-byte with the hub's committed fixtures pinned by a new wire test, so the V1 member (cost_centers,cost_center_policy_digest) stays parse-compatible while remaining enforcement-inert and unacknowledged, and the V2 member (cost_centers_v2) becomes the enforced contract: a per-router stable allocation statement (Hub-pre-resolvedkey_id -> cost_center_idownership, router fallback, provider/model and stable-backend fallbacks, per-center caps) plus the persisted dynamic budget snapshot it references (consumed-to-date counters, Hub-authored per-dimension verdicts, and the per-routerincluded_router_usage_seqwatermark). The statement is one atomic apply unit validated for bounds, duplicates, references, the router-scoped digest, the deterministic cursor, snapshot staleness, and watermark possibility, refused whole with a typed code (invalid_cost_center_policy,digest_mismatch,stale_budget_snapshot,invalid_usage_watermark) while the last-known-good unit stays enforced, and acknowledged exactly (stable cursor/digest plus dynamic budget cursor) on both the applied and rejected side of heartbeat status. Enforcement is local and the hub is never in the request path: the admission gates engage only on request paths that can produce a usage record the hub will meter; the key and router tiers are refused and atomically reserved (one request plus a deterministic token allowance: a bounded byte-derived prompt estimate plus the request's stated output maximum clamped to the tier'srequest_paramsceiling and a hard bound) at the admission middleware, with the reservation riding the response body to the last streamed frame and counting toward every other admission so concurrent at-limit requests cannot all pass, while a single request is refused only at the cap and never on nothing but its own estimate; the lower tiers (stable backend, then provider/model, in the hub's own precedence) are refused at admission when every candidate serving backend for the effective post-rewrite target allocates to a cost center that is itself over cap, the condition under which the pre-dispatch decision cannot diverge from the identity that serves and is accounted, since every identity the request could serve under is exhausted (the candidates need not resolve to the same cost center: requiring that was sufficient but not necessary, and it let a client keep serving past a cap by naming a model whose backends spread across two exhausted centers); and accounting for every tier settles exactly once at the usage seam, where the single usage-loop consumer stamps the persisted monotonicrouter_usage_seqon each record entering the push buffer and ledgers its metered usage under the cost center computed from the final record's own identities, the same inputs the hub allocates from at ingest. Accepting a newer snapshot discards exactly the ledger entries at or below the hub-included watermark (unreflected usage is never reset, included usage is never double-counted); a hub watermark naming sequences a fresh runtime never assigned (a lost sidecar beside a surviving credential) recovers by a durable forward jump of the sequence space instead of wedging into permanent rejection; a populated statement arriving without a budget block is the documented budget-unavailable state (hub #750) and drops the held counters and hub-authored verdicts rather than applying them under new caps; a record permanently dropped from the push buffer, an unsplittable oversize batch, or a hub per-record rejection retires its ledger entry; and the sequence counter, last-known-good unit, applied watermark, and unreflected deltas persist in an owner-only sidecar next tocontrol_plane.state_file(<state_file>.cost-center.json,0600, atomic rename) so restart resumes the sequence space instead of restarting it. Sell spend is Hub-authored only: the router never prices, decrements, or infers sell spend locally, and thesell_spend_statusverdict is the only sell-spend input (exhaustedrefuses; an unknown verdict from a newer hub never does).cost_center_limits_v2is advertised only while the complete contract is live (sidecar attached, identity bound, sequencing and enforcement wired); the legacycost_center_limits_v1string is never advertised, and the documented overshoot stays bounded rather than claimed away, with four stated terms: the snapshot cadence times the fleet size, the reservation-estimate error, one allowance per concurrently admitted request, and the in-process handoff between a response finishing and its record entering the push buffer. Documented in the English and Korean control-plane architecture references. - Add hub-managed guardrail policy, so a Continuum Hub can govern content-safety enforcement across a fleet while every router keeps ownership of execution (#1094, closing #1105, #1106, #1107, #1108, #1109, #1110, #1111, #1112, and #1122). v1.17.0 is the first release that executes hub-governed guardrail policy, which is the version the hub compatibility matrix should pin (
lablup/continuum-hub#603); an older router ignores the field, never advertises theguardrail_policy_v1capability, and is derived asunsupportedrather than left pending forever. Governance travels in the existing policy envelope undercontrol_plane.policy.enabledand is a closed, typed, integer-only surface:mode,category_thresholds(unsigned microunits,1000000is exactly1.0),stages,inspect_reasoning, andblock_behavior, with at most 64 route overrides keyed by resolved model id. Route scope coversmodeandcategory_thresholds; a route-scopedstages,inspect_reasoning, orblock_behaviorhas no slot in the local route schema and is declined with a warning naming the route and the knobs rather than folded into the global setting, because folding would widen a route-scoped decision to every route. Deployment wiring is never sourced from the hub: theproviderslist itself, providerendpoint/backend:/api_key_env, per-provider and globaltimeout_msandon_error, everystreaming_*knob,bypass_api_keys, theauditblock, per-routeenabledandproviderssubsets, and the global and per-routeallow/denymatch lists stay local and sovereign, and the policy type has no free-form payload field anywhere, so no prompt, completion, matched span, or redacted value can travel on it in either direction. Composition is strictest-wins per knob (enforcebeatsmonitorfrom either side, the lower threshold floor wins per category and a stated category with no local counterpart is added,stagesunion into every provider row,inspect_reasoningORs, and a statedblock_behaviorwins as a governance choice rather than a strictness ordering), recomputed whenever either input changes and applied atomically, so a local edit never drops the hub layer, a hub update never freezes local edits out, and a request is always evaluated against one whole policy generation. Delivery is tri-state rather than binary: an absent envelope member keeps the last policy, a stated policy replaces it, and an explicit clear returns the router to local-only configuration and is acknowledged with the canonical digest of the cleared policy rather than reported as absent, so a cleared tenant converges instead of resting atpendingorstale. A router that started withguardrails.enabled: false, or with noguardrails:block at all, materializes its guardrail service at runtime when an enforceable policy arrives, through the same factory path startup uses and with local provider wiring only. A policy demanding enforcement this deployment cannot serve, meaning nothing that can gate at all or a demanded stage no runnable provider covers, is rejected whole with the typed codeguardrails_unavailableand is never reported as active, so a Fleet view cannot show enforcement that is not happening; a body that fails validation is refused withinvalid_guardrail_policy, which keeps "this router is older than the policy" distinguishable from the shareddigest_mismatch. A refusal clears without hub involvement: the wire layer keeps the policy applied and re-evaluates on every local configuration change, so adding the missing provider row ordenyrule makes the same policy take effect on the next reload.guardrail_policy_v1is advertised only once a live guardrail-policy reconciler has attached, from the single capability list every status-writing path shares, because the capability is a promise about execution and not a build flag. The guardrail and tier tracks stay independent in both directions. Documented in the English and Korean guardrail guides (governance scope, local sovereignty, the composition table with a worked example, tri-state delivery, refusal codes, and the hub status derivation), the control-plane architecture reference, and the configuration-assistant skill doc (schema 1.30.0 to 1.31.0). - Report the serving backend's stable configuration id on control-plane usage records and heartbeat inventory, so a backend can be an allocatable cost center (#1153, hub
lablup/continuum-hub#686). The new optionalbackends[].backend_idis entirely operator-owned and nothing derives it:nameis a display label an operator may rename at any hot reload, and a(provider, model)pair is not a backend because several backends can serve one pair, so a derived value would silently merge two distinct cost owners that share a pair and silently split one cost owner across a rename, both invisible once the usage has been ingested. A backend without an id reports none and its usage falls through to provider/model attribution exactly as before, which is a normal state rather than an error. Values are 1 to 128 characters from the delimiter-safe ASCII set[A-Za-z0-9._-], the same alphabetfallback.fallback_chainsdocuments, because the value travels as an opaque key the hub matches byte for byte and never normalizes; whitespace,/,:,,, control characters, and non-ASCII including bidi overrides are refused at the source rather than left to change which cost center a request charges. Uniqueness is required within one router and only within one router, since the hub keys membership on(authenticated_router_id, backend_id), so two routers may reuse one local spelling. The bound runs on the shared ingestion gate ininfrastructure::config::validator, not only in theValidateimpl, because the real load path validates sections explicitly and never invokesConfig::validate(); rejecting the whole candidate there is also what keeps a hot reload atomic, leaving the running router on its last known good rather than a half-applied identity map. The/admin/backendshandler family does not reach that gate at all (it publishes straight to the hot-reload watch channel throughpropagate_config_change), so it enforces the same bound at its own boundary, andPOST /admin/config/validatechecks it too so the pre-save dry run cannot report a green that the following write rejects. The id is resolved at each serving seam against the config view the selection actually used, and carried on the usage event rather than looked up by backend name when the record is pushed, because a hot reload in that window renames the backend and a later lookup would resolve to nothing and drop the attribution for usage already served: a fallback hop overwrites it exactly as it overwrites the TTFT origin so a failed-over request is charged to the backend that produced the response, the mid-stream relay resolves each hop from the snapshot the request was routed against, a provider batch carries the id captured at submit so a completion hours later still names the backend that executed it,/v1/responsesstreaming resolves at context construction because it has no dispatch seam, and a local cache hit dispatched no backend and reports nothing. The display name is never substituted when an id is absent. On the wireUsageRecord.backend_idis a verbatim copy of the upstream field andBackendInfo.backend_idis additive ahead of an upstream re-sync, since the hub holds no backend inventory and heartbeat is the only way an operator can discover which ids exist to assign; both are optional with serde defaults, so a router with no ids configured serializes byte-identically to the previous wire andPROTOCOL_VERSIONstays 0.stable_backend_identity_v1is advertised unconditionally because, unlike guardrail policy, there is no separate executor that may or may not attach: config validation, inventory, and the usage taps are compiled and wired as one unit, so the capability describes the implementation rather than the data and a router with no ids configured still advertises it and reports none, which is the state the hub must be able to tell apart from a router that cannot report at all. Documented in the English and Korean configuration and architecture guides,config.yaml.example, and the configuration-assistant skill doc (schema 1.32.0 to 1.33.0). - Report metadata-only guardrail counters in the heartbeat inventory, so the hub can see fleet-wide guardrail activity without scraping every router (#1120).
RouterInventory.guardrailscarrieschecks_total,blocks_total,blocks_by_category,verdictsby(stage, mode, result),stream_buffer_cap_tripsby(strategy, outcome), and acounters_since_msstamped from the same process-start value asSupplySummary, so both counter families reset together. The counters come from an in-process tracker written at the same seams that emit the Prometheus families rather than from the registry itself, becausemetricsis optional and reporting zeros from a build without it would tell the hub that an actively blocking guardrail is idle. Every label space is closed, so a third-party moderation provider cannot expand the hub's stored key space or carry provider-authored text into it: any category outside the known vocabulary folds intoother, and an unrecognized stage, mode, result, strategy, or outcome folds onto anunknownslot instead of being dropped. The block is reported whenever a guardrail service exists, even before the first check, and omitted entirely when the router runs none, so absent means "guardrails are off" while zeros mean "on and quiet". Counts and bounded ids only: no prompt text, no completion text, no matched span, no matched rule, and no redacted value. - Add an optional in-flight request ceiling, so the router's per-request memory budgets finally imply a per-process bound (#1173). Every memory bound the router enforces is per request: the 10 MiB
GLOBAL_BODY_LIMIT,files.max_file_size(512 MiB by default, buffered whole in memory), and the 32 MiB aggregate file-resolution budget added by #1169 and #1174. Worst-case resident memory is the product of those and the number of requests in flight, and nothing bounded the second factor. A rate limit does not supply it: it bounds arrivals per unit time, not residency, and by Little's law the steady-state in-flight count is the arrival rate times the mean duration, soper_client: 10requests per second against completions averaging 30 seconds implies roughly 300 concurrent from one client entirely within policy, which is about 12.6 GiB at the file-resolution ceiling. The shipped default configures no rate limiting at all, in which case the ceiling is whatever a client can open.server.max_concurrent_requestsis unset by default, so no existing deployment starts shedding on upgrade; set it and the router admits that many requests at once and answers the rest with503, aRetry-After, and a JSON body naming the limit. It sheds rather than queues, because queueing trades an out-of-memory kill for unbounded latency while still holding the memory of everything queued. Implemented as atokio::sync::Semaphoreacquired withtry_acquire_ownedrather thantower'sConcurrencyLimit/LoadShed, which would have queued by default, needed aBufferwith a background task, given no control over the refusal body or the retry hint, and pulled newtowerfeatures into theembedgraph that deliberately excludes iOS-hostile dependencies; this adds no dependency at all. The permit rides the response body, so a streaming completion holds it until the last frame is delivered, which means a ceiling tuned for short non-streaming traffic will starve streaming traffic and should be sized against the longest requests. The layer is mounted application-wide but inside both CORS and the rate limiter, so a shed503is readable in a browser and a request the limiter would refuse anyway never occupies a permit; it covers the separately-nested/v1/filesgroup, which carries the largest per-request term of all, and exempts/health,/healthz, and the configuredmetrics.path, because shedding a liveness probe is how a correctly-degrading replica gets restarted mid-overload.SupplySummary::max_concurrencyandshed_requestsnow report the real ceiling and the real shed count instead of promising the hub that no ceiling exists; an unconfigured ceiling still reports as absent rather than0, since0would mean the router admits nothing. Memory-aware admission (charging a request its declared cost rather than counting requests uniformly) was evaluated and deferred: it needs a per-request cost estimate available before the body is read, and the only pre-read signal isContent-Length, which is absent under chunked encoding and does not predict cost anyway, since the #1168 vector was a 57 KiB body carrying 800 file references. The capacity arithmetic, the per-request terms with their config keys, and the interaction with rate limiting are documented for operators in the English and Korean deployment and performance guides, which also raise the Kubernetes example's512Mimemory limit that a single default-size upload already exceeds. - Add metadata for five models that shipped without entries, so
/v1/modelsserves display names, pricing, capabilities, and limits for them instead of nothing (#1091, closes #1090): Claude Opus 5 (claude-opus-5,claude-opus-5-latest, which also joins the native Anthropic backend's built-in supported-models list), Kimi K3, Qwen 3.7-Max, Qwen 3.8-Max Preview, and Solar Open 2. Opus 5 needed no capability-gating code, sinceclaude_family_version()already parses the id to(5, 0); new unit tests pin extended thinking, adaptive thinking, sampling-parameter handling, max effort, mid-conversation system prompts, and fast-mode eligibility so a refactor cannot silently regress it the way a substring gate once regressed Sonnet 5 (#854). Qwen 3.8-Max Preview keeps zero rates because preview access is credit-based with no published per-token rate, and Kimi K3 and Solar Open 2 omitknowledge_cutoffbecause neither vendor published one.solar-pro-2also gains the tool-use capability and the Japanese language support its own summary already implied. - Implement the
files.retention_daysstartup sweep, which was configurable and documented but never ran (#1234, closes #1229). A nonzero value now deletes stored files whose metadata sidecarcreated_atis at least the configured age, andretention_days: 0still keeps files forever.FileService::sweep_retained_filesdeletes metadata first, treats a missing content object as benign, restores the metadata if the content delete fails, logs each sweep, and reportsfile_retention_deletes_total. The read race boundary, the startup-sweep semantics, and the config examples are documented in the English and Korean guides, the Admin configuration schema, and the configuration-assistant skill. - Complete
BackendConfigBuildercoverage forBackendConfig(#1208). Setters were added forbackend_id,internal,role,region,endpoint_type,auth,org_id,model_configs,retry_override,health_check,anthropic_auto_cache_control,anthropic_fast_mode, andexternal_storage;timeoutandmax_retriesnow mutate the sameretry_overridefield as the explicit setter;backend_idformat is validated inBackendConfigBuilder::buildand uniqueness inConfigBuilder::build. An exhaustive no-rest-pattern field inventory guard fails compilation when a newBackendConfigfield is added without a builder decision.
Changed¶
- Reject a disabled thinking config paired with an effort above
highon Claude Opus 5, at the router's own boundary (#1103). Anthropic acceptsthinking: {"type": "disabled"}on Opus 5 only whileoutput_config.effortstays athighor below and returns HTTP 400 atxhighormax, so the invalid pair was previously forwarded and the client learned about it after a full round trip. The resolution is fail-fast rather than a rewrite: clamping the effort or dropping the thinking config would both silently rewrite an explicit client instruction, and disabled thinking on Opus 5 has two documented failure modes of its own (tool calls emitted as plain text instead of structuredtool_useblocks, and<thinking>tags leaking into the visible response), so neither is a neutral fallback. The 400 states the constraint and names both remedies. The guard runs at every ingress: the shared OpenAI-to-Anthropic transform, both Anthropic-shape streaming entry points (TCP and Unix socket), the typed/anthropic/v1/messageshandler after alias resolution andrequest_paramsapplication, and the Responses converter's independently built payload. - Remove the dead
Backend::max_concurrent_requests()trait hook and every production and test-double implementation (#1224). Nothing read it, so reporting the old hardcoded provider constants to the hub would have published an unenforced fiction. Per-backend control-plane inventory staysNone, with the comment now pointing at the real router-levelserver.max_concurrent_requestsadmission cap added in #1173. - Stream Files API uploads and downloads instead of materializing whole files in memory, and decide download authorization before any content is read (#1145).
FileStorageBackendnow hasstore_streamandretrieve_streamas its primitives, with the slice-basedstoreandretrievekept as provided wrappers, so peak resident memory per transfer is a 64KB chunk buffer rather than roughly 1.5x the file size.LocalFileStoragestreams each upload into a per-process, per-upload temporary file and fsyncs plus renames it into place only after every check has passed, so a reader never observes a partially written.bin, and an upload that is rejected, errors, or is cancelled mid-flight leaves neither a partial file nor a leaked temporary. Content validation became incremental while reaching the same verdict as before: the first 13 bytes cover the image and executable magic-byte checks, and a new non-allocatingUtf8Validatorcarries a partial multi-byte sequence across chunk boundaries, so a character split between two network chunks is judged exactly as it would be in a single buffer and a body ending mid-sequence is still rejected.GET /v1/files/{id}/contentandGET /admin/files/{id}/contentnow resolve authorization from metadata alone before opening the file, closing a path where a caller who knew a file id but did not own it could force a full-size read and allocation and still receive a403, and both then stream the response body instead of buffering it. The413semantics, the exactFile too large: exceeds maximum N bytesmessage, and every other observable Files API behavior are unchanged. Because the temporary now exists for the whole transfer, an abrupt death (SIGKILL, OOM kill, node eviction, power loss) can leave one behind where no in-process cleanup can run, and nothing else in the product could see it: the orphan scanner matches only.binand.meta.json, while the user-data retention sweep uses metadata sidecarcreated_atand ignores temporary upload dotfiles. Starting the router therefore reclaims every temporary older than 24 hours, ungated bycleanup_orphans_on_startupbecause an aged temporary is unambiguously garbage rather than possibly-recoverable data, andFileService::detect_orphansnow reports the count so the leak is observable before the next restart. The 24 hour threshold is what keeps the sweep from deleting an upload another process may still be streaming; a temporary's mtime advances with every write, so a live transfer keeps refreshing itself out of range.commitalso fsyncs the parent directory, so the rename is durable and not just the bytes it points at.
Alongside this, the upload routes' outer body limits are derived from files.max_file_size, clamped to the documented 1KB to 5GB range, instead of a hardcoded 512MB that silently capped anything larger, and a value outside that range is now reported as a startup warning on the real config-load path (warning rather than a hard error, because max_file_size: 0 is accepted today and a fatal check would stop a working router from starting; the clamp is what protects the routes). Relatedly, a body the transport-level cap truncated now answers 413 file_too_large rather than 400 invalid_request: both handlers classified every multipart failure as malformed input, which was academic while the cap was a fixed 512MB and became reachable once it derived from max_file_size. The previously unwired file_uploads_total, file_upload_size_bytes, file_upload_duration_seconds, file_downloads_total, and file_download_duration_seconds metrics are registered and emitted: an upload records its outcome from the session's drop, which is the only point that also sees an oversize rejection, a missing purpose part, or a client that disconnects mid-body, and a download is counted a success only when its body reaches the end without error rather than by virtue of having started. A startup warning fires when files.max_file_size exceeds a quarter of the cgroup v2 memory limit, staying silent when there is no limit, when it reads max, and on platforms without cgroups.
Fixed¶
- Stop two of the four self-hosted classifier templates from reading unrecognized guard-model output as a clean allow (#1203, follows PR #1182).
verdict_llama_guardandverdict_yes_notook the first token of the model's answer and returnedGuardrailVerdict::Allowfor anything that was not their single positive token (unsafe,yes), so an error string, a refusal to comply, a chat-template artifact, a generation truncated by the 32-token cap, or an answer in an unexpected language was served as safe. It was silent: the parser produced an ordinaryOk(Allow),ProviderOutcome::failurestayedNone, and undermode: enforcethe Prometheus error family, the heartbeat'serrors_total/fail_open_total/fail_closed_total, and the audit record all described an inspected, clean request. The lenient parsers were also the default path: an omittedtemplate, an unrecognizedtemplate,granite_guardian, andshieldgemmaall resolve toverdict_yes_no, so a single typo pointed a Llama Guard deployment at a parser whereunsafeis notyesand every check allowed. Both parsers now recognize both poles explicitly,safealongsideunsafeandnoalongsideyes, and treat only genuinely unrecognized output as a check error, which is what Qwen3Guard andcustom_classifieralready did; the four templates now agree on the rule. Behavior change to check before upgrading: a deployment whose guard model emits output the configured template cannot read no longer passes silently. It now fails per the provider's effectiveon_error, which under the defaultfail_openmeans unchanged traffic with new error counts onguardrail_errors_totaland the heartbeat'serrors_total/fail_open_total, and underfail_closedmeans refused traffic. Withapi_format: completionthe classified text is the entire prompt with no instruction wrapper, so a completion-served guard model that merely continues that text is the configuration most likely to see this, and it now fails loudly where it used to allow silently.
To avoid narrowing what a working deployment accepts, the verdict token is normalized deliberately rather than by fallthrough: surrounding whitespace, ASCII case, punctuation or markdown emphasis wrapping the token (**unsafe**, Unsafe:, Yes,), and a completed <think>...</think> reasoning block ahead of the verdict are all accepted. Whitespace and case were already accepted; the other two used to read as silent allows even when the model had answered unsafe, so they are now read as the positive verdicts they are. A reasoning block truncated before it closed carries no verdict and fails the check, which is the likely outcome at the 32-token cap these templates use. Nothing past the first token is scanned for a verdict word, because guard models restate both poles inside their prose ("this is not safe") and a scan would read an explanation or a refusal as a verdict, which is the failure this change exists to remove. The two allows that come from a real classification are untouched: an unsafe verdict whose reported hazard codes are all excluded by categories, and a positive verdict suppressed by a category_thresholds entry.
A second behavior change, in configuration validation. An unrecognized options.template on a self_hosted_classifier (or classifier) provider is now refused by Config::validate, where it previously fell back to granite_guardian with only a startup warn!. That fallback is what turned a typo such as llama-guard or shield_gemma into a total silent bypass, and because the failure direction was always permissive it could never produce the false block an operator would notice. A configuration carrying an unrecognized template value therefore stops loading, and continuum-router config validate reports it; fix the value or drop the key, since an omitted template is still valid and still means granite_guardian. The provider-side fallback is kept as defense in depth for construction paths that did not validate. The accepted values and the verdict-reading rules are documented in the English and Korean guardrails guides.
- Stop leaking the router's filesystem layout into client-visible error bodies on the Files API and the two
file_idresolution paths (#1191).FileError::Storage(_)carries a message the router writes itself at roughly twenty sites insrc/services/files/storage.rsandsrc/services/files/metadata.rs, every one of them aformat!that interpolatespath.display(), andmap_file_errorhanded that string straight to the client as themessageof a 500. A download of a file whose backing object had become unreadable answered with the absolute path underfiles.storage_path, the two-level sharding subdirectory, the stored.binfilename, and the underlyingstd::io::Error; a failed upload answered with the temporary filename, whichtemp_file_namebuilds as.{id}.{pid}.{sequence}.tmpand which therefore also disclosed the router's process id (1under a container, itself a deployment tell) and the per-process count of uploads begun since start. Under the defaultfiles.auth.method: api_keythe audience is every holder of a valid key with thefilesscope, including one whoseenforce_ownershipconfines it to its own files, so a tenant that cannot read another tenant's bytes could still read the deployment's storage layout out of an error body;method: nonewidens it to anyone who can reach the port. The substitution is made once at each response boundary through a newFileError::client_message, not at the construction sites, because the path is exactly what an operator debugging a storage failure needs and the log is where it belongs:Displayis untouched, so everyerror!andwarn!site keeps the full text at unchanged fidelity, and the tests assert both halves throughcapture_logs. The status, thetype, and thecodeare all preserved (500/server_error/storage_errorandio_error), so a client that branches on them sees no change and only the human-readable message becomes the fixedstorage error: the file operation could not be completed. The client-caused variants keep their messages verbatim, since those name the caller's ownfile_id, purpose, or limit. Three client-facing surfaces carried the same string and all three are covered: the public/v1/files*handler, the chat-completionsfile_idpath (where the resolution warnings are joined intoFileResolutionError::PartialResolutionFailedand written into a 404 body, so the redaction happens where each warning is built and the full error is logged there instead), and the Anthropic/v1/messagespath (whereFileResolverError::ReadErrorreaches a 502 asFile service error: ...). The Responses API needed no change and was left alone, sincetry_resolve_filesdegrades every non-limit error back to the original request so onlyLimitExceededandTimeoutever reach a client. Two adjacent defects found by verifying the per-variant claim rather than assuming it are fixed in the same change.FileError::NotFoundraised by the storage backend carried{shard}/{id}.binrather than the file id, so a download whose backing object had been deleted disclosed the sharding scheme through a 404;FileServicenow relabels it to the id the caller supplied, which is both safer and a better message. Andbuild_error_responsein the admin Files API hardcoded"type": "invalid_request_error"in every body it built, including the 500s thatmap_file_errorrouted through it, so the admin API labeled a server-side storage failure as client-caused and disagreed with the public handler, which takes the type fromerror.error_type(); the type now follows the error, with tests pinning one admin 500 and one admin 4xx so the two classes cannot silently collapse again. The admin surface redacts on the same terms as the public one rather than keeping the detail behind the admin boundary: the operator's answer is in the log either way, and one policy for both surfaces is what stops them drifting apart, which is how thetypebug arose.src/admin_config/prompts_api/handlers.rswas surveyed as part of the same sweep and carried a narrower instance: itsPathTraversalrejection for a parent directory outside the base rendered the resolved absolute path, while the sibling check a few lines below already reported the caller's relative path, so it now matches;PromptFileError::IoErrorandInvalidPathgained the same client/log split. - Stop a failing Azure Content Safety sub-check from discarding the other sub-check's refusal (#1194, follows PR #1182).
AzureContentSafetyGuardrail::check_input_textinspects the input stage with two independent endpoints, Prompt Shields (text:shieldPrompt) and Content Safety text analysis (text:analyze), and #1182 combined them with?when it converted the sub-checks to the reportableGuardrailCheckResultshape. The?short-circuited: a Prompt Shields failure meant text analysis never ran, the whole check became an error, and under the defaulton_error: fail_openthe service resolved that error toAllow, so content Azure text analysis would have refused was served instead. The symmetric case discarded a jailbreak block when text analysis was the endpoint that failed. Both sub-checks now always run, concurrently rather than back to back so a slow endpoint cannot eat the timeout budget the other one needs, and the outcome is chosen by whether the survivor already refuses: a survivingBlockis returned and enforced, while a survivor that does not refuse (Allow, or a non-blockingFlag) still returns the failure so the fail policy decides the disposition and the counters from #1182 (errors_total,fail_open_total/fail_closed_total, andguardrail_errors_total{kind="error"}) still move. The test is "does the survivor already refuse" rather than "did the survivor find anything" because the adapter deliberately cannot seeon_errorand so has to return something correct under both policies: aBlockis exactly as strict as the strictest verdict the fail policy could synthesize, while aFlagmaps to allow at the gate, so returning one would serve a requestfail_closedwas configured to refuse and would leave the endpoint outage uncounted. That keeps both invariants at once rather than a plain "return whichever succeeded": a half-completed inspection that refused nothing never reads as a clean pass, and a genuine refusal is never swallowed because the sibling endpoint happened to be down.fail_closedbehavior is unchanged, since an error and a block both refuse the request. The audit re-confirmed this was the only call site with that shape:check_outputruns text analysis alone (Prompt Shields is input-only), and the Bedrock provider routes both stages through a singleApplyGuardrailcall. - Report the per-backend inventory identity under the hub's
stable_idwire key, repairing a silent v0 wire defect from #1172 that made the entire backend cost-center tier inert (#1162 review, PR #1190). #1172 shippedBackendInfo.backend_idwhile the hub's independently written counterpart (hub #732) readsstable_id; both sides carry serde defaults with no rename and nodeny_unknown_fields, so the hub parsed the router's key as absent, no backend ever became assignable cost-center inventory, andbackend_assignmentswas always empty in practice. Verified against the hub clone at the pinned rev rather than inferred. The vendored field is renamed to the hub's key with the upstream doc comment adopted, the operator-facingbackends[].backend_idconfig key and the separate (correct)UsageRecord.backend_idfield are unchanged, and the hub's committedheartbeat_stable_backend_identity.jsonfixture is copied byte for byte and pinned in both directions with a guard that the identity never serializes under the local config key again. The same re-sync vendored the hub's committedpolicy_envelope.jsonas an envelope-level value pin, which immediately caught and fixed a second parse-fidelity gap:KeyEntry.project_id(hub #685) was silently dropped on re-serialization and is now carried verbatim, enforcement untouched pending #1152. - Stop a dead backend from staying in the per-request rotation under load, and stop non-listed statuses from being recorded as circuit-breaker successes (#1148). Killing a primary backend mid-traffic preserved availability, because streaming fallback rescued every request, but first-token p95 stayed degraded for the whole outage: the dead backend was re-attempted on every request. Three distinct gaps produced that metastable state, and the reporter's proposed mechanism (that fallback's success prevents failures from accruing) was not one of them, since every fallback hop already records the primary's transport failure before advancing the chain. First,
CircuitBreaker::record_failureconverted a status outsidefailure_status_codesintorecord_success, which in the Closed state zeroedfailure_countand pushed a success into the sliding window. That is the real defect the issue's title names: a backend interleaving 429s or 404s with genuine 5xx could never reach its threshold, and one answering only 429 looked healthy forever. Those statuses now record a neutral outcome that releases the half-open probe slot and touches nothing else, reported through a newOutcomeRecordreturn so the Prometheus mirror stops counting them as successes too.429stays out of the defaultfailure_status_codes: a transient rate limit means the backend is alive and throttling, opening its circuit would convert throttling into an outage, and the retry path from (#740)/(#742) already honors the upstreamRetry-Afterand fails fast on non-transient quota exhaustion. Second, the breaker is off by default, so every recording seam was a no-op and exclusion fell entirely to health checking, whose 90s default window (interval30s xunhealthy_threshold3) contains the reported degradation entirely. The breaker stays opt-in, because switching it on by default would change request routing for every existing deployment; instead the router now emits a startup warning whenfallback.fallback_chainsis configured without one, naming the failure mode and computing the exposure window from the configuredhealth_checksvalues, and the three shipped config templates that configure fallback gained acircuit_breakersection. Third, a large set of dispatch paths never touched the breaker at all: image generation and image edit, the Responses-only passthrough bridge, native Gemini multimodal embeddings, the whole Anthropic Messages ingress (native, OpenAI-compatible, Responses-backed, Bedrock Runtime, Unix-socket, and web-search emulation), the Responses ingress (all four conversion strategies plus passthrough and compact), and Anthropiccount_tokens. All of them now admit and record through a newproxy::circuit::Admissionguard, which pairs admission with exactly one outcome and releases the half-open probe slot on drop, so the multi-exit OAuth-retry paths cannot leak probe capacity. Crucially, admission is never applied on its own:proxy::selection::select_admissible_backendfilters candidates throughfilter_admissible, selects, admits, and re-selects on a lost race, returning the backend together with its guard. Pairing a dispatch-time gate with a selection step that does not know about circuits would have converted requests a healthy peer could serve into router-generated 503s.HalfOpenis the one state where a neutral outcome still advances the circuit toward closing, because that state asks whether to stop rejecting traffic rather than whether the backend is healthy; without it a backend answering only 429 would be pinned at its probe ceiling with no timeout to end it. The unwired second circuit state machine inservices::health_serviceis documented as off the data path rather than mistaken for the real one. - Converge the heartbeat
guardrailsblock on the hub's shape, so a guardrail-active router delivers inventory at all (#1150). The router'sGuardrailSummaryand the hub's were written independently and did not match, and becauseRouterInventory.guardrailsis a nested struct rather than a tolerated blob, serde failed the whole struct: a router with guardrails active andcontrol_plane.enabledtrue delivered no inventory whatsoever, not backends, not health, not load, not supply, notpolicy_status. Verified against hub58bb3d0rather than inferred.GuardrailSummarynow carries the six scalars the hub declares required (transforms_total,flags_total,errors_total,fail_open_total,fail_closed_total,stream_buffer_cap_trips_total), and the category breakdown is the hub'sby_categoryobject keyed by category id rather than theblocks_by_categoryarray this router used to send, which is the one genuinely breaking wire change and is safe precisely because no hub could parse the old shape.transforms_total,flags_total, andstream_buffer_cap_trips_totalare derived inGuardrailSnapshot::into_summaryfrom the breakdowns they summarize rather than counted twice, so the scalar the hub reads and the breakdown a later hub will read cannot drift; a test asserts each against its sum.errors_total,fail_open_total, andfail_closed_totalhad no source in the control-plane tracker at all and are now recorded byGuardrailTracker::record_errorfrom the same seam insrc/services/guardrail/service.rsthat recordsguardrail_errors_total, deliberately not read back from the Prometheus registry:metricsis optional, and reporting zeros from a build without it would tell the hub that a guardrail silently letting traffic through is idle. The two fail-policy counters split by the disposition that actually applied rather than by the configuredon_error: monitor mode never gates, so a failed check under monitor is traffic that was served uninspected and counts as fail-open whatever the policy says, and reporting it as fail-closed would zero out the one counter that surfaces an enforcement hole.errors_totalis a floor rather than a total, because only a provider timeout reaches that seam: a provider that fails fast resolves its own transport, status, or parse failure into a verdict internally, a gap the Prometheus surface shares and which needs an explicit failure marker onProviderOutcometo close. The router-onlyverdictsandstream_buffer_cap_tripsbreakdowns stay as additive optional fields, ignored by today's hub because it uses nodeny_unknown_fields. Category ids stay bounded in count, byte length, and charset by construction, since the only way a key entersby_categoryis aGuardrailCategoryIdliteral, andis_bounded()now checks all three against the hub's own ingestion rule. The hub's committedguardrail_summary.jsonis pinned here byte for byte, with the same "re-copy on any hub change, never regenerate from local types" rule the guardrail policy fixtures carry, because both repositories previously round-tripped only their own types and that is exactly how the shapes diverged while both stayed green. - Report the
guardrailsconfiguration section as an immediate hot reload rather than restart-only (#1127).ConfigSection::GuardrailsdeclaredHotReloadCapability::RequiresRestart, which the Admin API surfaced to operators and which had been wrong for the mode flips, threshold edits, route changes, and provider rows thatGuardrailService::update_confighas always applied live. Theenabledtoggle no longer needs a field-level exception either: since the guardrail service handle became late-settable, a startup-disabled router materializes a service the first time a reload or a hub policy enables guardrails, and a runtime disable is expressed asenabled: falseon the live service rather than tearing it down, so both directions are live. Nothing in the section requires a restart, and the section is now classified accordingly. - Rewrite response-scoped backend tool-call ids to a reversible, per-response-unique form at the
/anthropic/v1/messagesboundary (#1143, closes #1142).tool_use.idis conversation-scoped in the Anthropic protocol, but vLLM synthesizes{tool_name}:{index}for Kimi-K3 with the index restarting on every response, so an agent calling the same tool on two turns was handedread_file:0twice; Claude Code'sensureToolResultPairingthen treated the replay as corrupted, substituted a synthetic "tool use interrupted" result, and ended the turn, with the router reporting nothing but 200s throughout. Outbound ids that are not already known conversation-unique (thetoolu_,call_,fc_, andchatcmpl-tool-prefixes, and only when the whole id is charset-safe) are now rewritten tocrt_<nonce:12><base64url_nopad(raw_id)>; the 12-character UUID v4 nonce is what actually breaks the collision, since base64url alone is deterministic and would reproduce the same client-visible id on every turn. The raw id is restored byte-exact on the way back in rather than charset-cleaned, because a backend has been reported to dead-stop when replayed a mutated form of its own id template. Decoding never fails a request: anything that doesn't match the encoded shape passes through unchanged. Newsrc/http/handlers/anthropic/tool_id.rs; native Anthropic backends stream passthrough SSE and never reach it, and Gemini already synthesizes conversation-uniquetoolu_ids of its own. - Stop
POST /admin/config/exportwithformat: "toml"from failing unconditionally (#1159, closes #1138). Most optionalConfigsections carried#[serde(default)]withoutskip_serializing_if = "Option::is_none", so an unset section serialized to an explicitnull, and thetomlcrate has no representation for a null; a default export carried 19 such nulls. The 21 remaining top-level optionalConfigfields now skip when unset, along with the nested optionals that were still leaking a null into a default export (ServerConfig.workers,ServerConfig.connection_pool_size,ResponseCacheConfig.{redis,l1,l2,tiered},DisaggregatedServingConfig.default_external_storage,S3CacheLayerConfig.ttl_override, andRequestTimeoutConfig.image_generation), soGET /admin/config/full,POST /admin/config/export, and config-history snapshots now omit an unconfigured optional section instead of carrying it asnull.get_section_valuemaps that absence back toValue::Null, soGET /admin/config/{section}on a known-but-unconfigured section still answers200with a null body rather than404, andPATCHstill merges onto that null base instead of failing with500. The WebUI's structural-diff and history-diff helpers (config.js,history.js) now treat an absent key and an explicitnullas the same state, so pasting an older export against the new response shape no longer renders a vanished null key as a spurious addition or removal. - Truncate log strings on UTF-8 character boundaries instead of fixed byte offsets, so a non-ASCII value can no longer abort the whole router process (#1158, closes #1157). Eighteen byte-index
&strslice expressions across twelve log-truncation sites inproxy,core::files,http::middleware,http::handlers::anthropic,infrastructure::backends::anthropic, andservices::smart_routingpanicked whenever the fixed offset landed inside a multi-byte character, and the release profile setspanic = "abort"(Cargo.toml:311), so the panic terminated the process instead of failing the one request. Two sites are reachable by an authenticated client without any unusual deployment:proxy::utils::sanitize_file_id_for_logtruncates a raw file id onceFileId::from_stringhas already rejected it, and a byte-for-byte duplicate that used to live incore::files::transformertruncated a validated id, becauseFileId::from_string's Unicode-aware alphanumeric check accepts non-ASCII values; both panicked on ids such asfile-일이삼사오육.infrastructure::backends::anthropic::transform_openai_file_to_anthropic(a malformed multi-bytefile_datapreview) andhttp::middleware::admin_audit::mask_username(a multi-byteBasicauth username) are reachable the same way. Newsrc/core/text_utils.rsaddstruncate_on_char_boundaryandtruncate_tail_on_char_boundary, which back off to, or advance to, the nearest valid boundary instead of slicing blindly; every affected call site now routes through them, and thecore::files::transformerduplicate was deleted in favor of the shared sanitizer. Old and new output were diffed exhaustively across every ASCII length from 0 to 700 for four alphabets rather than checked by inspection, so existing log formats and doctested output are byte-identical. - Escape and cap client-supplied values recorded in Anthropic handler log fields, so an authenticated caller can no longer forge additional log records by embedding a newline in a value (#1161, closes #1151).
field = %valuewraps a value inDisplayValue, andtracing_subscriber's default formatter writes any field other thanmessageas a bare{:?}with no wrapping, so theDisplayoutput landed in the log line raw; interpolating into the format string fared no better, because theEscapeGuardapplied tomessageonly rewrites ANSI and C1 controls and lets\nand\rthrough. Roughly 40 client-influenced sites acrosssrc/http/handlers/anthropic/(tool names, tool-call and file ids, effort strings, the request model, theanthropic-versionheader, and enum discriminants such asAnthropicToolChoice::Simple) now record the value as a named field without the%sigil, so it routes throughVisit::record_strand is formatted withDebug for str, which quotes the string and escapes\n,\r,\t,",\, and non-printables into a single log line. Escaping alone stops forging but not bloat, so every one of those values also passes through a newcore::text_utils::cap_client_value_for_log, which truncates atCLIENT_LOG_VALUE_MAX_BYTES(256 bytes) through the existing boundary-safetruncate_on_char_boundaryrather than a byte-index slice. Router-controlled values (backend.name,url,status, typed enums, numerics) and twomime_typefields provably constrained to a fixed allow-list keep the%sigil, since escaping bytes that can never be client-chosen would only hide a real regression. - Preserve live backend credentials when a masked configuration export is re-imported, instead of overwriting them with their own mask placeholder while reporting success (#1160, closes #1139). Exporting a configuration with the default
include_sensitive: falseand importing it straight back, the flow the WebUI's Configuration page presents, wrote every masked credential through as its own placeholder and answeredsuccess: true; the breakage surfaced later as 401s from every backend, with recovery meaning re-entering each secret by hand. The old placeholder shapes (sk...(21 chars), the bare***MASKED***for a value of four characters or fewer,${***VAR***}for an environment reference) were derived from the secret itself, so none of them told a masked value apart from a real credential of the same shape. Every masked value the Admin API returns is now wrapped in a fixed sentinel,***CONTINUUM-MASKED:<hint>***(a value of four characters or fewer now reports(N chars)where it previously showed the bare***MASKED***), and only that sentinel is read as "keep the value that is already live".POST /admin/config/import,POST /admin/config/applywith aconfigcandidate,PUT/PATCH /admin/config/{section}, and thePOST /admin/config/validatepre-save dry run all resolve placeholders the same way, so the dry run predicts exactly what a save will do; a write takes the configuration modification lock before splicing so a concurrent change cannot move a secret out from under the resolution. Import reports every preserved path through a newpreserved_secret_pathsresponse field, and every write path, including the dry run, reports the same set asMASKED_SECRET_PRESERVEDvalidation warnings. Array entries resolve byidornamerather than position, so reorderingbackendsstill restores correctly; a list with neither, such asguardrails.bypass_api_keysorrate_limiting.bypass_keys, has only position to go on, so adding or removing an entry is refused rather than guessed, since two keys from the same provider can share a prefix and length and a shifted index would otherwise splice in the wrong one. A placeholder that cannot be matched to a live value fails the whole write withMASKED_SECRET_UNRESOLVEDand the failing path named, rather than borrowing a neighboring secret. The three legacy placeholder shapes are still recognized, but only to refuse the import withLEGACY_MASK_PLACEHOLDER: a document exported by a router built before this fix is refused rather than silently trusted, and the way out is to re-export from the upgraded router or replace the flagged value with the real secret. Both the preserved-path and refused-path lists are capped at 500 entries with aMASKED_SECRET_REPORT_TRUNCATEDmarker, so a document filled with placeholders cannot inflate the response. BackendPUT/PATCHand the WebUI's Configuration and History pages recognize the new sentinel too. Documented in the English and Korean Admin API and WebUI guides. - Enforce the per-API-key and per-client (IP) rate-limit dimensions on live traffic, which had never fired in production (#1164, closes #1147). Three wiring defects compounded: the limiter read a bare
SocketAddrrequest extension where axum insertsConnectInfo<SocketAddr>, soclient_ipwas permanentlyNoneand the per-client dimension, the IP whitelist, and the trusted-proxyX-Forwarded-Forpath were all dead; the limiter was layered on the whole application outside the API-key authentication that populatesAuthContext, so the per-key dimension had no identity to bind to; andrate_limiting.limits.per_api_keywas never read at all. Only theglobaldimension worked, so a single flooding tenant was not shed and collapsed a well-behaved tenant from roughly 22ms to 5600ms first-token latency. The limiter is now split by dimension across two mounts sharing one store: the global dimension is enforced by a single application-wide mount outside authentication and CORS, so every request is charged againstlimits.globalexactly once, including API requests authentication later rejects with 401 (key guessing stays metered), failed admin authentication, CORS preflights, and the 404 fallback; the per-client dimension is enforced on the API routes outside authentication and the remaining identity dimensions (per-key, per-model) inside it (mount placement subsequently refined by #1170). Because the mounts enforce disjoint dimensions, no request is ever counted twice against any bucket. On refusal the refusing dimension's numbers are authoritative, and when the global and an identity dimension would both refuse, the client sees the global refusal because it is checked first. Two behavior changes for existing configurations.rate_limiting.limits.per_api_keyis now the enforced default for every recognized key that has noapi_keys[].rate_limitof its own, so a configuration that already carried that block will start shedding keys that were previously unlimited; an explicitrate_limitstill overrides it andrate_limit: 0exempts a key. And the429body is now a single valid JSON document (error.message,error.type,error.code, anderror.details.limit_type), where it was previously a JSON error document re-embedded unescaped inside a second envelope and therefore unparseable; refusals now also carryretry-afterandx-ratelimit-*, withRetry-Afterfloored at one second per #1146. Dimensions skipped for missing identity (per-client on a Unix-socket listener, per-key for an anonymous caller in permissive auth mode) are now recorded in the newrate_limit_skipped_total{dimension}counter and a debug log. - Stop the rate limiter from disclosing caller identity and shared fleet state through its response headers, bind unauthenticated floods on the dimension that exists for them, and stop a full bucket map from refusing traffic (#1170). Enforcement is unchanged throughout: every configured dimension still charges and still refuses exactly as before, and refusal attribution,
Retry-After, and the429body are untouched. What changed is what the router reports and where two mounts sit.x-ratelimit-*now describes the caller's own quota and nothing else. The previous contract reported whichever dimension had the fewest remaining tokens, which made the numbers a function of who the caller was: withlimits.per_api_keyburst 7 andlimits.globalburst 100, a valid key read7/6and an unrecognized one read100/99, the only key-recognition signal the router emits in permissive auth mode. The same headers also handed any unauthenticated caller a live read of the shared global bucket at one token per probe, on401s, fallback404s, and unauthenticated200s such as/version, turning a global-exhaustion flood from blind into precisely timed; and because abypass_keysholder produced the exemption sentinel and therefore no headers at all while any other token got them, with both responses401, the limiter alone confirmed bypass-list membership. The rule is now that only quota tied to an authenticated identity is reported, which today meansper_api_key: the caller has proved they hold the key the bucket is keyed on, so the numbers tell them nothing they do not own. Everything else is withheld on an admitted response.global,per_model, andper_backendare drawn on by the whole fleet, andper_clientis drawn on by everyone behind one source address, so no shared level is the caller's quota;per_clientis additionally evaluated before authentication, where the router cannot name whom it would be reporting to, and reporting it would have reopened the very oracle finding 3 describes, since abypass_keysholder gets the exemption sentinel and no headers while any other token would get real ones on an otherwise identical401. A response that dimension did not evaluate carries nox-ratelimit-*at all, uniformly, which removes the discriminator from the401,404, unauthenticated200, whitelist, and bypass-key paths together. A refusal still reports the refusing dimension's numbers, including a shared one, because the client needs the retry hint and "the bucket is empty" is already implied by the429. Two consequences worth checking before upgrading. Admitted responses now carry quota headers only whenper_api_keyevaluated the request; configure that dimension if your clients read them. And the permissive-mode residual is accepted rather than fixed: a recognized key's response carries per-key headers while an unrecognized token's carries none, so their presence still distinguishes the two. Suppressing them for unrecognized callers would only move the signal to their absence, so the decision is to state plainly that permissive mode is a development and trusted-network convenience rather than an authentication boundary, since it already serves valid and invalid keys identically; setapi_keys.mode: blockingwhere key validity is sensitive.per_clientmoved outside authentication on the API routes, from inside it. The dimension keys on the peer address, which the connection supplies before authentication runs, so grouping it with the dimensions that genuinely needAuthContextleft it unable to constrain the exact pattern it exists for: twelve bogus-key requests from one address in blocking mode all returned401and none were shed, leaving the flood metered only by the shared global bucket and shedding every other tenant as it drained. Mounting it on the same route group but outside auth binds the flood on its own bucket without widening its reach to WebUI, admin, or static traffic. The request path on an API route now readsglobal -> per-client -> authentication -> per-key -> handler, and the three mounts still enforce disjoint dimensions, so nothing is double-charged. The metrics exemption is derived rather than fixed. Matching the literal/metricshad both halves backwards: it exempted a route that did not exist when metrics were disabled, leaving an unmetered404sink, and it limited a scrape an operator had moved to a custommetrics.path, shedding exactly the monitoring needed during an incident. The exemption now follows the effectivemetrics.pathand applies only whilemetrics.enabledis true. Keeping scrapes un-sheddable is deliberate, since a limiter that starves monitoring under load is worse than an unmetered scrape endpoint, so the residual is accepted and stated: a scrape's cost grows with metric cardinality and the limiter will not bound a flood of them, which is whymetrics.authand network policy matter./healthand/healthzstay exempt unconditionally so a saturated router keeps answering its own liveness probe instead of being restarted mid-overload. A full bucket map now fails open instead of closed.per_client,per_api_key, andper_backendeach cap at 100,000 entries with a 600 second TTL, and hitting the cap returned aCapacityrefusal; the client and backend checks had no already-present guard, so once full they refused callers that already owned a bucket too. Refusing saved no memory, because the refusal path inserted no bucket either, while 100,000 distinct source addresses are easy to source from a single IPv6/64, so failing closed let an attacker convert a bounded map into a ten-minute outage for every caller on that dimension. A full map now skips that dimension for the request, leaving the others enforcing, and records it onrate_limit_skipped_total{dimension}plus a per-dimension warning rate-limited to one line per minute; the inline expiry sweep is throttled to at most once per second per map, so a flood of unique keys cannot force a 100k-entry scan on the hot path while the background sweep every 60 seconds keeps doing the steady-state pruning. Per-client buckets still key on the full address, so an IPv6 source varying its low 64 bits both evades the dimension and saturates the map; bound that at the edge. A429is readable in a browser whichever mount produced it. The global mount sits outside the CORS layer, which is what makes preflights chargeable, so its refusal carried noAccess-Control-Allow-Originat all and a browser turned the one refusal a client most needs to act on into an opaque network error. The mount does not move; its short-circuited refusal now carries an allow-origin echo for an origin listed incors.allow_origins,Vary: Origin, and the credentials flag when configured. Separately,retry-afterand thex-ratelimit-*headers are now always added to the CORS layer'sAccess-Control-Expose-Headers, because without them cross-origin script could read a refusal's status but not its retry hint, which is the actionable half, and that gap applied to every mount rather than only the outer one. Documented in the English and Korean rate-limiting guides. - Bound Anthropic
file_idresolution per request, so one small request backed by one uploaded file can no longer expand into gigabytes of resident memory (#1169, closes #1168).src/http/handlers/anthropic/file_resolver.rsfanned out over messages, content blocks, and blocks nested inside atool_resultwith three unboundedtry_join_allcalls, and enforced its 10 MiBMAX_INJECTION_SIZEper file rather than per request, so roughly 57 KiB of request body carrying 800 references to a single 10 MiB upload resolved into about 10.7 GiB of base64 held simultaneously. Deduplicating repeatedfile_idvalues would not have fixed it:resolve_image_sourceandresolve_document_sourcecallBASE64_STANDARD.encodeonce per reference, so every reference keeps its own encoded copy resident whether the bytes were read once or 800 times, which is why the aggregate budget added here counts every reference including duplicates rather than summing unique files. Three bounds now apply, each closing a different term.MAX_TOTAL_RESOLVED_BYTES(32 MiB of raw bytes per request, about 43 MiB once base64 expands it) is charged inload_filefrommetadata.bytesbefore any content is read, through a resolver-ownedAtomicUsizewhose running total is tested on the valuefetch_addreturns rather than with a check-then-add that would race the other resolutions in flight, so the bound is exact rather than approximate: a read-modify-write always reads the value written immediately before it in the atomic's single modification order, which makes every observed running total a prefix sum, so the references that pass the test are exactly a prefix of that order and their sizes cannot sum past the budget under any interleaving.MAX_FILE_REFERENCES_PER_REQUEST(100) is applied in a single pre-pass over all three traversal levels before the first metadata lookup; it is deliberately five times the chat-completions limit of 20 (src/proxy/files.rs) because the Messages API replays the whole transcript on every turn, so a long multimodal session legitimately accumulates references a single chat-completions call never would, and the count cap is a cheap pre-filter rather than the memory bound.MAX_CONCURRENT_FILE_LOADS(8) is enforced by atokio::sync::Semaphoreon the resolver, because the three fan-out sites nest and a per-levelbuffer_unordered(8)would multiply to 512; the permit is held across the storage read only and never across the recursive resolution above it, so atool_resultcan never wait on a permit held by one of its own ancestors. The resolution phase also gained the 30 second timeout the chat-completions path already had, mirrored rather than imported becauseproxy::filesis private tocrate::proxy. Both new limits surface as400 invalid_request_errorand a timeout as504, through a newFileResolutionResult::into_request_or_responsethat the streaming and non-streaming handlers now share in place of their two duplicated match blocks, so a resolution outcome cannot be handled on one path and silently missed on the other. The pre-existing per-fileFileTooLargestill answers 502 and was left alone. Documented in the English and Korean API guides. - Keep a classifier verdict that precedes a closing reasoning tag (#1241, follows PR #1214).
strip_reasoning_prefixkeeps everything past the last</think>on the rule that a reasoning block sits ahead of the verdict, so output where the tag lands after the answer (unsafe\nS1</think>) stripped to an empty head. Since #1203 made unreadable output a failed check, a real refusal became a failed check and, under the defaulton_error: fail_open, was served. That is the outcome #1203 exists to prevent, relocated rather than removed. - Report a fast-failing guardrail provider as a failure rather than a clean allow (#1182). A transport error, a non-success HTTP status, or an unparseable body was resolved into a fail-policy verdict inside each adapter, so the telemetry seam, which fired only on
outcome.timed_out, never saw it: underfail_opena moderation outage produced a heartbeat byte-identical to a healthy guardrail on clean traffic, and underfail_closeda flood of uncategorized safety blocks. TheGuardrailtrait now returnsResult<GuardrailVerdict, GuardrailCheckError>, so a new adapter physically cannot swallow a failure and the fail policy is applied at the single service seam shared by the buffered and streaming paths, resolved per check from the live config snapshot instead of frozen at construction. The trait change is breaking for out-of-tree adapters. The PII provider keeps a localon_errorfor its documented degrade path only, where an external-recognizer failure under fail-open completes the check with builtin detections, which is a reduced-fidelity inspection rather than a failed check. - Enforce the
guardrails.allowandguardrails.denymatch lists, which were parsed, regex-validated at startup, settable through the Admin API, and documented as enforcement features while no code in the request path ever read them (#1104). A newmatchlistmodule compiles the global and per-route lists once per configuration snapshot, so no regex is compiled on the request path, and the config snapshot became aPolicySnapshotcarrying the config and its compiled rules behind one lock, so a check can never read config from one generation and rules from another. Precedence is deny, then allow, then providers: a deny match blocks before any provider runs, an allow match short-circuits with no provider call, and route lists extend the global lists rather than replacing them, so a route override cannot weaken a global deny rule.exactentries are case-insensitive literal substring matches, since a case-only variation must not evade a security control; blank literals and empty patterns are dropped with a warning rather than matching everything. Matches are recorded under the reserved pseudo-provider labelsmatch_list_denyandmatch_list_allow, so a monitor-mode match is measurable before enforcing, and the block reason never echoes the matched text or the rule. Enforce mode now requires at least one provider or at least one deny rule, where it previously rejected a deny-list-only policy that genuinely gates. - Correct and complete the built-in OpenAI model catalog, which stopped at GPT-5.2 and left every newer tier to name heuristics (#1177). A discovered
gpt-5.3-codexwas advertised with a 256K context window, a 4096-token max output, and a fabricated \(10/\)30 per 1M price instead of 400K/128K at \(1.75/\)14. The GPT-5.5, GPT-5.4, and GPT-5.3 families plusgpt-5.2-codexwere added to the built-in table,gpt-5.3-codex-sparkand the three earlier codex tiers tomodel-metadata.yaml.gpt-5.1was wrong in both catalogs in different ways and is now 400K/128K at \(1.25/\)10 with a 2024-09 cutoff and noaudiocapability it never had;gpt-5.1-codexandgpt-5.1-codex-miniare corrected likewise. Every codex tier is documented as Responses-API-only, so all four now carryresponses_onlyand the router bridges Chat Completions requests for them to/v1/responses. Retirements were decided by cross-checking OpenAI's deprecations page against the Azure Foundry retirement schedule. - Correct
solar-pro-3pricing, capabilities, and aliases (#1097, closes #1095). The entry advertised zero rates, which is this file's convention for open-weight models with no hosted API cost, so/v1/modelsreported a paid hosted model as free and any downstream cost estimation under-reported Solar Pro 3 spend to nothing; Upstage charges $0.15 per 1M input and $0.60 per 1M output tokens. Thereasoningcapability was missing while the entry's own summary claimed improved reasoning accuracy. Thesolar-pro3andsolar-pro3-260323names Upstage actually publishes resolved to nothing, since neither the 4-digit nor the 6-digit date suffix is touched by the 8-digit date-suffix normalization, so both are now listed explicitly.solar-pro-2gains its ownsolar-pro2andsolar-pro2-251215aliases for the same reason (#1099, closes #1098). - Rebuild the runtime state a configuration write drops, so an Admin API change no longer publishes a config with broken backend lookups and model metadata (#1100 closes #1089, #1136 closes #1116).
put_config_sectionandpatch_config_sectionround-trip the running configuration through JSON, which silently resets the#[serde(skip)]runtime state (backends_index,model_metadata_cache,response_defaults_cache) to its type default, and nothing rebuilt it, so a successful sectionPUTorPATCHbroke backend lookup and metadata resolution until the next full config load or file-based hot reload. Both handlers now rebuild the index and reload metadata in the same orderreload_config_from_fileuses, and the follow-up extends the same treatment to the full-config apply, import, and rollback publication paths and centralizes index rebuilding across the create, update, delete, weight, and models mutations. - Fail a hot reload when model-metadata assembly fails, instead of warning and publishing a revision with empty caches (#1102).
publish_config_revisionassigns the wholeConfig, somodel_metadata_cacheandresponse_defaults_cachewere replaced wholesale and the router kept serving with pricing, context windows, capabilities, and/v1/modelsresponse defaults silently reverted to backend-reported values, with no restart to explain it. Metadata assembly was the one reload step that warned and continued where path validation, the oversize check, parse failure, env overrides, secret processing, and config validation all returnErrand leave the previous revision serving intact; it now does the same. Only genuinely fatal conditions reach that path: an unreadable or unparseable base file, a merged document that does not match the metadata schema, and theMAX_METADATA_LAYERS(64) andMAX_TOTAL_METADATA_BYTES(8 MiB) caps. A missing base file and a drop-in that fails to read, parse, or validate are still warned and skipped. The cap breaches are what made this newly reachable, since anyone with write access to amodel-metadata.d/directory can add a 65th file without touching an existing one. - Validate every
/admin/backendshot-reload candidate through the shared config validator before publishing it to the live watch channel (#1206), so the backend-specific Admin API agrees with the file and config ingestion paths. Validator failures map to400while watch-channel send failures keep their server-error behavior, and thebackend_id-only Admin API validation helpers were removed because format and uniqueness now flow through the same whole-config gate as the rest ofBackendConfig. Regression tests on create, update, delete, weight, and models prove a rejected candidate is neither published nor recorded in config history. - Allow an empty backend pool at startup in both factory paths, honoring the existing zero-backend deployment contract, while preserving fail-fast behavior for a non-empty configuration whose backends all fail (#1134, resolves #1133).
- Defer half-open circuit admission to the dispatch seam, so selecting a backend for local preflight, cache, or guardrail work no longer consumes a probe slot (#1236, follows #1148). A deferred
SelectedBackendtoken is produced only by circuit-filtered selection and consumed into anAdmissionimmediately before upstream I/O, with a late admission race reselecting over the remaining eligible candidates; Anthropiccount_tokens, Anthropic Messages, Responses, compact Responses, and the responses-only Chat-to-Responses bridge all moved to inspecting selected backend metadata before admission. Half-open progress is also split from genuine success evidence, so a probe window carrying only neutral outcomes or timeouts returns the circuit toOpenwith a fresh cooldown, while a window mixing neutral outcomes with real successes may still close. - Deduplicate CORS exposed response headers by retaining the first parsed header name, so an operator-configured duplicate of a built-in header no longer appears twice (#1220). The previous
Vec::dedupwas adjacency-dependent and missed a configuredretry-after, whose built-in counterpart is not adjacent in the combined list. - Preserve per-key in-memory stats attribution when Prometheus metrics are disabled (#1209, closes #1196). API-key attribution and label sanitization moved into an always-compiled core module, and both the local and hosted CI matrices gained metrics-off regression coverage.
- Surface guardrail dry-run provider failures in
POST /admin/guardrails/testand the Guardrails WebUI test console, so a fail-open substitute is distinguishable from a genuine allow (#1221). Sanitized failure metadata now travels throughGuardrailService::dry_run, and the console renders fail-open and fail-closed result chips with the failure detail. Documented in the English and Korean WebUI guides. - Surface PII external-recognizer degradation as local telemetry (#1223). The new
guardrail_degraded_total{provider,kind}recordskind="external_unavailable"when the PII provider falls back to builtin detections under fail-open because its optional external recognizer is unreachable, which is a reduced-fidelity inspection thatguardrail_errors_totaldeliberately does not count. The built-in-only disposition and the fail-closed error behavior are unchanged. - Restore the four
BackendConfigstruct literals #1172 broke, and close the CI gap that hid them (#1200).#[serde(default)]covers deserialization and does nothing for a Rust struct literal, so adding the requiredbackend_idfield required updating every complete literal in the same change; the two inside aget_health_check_configrustdoc example survived because nothing in CI compiled doctests, and the two Bedrock integration literals survived becausebedrock-sigv4was built by no job and no release binary..github/workflows/ci.ymlandscripts/local-ci.sheach gain two mirrored steps,cargo test --docand an all-featurescargo check --all-targets --keep-going, the second chosen over a narrow--features bedrock-sigv4because it covers every unpinned feature at once and keeps covering features added later without a matrix edit. - Add the no-op
FileMetrics::record_retention_deleteto the metrics-disabled compatibility surface (#1238), restoring compile parity for every no-default feature graph that includes the always-compiled Files service. The gap appeared only once #1234 and #1233 were combined onmain. - Clarify what the guardrail heartbeat
verdictsanderrors_totalfields count (#1227), so they are read against the right units for a multi-provider stage and for fail-closed substitute blocks. Thecontinuum-protocolrustdoc and the English and Korean guardrails guides now describe failed checks explicitly, bound the fail-open reading as an upper bound on uninspected allow traffic, and document whereverdictsandblocks_totaldiverge under fail-closed.
Dependencies¶
- Enable the
iofeature ontokio-utilfor the streaming Files API paths (#1189), and makearc-swapa non-optional dependency now that the guardrail runtime service materialization path depends on it unconditionally (#1126). No crate was added to or removed from the dependency graph in this release.
v1.16.0 - 2026-07-28¶
Run the streaming output guardrail gate on every streaming path, make it redact instead of only block, keep it checking past its 4 MiB buffer cap, give monitor mode something to observe, and add metadata download with layered model-metadata drop-in directories.
Added¶
- Add layered model-metadata drop-in directories so a vendor baseline and operator customizations stop competing for the same file (#1069).
model_metadata_filehad exactly one slot, which forced a choice between never updating the shipped copy and hand-merging every upstream change, and mademetadata download(#1067) unsafe next to local edits because it replaces that file wholesale. Metadata is now assembled from a layered search path, lowest precedence first: the basemodel_metadata_file, then/etc/continuum-router/model-metadata.d/, then<user config dir>/continuum-router/model-metadata.d/, then./model-metadata.d/, then every directory in a newmodel_metadata_dirsconfig key in the order given. That is the inverse offind_config_file's first-match-wins order and deliberately so: expressed as layers, the more specific location wins by being applied last.model_metadata_dirssupplements the conventional locations rather than replacing them and its entries are used verbatim, so a Kubernetes ConfigMap mounted at/etc/router/metadata/is listed as that path; it defaults to empty, so existing configs deserialize and behave exactly as before. Within a directory only regular.yamland.ymlfiles are read, in lexicographic filename order so the10-/50-/90-convention works, with no recursion into subdirectories, dotfiles and*~and*.swpskipped, symlinks resolved but required to land on a regular file, and a missing directory treated as empty rather than an error. Merging happens on the parsedserde_yaml::Valuebefore deserialization, which is load-bearing rather than incidental: mappings merge recursively key-wise so an override carries only the fields it changes, sequences and scalars are replaced wholesale so an inheritedaliasesorcapabilitiesentry can be removed,modelsentries are keyed byidso an existing id merges in place and a new id is appended, andresponses_onlykeeps working because a plainboolwith#[serde(default)]is indistinguishable from absent after deserialization, so a typed merge would let an override file that never mentions the key silently clear an inheritedtrue. A new field onModelMetadatamerges correctly with no new merge code for the same reason. The merged document is deserialized and validated once, and backendmodel_configsstill take precedence over it, which still takes precedence over the built-in OpenAI registry. A drop-in that fails to read, parse, or validate is skipped with a loud warning and the rest of the search path still loads, matching the router's existing warn-and-continue posture at startup and on reload so one malformed operator file cannot take down a running router; a malformed basemodel_metadata_filestays fatal exactly as before. Blame is assigned once, to the layer that was merging when the document stopped matching the schema, so an already-invalid base file is reported as the cause instead of every innocent drop-in layered on top of it.--resolvedalso stops exiting 1 on an invalidresponse_defaults, mirroring the router's own warn-and-fall-back behavior there instead of refusing to print the document the router actually serves, and both report renderings surface a config file that was found but could not be loaded, or that could not be found at all, as a# WARNING:header line and aconfig_warningJSON field rather than silently describing a search path that omits that config'smodel_metadata_fileandmodel_metadata_dirs. At most 64 files totalling at most 8 MiB are assembled, and exceeding either cap is an error rather than a truncation, because applying a partial override set silently is worse than refusing to assemble. Because skipping is invisible to clients, a newcontinuum-router metadata show [--resolved] [--json]prints the search path, the applied layers in application order with every model id a later layer took over and the file it came from, and every skipped file with its reason; the default mode prints the raw merge verbatim so a typo is visible,--resolvedprints the effective typed document afterresponse_defaultsvalidation and sanitization, and the human header is written as YAML comments so redirecting the output still produces a loadable file. The applied file list and every model-id override are also logged at startup. Reading a layer, including the base file, opens the path once withO_NONBLOCKon unix, checks it is a regular file throughfstaton that same descriptor, and reads it through atakebounded by the remaining byte budget, so the type check, the byte cap, and the bytes actually read all describe the same object instead of three separate lookups of the same path; a fifo or device node dropped into an enumerated drop-in directory, or swapped in as the base file, can no longer blockopenwith no timeout, and a file that grows or misreports its length after being stat'd can no longer defeat the size cap. Merging themodelslist is linear rather than quadratic in model count, indexing the accumulated entries byidonce per layer instead of rescanning the whole list per overlay entry.ConfigSection::ModelMetadataDirsis also wired intoFromStr, closing a gap where the section was advertised by the section listing and the admin JSON schema but rejected as unknown by the per-section admin GET/PUT handlers.metadata downloadstill writes only the base file and never reads from or writes into a drop-in directory, which is what makes it safe to run repeatedly, and the--model-metadataflag keeps its meaning: it sets the base file, it does not disable drop-ins. Bothmodel_metadata_fileandmodel_metadata_dirsremainrequires_restart.libcis now acfg(unix)dependency forO_NONBLOCK, already present in the lock file, so no new crates are resolved. Documented in the man page, the English and Korean configuration guides, the example config, and configuration-assistant guidance (schema 1.27.0 to 1.28.0). - Add
continuum-router metadata download(visible aliasmetadata update) to fetch the canonicalmodel-metadata.yamlfrom the repository and install it at the path the router actually reads (#1067). A release archive, Debian package, or container ships without the file, and an existing copy goes stale silently as metadata-only commits land new frontier models, so there was no supported way to pick those up short of cloning the repo. The destination resolves in a documented order (--output, then the global--model-metadata, thenmodel_metadata_filefrom the loaded config with the same tilde expansion the loader uses, thenmodel-metadata.yamlnext to the discovered config file, then~/.config/continuum-router/model-metadata.yaml) and is printed on the failure paths as well as the successful ones.--model-metadatasits above the config field because that is exactly what it does for the router itself, so--model-metadata /etc/cr/meta.yaml metadata downloadinstalls the file where that instance will actually read it. The download is parsed and run through the loader'sresponse_defaultsvalidation before anything on disk is touched, and unlike the loader (which warns and falls back to defaults) the command treats a validation failure as fatal, so a malformed or schema-invalid response exits 1 and leaves a working deployment byte-for-byte unchanged. Replacement is atomic through a temp file in the destination directory, created exclusively under a per-run unique name so a symlink planted at a predictable sibling path in a directory another user can write to cannot steer the write and two concurrent runs cannot interleave into one buffer file, preserves an existing file's mode (0644for a new file, not the0600of the token store), and keeps the previous file as<file>.bakunless--no-backup. Content is compared by SHA-256 so an unchanged file is skipped without a write unless--force.--checkwrites nothing and exits 2 when an update is available, so cron and CI can branch on staleness without treating it as an error. Only HTTPS sources are accepted, redirects that leave HTTPS are refused rather than followed by reqwest's default policy, and the streamed body is capped at 8 MiB rather than trusting an advisoryContent-Length. While the repository is private a GitHub token is required, because an unauthenticated raw request returns 404 for every ref: the token is read fromCONTINUUM_GITHUB_TOKEN,GITHUB_TOKEN, orGH_TOKENin that order, first non-empty value wins, whitespace trimmed, and there is deliberately no--tokenflag because a secret in argv leaks into shell history andpsoutput. The credential is attached only when the request host is exactlyraw.githubusercontent.comorapi.github.com, compared case-insensitively rather than by suffix, so a--urlmirror or a lookalike such asraw.githubusercontent.com.evil.testnever receives it, and it is never logged, printed, or placed in the--jsonreport, which names the source variable instead of the value. Basic-auth credentials embedded in a--urlare stripped the same way from every rendering of the source, so a private mirror's password does not reach the human report, the--jsonsource_urlfield, or an error message. A 404 no longer conflates the two causes: without credentials it names the three variables and says the repository may be private, with credentials it says the ref or path may not exist or the token may lack access.--refpins to a branch or tag,--urloverrides the source for mirrors and air-gapped installs, and--jsonemits the same report as an object. The report covers source URL, resolved ref, destination, model count before and after, added and removed model ids, backup path, and both hashes, plus a reminder that updating the file does not hot-reload a running router. No new crate dependencies. Documented in the man page, the English and Korean configuration guides, and the configuration-assistant CLI quick reference.
Changed¶
- Mirror release container images to
cr.backend.ai/product/continuum-routeralongsideghcr.io(#1068). Each Debian and Alpine multi-arch tag is now pushed to both registries in one build step, and the finalimagetools createsources its manifest from Harbor so a tag is only ever created from a manifest Harbor is known to hold, regardless of ghcr-side state. Downstream deployments get a source that does not depend on ghcr availability or per-user PATs. Nothing changes on the ghcr side.
Fixed¶
- Run the streaming output gate on every streaming path, in each path's own wire format (#1087, closes #1076).
StreamingOutputGatehad exactly one production construction site, the OpenAI chat streaming handler, so output guardrails silently did not run on any other streaming surface:/anthropic/v1/messagesin all five reachable arms, Gemini through the SSE pipeline, mid-stream fallback, all four/v1/responsesstreaming strategies, the thinking-pattern transform, Bedrockruntimeandconverse, every Unix-socket streaming constructor, and theresponses_onlychat bridge. Each is now gated on what the client actually receives. A new Anthropic-wire driver renders a well-formed Anthropic terminal on a block (close the open block, refusal text block,message_delta,message_stop, or a singleerrorevent underblock_behavior: error); the SSE pipeline gains an output-gate stage between the observability tap and the caching tap, with a shared veto so a blocked or masked stream is never stored;MidStreamFallbackContextcarries one gate across every fallback hop so text held through a backend switch is checked as one completion, and a policy block completes the stream rather than triggering a fallback retry; the Responses passthrough arm defers theresponse.output_text.doneandresponse.completedmirror events past the end-of-stream check and rewrites them from the gate's client-visible text, so a mirror cannot leak what the deltas masked. Gate construction also stopped hardcoding the globalguardrails.mode: a sharedbuild_streaming_gateresolves the per-routemode, the per-routeenabled, and thebypass_api_keysallowlist, so a route overriding toenforceunder a globalmonitornow gets an enforcing gate instead of a logged check that never blocks, and a disabled route or a bypassed key builds no gate at all instead of one that delays the whole stream only to short-circuit every check toAllow.stream_with_auto_backend_selectionis the one out-of-scope entry point (library-only, no HTTP route registers it); its rustdoc records the decision and it warns once per process if an embedder reaches the ungated arm with guardrails configured. Monitor mode costs one output provider call per stream on each newly gated surface, which the guardrail guides now state. - Apply
Transformverdicts on the streaming output path, so output PII masking is no longer silently bypassed on the default traffic shape (#1075, closes #1074).finalizeandrun_window_checkcollapsed every verdict tois_block(), so theTransformamaskaction produces fell into the allow branch: the original plaintext chunks were released and the masked text discarded, while the same request masked correctly when non-streaming. Held chunks are now released rewritten to carry the redacted text, with per-chunk source-shape detection so one gate serves both OpenAIchoices[].delta.contentand native-Anthropiccontent_block_deltapayloads, and framing events (role opener, tool-call deltas,finish_reason, usage,message_start,ping,message_stop,[DONE], and anything unparseable) pass through byte-for-byte and in position. Underbuffer_fullthat is a complete redaction, since the whole completion is held. Underchunkedthe masked window is stripped of its longest common prefix against the already-released context before release and spliced back over the checked region of the accumulator; without that splice the next window's context re-includes the original text, the provider redacts it a second time, and the client sees a duplicated placeholder. The built-in PII provider now overridescheck_streaming_chunkinstead of inheriting a default that returnsAllowfor every chunk, which had left it inert understreaming_mode: chunked.streaming_stream_first: true, and enforce withstreaming_mode: passthrough, release before checking, so a masking verdict has nowhere to land; the gate warns once per stream and leaves the sent text alone rather than re-sending a masked copy. For OpenAIn > 1the masked text collapses onto choice 0, which is documented: mangled for multi-choice streams, never leaking. - Keep checking streamed output past the 4 MiB gate buffer cap (#1082, closes #1078). Reaching the cap failed open: the gate switched to
passthrough, flushed everything it was still holding with no check having run, and released the rest of the stream unchecked, so one long completion turned output blocking and PII masking off for that entire response with a singlewarn!line as the only trace. The cap now degrades tochunkedinstead. The full-text check runs over everything held and the verdict is applied in full (a block cuts the stream, aTransformredacts the held chunks before release, only a clean verdict releases them),stream_firstis forced off for the remainder becausebuffer_fullwas configured on the promise that output is checked before release, and the already-checked history is compacted down to the trailingstreaming_context_sizewindow so memory stays bounded while live checking continues.chunkedstreams now compact rather than degrade, so their behavior is otherwise unchanged. A newguardrail_stream_buffer_cap_trips_total{strategy,outcome}counter and one warning fire once per stream. The gate also stopped keeping a third full copy of the stream that existed only to measure the other two. - Observe streamed output in monitor mode (#1086, closes #1079). Monitor performed zero output-stage evaluation on streaming responses:
StreamingOutputGate::newfolds everystreaming_modedown toPassthroughin monitor (correctly, since monitor must never hold or cut), that arm forwarded without recording, andfinalize's passthrough arm is guarded on a non-empty accumulator, so nothing was ever checked. Monitor is how a team decides whether a policy is safe to enforce and streaming is the default for chat UIs and agents, so a team could run monitor for a week, see no output-stage verdicts, and conclude their traffic was clean. The passthrough arm now accumulates when the mode is monitor, keeping only extracted text and never a held chunk, so nothing is held, delayed, cut, or rewritten and the end-of-stream check produces the verdict the rustdoc and the guardrail guides already promised. Enforce withstreaming_mode: passthroughstays a true zero-check, zero-provider-call path. Monitor cannot take the cap'schunkedfallback, because checking a window before releasing it means holding the stream, so it stops observing at 4 MiB and the truncation is counted asguardrail_stream_buffer_cap_trips_total{strategy="monitor",outcome="truncated"}.is_active()is no longer a safe skip signal and its rustdoc now says so: a monitor gate answersfalseand still needs every chunk. Monitor results recorded by older releases carry no information. - Inspect array-shaped streaming
delta.content, and bring reasoning text into output-guardrail scope behind a new opt-in (#1083, closes #1080). The streaming extractor skipped an array-shapeddelta.contentthat its non-streaming twin read. A backend survey found no supported backend emits that shape today, so this closes an inspection asymmetry as defense in depth rather than fixing a live leak, but a future backend or proxy that streams content parts is now inspected and masked instead of bypassing the output stage. Reasoning and extended-thinking text was invisible to the output stage by accident rather than by decision, and is now inspectable throughguardrails.inspect_reasoning(bool, defaultfalse), coveringreasoning_contenton both the streaming and non-streaming OpenAI-shaped paths andthinking_deltaandthinkingblocks on the native Anthropic path. It is off by default because reasoning roughly doubles the text volume providers see, which costs latency and, for network-backed providers, money, and it deliberately covers the non-streaming path too, since enabling it for streaming alone would recreate the very asymmetry the fix removes. The scope is an explicitTextScopethreaded through every extractor, rewriter, and transform applier on both paths, and a table-driven test asserts extractability and rewritability agree across every chunk shape under both scopes, so the two sides cannot drift apart again. Configuration-assistant schema 1.28.0 to 1.29.0. - Redact the verdict payload from guardrail debug logs (#1081, closes #1077).
GuardrailService::evaluatelogged the aggregated verdict with?aggregated, Debug-formattingGuardrailVerdictand printingTransform::new_content(and, for a block,Block::reason) in full, bypassing the audit module's deliberately text-free design. Bothdebug!sites now log aredacted()view that prints only the verdict's shape: variant name, category, and score, never the payload. With the built-in PII provider the transformed text is already redacted, so this is log hygiene and defense in depth; the exposure it closes is a custom or self-hosted provider that puts raw content into either field. - Guard
test_legacy_routing_section_is_ignored_on_loadwithENV_TEST_MUTEX(#1085, closes #1084). It was the only test in its module that read environment-sensitive config without holding the lock, so it raced the dozen or more siblings that setCONTINUUM_BACKEND_URLSand failed intermittently in CI once the guardrail work reshuffled parallel test scheduling. Test-only, and latent since the test was introduced in #1027.
Dependencies¶
- Bump the minor-and-patch group with 6 updates (#1073):
tokio1.53.0 to 1.53.1,tokio-util0.7.18 to 0.7.19,clap4.6.3 to 4.6.4,tokio-stream0.1.18 to 0.1.19,aws-config1.9.0 to 1.10.1, andlibc0.2.186 to 0.2.189. - Bump
actions/setup-pythonfrom 6 to 7 in the CI workflows (#1072).
v1.15.5 - 2026-07-24¶
Restore stateless multi-turn tool calling on /v1/responses for clients that replay the model's own output items, and stop startup from gating listener binding on unbounded backend probes.
Added¶
- Add
health_checks.block_startup(bool, defaulttrue) so listeners can bind before backends are probed (#1066, closes #1065). At the default the startup sequence is byte-for-byte identical to before: connection pre-warm and the initial health-check round run inline and gate listener binding. Set tofalse, the listeners bind first and both steps run in backgroundtokio::spawntasks that log completion, so/healthand/v1/modelsanswer immediately while per-backend health converges through the admin and model endpoints. This matters when an orchestrator polls/healthunder a readiness cap (Backend.AI GO uses 30s) and a single unreachable or stalling backend pushes the pre-bind window past it./healthis a router-liveness probe that never reported backend health, so it stays meaningful during background warm-up. Documented in the English and Korean health-and-caching, configuration, and deployment guides, the example config, and configuration-assistant guidance (schema 1.26.0 to 1.27.0).
Fixed¶
- Keep replayed Responses input items convertible, so stateless multi-turn tool calling (
store: false) works again on conversion backends (#1064, fixes #1063). A v1.15.0 regression (#1051) treated plainidandstatusas native-only metadata alongside the betaphase/agent/callerfields, so themessage,function_call, andfunction_call_outputitems that every OpenAI-compatible client replays verbatim (including the model's own itemid) became opaque passthrough values, andconvert_to_anthropicandconvert_to_geminirejected the whole request withRequest field 'native Responses input item' ... would lose it. The preserve-exactly heuristic now triggers only on the genuinely lossy beta fields, and only bounded standardidstrings and recognizedstatusvalues count as droppable replay metadata. Oversized, malformed, or unknown metadata stays byte-preserved, native-gated, and accounted for by request-size validation, so conversion still fails closed. - Serialize replayed reasoning items byte-faithfully (#1064).
encrypted_content,content, andstatuson a reasoning input item now skip serialization when absent, so a forwarded item no longer gains explicitnulls that OpenAI rejects withUnknown parameter: 'input[N].status'. - Apply the configured
health_checks.timeoutas a per-request timeout on the startup health-check path (#1066).HealthChecker::with_shared_clientreuses the router's shared reqwest client, whose global timeout is the 600s streaming total, so a backend that accepted a connection and then stalled could hold a health-check round for up to 10 minutes. The timeout now bounds every check on both the shared-client and dedicated-client constructor paths; theDurationis read out of the config lock and the guard dropped before the request, so no lock is held across the await. - Bound the generic connection pre-warm HEAD request at 10s (#1066), matching the cap the Anthropic branch already used, instead of inheriting the shared client's 600s ceiling.
v1.15.4 - 2026-07-23¶
Accept the Anthropic-native x-api-key header in standard blocking-mode authentication, and key the hub policy hash, guardrail bypass allowlists, and per-key stats off the same presented credential.
Fixed¶
- Accept
x-api-keyin standard blocking mode (#1062, closes #1061). Withapi_keysconfigured in blocking mode, the standard (non-AppProxy) path of the dynamic auth middleware only readAuthorization: Bearer, so Anthropic-native clients (the Anthropic SDKs, Claude Code with onlyANTHROPIC_API_KEYset) presenting a valid key inx-api-keygot 401. A sharedextract_presented_api_keyhelper (Bearer preferred,x-api-keyfallback, whitespace trimmed, raw credential never logged) now feeds the middleware in both blocking and permissive mode, the AppProxy ROUTER key extraction delegates to it, and the Anthropic messages, count-tokens, and models handlers derive the authenticated user from the middleware-attached auth context instead of re-requiringx-api-key. Removing the handlers' early return also means an authenticated request with a malformedanthropic-versionheader now gets 400 instead of silently passing. - Key every credential consumer off the presented credential the auth middleware validated (#1062). The control-plane hub key hash now trims a whitespace-padded Bearer credential exactly like the auth path, so a padded
Authorizationheader can no longer authenticate while evading hub rate, budget, and tier enforcement and usage attribution. The guardrail bypass allowlists (non-streaming, streaming, and Responses-only paths) and per-key stats attribution read the same presented credential, sox-api-key-only clients get correct bypass and stats, and a stray non-authenticatedx-api-keythat matches a bypass key cannot trigger a bypass when the request authenticated under a different Bearer key.
v1.15.3 - 2026-07-22¶
Enforce Qwen3Guard verdicts in the self-hosted guardrail classifier, isolate the AppProxy feature graph from the Admin API, fix empty tool-call arguments on the Chat-to-Responses bridge, and refresh five dependencies including the rmcp 2.x migration.
Added¶
- Add a native Qwen3Guard template to the self-hosted classifier so its structured plaintext verdicts are enforced instead of silently allowed by an incompatible parser (#1060, closes #1059). The template parses bounded, line-oriented
Safety:andCategories:output for Safe, Unsafe, and Controversial results, maps Qwen's official taxonomy with case-insensitive selection and mapped thresholds (categorizing injection positives as jailbreaks), and adds a configurablecontroversial_action: flag | block | allowwith a non-blockingflagdefault. Malformed output follows the effective fail-open/fail-closed policy. The classifier options, Qwen parsing, and tests are split into bounded modules under 500 lines each, and the configuration is documented in the English and Korean guardrail guides, the example config, and configuration-assistant guidance.
Changed¶
- Make the AppProxy feature graphs compile in isolation without transitively activating the Admin API (#1058, closes #1053). The process-wide config-mutation lock moves into
infrastructure::configand CIDR-aware IP allow-list evaluation into a feature-independent HTTP middleware helper, both with compatibility re-exports, andappproxy-commonno longer enablesadmin. An isolated--no-default-featuresAppProxy build therefore neither compiles nor mounts the Admin route tree, while official release builds still receive Admin through the defaultfullfeature set, so their behavior is unchanged. Isolatedappproxy-common,appproxy-router, andcontrol-plane,appproxy-routerchecks are added to CI.
Fixed¶
- Preserve function-call arguments when a Responses stream provides the complete JSON only on
response.output_item.addedordoneevents, so Chat Completions tool calls no longer reach clients with an emptyargumentsstring (#1057, closes #1056). The converter buffers arguments seen onoutput_item.addedper output index, tracks deltas already forwarded to each tool call, and reconciles the terminal events by emitting only the longest prefix-compatible missing suffix.
Dependencies¶
- Bump
rmcpfrom 1.8 to 2.2 and migrate the MCP resource listing to the non-annotatedResource/ResourceTemplatebuilders, sinceRawResource,RawResourceTemplate,AnnotateAble, andno_annotation()were removed in the 2.x API. Bumptower-httpto 0.7 (cors layer),object_storeto 0.14 (s3-cache),aws-smithy-eventstreamto 0.61 (bedrock-sigv4), andtokio-tungsteniteto 0.30 (control-plane policy stream).
v1.15.2 - 2026-07-21¶
Ship the Windows release binary with the MSVC C runtime statically linked so it launches on a clean Windows install, and refresh the dependency tree.
Fixed¶
- Statically link the MSVC C runtime in the
x86_64-pc-windows-msvcrelease build so the shippedcontinuum-router.exeno longer requires the Visual C++ Redistributable (#1055). Every Windows release since v0.23.1 linked the CRT dynamically, so on a Windows machine without the VC++ 2015-2022 Redistributable the binary failed to start with a missingVCRUNTIME140.dllerror. The release workflow now builds the Windows target with-C target-feature=+crt-static, and because that job builds with an explicit--target, the flag applies only to the final binary and not to host build scripts or proc-macros. macOS and Linux builds are unchanged.
Dependencies¶
- Bump a group of 12 minor and patch dependencies (#1052):
tokio1.52.3 to 1.53.0,redis1.3.0 to 1.4.1,uuid1.23.5 to 1.24.0,serde1.0.228 to 1.0.229,serde_json1.0.150 to 1.0.151,thiserror2.0.18 to 2.0.19,async-trait0.1.89 to 0.1.91,clap4.6.1 to 4.6.2,futures-util0.3.32 to 0.3.33,fastrand2.4.1 to 2.5.0,regex1.13.0 to 1.13.1, andtoml1.1.2 to 1.1.3.
v1.15.1 - 2026-07-20¶
Align the GPT-5 family across the OpenAI Chat Completions and Responses APIs. This release registers the GPT-5.6 models and GPT-5 Pro, validates reasoning_effort against the exact per-model matrix without silent downgrades, and preserves compatible request fields across the Chat-to-Responses bridge instead of dropping them.
Added¶
- Register the GPT-5.6 Sol, Terra, and Luna models with family aliases, wired into OpenAI model normalization and reasoning-effort handling (#1051). Model metadata covers the new generation, and the family aliases resolve to the concrete models.
- Add GPT-5 Pro as a Responses-only model so
/v1/chat/completionstransparently bridges to/v1/responses(#1051). GPT-5 Pro is Responses-only upstream, but the router keeps Chat Completions compatible through its existing Chat-to-Responses bridge. - Expand the Responses API schema for reasoning context and mode, text verbosity, prompt caching, programmatic tool calling, multi-agent items, native replay metadata, safety identifiers, context management, and tool-call limits (#1051).
Changed¶
- Validate OpenAI
reasoning_effortagainst the exact GPT-5 generation and model matrix on both streaming and non-streaming Chat Completions requests (#1051). The originalgpt-5family acceptsminimal, GPT-5.1 starts atnone/low, GPT-5.2 through GPT-5.5 addxhigh, GPT-5.6 addsmax, andgpt-5-proaccepts onlyhigh. The router never rewritesauto,xhigh, ormaxto another effort, and OpenAI models no longer pass through the former Gemini-normalization interference. - Preserve compatible Chat fields across the Chat-to-Responses bridge, including flat
verbosity, metadata, safety identifiers, and prompt-cache controls, rejecting invalid values instead of dropping them (#1051). Native OpenAI persisted-response state and beta headers are kept intact, while local session state is resolved only for conversion strategies and nativeprevious_response_idvalues are forwarded unchanged. Prompt-cache TTL validation accepts the documented30mvalue.
Fixed¶
- Fail closed with
unsupported_request_parameterwhen native-only Responses controls would otherwise be silently dropped by non-native conversion paths (#1051). Native Responses requests carrying GPT-5.6-only controls bypass response caching and are rejected on conversion-only backends rather than degraded silently.
Breaking Changes¶
- OpenAI Chat Completions now rejects unsupported model and effort pairs with
400 invalid_request_errorinstead of silently rewriting the effort (#1051). A client that previously relied on the router adjusting an out-of-rangereasoning_effortreceives an explicit error and must send a value the target model supports.
v1.15.0 - 2026-07-20¶
A configuration truth pass. A manual code audit of the v1.14.0 tree found configuration knobs and endpoints that the schema accepted and the manuals documented but that no production code path honored. This release wires each one into the runtime so the documented behavior actually takes effect, adds the environment overrides and interpolation the manuals already described, and finishes with a documentation alignment audit and a post-merge hardening pass.
Added¶
- Interpolate
${VAR}environment references in every string-valued config field, not just the previously hardcoded secret/URL/path set (#1039, closes #1032). The loader walks the parsed YAML/TOML tree and resolves${VAR},${VAR:-default}, and a$$literal-dollar escape before typed deserialization, with variable-name validation and bounds against oversized values and excessive substitutions..envfiles load before parsing so their values feed interpolation, an unset required reference aborts startup and hot reload with a path-aware error,config validatereports unset references as warnings,config show --resolvedandconfig diffinterpolate (diff still redacts secret-named fields), andconfig showplus the MCP surface keep references literal and read no environment values. - Add a validated
model_aggregationconfig section wired into the runtime (#1042, closes #1022). The section maps to the aggregation service, cache TTLs, fetcher and entry limits, deduplication strategy, force-refresh gate, and background refresh task, soallow_force_refreshandbackground_refreshare operator-controllable instead of pinned to defaults. Reload class is restart, because the cache and services are built at startup. Schema count moves to 35 with the addition. - Read the
CONTINUUM_SELECTION_STRATEGYenvironment override in the config loader (#1037, closes #1023). It was documented but never read; the loader now parses all six strategies with the same spelling rules as--selection-strategy, an unrecognized value fails the config load with a clear error instead of being silently ignored, and precedence is--selection-strategy>CONTINUUM_SELECTION_STRATEGY> file value >RoundRobindefault. - Read the four
CONTINUUM_FILES_AUTH_*Files API auth environment overrides in the config loader (#1046, closes #1033).CONTINUUM_FILES_AUTH_METHOD,CONTINUUM_FILES_AUTH_SCOPE,CONTINUUM_FILES_ENFORCE_OWNERSHIP, andCONTINUUM_FILES_ADMIN_ACCESS_ALLwere documented but inert; they now apply tofiles.auth, construct a defaultFilesConfigwhen the file has nofiles:section, and the two booleans accept only literal lowercasetrue/false, rejecting1,0,yes,no, and mixed case with a clear error. - Emit the
X-Fallback-Attemptsresponse header the router already advertised (#1035, closes #1024). The fallback path discarded the attempt count before building the response; the header now reports the total model attempts including the primary, appears only when a fallback backend served the response (minimum value 2), and is gated by the per-modelnotify_on_fallbacksetting. Streaming responses flush headers before the body, so mid-stream recovery stays observable through thestreaming_fallback_*metrics instead.
Changed¶
- Align the English and Korean manuals, CLI
--help, generated configuration guidance, Debian packaging text, Docker examples, and the manpage with the implemented router surface (#1034). Historical, aspirational, and nonexistent-feature claims were removed from user-facing docs while real endpoint, feature-gate, authentication, fallback, circuit-breaker, environment-expansion, model-aggregation, and hot-reload boundaries were documented, and Admin and MCP hot-reload metadata and regression tests were aligned with actual wiring. This audit surfaced the follow-up findings that the rest of this release closes. - Bump the Debian Build-Depends Rust floor from 1.75 to 1.96 to match the lablup PPA, and reconcile the packaging docs (#1021, closes #1020). Corrects the false edition-2024 MSRV claim (the crate is edition 2021), the wrong Noble codename mapping, and the distro guidance to state that stock Ubuntu series do not ship Rust 1.96 and the lablup PPA must be enabled.
Fixed¶
- Drive the circuit-breaker state machine from ordinary proxy traffic (#1044, closes #1028). The breaker was implemented, configurable, hot-reloadable, and Admin-visible, but no LLM proxy request drove or consulted it, so enabling it did not protect the routing path. Selection now excludes open circuits, a pre-dispatch gate admits or rejects, and terminal outcomes are recorded across Chat, Completions, Responses, Embeddings, Images, Rerank, and every fallback hop plus standard streaming. Upstream 5xx and configured
failure_status_codesopen the circuit, timeouts honortimeout_as_failure, transport errors are unconditional failures, client-side 4xx are not recorded, and admission rejections become a retryable error so selection and fallback advance to another backend before returning the standard 503. - Apply
retry.*andtimeouts.*hot-reload revisions to request execution (#1045, closes #1029). Both were classified hot-reloadable, but the request path read only the startup snapshots, so a reload changed the config and reported success while effective behavior stayed at startup values. Each request surface now snapshots the live policy and timeout budget at entry, keeping one coherent policy per in-flight request; the reqwest client connection and overall timeouts remain restart-only because they are baked in at client construction. - Apply live
selection_strategyandprefix_routing.load_factor_epsilonchanges to the runningBackendPool(#1038, closes #1025). A watcher-driven strategy change was detected and advertised as applied, butHotReloadServicenever pushed it to the live pool, so routing kept the startup strategy. Both are now shared lock-free cells swapped atomically while preserving backend membership, per-backend stats, in-flight accounting, and hash-ring safety, and the Admin, MCP, and docs surfaces report them as immediate. - Wire the accepted-but-inert
prefix_routing.virtual_nodesandanthropic_cache_control_injectionknobs into the runtime (#1041, closes #1026).virtual_nodesnow sizes the consistent-hash ring (rebuilding the cache on change),anthropic_cache_control_injectiongates automatic Anthropic cache-control injection as a fleet-wide master enable, both are hot-reloadable, and/admin/prefix-routing/statsreports the active pool values. - Inject the shared API key store into Admin authentication so
admin.auth.method: api_keyworks (#1036, closes #1030). The Admin routes were mounted withAdminAuthState::without_api_key_store(...), so everyapi_keyrequest hit the empty-store branch and returned500 Internal Server Error. Admin API-key auth now validates against the same store as ordinary/v1auth and Admin key CRUD (bothAuthorization: BearerandX-API-Keyare accepted), enforcesrequired_scope, and honors optionalallowed_ips; disabling, rotating, expiring, or deleting a key stops it authenticating immediately with no restart. - Make
POST /admin/config/applytruthful (#1047, closes #1031). The endpoint was a placeholder: it recorded the unchanged current config as a new history version, never published throughconfig_sender, and could reporthot_reload_triggered: truewithout triggering anything. It now takes an optional fullconfigcandidate; with no candidate it is an explicit no-op (no history version,hot_reload_triggered: false), and with a candidate it validates, diffs against the running config, and publishes atomically under the modification lock only when it differs.hot_reload_triggeredistrueonly after a successful publish of a changed config,updated_sections/requires_restartreflect the real diff,hot_reload: falsepreviews the diff without applying, and repeated no-candidate calls never create empty history versions. - Add a
metrics::securitystub sosrc/errors.rsbuilds under--features embed(#1048). The always-compiled error path callscrate::metrics::security::truncate_at_char_boundary, which lives behind themetricsfeature; the embed stub module gained the submodule with the real UTF-8-boundary-safe truncation logic. Theembedfeature is not in the CI matrix, so the break (introduced by #1017) was caught only by running the off-feature build matrix locally. - Scope the capacity integer-only heartbeat assertion to the capacity fields (#1040). The release feature-set CI job failed deterministically since 1.14.0 because the assertion checked the whole serialized heartbeat for
.0and tripped on the legitimate dot-zero inrouter_version"1.14.0"; it now verifies only that the threeobserved_*_capacityvalues serialize as JSON integers. - Consolidate post-merge corrections for the configuration and routing changes above (#1050). Made circuit-breaker admission, half-open accounting, and hot reload coherent; snapshotted the retry policy and hardened hash-ring generations; exposed the complete Admin config catalog with truthful reload metadata; and covered every streaming transport while synchronizing configuration documentation.
Removed¶
- Remove the inert legacy
routingconfig section (#1043, closes #1027). The schema accepted it and the docs described it as advanced routing overrides, but no production code read it, so settingroutingvalidated while none of its values affected traffic. Live selection uses top-levelselection_strategy, per-backendweight/models, andsmart_routing. A config that still carries a top-levelrouting:key keeps loading (the unknown key is ignored) and the loader andconfig validatenow emit a migration warning pointing at the live settings instead of silently doing nothing. Schema count returns to 34 andschema_versionmoves to 1.26.0.
v1.14.0 - 2026-07-19¶
Added¶
- Overhaul the embedded WebUI into a full admin console (epic #931). The single-file SPA is vendored (Alpine.js, uPlot, a prebuilt Tailwind stylesheet), drops every CDN dependency so it runs air-gapped under a strict
default-src 'self'CSP, and is restructured into a per-page module registry (#948). A login flow with credential validation and session UX gates the console (#949), and aGET /admin/capabilitiesendpoint drives page-level feature gating so panels for disabled subsystems degrade instead of breaking (#950). New management pages cover guardrails (#951), cache operations (#952), smart routing (#953), usage analytics (#954), prompts (#955), files (#956), models and routing with catalog, aliases, and fallback chains (#957), backend deep-edit forms with an on-demand health probe (#958), schema-driven config forms with diff preview and hot-reload indicators (#960), and integrations status for control-plane, ACP, and AppProxy (#961). Config PATCH saves that would silently drop diff-shown removals are blocked (#962), API keys gain allowlists, annotations, and usage drill-down (#963), the dashboard adds trends, uptime, and circuit detail (#964), and a UX pass adds a command palette, keyboard shortcuts, responsive layout, and accessibility fixes (#965, #966, #1004). - Add a typed operator request-parameter policy across every LLM API (epic #990). A protocol-independent policy engine (#996) applies operator-defined defaults, overrides, and min/max clamps for the seven canonical parameters (
temperature,top_p,max_tokens,presence_penalty,frequency_penalty,top_k,min_p) with typed values and validated ranges, exposed through config (#1006) and enforced on the Chat Completions and Completions proxy path (#1007), the native Anthropic Messages path (#1008), and the Responses API (#1009). Mutations emit deterministic, value-free metadata, and an invalid policy is distinguished from an invalid request field. Runtime paths and the local-bound preservation guarantee were hardened after audit so a Hub tier limit can only tighten, never loosen, a locally configured bound (#1013, #1014, #1015). - Enforce Continuum Hub tier request-parameter limits on the Router data plane (#1002, Hub #299 / PR #314 and #301 / PR #319). The additive version-0 wire contract carries integer-microunit bounds for seven canonical parameters, exact alias-resolved model scopes, a canonical public tier digest, and typed capability/active/rejection evidence. A full envelope is validated and converted before one atomic last-known-good swap; invalid, stale, or digest-mismatched candidates never partially apply. Each authenticated Hub key freezes the tightest intersection of Hub and deployment-local limits once before cache identity, substitution, arbitrage, backend selection, retry, or fallback across Completions, Chat Completions, Responses, and Anthropic Messages. Authoritative clears, mixed-version Hubs, feature-off/default builds, and Hub outages preserve their documented behavior, and logs, metrics, and heartbeat status remain value-free. The vendored protocol is pinned to Hub PR #319 merge
59387265e755bf02a9dbbb238da369e36d566d49. - Apply Continuum Hub fleet request-parameter configuration through the outbound-only control-plane agent (#1001, Hub #302 / PR #317). The separate
control_plane.config_sync.enabledopt-in advertises a masked, bounded structuredrequest_paramscapability, fetches revision-bound public scalar content, verifies the canonical Hub-compatible digest and full effective config, layers Hub leaves below explicit local pins, and publishes one atomic hot-reload snapshot. Dry runs, rollback revisions, duplicate delivery, lost acknowledgments, reconnects, and restarts are idempotent through a private last-known-good state/outbox; transient Hub failures never become terminal rejections, in-flight requests keep their captured config, and snapshots, logs, and apply results remain value-free. The vendored protocol stays additive at version 0 and is pinned to Hub PR #317 head771591f. - Estimate per-backend observed RPM, input TPM, and output TPM for Continuum Hub inventory (#972). The control-plane agent promotes a rolling 60-second throughput observation only after a confirmed transient upstream
429, requires a full window with at least two successful completions, requires complete token accounting before emitting either token dimension, and retains a 15-minute expiring high-water mark. Cold-start, idle, lightly loaded, token-incomplete, quota-exhausted, and stale backends remain absent rather than reporting0; observed capacity never reads or merges the operator-declared capacity stored by the hub. The same optional estimates are visible through/admin/stats/backendsin control-plane builds. - Add router-managed Gemini context caching (#929, closes #928). When
gemini_context_cache.enabledis set, the router caches large, stable system-prompt prefixes sent totype: geminibackends as GooglecachedContentsresources and reuses them across requests, transparently to OpenAI-compatible clients, guaranteeing the cached-token discount and cutting prefill latency for workloads that resend a large fixed system prompt. It mirrors the existing Anthropiccache_controlinjection on both the compat and native Gemini surfaces, keyed by (backend, model, prefix digest) with single-flight creation, negative caching of API-rejected prefixes, LRU eviction, and TTL extension on hit. Default off: explicit caching bills storage per token-hour, and with the feature disabled it is a strict no-op with no map allocation, background task, or added per-request latency. Acontinuum_gemini_context_cache_*metric family reports requests, creates, failures, entries, evictions, and cached tokens. - Execute cost-bounded, Hub-synced synthetic provider/model probes (#969, #1019). When the
control-planefeature is compiled andcontrol_plane.enabled,control_plane.policy.enabled, and an enabled HubProbePolicywith targets all hold, the agent runs one non-streaming probe call per listed(provider, model)target on the policy cadence through the internalBackend::execute_probe_chat_completionseam (normal transformation and effective auth, structured outcomes, no substitution, fallback, cache, or retries, and noUsageEvent), bounded by a per-router monthly budget ceiling persisted in an owner-only sidecar next tocontrol_plane.state_file(0is unlimited; positive ceilings reserve before dispatch and fail closed without price or state). Results post toPOST /api/agent/v1/probeswith stablereport_id/probe_idredelivery and a non-fatal rate-limited 404 path for older hubs. No config field is added, and every gate off means zero probe behavior, cost, or wire traffic. - Report per-backend served models (#920), per-backend auth and quota rejection counters (#925), and token-supply and saturation telemetry (#968) to Continuum Hub inventory, and add an opt-in
policy.unmatched_keyreject strict mode that fails closed on an unrecognized key instead of falling open (#923). All behind thecontrol-planefeature withPROTOCOL_VERSIONunchanged. - Add a
GET /admin/capabilitiesendpoint that reports compile-time Cargo feature flags and runtime-enabled subsystems in one read-only response, gated by the existing admin auth and audit middleware (#950).
Changed¶
- Bump 12 minor and patch dependencies in one group (#976), including
regex1.12.4 to 1.13.0,aws-config1.8.18 to 1.9.0,http-body1.0.1 to 1.1.0,rust-embed8.11.0 to 8.12.0, plusuuid,bytes,lru, andsocket2.
Fixed¶
- Wire live Prometheus metric sources and reconcile the legacy collectors for the router observability epic (#973; #982, #986, #993). Metric families that were registered but never written now have production writers, legacy
lazy_staticcounters are synced from the modern metric sources, and the English and Korean metrics references document the exported metric and label names. - Report observed per-backend capacity through the control-plane inventory (#972, #983), reading the single in-flight gauge rather than a parallel counter.
- Hide internal backends across every user-facing API surface so a
backend:-referenced internal model never leaks into model listings or the routing candidate set (#985). - Harden the epic #990 response cache identity so cache keys cannot collide across distinct effective requests (#1018).
- Surface upstream error bodies on the Unix-socket streaming path (#1017, closes #1016). Non-2xx backend responses on the plain, thinking, and Anthropic Unix-socket streams now drain the error body under an 8 KiB cap and a two-second budget bounded by the remaining request timeout, reaching error-body parity with the TCP streaming path instead of discarding the backend's explanation.
- Account for Unix-socket usage in streaming responses so control-plane usage records are not dropped on that transport (#927).
- Harden control-plane observability and strict auth (#926), reconcile a renamed backend name during health sync (#921), stamp an unattributed sentinel on fail-open usage attribution (#922), and resolve the usage provider from the live config per event rather than a stale snapshot (#919).
- Harden epic #973 observability state after the merged implementation audit. Capacity samples are now kept in timestamp order so concurrent, out-of-order completions cannot retain stale throughput or surface future observations; backend removal and same-name endpoint replacement unconditionally reconcile name-keyed capacity and credential-health state even when health checks are disabled; and the registered aggregate model-usage, token, and final-timeout counters now have production writers. The English and Korean metrics references now document the exported metric and label names.
- Dispatch live HTTP traffic through the configured
selection_strategy(#975). No HTTP request path ever called the pool selector: chat completions, streaming chat, the Responses API (non-streaming, streaming, and compact), Anthropic Messages and count_tokens, and image generation each picked the FIRST healthy candidate in model-lookup order, which left RoundRobin, WeightedRoundRobin, Random, LeastLatency, ConsistentHash, and PrefixAwareHash all inert for live traffic. All of these paths now share one selection seam: each keeps its own pre-filters and error semantics (model lookup, internal-backend filter, per-key allow-list, retry-state exclusion, and the model-not-found / 403 / all-unhealthy distinctions are unchanged), the seam health-filters the surviving candidates so an unhealthy backend can never be selected, and the final pick goes through the new candidate-restricted pool entry pointBackendPool::select_from_candidates, which shares the strategy core withselect_backend_with_contextand keys the consistent-hash ring cache by the participating backend names so per-model candidate subsets each hash against a correct ring. Prefix-aware routing is live end to end: withprefix_routing.enabled, the prefix key is extracted from the request body (OpenAImessages[]and Anthropic top-levelsystemshapes) and drives PrefixAwareHash CHWBL placement over the live in-flight gauge from #971, and theKvOverlapScoreris registered at startup when a KV cache index is also configured. Traffic-distribution behavior change: deployments that configured a non-defaultselection_strategynow actually get it enforced, and default deployments change from "always the first candidate backend" to real round-robin across the healthy candidates for each model. - Wire per-backend in-flight tracking into the production request path so the CHWBL load cap is no longer degenerate (#971).
BackendStats.in_flight_requestspreviously had no production writer, so the bounded-load half of thePrefixAwareHashselector computed its cap over a permanent zero and could never bind. The backend pool now owns an always-compiled RAII in-flight tracker written at every dispatch seam (chat proxy, streaming chat, Responses API, Anthropic Messages and count_tokens, image generation, image edit), with streaming guards riding the response body until the last frame or a client disconnect. The cap formula now counts the request being placed,ceil((total_in_flight + 1) * (1 + epsilon) / backend_count), andprefix_routing.load_factor_epsilon(default 0.25) finally reaches the pool, so the documented knob takes effect. The gauge is the single source of truth: the adminprefix-routing/statsendpoint and the control-plane inventory's per-backendactive_requestsnow read it, replacing the parallel control-plane gauge from #968, and both report real load instead of zeros. - Note on scope: this fixes the gauge the CHWBL selector reads, not which code paths invoke that selector. The router's HTTP request paths still dispatch to the first healthy backend serving the model, so
selection_strategy(CHWBL included) does not yet influence live traffic distribution and no request will be routed differently after this change. Wiring the pool selector into the request funnel landed in #975 (see the entry above). - Prevent the Unix socket integration tests from hanging and restore them in CI (#984, #992, #995).
v1.13.1 - 2026-07-10¶
Added¶
- Amazon Bedrock multi-provider support via the Converse API (#615). A new
endpoint_type: converseontype: bedrockbackends targetsPOST /model/{modelId}/converse[-stream]and reaches any chat model on Bedrock (Claude, Nova, Llama, Mistral, Cohere, AI21 Jamba, DeepSeek) through one unified wire format: OpenAI-shape requests are transformed into Converse content blocks (system-prompt extraction,toolConfig/toolUse/toolResult,inferenceConfigsampling parameters), signed with SigV4 through the shared #614 plumbing, and the binary ConverseStream event stream is decoded back into OpenAI SSE with tool-call and reasoning deltas. A hard-coded per-model capability matrix (chat, streaming, tools, vision, documents, guardrails, prompt caching, reasoning) rejects unsupported-capability requests with a 4xx naming the capability before signing, vendor-specific options pass through verbatim viaextra_body/additionalModelRequestFields, remote image URLs are fetched (SSRF-validated, size-capped) and inlined as base64, and Converse-native bodies are detected and forwarded without double transformation. Gated behind the existingbedrock-sigv4Cargo feature. - Backend-level
internalvisibility flag (#908, #911). Settinginternal: trueon a backend keeps its infrastructure-only models (a guardrail classifier, an internal summarizer, an embeddings model) out of user-facing surfaces: internal backend names are stripped from the OpenAI/v1/models,/v1/models/extended, single-model, and Anthropic/anthropic/v1/modelslistings, and removed from the routing candidate set before the per-key allow-list so a user request for an internal-only model returns model-not-found (404) rather than a misleading Forbidden. Router-internalbackend:references and admin surfaces still resolve internal backends. The field defaults tofalseand is omitted on serialize, so existing configs are unchanged. - Accept the retiring hub key hash during a control-plane key-rotation overlap (#906).
KeyEntrygains additiveprevious_key_hashandprevious_retires_at_msfields so a router admits both the current and the previous hash during the overlap window, retiring the previous hash at the local timestamp or when the hub omits it on the next sync, whichever comes first; a current hash never shadows the previous one and revocation still rejects both. Behind thecontrol-planefeature; older envelopes parse unchanged andPROTOCOL_VERSIONstays 0. - Stamp streaming time-to-first-token on control-plane usage records and keep heartbeat inventory fresh under load (#912).
UsageRecordgains an additivettft_ms, measured from the same request-start instant used forlatency_msup to the first streamed byte across the chat, thinking, Anthropic, and Gemini SSE loops, clamped tolatency_ms, and omitted rather than fabricated for non-streaming, local-cache-replay, and batch responses. The heartbeat loop subtracts each beat's build and send time from the interval so a slow beat under load does not drift the cadence past the hub's load-sample interval, and every beat still carries a full router inventory. Behind thecontrol-planefeature;PROTOCOL_VERSIONstays 0.
Changed¶
- Official release binaries now ship with the
control-planeandappproxy-routerfeatures compiled in (#905). The Release workflow builds all six targets with--features control-plane,appproxy-router, and the Debian packages and Docker images repackage those binaries, so every distribution channel carries them. Both features stay out of the cratedefault/fullset, so a plaincargo buildfrom source is byte-for-byte unaffected; for packaged binaries the opt-in moves to runtime configuration (control_plane.enabled, defaultfalse, and theappproxy_routersection, absent by default), so existing deployments see no behavior change. The legacyappproxy/appproxy-legacyworker remains a source-build opt-in.
Fixed¶
- Resolve clippy lint drift that red-failed the default-feature
cargo clippy -- -D warningsCI step on every branch (#907). Two mechanical, clippy-suggested rewrites in the Anthropic Gemini transform and the Bedrock reasoning transform, with no behavior change.
v1.13.0 - 2026-07-09¶
Added¶
- Continuum Hub control-plane enforcement and optimization, promoting the opt-in agent from outbound telemetry (v1.12.0) to a full policy layer. Everything is gated behind the
control-planefeature and fails open when no policy is loaded, so a default build has zero added dependencies, zero runtime cost, and is byte-for-byte unchanged. - Enforce a hub-synced key table, tier rate limits (rpm plus cache-aware input/output TPM), monthly budgets, and model allowlists through a post-auth middleware that emits
x-continuum-ratelimit-*headers, fed by an additiveKeyUsageSnapshotprotocol type and poll/WebSocket policy sync into an ArcSwap store (#877). - Gate the exact response cache on tier
cache_enabledplus the org optimization policy, enforce thex-continuum-cacheandx-continuum-batchper-request override headers within tier bounds (off always wins, on never exceeds the tier), emitx-continuum-cachehit/miss/bypass outcome headers, and meter local cache hits as metadata-only events that charge no token budget (#878). - Stamp a
CacheHitType(exact/prefix/semantic, plus an open-ended unknown) on usage records alongside the already-meteredcache_hit,cached_input_tokens,batched, andprovider_batch_idfields; additive with a serde default andPROTOCOL_VERSIONunchanged (#879). - Dispatch batch-eligible traffic through OpenAI-style and Anthropic Message Batches endpoints on a separate batch rate pool (
batch_rpmplus an in-flightbatch_queue_depthgauge) that never consumes the synchronous rpm/tpm limits, report each provider job's lifecycle to the hub via at-least-once, 404-tolerantBatchStatusUpdatemessages, and expose a full key-scoped/v1/batchessurface (create, retrieve, list, cancel, results) behind a per-key ownership gate with an age-based reclaim backstop; inert unlesscontrol_plane.batchis enabled (#880, #895). - Execute a prompt-prefix response cache on a keyspace disjoint from the exact-match cache for OpenAI chat and Anthropic Messages non-streaming, keeping the prompt-determining fields and dropping the sampling-bound tail, guarded to store only naturally-completed responses and serve only when the stored entry fits the new request's
max_tokens(#895). - Select the cheapest equivalent provider via price arbitrage (
x-continuum-arbitrage, tierarbitrage_enabled, tri-stateequivalence_classes) only after health, circuit-breaker, latency, tier, and per-request header gates pass, constraining dispatch to the selected backend so a later selection pass cannot override the policy decision (#896, closes #891). - Compress request context router-side for managed requests (
x-continuum-compression,TierLimits.compression_enabled,CompressionPolicy), deterministically shrinking chat history before responses-only routing, streaming, cache lookup/storage, and dispatch while preserving leading system/developer and recent turns, and reportingcompressed/uncompressed_input_tokenswithout exposing content to the hub (#898, closes #892). - Defer eligible
/v1/batchessubmissions until the next matching UTC routing window (PolicyEnvelope.routing_windowstri-state, weekday masks, tier scoping, end-exclusive and wrap-around-across-midnight boundaries) with batch admission checked at dispatch time so no pool slot is held while a request sleeps, and add integer-onlyLoadSummaryrequest/error-rate/p50/p99 counters to router inventory heartbeats plus advisoryOptimizationPolicy.loadthresholds (#900, closes #893). - Substitute the requested model per hub policy on the control-plane path, keeping substitution and arbitrage metadata mutually exclusive (#894).
- Consume hub-delivered, org-scoped provider credentials (
PolicyEnvelope.provider_credentials, hub M3 vault) in place of the locally configuredapi_key, applying rotations without a restart or dropping in-flight requests; secrets stay in memory only, are redacted from every Debug rendering, and are resolved value-only at every provider-dispatch send site (proxy, streaming, images, Responses, batch, native Anthropic, count_tokens, embeddings) so auth-header presence and OAuth/SigV4/Bedrock gating are unchanged (#901). - Extend hub-policy enforcement and usage attribution to the native Anthropic Messages path (streaming and non-streaming, across the native, OpenAI-bridge, Responses-bridge, and Bedrock-runtime sub-paths) and streaming
/v1/responses, accepting the Anthropic-nativex-api-keycredential and charging cache-aware token usage under the hubkey_idinstead ofanonymous(#882, #889).
Fixed¶
- Harden tiered KV cache event routing: wire configured KV event sources into the index at startup, preserve existing affinity scores when an event only changes the storage tier, expose the full offload/reload/purge event set through metrics and admin status, constrain disaggregated fast-decode execution to the decode backend selected from the KV index, honor per-client backend allow-lists during orchestration, and return a service-unavailable error instead of a false successful prefill/decode header when the two-phase worker protocol is unavailable (#903).
- Harden the epic 864 guardrail hot-reload behavior across the config validator and the guardrail service (#864).
- Thread the model-substitution argument through the prefix cache-hit metering call sites so the tree compiles under
--features control-planeafter #894 and #895 merged cleanly but on divergent bases (#897).
Documentation¶
- Redesign the AppProxy worker-mode reference (English and Korean) around the new ROUTER frontend: a single binding address per worker, cluster-level model abstraction, two-level hierarchical routing, event+pull propagation of mappings and hash-only key custody, and a BEP-1053 cross-reference (#748).
- Backfill v1.11.0 and v1.12.0 into the
docs/enanddocs/kochangelog mirrors, which had lagged two releases. - Correct the config-assistant sync note for the relaxed drift guard and note the CHANGELOG en/ko mirror requirement.
Dependencies¶
- Bump the minor-and-patch group with 2 updates (#883):
rand0.10.1 to 0.10.2 andarc-swap1.9.1 to 1.9.2.
CI¶
- Relax the skill-doc version drift guard from an exact
CARGO_PKG_VERSIONmatch to a compatibility-window check, so a routine in-range version bump no longer needs a hand edit while a window-crossing major bump still fails the build for re-review.
v1.12.0 - 2026-07-05¶
Added¶
- Configuration assistant for authoring and validating
config.yaml(epic #530). - A
configCLI subcommand withvalidate,generate,diff, andshow --resolvedverbs, plus theconfiguration-assistantskill and config templates (#829). - An
mcp-servestdio MCP server mode (behind themcpfeature, included indefault/full) that answersclaude mcp add continuum-router-config -- continuum-router mcp-serveout of the box (#830). - An IDE rule generator (
scripts/generate-ide-rules.sh) and documentation for the assistant (#831), and a@lablup/continuum-router-mcpnpm wrapper package (#832). - Verification and CI drift guards that fail the build when a schema change skips the assistant doc/template update (#833), plus follow-up hardening (#834).
- Continuum Hub control-plane agent behind the opt-in
control-planefeature (#875, closes #874). An outbound-only task that enrolls a router against a hub, heartbeats liveness plus a router inventory, and pushes batched, metadata-only usage records. Not indefault/full, so a default build has zero added dependencies and zero runtime cost; the sole new dependency is the vendored serde-onlycontinuum-protocolcrate, compiled only under the feature. - Backend.AI AppProxy ROUTER worker mode (epic). ROUTER wire types, events, and worker config (#815); a ROUTER event overlay with per-node applied-ack (#816); reconciliation of circuits and mappings into backends (#817); a coordinator client with router-config pull (#818); a model-level API-key gate via
allowed_models(#819); and the ROUTER worker lifecycle, config wiring, and feature flags (#820). - Claude Sonnet 5 support (#857).
- Native streaming Gemini Responses conversion via
streamGenerateContent(#856). - Two guardrail providers: reference a served backend as the guardrail model with a
backend:prefix (#871), and acustom_classifierprovider that returns a prompt-defined JSON verdict (#870). - Configurable web-search provider base URL for internal mirrors (#836).
- A cli-less library build behind the
embedfeature for in-process iOS embedding (#850), exposingcontinuum_router::serve_embedded(config, addr).
Changed¶
- Consolidate the two backend connection pools into a single trait-based pool (#851, refs #812) and rename the legacy
Backendstruct toPooledBackend(#843, refs #511). - Unify the dual streaming paths: extract SSE pipeline concerns into composable stream middleware (#839), route Gemini streaming through the
Backendtrait (#840), collapse the duplicated Anthropic streaming transform (#841), and apply the OpenAI reasoning normalization on the streaming path (#842). - Extract a shared LLM-backend transport core for guardrails (#869), and add a native-Anthropic system gate with an alloc-free family lookup (#861).
- Relocate the current AppProxy worker to
legacy(appproxy v3) and extract a shared common core (#813).
Fixed¶
- Preserve usage attribution metadata on the control-plane usage push (#876).
- Validate
/v1/responsesstreaming requests beforefile_idresolution so a malformed request is rejected with the right error instead of failing during resolution (#873). - Honor the per-key allow-list on the
/v1/responsesstreaming paths (#863). - Normalize Bedrock model prefixes for Anthropic capability gating (#859).
- Reject a Brave
web_searchbase_urlthat carries a query string (#852). - Harden the native Gemini Responses audio and image conversion (#853, follow-up to #780) and wire the native Gemini Responses conversion for
input_audio(#835). - Harden the epic 505 streaming prompt parity (#846) and the epic 530 configuration assistant (#834).
- Harden AppProxy ROUTER request gating (#823).
- Anchor the daily-series prune and read tests to the wall clock so they no longer flake (#814).
Documentation¶
- Document the SSE streaming pipeline overhead with a reproducible benchmark (#848) and audit the divergences between the two streaming paths (#837).
- Add backend transform consistency integration tests (#838).
Dependencies¶
- Bump the minor-and-patch group across 1 directory with 3 updates (#845) and with 2 updates (#802).
- Bump
actions/cachefrom 5 to 6 (#844) andactions/checkoutfrom 6 to 7 (#801).
v1.11.0 - 2026-06-21¶
Added¶
- Content-safety guardrails subsystem (epic #644), config-gated through an
Option<GuardrailsConfig>that defaults to off, so an absentguardrailskey leaves the feature inert and existing configs are unaffected. - Foundation: the object-safe, async
Guardrailtrait with input/output/streaming check stages, aGuardrailVerdict(Allow/Block/Transform/Flag) with most-severe-wins severity ordering, a borrowingGuardrailContext, and aGuardrailsConfigschema (globalmonitor/enforcemode, providers with credentials by env, per-route overrides, per-API-key bypass, per-guardrailtimeout_msandon_errorfail-open/fail-closed, block behavior, streaming mode, and exact and regex allow/deny lists) with a validator that rejects invalid combinations such asenforcewith no providers, thresholds outside[0,1], non-compiling regex, and zero timeouts (#779, closes #644). The validator is wired into the real config load path so an invalidguardrailsblock is rejected at load time (#781). - Service: a
GuardrailServicepipeline that runs the configured guardrails with per-guardrail timeouts and fail-open/fail-closed handling, hot-reload, and a block-response builder (#782). - Providers behind a factory: OpenAI Moderation (#783), a self-hosted classifier (#785), PII detection and redaction (#786), and AWS Bedrock and Azure cloud guardrails (#787).
- Gating: input gating wired into the chat, Anthropic, and responses dispatch paths (#788), output gating on the non-streaming response paths (#789), streaming output gating with buffer/chunked/passthru modes (#790), and per-route policy threaded through the dispatch handlers so a
guardrails.routes[<model>]override (mode, enable/disable, provider subset, thresholds) takes effect end to end, keyed by the request model id (#796). - Controls and observability: admin runtime controls for guardrail policy (#784), Prometheus metrics and per-decision audit logging (#792), an end-to-end integration suite for input/output gating (#791), and a guardrails guide with architecture and metrics coverage plus site nav (#793).
- Per-model and daily usage-series dimensions on the admin stats API (#799, closes #798).
GET /admin/stats/api-keys/{id}/modelsandGET /admin/stats/users/{user_id}/modelsreturn a per-model token and request breakdown, and.../{id}/seriesand.../{user_id}/seriesreturn an ascending daily time series oftotal_requests,prompt_tokens,completion_tokens, andtotal_tokenskeyed by the UTC completion date (from/toaccept Unix-millis or RFC 3339;intervaldefaults todayand any other value returns400). Four cardinality-boundedDashMaps on theStatsCollector(keyed by composite keys joined with aU+001Fseparator that cannot occur in ids, models, or dates) back the dimensions, aseries_retention_daysoption (default 30, serde-defaulted) prunes daily buckets older than the cutoff, and the four maps are added to the persisted snapshot as#[serde(default)]so snapshots written before this change still load.
Fixed¶
- Harden the daily-series pruning lifecycle (#800, refs #799). Restore now counts every separator-bearing composite key, including the
anonymousdaily and model buckets that the runtime insert path treats as non-reserved, so pruning an expired bucket after a restart no longer decrements a slot that was never reserved, underflows theAtomicUsize, and silently folds new real buckets into the overflow row. A dedicated hourly series pruner is spawned when stats persistence is off (where the snapshot task that drove pruning never runs), so the default config physically drops expired daily buckets instead of accumulating them up to the cardinality ceiling. - Record admin stats for
/v1/responsestraffic (#794), correcting the stats success and failure boundary for conversion failures and strengthening regression coverage for the registered per-key and per-user admin stats endpoints. - Handle OpenAI
input_audiocontent blocks in the native Gemini and Anthropic transforms (#778). The Gemini Responses converter now maps audio to aninline_datapart with amime_typederived from the format (wavtoaudio/wav,mp3and other toaudio/mp3), and routes adata:URL through the existing inline-data path, instead of discarding it. The Anthropic Messages API has no audio content type, so the native transform now returns aValidationErrorsurfaced as HTTP 400 at both the non-streaming and streaming entry points instead of silently dropping the block or forwarding it verbatim.
Dependencies¶
- Bump the minor-and-patch group with 5 updates (#769):
uuid1.23.2 to 1.23.3,regex1.12.3 to 1.12.4,redis1.2.2 to 1.2.3,aws-smithy-eventstream0.60.20 to 0.60.21, andaws-smithy-types1.4.9 to 1.5.0.
v1.10.2 - 2026-06-15¶
Added¶
- Kimi K2.7-Code and GLM-5.2 model metadata (#775). Kimi K2.7-Code is a 1T-parameter MoE (32B active) with a MoonViT vision encoder and 256K context that runs only in thinking mode and returns reasoning in the native
reasoning_contentfield, so it carries no<think>marker config. GLM-5.2 is the GLM-5 line coding flagship with a 1M context window, 131K max output, and two thinking-effort levels (High and Max); it carries the standard<think>/</think>marker config like the rest of the GLM family. GLM-5.2 standalone API pricing was not published at launch, so its input/output rates are estimated from the GLM-5/GLM-5.1 tier.
Fixed¶
- Normalize the vLLM/OpenAI-compatible
reasoningfield to the canonicalreasoning_contenton/v1/chat/completions(#776, closes #774). Newer vLLM renamed its reasoning output field fromreasoning_contenttoreasoning(streamingdelta.reasoning, non-streamingmessage.reasoning), and the router relayed it unchanged, so clients readingreasoning_contentsilently dropped all reasoning text from self-hosted vLLM reasoning models. The rename runs across every OpenAI-compatible relay path (the streaming default and thinking transformers, the unix-socket relay, the mid-stream fallback relay, and the non-streaming proxy body), only whenreasoning_contentis absent so an upstream already using the canonical name is never overwritten, and is scoped away from the Gemini and Anthropic handlers and the Responses API.
Documentation¶
- Note in the reasoning-effort architecture reference (English and Korean) that the router normalizes the upstream vLLM
reasoningfield toreasoning_content.
v1.10.1 - 2026-06-15¶
Added¶
- Per-API-key and per-user usage statistics REST API: four admin endpoints
GET /admin/stats/api-keys,GET /admin/stats/api-keys/{id},GET /admin/stats/users, andGET /admin/stats/users/{user_id}, mirroring the existing/admin/stats/modelsshape under the admin auth router (#772, closes #770). TheStatsCollectorgainsper_api_keyandper_userdimensions recorded from the same spawned task that updates the Prometheusllm_tokens_totalcounter, so the in-memory dimensions no longer depend on themetricsfeature and are attributed even for failed and zero-token requests. Theapi_key_idis the derived, non-reversible id resolved once off the request hot path; unauthenticated requests bucket under"anonymous", and ids beyond the 1000-per-dimension cardinality cap fold into an"unknown"overflow bucket so usage is still counted in aggregate. EachGET /admin/stats/.../{id}returns404when the id has no recorded usage, and the optionalwindowquery param is echoed back but does not filter, since the aggregates are all-time atomic counters like/admin/stats/models. The snapshot/persist format adds both dimensions as#[serde(default)], so snapshots written before this change still load without a format-version bump.
Changed¶
- Ignore root-level
.shscripts via.gitignoreso local helper scripts are not accidentally committed.
Documentation¶
- Document the admin API key management endpoints and the per-API-key/per-user usage statistics in the Admin REST API reference for both English and Korean (#773, closes #770). A new "API Key Management APIs" section covers the eight
/admin/api-keysendpoints (create, list, get, update, delete, rotate, enable, disable) with request/response schemas reflecting thesk-***abcdmasking and the full-value-once-on-create behavior, theApiKeyConfigfields, thepermissivevsblockingapi_keys.modesemantics, and runtime-key persistence viapersistence_filewith hot-reload. The Statistics section gains the four new stats endpoints, thewindowecho, theanonymousandunknownbuckets, the per-dimension cardinality cap, and theapi_key_id-to-issued-key linkage.
v1.10.0 - 2026-06-12¶
Added¶
- Dynamic model enumeration for Codex (ChatGPT OAuth) backends (#753, closes #752). The Codex backend has no standard
/v1/models; the router instead queries the plan-gatedGET <base>/models?client_version=<ver>endpoint with the loaded OAuth token during model discovery, keeps only user-facing entries (visibility: listand anavailable_in_plansthat is empty or matches the account'schatgpt_plan_typeclaim), and uses the result to populate/v1/modelsand routing. On any failure (network error, non-2xx, empty list) the configuredmodels:list remains the fallback, and the behavior applies only to Codex OAuth backends.
Changed¶
- Extract a shared
parse_responses_bodyinsrc/services/responses/sse.rsand a sharedbuild_openai_chat_request_coreinsrc/http/streaming/handler.rs, eliminating the duplicated/responsesSSE-aggregation parse path and the duplicated streaming request-construction logic that existed across the Codex, passthrough, and Anthropic handler variants (#766, closes #762). No behavior change; the consolidation removes drift risk where a fix to one copy would not propagate to the others.
Fixed¶
- Route Codex (ChatGPT OAuth) chat completions to the backend's
/responsesendpoint (#755, closes #754).build_responses_urlappended/v1/responsesto the.../backend-api/codexroot, and the upstream edge rejects that path with a 403 HTML page that the router surfaced as an opaque authentication error despite a valid OAuth token; the Codex root now routes through the shared URL composer that strips the internal/v1. With the path fixed, Codex rejects a bare-stringinputwith HTTP 400 "Input must be a list", sosanitize_codex_responses_requestcoerces a single-message stringinputinto a one-element item list. - Accept an RFC3339
expires_atin the OAuth token store (#756, closes #751). The field was a plainu64, so a token store that wrote the field as a datetime string failed to deserialize, the backend silently lost its OAuth strategy, and requests went out unauthenticated, surfacing as an opaque 403 that hid the real cause.expires_atnow deserializes leniently (integer or float epoch seconds, a numeric string, or an RFC3339 datetime with offset, all normalized to epoch seconds) and rejects anything else with an error naming the accepted formats; the canonical on-disk shape remains a JSON number, so existing stores and the save/load roundtrip are unchanged. - Strip local-engine-only fields (
chat_template_kwargs,thinking_budget_tokens,enable_thinking,preserve_thinking,top_k,min_p,repeat_penalty) from/v1/chat/completionsrequests bound for cloud OpenAI, which rejects unknown top-level keys with HTTP 400 "Unknown parameter" (#760, closes #758). The strip mirrors the existing cloud Gemini behavior through a sharedfield_filtermodule gated onapi.openai.com, runs at every cloud-OpenAI chat send site (non-streaming, streaming, and per-hop in the fallback loops), never touchesextra_bodyorreasoning_effort, and is a no-op for local OpenAI-compatible engines, preserving the backend-passthrough contract. - Respect the configured
models:selection for Codex dynamic enumeration and stamp the cleanowned_byon enumerated models (#765, closes #763). When live enumeration succeeded, the operator-configuredmodels:allowlist was bypassed, so/v1/modelsexposed every enumerated Codex model; the post-filter now applies uniformly, with a non-empty list intersecting the enumerated set down to the selected subset and an empty list exposing the full set. Enumerated Codex entries also carried the raw backend name asowned_by; the owner is now resolved from the backend type (openai), matching every other backend. - Return a valid non-streaming response for Codex (ChatGPT OAuth) backends on
/v1/chat/completionswithstream:false(#764, closes #761). Codex's/responsesendpoint only acceptsstream:true+store:false; sendingstream:falseproduced HTTP 400 "Stream must be set to true".sanitize_codex_responses_requestnow forcesstream:trueandstore:falseunconditionally. A second fix makes SSE detection inPassthroughService::execute_requestrobust to Codex responses that carry an SSE body withoutContent-Type: text/event-stream: alooks_like_ssesniffer checks the first non-empty line fordata:/event:/:prefixes, and a failed JSON parse retries SSE aggregation once before surfacing the original parse error. - Reconstruct the assistant message from
response.output_text.deltaevents for Codexstore:falseresponses (#768, closes #767). When Codex runs withstore:falsethe terminalresponse.completedevent carries an emptyoutputarray and the assistant text arrives only as incrementalresponse.output_text.deltaevents; the SSE aggregation now accumulates those deltas and synthesizes a message item when the completed event carries no assistant text, so non-streaming clients (including the title/summary utility path) receive the text instead of an empty response.
Documentation¶
- Document the post-v1.9.1 Codex and transport changes across the English and Korean manuals: the Codex Chat-to-Responses request handling and lenient
expires_atparsing inbackends.md, the cloud-OpenAI field strip inbackend-passthrough.md, anditem_referenceresolution on the Responses API inapi.md, and convert the remaining Korean pages to the polite-습니다register so every page reads in one voice.
v1.9.1 - 2026-06-11¶
Added¶
GET /versionendpoint that returns{ "version": "<CARGO_PKG_VERSION>" }, registered unconditionally on the base router (not behind theappproxyor any other Cargo feature) so it is present in the standard release binary, and aversionfield on the existingGET /healthresponse (#750, closes #749). Both endpoints stay outside the API-auth boundary, matching/health, so a downstream consumer can probe the running router version for feature-gating instead of failing open against a 404.
v1.9.0 - 2026-06-10¶
Added¶
- AppProxy worker mode behind an opt-in
appproxyCargo feature, letting Continuum Router run as a Backend.AI AppProxy inference worker driven by an AppProxy coordinator (epic #709). The feature is deliberately left out offull, so default builds are unaffected. - Foundation: the typed
AppProxyWorkerConfigsection (coordinator URL, sharedapi_secret/jwt_secret,redis_url, wildcard frontend parameters, heartbeat/reconcile durations, and an events toggle, with both bearer secrets redacted inDebugand${ENV_VAR}references resolved through the same path asbackends[].api_key), theSerializableCircuit/RouteInfowire types andProxyProtocol/AppMode/FrontendModeenums (snake_case and kebab-case tolerant, unknown fields ignored), and the module scaffold (#716). - Coordinator REST client
CoordinatorClientwithregister,heartbeat,deregister,list_circuits, andget_circuit, each carryingX-BackendAI-Tokenand a fresh per-callX-BackendAI-RequestID, and an error type that separates retryable (connection, timeout) from fatal (HTTP 4xx) failures (#717). - Circuit-to-backend translation and reconcile:
circuit_to_backendsbuilds oneBackendConfigper replica (namedappproxy-<circuit_id>-r<route_key>, traffic-ratio mapped to a1..=1000weight that never drops a route to 0, vLLM detection fromruntime_variant), andapply_circuitsinjects the translated backends through the existing hot-reloadconfig_sender, namespaced by theappproxy-prefix so statically configured and admin-API backends are preserved (#718). - Worker lifecycle service and a
/statusendpoint:run_workerregisters with backoff, performs an initial circuit pull, then runs a heartbeat loop (kept under the coordinator's 30s LOST timeout) and a pull-reconcile loop (the always-on backstop for missed events), auto-discovering each circuit's model from a replica'sGET /v1/modelsand deregistering on shutdown. The shared in-memoryAppProxyRegistryis indexed by subdomain and circuit id (#719). - Host/subdomain ingress resolver that turns a manager-issued endpoint subdomain into a concrete circuit and pins the request model so the existing selection path serves that circuit's replicas, with HS256 circuit-bearer verification that checks the decoded
idagainst the circuit id and rejects analg:nonedowngrade, an optionalaggregation_hostsfield for cross-circuit model aggregation, and a pure fall-through when nowildcard_domainis configured (#720). - Redis Pub/Sub circuit-event overlay that gives legacy-mode coordinators sub-second circuit updates: a subscriber loop with exponential-backoff reconnect, a base64 + msgpack envelope codec, handlers for
circuit_created/circuit_removed/circuit_route_updated, and the ack envelope that prevents the coordinator's E10001 "Proxy worker not responding" error (#721). - Claude Fable 5 (
claude-fable-5) and Mythos 5 (claude-mythos-5) model support (#747). Both are 1M-context, 128K-max-output models priced at \(10/\)50 per MTok; Mythos 5 is the same underlying model with safety classifiers lifted, shipped only through the limited Project Glasswing release. A newis_mythos_classhelper routes both ids through the Anthropic capability gates: adaptive thinking is required (legacybudget_tokensis rejected with HTTP 400 and normalized to adaptive),temperature/top_p/top_kare dropped, themaxeffort level is supported (xhighmaps tomax), and mid-conversation system messages are preserved. Both reject an explicitthinking.type == "disabled", soexplicit_thinking_for_modelnow returns anOptionand the router omits the thinking parameter entirely instead of forwarding a value that would 400.opus_supports_max_effortis renamed tosupports_max_effortbecause themaxeffort level is no longer Opus-only. The same handling applies to the OpenAI Responses API conversion path. - Gemma 4 QAT model metadata for the five quantization-aware-training checkpoints (E2B, E4B, 12B Unified, 26B-A4B MoE, 31B dense) with load-bearing
-it-qataliases and resolution/drift-guard tests (#723, closes #722). - Gemma 4 12B Unified model metadata (#705).
Changed¶
- Log request-body extractor rejections at
warn, so a malformed or oversized body that Axum rejects before the handler runs is visible in the logs instead of failing silently (#707).
Fixed¶
- Stop the retry loop from hammering the same upstream on HTTP 429 by distinguishing transient rate limits from non-transient quota/credit exhaustion and by honoring the upstream
Retry-Afterhint (#742, closes #740). Previously every 429 was retried up tomax_attempts(default 3) with a fixed exponential backoff that ignored the provider'sRetry-After, so for a model served by a single upstream the router re-hit the same exhausted endpoint, amplifying load and adding latency before an inevitable failure. TheRouterError::RateLimitedvariant now carries aretryableflag: non-transient 429s (OpenAIinsufficient_quota/billing_hard_limit_reached, and clear credit-depletion language such as "prepayment credits are depleted") are classified as non-retryable so the router fails fast after a single call and passes the provider status and body through, while transient signals (bareRESOURCE_EXHAUSTED/RPM throttling,rate_limit_exceeded,rate_limit_error) stay retryable. The classifier is deliberately narrow: the over-broad "billing" and "exceeded your current quota" markers that Google reuses verbatim for transient throttling were removed so a recoverable Google 429 is no longer flipped to fail-fast. A retried 429 uses the upstreamRetry-Afterfor its backoff (capped tomax_delay) and fails fast without sleeping when the requested interval would exceed the remaining total-timeout budget. The hint is preserved end to end (parsed from Google'sRetryInfo.retryDelayand the integer-secondsRetry-Afterheader, then reflected in the client-facingRetry-Afterheader). The budget probe now usessaturating_addand the parsed hint is clamped to 24 hours (MAX_RETRY_AFTER_SECS), closing a remotely triggerable panic where a hostile upstream's near-u64::MAXRetry-Afteroverflowed theDurationaddition and aborted the request task. - Persist the accumulated
/v1/responsesresponse on the streaming conversion paths (Anthropic, Chat-Completions/Gemini fallback) so a follow-up request that references a streamed output item via{"type":"item_reference","id":"item_..."}(the default behavior of the OpenAI and Vercel AI SDKs) resolves instead of returning HTTP 400, even when step 1 usedstore:true(#746, closes #745). The completed response is stored before the firstresponse.completedevent reaches the client; error-terminated streams that never emitresponse.completedare not stored, matching the non-streaming error paths, and passthrough streaming is unchanged because the upstream owns storage there. - Resolve
item_referenceinput items before strategy dispatch so/v1/responsesno longer returns HTTP 400 for Anthropic/Claude backends on a multi-step tool round-trip that submits anitem_referenceinstead of an inline item (#743, refs #741). References are rewritten to inlineFunctionCall/Message/FunctionCallOutputitems (de-duplicated bycall_id, first-wins),build_context_for_userreconstructs stored function-call output items as proper tool_use/tool_result pairs, the OpenAI/Azure passthrough path still forwards references unchanged, an unresolvable reference returns a descriptive400naming the id, and a 256-item cap (MAX_ITEM_REFERENCES) bounds the per-request session-store scan. - Propagate
server.workersto the Tokio runtime (#736, refs #734). The value was documented and shipped inconfig.yaml.examplebut had no effect, becausemainused an argument-less#[tokio::main]and the runtime always ran withnum_cpus::get()worker threads.mainis now synchronous: it peeksserver.workersfrom the config file, builds a correctly sized multi-thread runtime through the existingRuntimeConfig::build_runtimepath, and runs the async body on it, falling back to the CPU count when the value is unset or 0. - Emit conformant
function_calloutput items and argument events on/v1/responsesstreaming for non-passthrough providers (Anthropic and chat-completions-backed routes), preserving text output payloads and tracking interleaved parallel tool-call arguments by upstream index (#725). - Reuse the shared
ApiKeyStorein the Files API routes instead of constructing a separate store, so a runtime-managed API key is recognized consistently across the Files API and the rest of the router (#706). - AppProxy: preserve sibling circuits on single-circuit events (#737, closes #731). A worker serving two or more circuits previously wiped every unaffected sibling's
appproxy-*backends on any single-circuit event (leaving them 404/502 until the next pull-reconcile, up to 15s), becauseRegistryEntrycarried no route info and the rebuilt set held only the delta circuit. The full circuit is now cached on eachRegistryEntryand unchanged siblings are rebuilt from it, so only the delta circuit's backends change. - AppProxy: reach the fallback chain from wildcard subdomain ingress (#738, closes #735). A registered circuit whose replicas are all down is no longer a dead end; after per-circuit authorization it is pinned to its canonical model and handed to the normal pipeline, where
FallbackServicetakes over (the "deployment went down, traffic goes to a cross-provider model" behavior operators expect). The fall-through is scoped to registered-but-down circuits, a truly unknown subdomain stays a 404, and the open-to-public / bearer-token / IP-allow-list gates still run first. - AppProxy: preserve event-known models during periodic reconcile (#739). When the Redis event overlay learned a circuit's model before any successful pull probe, a reconcile could evict the registered-but-down registry entry and break scoped fallback; reconcile now reuses the shared registry's known model before probing replicas.
- AppProxy: align
WorkerRegisterResponsedeserialization with the Backend.AI coordinator's actual response shape, which carriesslotsas an array plusavailable_slotsas the count (#726).
Documentation¶
- Rewrite the Zensical user documentation as a current-state manual: drop development-log narration, the roadmap, and "coming soon" entries; correct configuration-reference drift against the actual config structs (nonexistent sections and keys, retry field names, the admin auth shape, the environment-variable tables, and the config discovery order); document previously missing shipped behavior (seven CLI flags, the auth login subcommand, Windows AF_UNIX support and SSE over Unix sockets, and the Windows/musl and .deb release artifacts); and bring the Korean docs to parity with English.
- Add the AppProxy worker mode design document (#708).
- Condense the README "Recent Updates" list to one concise line per release.
Dependencies¶
- Remove the
validatorderive dependency and its transitiveproc-macro-error2, clearing the RUSTSEC-2026-0173 advisory that previously needed a temporarycargo-denyignore whilevalidatorhad no safe upgrade path (#733, #732). - Update Rust package versions (#732).
v1.8.2 - 2026-06-02¶
Fixed¶
- Stop forwarding the client
Accept-Encodingheader on the/v1/responsespath (#702). When a client sentAccept-Encoding: gzip, deflate, br, the responses-path header filter omittedaccept-encodingfrom its block list and forwarded it to the upstream backend, which then negotiated gzip and returned compressed bytes. Because reqwest disables automatic decompression once anyAccept-Encodingheader is set manually (the explicit.header("Accept-Encoding", "identity")call only appended a second value rather than replacing the forwarded one), the SSE transform received raw gzip bytes, parsed them as text, and dropped the leadingresponse.created,output_item.added,content_part.added, andoutput_text.deltaevents, leaving only tail fragments with emptyitem_idandtext."accept-encoding"is now inFILTERED_HEADERSfor both the primary convert path (src/http/handlers/responses.rs) and the responses-native passthrough path (src/proxy/responses_only.rs), restoring parity with the chat-completions proxy (src/proxy/backend.rs) so the upstream only ever receivesAccept-Encoding: identity. - Stop double-wrapping Responses SSE lines so the non-GPT
/v1/responsesstreaming conversion emits single-layer OpenAI-compatible SSE records on the converted Anthropic and Chat-Completions paths instead of nested ones (#701).
Dependencies¶
- Bump
uuid1.23.1 → 1.23.2,redis1.2.1 → 1.2.2,socket20.6.3 → 0.6.4, andserial_test3.4.0 → 3.5.0 (#699).
Tests¶
- Hardening regression coverage for the production StreamService conversion processors, asserting single-layer SSE output on the converted Anthropic and Chat-Completions paths (#701).
- Integration regression for the
/v1/responsesstreaming path with an Anthropic backend and a gzip-requesting client, asserting the upstream request receives onlyAccept-Encoding: identityand the transformed Responses SSE stream retains the full event sequence with populated text and item ids (#702).
v1.8.1 - 2026-05-29¶
Added¶
- Claude Opus 4.8 recognition with a
claude_family_versionparser that replaces the hardcodedopus-4-7/opus-4-6substring gates (#693, part of #687). The four Anthropic capability predicates now compare a parsed(major, minor)version:uses_adaptive_thinking_api≥ (4,6),model_requires_adaptive_thinking/model_forbids_sampling_params≥ (4,7), andopus_supports_max_effort= Opus and ≥ (4,6). The parser treats the first integer token as the major and the next version-like token (1 to 2 digits, value < 100) as the minor, so an 8-digit date suffix like20250514yields minor 0 and is never mistaken for a version, and new minor releases are recognized without per-version edits. Adds theclaude-opus-4-8metadata entry (1M context, 128K output, \(5/\)25 pricing, adaptive thinking, Jan 2026 cutoff) and registersclaude-opus-4-8/claude-opus-4-8-latestin the built-in supported models and config samples. Behavior for 4.5/4.6/4.7 and Sonnet variants is preserved. - Anthropic fast mode behind a per-backend
anthropic_fast_modeopt-in (default off) (#694, part of #687).is_fast_mode_eligiblereturns true only for Opus 4.6/4.7/4.8 and later Opus minors;merge_beta_headercomma-joins and de-duplicates beta tokens while preserving any client-suppliedanthropic-beta. On the native/anthropic/v1/messagespath,resolve_fast_mode_betainjects the mergedfast-mode-2026-02-01beta header only when the request isspeed: "fast", the model is eligible, the backend is native Anthropic (never Bedrock), and the opt-in is enabled; when fast mode does not apply,speedis stripped from the outgoing body so it cannot trigger a spurious upstream 400. The OpenAI-compatible path forwardsspeed: "fast"and injects the beta header only for eligible, opted-in, native Anthropic targets. TheAnthropic -> OpenAIandAnthropic -> Googlefallback parameter mappings removespeedso a fast-mode request that falls back to a non-Anthropic backend does not leak the native-only field.usage.speedis preserved on the response. - Mid-conversation system messages for Claude Opus 4.8+ (#695, part of #687). A
role:"system"entry inside themessagesarray (which earlier Claude families reject with HTTP 400) is now accepted, gated on a newsupports_mid_conversation_system(model_id)predicate that reusesclaude_family_versionand matches family version ≥ (4,8). The native handler round-trips the entry unchanged to a native Anthropic backend; the cross-provider transforms map theSystemrole onto the OpenAIsystemrole and preserve it as user-role text for Gemini and Responses. The OpenAI-compatible transform emits mid-conversationsystem/developermessages (after the first user turn) as in-arrayrole:"system"entries for supporting models, while leading system messages still fill the top-levelsystemfield. Non-supporting models (Opus 4.7 and below, all Sonnet/Haiku, Bedrock-prefixed ids, non-Claude ids) keep the historical flattening into the single top-levelsystem. - Refusal
stop_detailsand therefusalstop reason propagated through the full Anthropic response pipeline (#696, part of #687).map_anthropic_finish_reasonmaps"refusal"to"content_filter"; the non-streaming transform and the streamingmessage_deltahandler attach thestop_detailsobject to the choice whenstop_reasonis"refusal", and omit the key (rather than forwarding a null) when upstream sends an explicit null.
Fixed¶
- Accept an
input_imagecontent part that references a Files API upload viafile_idinstead of an inlineimage_urlonPOST /v1/responses(#686, refs #681). Because the parent enums are#[serde(untagged)], a{"type":"input_image","file_id":"file-..."}part previously failed deserialization with a generic untagged-enum error and Axum returned HTTP 422.image_urlis now optional with an addedfile_id, mirroringinput_file; a sharedresolve_local_file_to_data_urlhelper resolves a localfile_idto an inline base64 image_url data URL through the same metadata, ownership, size, load, and base64 sequence, honoring ownership and the 10MB size limit. The OpenAI/Anthropic/Gemini converters handle the optionalimage_url, emitting the image when resolved and warning + skipping an unresolvedfile_id.validate_requestwalks message content and rejects aninput_imagewith neitherimage_urlnorfile_idwith a clear 400 before file resolution. - Harden the Claude Opus 4.8 routing gates so fast-mode speed is only forwarded when the transport has confirmed native-Anthropic opt-in and beta-header injection, non-Opus Claude families keep mid-conversation system messages flattened, and OpenAI-compatible Anthropic responses preserve
usage.speed(#698, refs #687).
Documentation¶
- Document Claude Opus 4.8 support in English and Korean (#697, closes #692, part of #687): add
claude-opus-4-8-*to the adaptive-thinking model list and the sampling-params-deprecated warning inreasoning-effort.md; add the Claude Opus 4.8 model detail, an Anthropic Fast Mode section, a Mid-Conversation System Messages section, and the refusalstop_reason->content_filtermapping tobackends.md; and add a changelog entry covering model recognition, fast mode, mid-conversation system messages, and refusalstop_details. - Document
input_imagefile_idsupport inapi.md(English and Korean), noting that exactly one ofimage_urlorfile_idis required and thatfile_idis resolved to an inline base64 data URL before reaching the backend under the same ownership and 10MB size limit as theinput_filepath (#686, refs #681).
Tests¶
claude_family_versionandsupports_mid_conversation_systemboundary tests (4.7 false, 4.8 true, Sonnet/Haiku/older false, Bedrock-prefixed and cross-region/ARN ids false), fast-mode eligibility, beta-header merge/dedup,speed/usage.speed(de)serialization,resolve_fast_mode_betagating with client-beta merge, andspeednon-leak versus preservation across fallback providers (#693, #694, #695).- Refusal coverage: non-streaming and streaming refusal with and without
stop_details, the explicit-null omission path, and regression tests forend_turn/max_tokens/stop_sequence/tool_use(#696). input_imagefile_id-only andimage_url-only deserialization, the full failing request payload as a regression, FileResolver resolution honoring ownership and the size limit, converter output across all three backends, and the neither-field validation case (#686).
v1.8.0 - 2026-05-28¶
Added¶
- Per-API-key backend access control via an optional
allowed_backendsallow-list on client API keys (#677, closes #674). When the list is non-empty, requests authenticated with that key may only route to the named backends; an empty or absent list keeps the existing unrestricted behavior. The field is integrated end-to-end: config file and hot-reload, the runtimeApiKeyandAuthContext, the backend-selection chokepoint and the Responses / Anthropic selection paths, cross-provider fallback, the Admin REST API (create/update/get/list), runtime-key persistence, and the models-listing endpoints. select_backend_with_retryand the Responses (StreamService), Anthropic-native,count_tokens, and image handlers filter candidates by the key's allow-list. When the model exists but the allow-list rejects every candidate, the request is rejected with a newRouterError::Forbiddenvariant mapped to403witherror_type = "permission_error"(non-retryable), distinct from the401 AuthError./v1/models,/v1/models/extended, and/anthropic/v1/modelsare filtered to models served by at least one allowed backend when a restricted key is authenticated;GET /v1/models/{model}returns404for a model the key cannot reach. Unauthenticated or unrestricted callers see the full list.- A new
api_optional_auth_middlewareis layered in permissive mode. It validates a presented bearer token on a best-effort basis and attachesAuthContextwithout ever rejecting, so per-key restrictions apply to authenticated callers while anonymous and invalid-token callers pass through unrestricted. The existing blocking-modeapi_auth_middlewareis unchanged; the two are never layered together. - Config validation warns (does not hard-fail) when a key's
allowed_backendsreferences an unknown backend name, so a backend rename does not brick the router before operators update the keys. fallback.mid_stream_enabledconfig field (defaulttrue) so operators can keep cheap pre-stream backend re-selection on the initial connection while turning off the per-stream mid-stream buffering (#680, closes #676). Previouslyfallback.enabledwas all-or-nothing: on meant both pre-stream and mid-stream fallback (with a per-streamStreamAccumulatorbuffering roughly 100 to 200 KB), off meant no fallback at all. Memory-constrained or high-concurrency hosts now have a middle ground.- The streaming dispatch becomes a three-way decision factored into a pure
decide_streaming_fallback_dispatchhelper. Withfallback.enabledtrue and a chain configured,mid_stream_enabled = truekeeps the buffering path (handle_streaming_with_mid_stream_fallback),mid_stream_enabled = falseroutes to the revivedhandle_streaming_with_pre_stream_fallback(noStreamAccumulatororMidStreamFallbackContextallocation; mid-stream failures surface as a normal stream error), and otherwise the standard no-fallback path runs. - The previously dead
handle_streaming_with_pre_stream_fallbackandadvance_to_next_fallbackare now live; the#[allow(dead_code)]markers are removed and the per-key allow-list is threaded through fallback re-selection so the newly-live path does not bypass per-API-key access control.
Changed¶
- Removed the streaming-local copies of
transform_payload_for_openaiand itsrequires_max_completion_tokenshelper fromsrc/http/streaming/handler.rs; both call sites now resolve to the canonical implementations incrate::proxy::utils(#679, closes #660). The two copies were byte-for-byte identical, creating a drift risk where a change to one would not be mirrored to the other. The streaming-local duplicate unit tests are removed since the canonical tests insrc/proxy/utils.rsalready cover the same contract.
Fixed¶
- Enforce the per-API-key backend allow-list in the default mid-stream fallback handler when it re-selects a backend after a mid-stream failure (#683). With
fallback.mid_stream_enabled = true(the default), a restricted key whose fallback chain mapped to a disallowed backend was transparently switched to it on a mid-stream failure, an access-control bypass that the newmid_stream_enabledwork in PR #680 had only closed on the pre-stream path.handle_streaming_with_mid_stream_fallbacknow takes an ownedallowed_backends: Option<Vec<String>>(moved into its spawned streaming task), and a newresolve_allowed_backend_name_for_modelhelper wrapsresolve_backend_name_for_modeland applies the sameallowed_backends.filter(|l| !l.is_empty())+ exact-name membership semantics used everywhere else in the module (try_get_healthy_backend_for_model,get_backend_for_model_streaming,get_healthy_backend_for_streaming). All three fallback re-selection sites resolve through it; a disallowed or unresolvable candidate returnsNone, so the existingwarn + continuearm skips it and the chain index advances. When every remaining candidate is filtered out the loop terminates via the existing chain-exhausted paths and surfaces an error to the client. An empty orNoneallow-list is byte-for-byte the prior unrestricted behavior. - Resolve an allowed fallback backend before rebuilding the fallback payload in mid-stream fallback, so a disallowed chain entry can no longer mutate
current_payloadbefore being skipped (#684). - Anthropic-native
x-api-keycallers now supply the sameallowed_backendspolicy asAuthorization: Bearer-authenticated callers when noAuthContextis present, covering Messages,count_tokens, and models listing (#684). The previously documented limitation that the per-key allow-list was unenforced on the native Anthropic surface is now closed. - Close a HIGH-severity authorization-bypass on
POST /v1/responses/compactfound in the post-merge security audit of #677. The endpoint was the one client-facing model-routing handler that never readAuthContextfrom request extensions, so a key scoped to backend set A could reach a disallowed passthrough backend B (OpenAI / Azure) through compaction whenever B served the requested model.compact_responsenow mirrorscreate_responseexactly: it derives the per-key allow-list viaallow_list_from_auth, filters the model's candidate backends against it before any health-check or passthrough, and returns a deterministic403 permission_errorwhen the model exists but the key cannot route to any backend serving it. The filter precedes backend forwarding, so the rejection happens without contacting an upstream.
Documentation¶
- Clarify in
config.yaml.example,src/services/streaming/mid_stream_config.rsrustdoc, anddocs/en/configuration/advanced.mdthatmid_stream_fallback.enableddoes not disable mid-stream buffering or reduce memory: regardless of its value theStreamAccumulatoris still constructed and still buffers up to roughly 100 KB per stream, and mid-stream fallback still activates on backend failure (#678, closes #675). The flag only selects continuation versus restart mode for the fallback request. The real kill-switch for the buffering and memory isfallback.enabled: falseor omitting the model fromfallback.fallback_chains. The Korean docs (docs/ko/configuration/advanced.md) had no corresponding Mid-Stream Fallback section so no Korean change is made for the clarification, but #680 separately added a translated "미드스트림 버퍼링 비활성화" subsection covering the new toggle. - Document the per-API-key
allowed_backendsallow-list in the security docs (English and Korean) including the now-closedx-api-keyAnthropic Messages limitation (#677, #684).
Tests¶
- Deterministic unit tests for
resolve_allowed_backend_name_for_model: allow-list excludes the resolved backend returnsNone, includes it returnsSome, empty list returnsSome,Nonematches the unfiltered resolver, unresolvable model returnsNone(#683). - Integration tests in
tests/per_key_backend_access_test.rsdriving/v1/responses/compactbehind blocking auth: a restricted key requesting a model served only by a disallowed backend gets403 permission_error(the security case), and a restricted key requesting an allowed backend's model passes the filter (#677). - Unit test for the new
decide_streaming_fallback_dispatchhelper covering the three-way decision matrix betweenfallback.enabled,mid_stream_enabled, and chain presence (#680).
v1.7.1 - 2026-05-28¶
Fixed¶
- Anthropic
web_search_20250305server-tool emulation no longer masks provider failures as empty result sets. A missing API key, HTTP 401/403/429, timeout, or parse error now surfaces to the client as aweb_search_tool_result_errorcontent block with a mappederror_code("too_many_requests"for HTTP 429,"unavailable"for all other failures). A genuinely empty-but-successful search continues to emit an emptycontent: []array, so the two outcomes remain distinguishable. Both non-streaming and streaming SSE code paths are covered, with an explicit SSE event-ordering assertion guarding the streaming error path. Serper response-shape drift (a body that parses cleanly but omits theorganickey) is now detected and logged with a provider-taggedwarn!that records only the observed top-level keys, never the user query text. (#671, #672, #673)
Dependencies¶
- Bump
lru0.16 → 0.18,reqwest0.13.3 → 0.13.4, andrusqlite0.39 → 0.40 (libsqlite3-sys0.37 → 0.38, unpinned now that CI runs Rust 1.95), plus a transitive lockfile refresh (aws-lc-rs1.16.3 → 1.17.0,h20.4.13 → 0.4.14,http1.4.0 → 1.4.1, andhyper/axum/reqwestknock-on revisions);cargo auditreports zero vulnerabilities across the dependency graph (#669, #670).
v1.7.0 - 2026-05-26¶
Added¶
- AWS Bedrock Claude backend Phase 1 (bedrock-mantle) over a Bearer token (#616, closes #613)
- New
type: bedrockbackend with serde aliases (aws-bedrock,bedrock-anthropic,AwsBedrock, ...).is_commercial()returns true andowned_by()returnsSome("anthropic")so OpenAI-shaped clients see the expected model lineage. endpoint_type: mantle(default) speaks the native Anthropic Messages API at the region-templatedhttps://bedrock-mantle.{region}.api.aws, routes to/anthropic/v1/messages, usesAuthorization: Bearer, and omitsanthropic-version(Bedrock returns HTTP 400 if present). An expliciturl:field overrides the template for proxies and tests; empty or uppercase regions are rejected at load time.model_ids.rsrecognizes plain (anthropic.<family>), geographic (us./eu./jp./au.), global (global.anthropic.<family>), and full-ARN identifiers, forwarded unchanged with no automatic alias mapping because the geo prefix carries real billing and residency consequences.-
The existing OpenAI/Anthropic body transforms and the Anthropic SSE stream transformer are reused unchanged, so per-model quirks (Opus 4.7 sampling-param ban, adaptive thinking) apply identically to Bedrock without duplication.
endpoint_type: runtimeis reserved here and implemented in Phase 2 below. -
Request-path rate limiting is now enforced, plus a Redis storage backend (#635, #632, closes #626)
state.rspreviously dropped theMiddlewareLayerreturned byinitialize_rate_limiting, so therate_limiting.*config was a silent no-op. The layer now flows throughServiceHandles→ContinuumRouterBuilder::build_router→Router::layerand attaches to the assembled Axum app, so configured budgets actually return429.- All five
rate_limitingdimensions are now optional:per_client,per_backend, andglobaljoin the already-optionalper_api_key/per_model. Operators may omit any dimension and load with it disabled; theDefaultimpl retains all three so existing deployments are unaffected (#632). -
New
redis-cache-gatedrate_limit_v2::redis_backendruns a token-bucket and a sliding-window Lua script, each as a single atomicEVAL, reusing the sharedcreate_redis_poolhelper with keys likecr:rl:per_client:10.0.0.1. On any Redis failure (pool unavailable, timeout, Lua error) the backend reportsBackendUnavailableand the caller falls through to the in-process token bucket, degrading to per-replica enforcement rather than dropping requests. -
Cross-provider fallback is now wired into request dispatch and hot-reload (#631, #637, #665)
- The completed
src/core/fallback/module (~4,900 lines, 36 unit tests) was never called from the request path, so configuredfallback.fallback_chainswere a silent no-op.FallbackServicenow runs forchat_completions(non-web-search),completions,embeddings,rerank,sparse_embeddings, and image generation, with anexecute_with_optional_fallbackwrapper (no overhead when fallback is unconfigured),X-Fallback-*response headers, and aFrom<RouterError>toTriggerReasonmapping that satisfies the executor bound (#631). -
fallback.fallback_chainsandfallback.fallback_policychanges now apply at runtime through the hot-reload subscriber viaFallbackService::update_config; togglingfallback.enabledremains restart-only and is documented as such inconfig.yaml.example(#637, #665). -
POST /v1/models/refreshforce-refresh endpoint for interactive desktop use (#593, #664) - Clears the
ModelCache(all_modelskey) and synchronously re-aggregates from all configured backends before responding, returning the same{"object":"list","data":[...]}shape asGET /v1/models. Desktop clients (e.g. backend.ai-go "Refresh models" button) can use the response immediately without a second round-trip. - Rate-limited per verified API key, with anonymous or invalid-token callers sharing one global anonymous bucket: 3 requests per 5-second burst window, 12 per minute. Callers that exceed the limit receive
429 Too Many Requests. The limit is intentionally tighter than the regular list endpoint because each call triggers an upstream fetch from every configured backend. - Gated by a new
model_aggregation.allow_force_refresh: boolconfig field (defaulttrue). Setting it tofalsemakes the endpoint return403 Forbidden, suitable for hardened deployments where clients must rely on TTL-based expiry. - Each refresh logs the verified API key ID when available, or
anonymousotherwise, atINFOlevel for audit correlation. config.yaml.exampleextended with desktop-embedded guidance:cache_ttl: 10,soft_ttl_ratio: 0.5, andallow_force_refresh: truefor backends.ai-go style embedded proxy.-
New
force_refresh(state)helper onModelAggregationServiceencapsulates the clear-then-aggregate flow.allow_force_refresh()accessor exposes the config flag to handlers. -
AWS Bedrock Claude backend Phase 2:
bedrock-runtimewith SigV4 + AWS binary event-stream (#614) - New
endpoint_type: runtimevalue ontype: bedrockbackends targetshttps://bedrock-runtime.{region}.amazonaws.com/model/{modelId}/invoke[-with-response-stream]. The router signs each request with AWS Signature V4 (service: "bedrock"), wraps the OpenAI → Anthropic body with"anthropic_version": "bedrock-2023-05-31", strips the top-level"model"field (Bedrock takes the model ID from the URL path), and percent-encodes the model identifier into the path so versioned foundation IDs (anthropic.claude-3-5-sonnet-20240620-v1:0) and full ARNs round-trip cleanly. - Streaming responses arrive in the AWS
application/vnd.amazon.eventstreambinary frame format. A newbedrock::event_stream::EventStreamDecoderreassembles frames that span multiple TCP reads, base64-decodes eachchunkpayload, and emits syntheticevent: <type>\ndata: <json>\n\nSSE bytes for the existingAnthropicStreamTransformerto translate into OpenAI-shape SSE. Exception frames (ThrottlingException,ValidationException, ...) surface as syntheticevent: errorSSE chunks instead of being silently dropped. - AWS credentials resolve in this order: inline
auth.aws.access_key_id+auth.aws.secret_access_key(+ optionalsession_token), then a named profile viaauth.aws.profile, then the standard AWS chain (env vars, shared config, IMDS, IRSA / EKS pod identity, ECS task role). The resolver is fronted byaws_credential_types::provider::SharedCredentialsProviderso temporary credentials refresh transparently between requests. - New
BackendAuthType::Sigv4variant onBackendAuthConfig, plus anAwsAuthConfigsub-block underauth.aws. Both are wired throughDebugredaction so static credentials never leak into logs.BackendAuthTypeacceptssigv4,aws_sigv4, andaws-sigv4as YAML spellings. - Health check for runtime probes
POST /model/{probe_model}/invokewith a single-token body; HTTP 2xx, 400, 401, 403, and 429 all count as healthy because they prove the AWS surface is reachable and the operator can address auth/billing issues separately. - All AWS SDK crates (
aws-sigv4,aws-smithy-eventstream,aws-credential-types,aws-config) sit behind a new optionalbedrock-sigv4Cargo feature. Default builds do not pull them in; configuringendpoint_type: runtimewithout the feature returns a clear error pointing at the rebuild flag instead of failing at the AWS edge. The Phase 1 mantle path is unaffected and works with or without the feature. - Proxy header policy in
src/proxy/backend.rsandsrc/http/handlers/anthropic/handler.rssplits onendpoint_type: Bedrock-mantle keepsAuthorization: Bearer, Bedrock-runtime suppresses the static-Bearer injection because the Backend trait implementation signs each request with SigV4. -
English and Korean docs in
docs/{en,ko}/configuration/backends.mdextended in place with the newendpoint_type: runtimeconfiguration, build requirement, credential chain, IAM policy snippet, geo/global profile behaviour, and a streaming-pipeline overview. -
EXAONE 4.0 (vLLM) registration with a request-gated hybrid-model thinking transform (#640, refs #639)
- EXAONE 4.0 (e.g.
EXAONE-4.0-32B-FP8-RNGD) is a hybrid reasoning model: in reasoning mode it streams chain-of-thought inline incontentended by a lone</think>; in non-reasoning mode it emits a plain answer with no</think>. - The
assume_reasoning_first(unterminated_start) transform is now gated on the request actually enabling thinking (chat_template_kwargs.enable_thinkingor top-levelenable_thinking; conservative default false) at both the HTTP and Unix-socket streaming decision points, so non-reasoning mode no longer emits the whole answer asreasoning_contentwith emptycontent. Standard-pattern models keyed off a real<think>marker are unaffected. -
Registers
exaone-4.0-32bwith theunterminated_startconfig; the served-RNGDname resolves via the hardware-suffix peel below. -
NPU/accelerator hardware-variant suffix normalization in model-id matching (#662)
- Adds a HARDWARE/ACCELERATOR category (
rngd,warboy,atom,atommax,rebel) tois_recognized_format_token()so FuriosaAI (RNGD/WARBOY) and Rebellions (ATOM/ATOMMAX/REBEL) serving-target suffixes normalize to the canonical base metadata entry through the existing layered peel chain, without a per-model alias.layered_format_strip()lowercases before peeling, so runtime-emitted upper-case names resolve correctly. - Exact-id and exact-alias phases run before the peel, so any model legitimately registered as
*-atomor*-rebelwins via exact match first; a grep of shippedmodel-metadata.yamlconfirms zero current collisions.EXAONE-4.0-32B-FP8-RNGDnow normalizes toexaone-4.0-32bby peel (-rngdthen-fp8).
Changed¶
- Narrow the fallback handler's LM Studio compatibility shim so only
/and/v1/modelsreturn200; all other unmatched routes now return404. The JSON error body shape is preserved unchanged, so any consumer already reading the body continues to work (#628).
Fixed¶
- Strip seven non-OpenAI top-level fields (
chat_template_kwargs,thinking_budget_tokens,enable_thinking,preserve_thinking,top_k,min_p,repeat_penalty) before forwarding to Gemini's/v1beta/openai/chat/completionsendpoint, which returns HTTP 400INVALID_ARGUMENTfor unknown keys; theextra_bodyescape hatch is untouched andreasoning_effortstays (Google maps it tothinking_level). Also extend the3.5-flashthinking-disable and is-thinking matchers, since3-flashdid not substring-match3.5-flash(#642). - Wire request-stats recording into every Anthropic handler code path (native HTTP/Unix, Bedrock mantle/runtime, OpenAI-compatible, Responses API) for both streaming and non-streaming, adding an
AnthropicStreamUsageTrackerthat accumulates input/output tokens from raw passthrough SSE (#627, #634). - Use the configured
timeouts.request.streaming.chunk_intervalinstead of a hardcoded 60s for mid-stream inactivity, and emit bounded keep-alives so a silent backend now advances to the next fallback model viaStreamOutcome::Failedrather than emitting keep-alive comments forever (#633). - Accept partial
model_overrides.<model>.streaming/standardblocks via newStreamingTimeoutOverride/StandardTimeoutOverridestructs whoseOption<String>fields merge over the base config, fixing YAML parse failures on the--generate-configoutput path (#630). - Gate
admin/metrics/metrics-persistence/webuiimports and functions behind their Cargo features, and add a#[cfg(not(feature = "metrics"))]no-op metrics stub mirroring the public surface used by always-compiled callers, so feature-reduced builds compile cleanly (#629, #666, closes #636). - Harden force-refresh rate limiting so anonymous and invalid-token callers share one global bucket, preventing spoofed
Authorization/X-Forwarded-For/X-Real-IPheaders from bypassing the budget. - Metrics history query limiting, UTF-8-safe metric label truncation, and Bedrock runtime routing through the typed SigV4 implementation (follow-up to #608, #609, #613, #614).
Documentation¶
- Document the Bedrock backend (region selection, geographic vs global inference profiles, model ID format, credential chain, IAM policy snippet, and streaming pipeline) in
docs/{en,ko}/configuration/backends.md; add a Force-Refresh Models section todocs/en/api.md; and extendconfig.yaml.examplewith desktop-embedded model-aggregation guidance and fallback hot-reload annotations.
Tests¶
- Negative and positive case coverage for
transform_payload_for_openai(#661). - Rate-limit middleware hot-reload tests with documented bucket-reset behaviour, plus
router_wiring_teststhat build a realContinuumRouterand assert429fires when the burst is exhausted (#635, #638, #667). - Verify
web_searchinjection interacts correctly with the passthrough contract (#663). - MLxcel streaming passthrough integration test (#659).
- Bedrock unit and integration coverage: serde aliases, URL templating, header policy, model-ID parsing (geo/global/ARN), runtime SigV4, and event-stream frame decoding driven against a wiremock server (#616, #614).
Dependencies¶
- Bump tokio 1.52.1 → 1.52.3, tower-http 0.6.8 → 0.6.11, dashmap 6.1.0 → 6.2.1, serde_json 1.0.149 → 1.0.150, aws-config 1.8.16 → 1.8.17, aws-sigv4 1.4.3 → 1.4.4, and aws-smithy-types 1.4.7 → 1.4.8 (#619, #658).
v1.6.3 - 2026-05-12¶
Added¶
- Per-API-key LLM token usage metrics (#608, #610)
- New Prometheus
llm_tokens_total{api_key_id, model, backend, kind}counter that records actual prompt and completion token consumption per API key, model, backend, and token kind. The hot-path counter's label set is intentionally minimal — extra dimensions live on the companion info-metric below. - Companion
api_key_info{api_key_id, ...}info-metric exposes a configurable allowlist of per-API-key annotation labels (e.g.email,team,environment) so dashboards can group/filter the token counter via standard PromQL* on(api_key_id) group_left(...)joins without bloating the hot-path counter's label set. derive_api_key_idreturns either the configuredid(when the auth layer matched the request) or a SHA-256 first-12-hex prefixk_<hex>of the raw bearer token. The raw key is never used as a label. A dedicatedApiKeyCardinalityTracker(default cap: 1000 unique key IDs) prevents label-cardinality explosion.ApiKeyConfigand the in-memoryApiKeygain anannotations: HashMap<String, String>field.MetricsConfiggainsannotation_labels: Vec<String>— the allowlist that materializes as labels onapi_key_info. Reserved canonical annotation keys are documented (email,uuid,owner,team,environment); operators may add custom keys.- Streaming and non-streaming paths record at the existing
usageparse site through a newStreamObservabilityContextfield onStreamTransformConfig, threaded throughhandle_anthropic_streaming/handle_gemini_streaming/handle_successful_backend_responseso OpenAI-compat / Anthropic / Gemini / thinking-pattern streaming response builders all emit the counter without duplicate parsing. The router already injectsstream_options.include_usage=truefor OpenAI-compat backends, so streaming metrics work uniformly regardless of client opt-in. api_key_infois initialized once at startup frommetrics.annotation_labels; label names are frozen at registration (Prometheus does not allow renaming labels). Annotation values hot-reload through the existing config-watch path viaApiKeyStore::refresh_info_metric, called fromload_from_config,add_key, andremove_key_by_idso admin operations stay in sync.- All label values flow through
CardinalityManager/sanitize_label_value. Annotation values use a slightly less strictsanitize_annotation_valuethat preserves@,+, and:so emails and namespaced identifiers round-trip cleanly. - Persistent local metrics log backed by SQLite with configurable retention (#609, #611)
- New
MetricsStoreasync trait + bundledrusqlitev1 implementation undersrc/metrics/persistence/with WAL mode, prepared-statement cache, andPRAGMA user_versionschema versioning (store.rs/sqlite.rs/snapshot.rs/snapshot_task.rs). Histograms and summaries are fanned into row-per-sample form. - Counters and gauges are NEVER restored on startup — the persistent log is a separate read path so the live
/metricsendpoint keeps Prometheus monotonic-counter semantics. Historical samples are read through a newGET /admin/metrics/history?metric=...&from=...&to=...surface (src/admin_metrics_history.rs) that returns 404 when persistence is disabled at runtime and 503 when the feature is not compiled in. No PromQL in v1. - Hot-reload pipeline in
src/server/serve.rstranslates config changes intoPersistenceCommand::{SetSnapshotInterval, SetRetentionDays, SetCompaction}messages, atomically rebuilding the ticker and prune cutoff without dropping in-flight snapshots. - Compaction schedule honors a
minute hour * * *cron subset to avoid pulling in a full cron crate for what is effectively a daily timer. - Defaults to
enabled: true; switch off viametrics.persistence.enabled: false. Theredbandduckdbvariants are reserved keywords in the YAML schema and returnNotImplementedat startup until they get implementations. - Disk usage: measured ~119 bytes/sample on a synthetic 100-series × 10-snapshot workload (see
tests/metrics_persistence_test::disk_usage_smoke_check_under_synthetic_load). Documented formula indocs/en/persistent-metrics.mdandconfig.yaml.example.
Fixed¶
- Coerce token-usage label values to
&strinwith_label_valuesso the release build no longer fails type inference. Mixing&Stringlabel variables with a&strliteral ("prompt"/"completion") made the compiler pick&[&String]and reject the literal — regression introduced in #610.
Documentation¶
- Korean translations for the two metrics features (#612)
docs/ko/metrics.md: new### API 키별 LLM 토큰 사용량section coveringllm_tokens_total,api_key_idderivation,annotation_labelsallowlist,api_key_infoinfo-metric, PromQL examples, Grafana panel, and verification steps.docs/ko/persistent-metrics.md: new page translatingdocs/en/persistent-metrics.md(SQLite-backed snapshot semantics, configuration fields, disk-usage formula,/admin/metrics/historysurface, schema layout, operational notes).docs/ko/admin-api.md: insert## 지속 메트릭 로그 APIsection between Stats and Response Cache, plus a matching TOC entry.zensical.ko.toml: add지속 메트릭 로그nav entry under 운영 so the page is reachable from the Korean sidebar.- New
docs/en/metrics.md### Per-API-Key LLM Token Usagesection covering metric definition,api_key_idderivation rules, annotation config schema, cardinality and hot-reload semantics, example PromQL (tokens-per-email, top-10 keys, per-team rollup), a Grafana panel example, and verification steps.config.yaml.examplegains a documentedmetrics.annotation_labelsblock and anannotations:example under each API-key entry.
Tests¶
- Per-API-key token-usage unit coverage:
derive_api_key_idpriority (configured id wins; otherwise hash; otherwise anonymous), determinism, hash format^k_[0-9a-f]{12}$, annotation-label normalization, info-gauge one-time-init, refresh atomicity, cardinality bounds, email-preserving annotation sanitizer; streaming-transformer write-through; middleware annotation-snapshot exposure; integration coverage intests/metrics_integration_test.rs(4-label counter with both kinds, anonymous fallback, hash regex). (#610) - Persistent-metrics SQLite store unit coverage (insert, query by time range, retention deletion, idempotent open, unknown-kind round-trip) and integration coverage in
tests/metrics_persistence_test.rs(snapshot task lands rows in SQLite, retention prunes only old samples, retention hot-reload preserves in-flight snapshots, disk-usage smoke check). (#611)
v1.6.2 - 2026-05-10¶
Fixed¶
/v1/responsesand/v1/chat/completionsnow accept the OpenAI reasoning-APIdeveloperrole (#603, #605, #606)- Add
MessageRole::Developerwith a serde lowercase rename so"developer"deserializes as a first-class variant. The previous failure surfaced as a misleadingdid not match any variant of untagged enum ResponseInputrather than naming the unknown role; the implicit-message deserialization error now names the offending role string and lists the valid roles. - Per-backend translation: pass through as
developerfor OpenAI-compatible servers; merge into the Anthropic top-levelsystemparameter (concatenated with\n\nwhen both system and developer text are present, fixing a pre-existing overwrite bug); merge into Geminisystem_instruction; map tosystemfor Ollama (older builds rejectdeveloper). - Chat Completions → Responses converter recognizes
developeras instruction-bearing: the first occurrence becomes top-levelinstructions; subsequent occurrences remain as input items with their original role preserved on the wire. - Treat
developerandsystemequivalently in cross-cutting string-based recognition sites: prefix-cache key extraction, cross-provider fallback translation, the OpenAI-to-Anthropic transform's system-content extraction, the global-prompt injector's existing-system-message lookup, and the smart-routing classifier / LLM prompt builder.
Documentation¶
- Migrate the docs site from MkDocs to Zensical and restore brand styling (#602)
- Remove
mkdocs.ymlandmkdocs.ko.ymlin favor of nativezensical.tomlandzensical.ko.toml, both rooted under the[project]namespace per Zensical's TOML schema; per-extension options live inside[project.markdown_extensions]as a dict (Zensical's config loader ignores any separatemdx_configstable). - Replace
docs/en/sharedanddocs/ko/sharedsymlinks withrsync -a --delete docs/shared/ docs/{en,ko}/shared/invoked before each build, since Zensical does not follow symlinks for asset directories. - Register the lablup brand color via Zensical's documented
primary = "custom"mechanism plus a[data-md-color-scheme="default"][data-md-color-primary="custom"]selector indocs/shared/stylesheets/extra.cssthat defines the orange CSS variables. - Mermaid is registered as a
pymdownx.superfencescustom fence rather than relying on the now-incompatiblemermaid2plugin; favicon falls back tologo.pngwhen missing. - Restore Zensical render output for icons, diagrams, and brand color (#604)
- Re-enable
pymdownx.emojiwith thezensical.extensions.emojitwemoji index/generator (replaces the removedmaterialx) so:material-*:icon syntax stops rendering as literal text. - Reimplement the
<!-- diagram: PATH --> ... <!-- /diagram -->ASCII-replacement as a Python-Markdown extension (docs/hooks/diagram_extension.py); the prior MkDocson_page_contenthook does not run because Zensical exposes no MkDocs hook lifecycle. Adddocs/__init__.pyand prefix builds withPYTHONPATH=.so the extension is importable from Zensical's console-script entry point. - Set
--md-primary-bg-coloron the custom palette and override.md-header/.md-tabsso the orange brand band paints on top of Zensical's modern layout. - Move the
navtable above the first[project.X]sub-table in both TOMLs so it stops being silently parsed under[[project.extra.social]](alphabetical fallback was producing an unsorted top menu and wrong prev/next footer neighbors).
Tests¶
- Regression coverage for system/developer concatenation in the Anthropic transform on both streaming and non-streaming paths, plus per-backend converter mapping for the
developerrole across all five backends and the Chat Completions → Responses converter's developer-then-system ordering (#605, #606).
Dependencies¶
- Bump
redis1.2.0 → 1.2.1 (#598).
v1.6.1 - 2026-05-07¶
Fixed¶
- Claude Opus 4.7 (
claude-opus-4-7) now routes correctly through the Anthropic backend (#599, #600, #601) - Extended the adaptive thinking API gate (
uses_adaptive_thinking_api) to include 4.7-series model IDs. Claude Opus 4.7 requiresthinking.type == "adaptive"+output_config.effort; sending the legacybudget_tokensshape produces HTTP 400. - Added
model_requires_adaptive_thinkingandmodel_forbids_sampling_paramspredicates for 4.7-series request-shape rules: explicit manual thinking is normalized to adaptive thinking andtemperature,top_p, andtop_kare dropped unconditionally before forwarding. - Extended
opus_supports_max_effortto include Opus 4.7 soxhighreasoning effort maps tooutput_config.effort = "max"on Opus 4.7. - Added
claude-opus-4-7andclaude-opus-4-7-latestto the built-in supported-models list and tomodel-metadata.yaml; the speculativeclaude-sonnet-4-7entry is intentionally not advertised until Anthropic publishes it (defensive request-shape matching is retained for user-supplied configurations).
Documentation¶
- Update reasoning-effort docs (EN + KO) and
backends.mdto cover the Claude 4.7 family adaptive-thinking requirement and unconditional sampling-parameter deprecation (#600).
Tests¶
- Responses API regression coverage for Opus 4.7 adaptive thinking and unconditional sampling-parameter stripping; both transform paths (Chat Completions and Responses) for the 4.7 family with negative regression on Opus 4.6 / Sonnet 4.6 / Haiku 4.5 / Haiku 3.5 (#600, #601).
v1.6.0 - 2026-05-04¶
Added¶
- ChatGPT subscription / Codex backend authentication via OAuth device flow (#551, #592)
continuum-router auth login --backend <name>runs the OpenAI Codex three-step headless device-code flow:POST /api/accounts/deviceauth/usercodeto mint a one-timeuser_code,POST /api/accounts/deviceauth/tokenpolling, and a PKCE exchange at/oauth/token. Standards-compliant RFC 8628 device flow remains available for any future provider that implements it; the newOpenAICodexDeviceFlowClientis selected automatically forprovider: openai.- Tokens are wrapped in
SecretString, written to the configuredtoken_storewith mode0600on Unix using anO_CREAT|O_EXCLopen + atomic rename; a random tempfile suffix prevents concurrent saves from colliding, and a partial write is unlinked on failure so secret material does not linger on disk. - Access-token expiry is parsed from the JWT
expclaim (with a 1-hour fallback for non-JWT tokens) and clamped to a useful minimum so a degenerateexpires_infrom the provider cannot trigger a refresh storm. - Proactive refresh fires 60 s before expiry, single-flighted with a
tokio::sync::Mutex. A401from the upstream backend triggers exactly one forced refresh and a single retry; the previous refresh token is preserved race-free when the provider omitsrefresh_tokenfrom a refresh response. - The strategy reports an
identity_fingerprint()(backend name,client_id,token_store) so that hot-reload rebuilds the strategy when any of those rotate, instead of silently keeping the prior in-memory state. - The CLI strips C0/C1 control characters from
verification_uri_completeanduser_codebefore printing, so a hostile provider response cannot inject ANSI escapes that rewrite the terminal. - Every device-flow and runtime request to
auth.openai.com/chatgpt.com/backend-api/codexcarriesoriginator: codex_cli_rs(configurable viaauth.oauth.originator) and acodex_cli_rs/<version>User-Agent(configurable viaauth.oauth.user_agent), matching the official Codex CLI so Cloudflare admits the traffic instead of returning a 403 JS challenge. auth.type: oauthis accepted in YAML alongside the legacyo_authsnake_case rendering.client_idandscopedefault to the public Codex CLI values; onlytoken_storeis required for the ChatGPT-subscription case.- Anthropic Messages and Chat Completions surfaces both transparently route to the ChatGPT Codex backend (#592)
- Any backend whose
auth.typeisoauthand whose provider uses the Codex flow (currentlyopenai) is forced through the Responses API for every request, regardless of per-modelresponses_onlymetadata.chatgpt.com/backend-api/codexexposes/responsesonly — no/chat/completions— so chat-shaped models (e.g.gpt-5.5, alias-mappedclaude-haiku-4-5) and unknown model IDs all dispatch through/v1/responses→…/backend-api/codex/responses. Non-OAuth OpenAI backends continue to honor the per-modelresponses_onlyflag. - New
core::url_utils::compose_backend_urlcentralizes backend URL composition for the three OpenAI-compatible roots (/v1,/openai,/backend-api/codex). Replaces ad-hocends_with("/v1") || ends_with("/openai")checks acrossproxy/backend.rs,http/handlers/responses.rs,http/streaming/handler.rs,services/responses/stream_service.rs, and the Anthropic handler so the/backend-api/codexrule applies uniformly. - The proxy hot path (
proxy/backend.rs,proxy/responses_only.rs,proxy/image_gen.rs,proxy/image_edit.rs) now flows through a backend-name-keyedAuthStrategyRegistryexposed onAppStateviasrc/proxy/oauth_helper.rs. The helper looks up the strategy, callsrefresh_if_needed()before sending, replaces the static-bearer header with one derived from the strategy, and force-refreshes + retries once on a 401. Staticapi_keyauth continues to work unchanged when no strategy is registered. - The Anthropic-compatible handler (
src/http/handlers/anthropic/handler.rs) consults the same registry. Client-suppliedAuthorization: sk-ant-…andx-api-keyheaders are dropped when the backend has an OAuth strategy, instead of being forwarded to OpenAI as the bearer. - Model fetcher detects OAuth-authed backends and falls back to the configured
modelslist rather than probing/v1/models, sincechatgpt.com/backend-api/codexdoes not expose a models endpoint. - Codex-compatible Responses API extensions (#536, #537)
POST /v1/responses/compactendpoint for context compaction — passthrough to OpenAI / Azure OpenAI native/v1/responses/compact; other backend types return501.storefield onResponsesRequest(defaults totrue) controls upstream session persistence; Codex sendsstore: falsefor ephemeral requests.output_textcontent part type alongsideinput_textso converters can differentiate assistant vs. user content in input items. All converters (OpenAI, Anthropic, Gemini) handle the new variant.
Documentation¶
- Sync Codex / Responses-API extensions across the root
CHANGELOG.mdand the Korean docs (ko/configuration/backends.md,ko/configuration/advanced.md,ko/api.md,ko/architecture.md); resolve all zensical build warnings on both EN and KO builds and preserve unicode in toc anchor slugs viapymdownx.slugs.slugify(#596). - Clean up AI-slop patterns across English and Korean mkdocs sources — replace em dashes in prose, remove filler/slop words, rewrite trailing participial clauses and inflated verbs, collapse colon+bullet AI-style intros, and replace closing summary slop with concrete next-action links (#597).
CI/CD¶
- Bump
apple-actions/import-codesign-certsfrom 6 to 7 (#590).
Dependencies¶
- Bump
tokio1.51.0 → 1.52.1,axum0.8.8 → 0.8.9,reqwest0.13.2 → 0.13.3,clap4.6.0 → 4.6.1,fastrand2.4.0 → 2.4.1,uuid1.23.0 → 1.23.1,rand0.10.0 → 0.10.1, andlru0.16.3 → 0.16.4 (#595).
v1.5.6 - 2026-04-29¶
Fixed¶
/v1/chat/completionsreturned HTTP 502responses_parse_failedforresponses_onlyreasoning models (gpt-5.4-pro, gpt-5.5-pro). OpenAI's/v1/responsespayload for these models contains output items shaped like{ "id": "rs_...", "type": "reasoning", "summary": [] }, butOutputItem::Reasoningrequiredcontentandstatus, so serde rejected the payload withmissing field 'content'. The Anthropic Messages surface bypassed the strict variant on a different conversion path, masking the bug until directly tested.contentandstatusare now optional onOutputItem::Reasoning; reasoning items are dropped before reaching Chat Completions clients (per existing project policy), so body shape is irrelevant beyond successful deserialization. (#594)
Changed¶
- Realign
gemini-3.1-pro-previewas the canonical metadata id for the Gemini 3.1 Pro family inmodel-metadata.yaml, withgemini-3.1-pro(and existing-latest/-customtoolsforms) demoted to aliases. Matches whatgenerativelanguage.googleapis.comactually serves today — the canonicalgemini-3.1-proform returns 404 from upstream — and avoids implying GA availability that does not exist yet. The metadata cache still resolves both forms to the same entry. Note: alias-to-canonical rewriting on the upstream-bound payload is out of scope for this release; clients calling with thegemini-3.1-proalias will still hit upstream 404 until that work lands. (#594) - Sample
config.yamlregisters the newly-available pro / 5.5 family models so theresponses_onlydispatch path can be exercised end-to-end against real upstreams (gpt-5.4-pro,gpt-5.2-pro,gpt-5.5,gpt-5.5-pro,claude-opus-4-7,gemini-3.1-pro,gemini-3.1-pro-preview); duplicateclaude-haiku-4-5entry removed.
v1.5.5 - 2026-04-27¶
Added¶
- Transparent Responses-API routing for OpenAI Pro models (epic #581)
- New
responses_only: truecapability flag inmodel-metadata.yamland the built-in OpenAI registry marksgpt-5.2-pro,gpt-5.4-pro, andgpt-5.5-proas served only on/v1/responsesupstream (#574, #582) /v1/chat/completionsrequests forresponses_onlymodels are dispatched to the upstream/v1/responsesendpoint and translated back into a strict-modechat.completion(orchat.completion.chunkfor streaming) envelope, transparent to the client. Streamusageis gated bystream_options.include_usage, and per-model latency / success counters are recorded for the responses_only path (#578, #584)/anthropic/v1/messagesrequests forresponses_onlymodels are converted to the Responses API shape, dispatched to/v1/responses, and translated back into Anthropic Messages JSON (or the Anthropic SSE event sequence for streaming) — tool-call round-trips, web-search emulation, and Unix-socket transports all branch on the flag (#575, #577, #583, #585, #586)- Anthropic Messages <-> Responses request transformer covers system → instructions, tools, tool_choice (including
disable_parallel_tool_use→parallel_tool_calls: false),max_tokens→max_output_tokens, reasoning effort derivation, and multi-turn tool round-trips; the response transformer preserves thinking/text/tool_use ordering and stop-reason fidelity (#575, #583) - SSE streaming bridge (
AnthropicResponsesStreamTranslator) maps Responses API events to Anthropic Messages events while preserving Anthropic's strict event-ordering invariants (singlemessage_start, pairedcontent_block_start/content_block_stop, terminalmessage_stop); handles mid-streamerror/response.failed/response.cancelled,response.incomplete→stop_reason: max_tokens, deferred input tokens, and graceful early-close synthesis (#576, #585) - Only OpenAI and Azure OpenAI backends serve
/v1/responses; pairing aresponses_onlymodel with another backend type produces a400 invalid_request_errorbefore any upstream call (rejection fires on both/v1/chat/completionsand/anthropic/v1/messagessurfaces) (#577, #589) - The first dispatch per
(backend, model)pair logs atinfolevel so operators can confirm Responses-API routing without enabling debug logs - Anthropic Messages → Responses requests explicitly send
store: falseto avoid upstream side-effects (#589) - 22 deterministic, in-process integration tests covering the {Anthropic, Chat} × {gpt-5.4-pro, gpt-5.2-pro} × {non-streaming, streaming} × {plain, tool-call, reasoning} matrix, mid-stream backend-failure negatives on both surfaces, and an upstream byte-fragmentation regression guard (#579, #588)
- Documented in
docs/en/configuration/advanced.md(Responses-API-only Models section split into Models-marked-out-of-the-box, Marking-a-new-model, Dispatch-behavior, and Backend-type-constraint subsections),docs/en/architecture.md(Responses-API Routing data-flow diagram), and thedocs/en/api.mdChat Completions and Anthropic Messages surface notes with a Transparent-Responses-API-routing subsection (#580, #587)
Fixed¶
- Chat Completions responses-only routing now rejects incompatible-only backend configs before upstream dispatch and chooses a compatible OpenAI/Azure Responses backend when available (#589)
- Chat assistant
tool_calls[]are preserved as Responsesfunction_callinput items for stateless tool-result turns over/v1/chat/completions(#589)
v1.5.4 - 2026-04-25¶
Changed¶
- Refresh
model-metadata.yamlfor late-April 2026 frontier model releases (#572, #573) - Add GPT-5.5 ($5/$30 per 1M, 1M context, knowledge cutoff 2025-12, omnimodal, leads Terminal-Bench 2.0 at 82.7%) and GPT-5.5 Pro ($30/$180 per 1M, Responses API only, deep reasoning) — released 2026-04-23
- Add DeepSeek V4 Pro (1.6T total / 49B active MoE, 1M context, 384K max output, three reasoning effort modes) and DeepSeek V4 Flash (284B total / 13B active MoE, 1M context, 384K max output) with
deepseek-chatanddeepseek-reasonerretained as deprecated aliases per official API docs — released 2026-04-24 - Add
gpt-image-2(token-billed instead of per-image: text $5/$30, image $8/$30 per 1M tokens; 1K/2K/4K resolution tiers; ~99% text accuracy in any language; built-in reasoning before generation; context-aware multi-turn editing;gpt-image-2-latestalias) — released 2026-04-21 - Add Claude Opus 4.7 ($5/$25 per 1M, 1M context, 128K max output, knowledge cutoff 2026-01, high-resolution image support up to 2576px / 3.75MP, new tokenizer with ~1.0–1.35× token usage vs prior models, new
xhigheffort level) — released 2026-04-16 - Promote Gemini 3.1 series from preview to GA, retaining
-previewsuffix as alias for fallback compatibility (#573) gemini-3.1-pro-preview→gemini-3.1-pro(withgemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools, andgemini-3.1-pro-latestaliases)gemini-3.1-flash-image-preview→gemini-3.1-flash-image(withgemini-3.1-flash-image-preview,nano-banana-2, andgemini-3.1-flash-image-latestaliases)gemini-3.1-flash-lite-preview→gemini-3.1-flash-lite(withgemini-3.1-flash-lite-previewandgemini-3.1-flash-lite-latestaliases)- Updated
gemini-3-flash-previewdeprecation note to point to the new GAgemini-3.1-proid
v1.5.3 - 2026-04-23¶
Added¶
- HuggingFace repo-prefix stripping as a new matching phase (phase 5) in
src/models/pattern_matching.rs(#555) try_strip_hf_repo_prefix()validates avendor/repo(ororg/team/repo) prefix against aMAX_PREFIX_SEGMENTS = 3bound, rejects empty segments (/repo,vendor/,vendor//repo), and rejects any ASCII whitespace before returning the residual- Phase 5 re-enters phases 1-4 on the stripped residual with a structurally-enforced recursion depth of exactly 1 (the re-entry call clears the
allow_prefix_stripgate), so prefix stripping composes with the existing layered suffix peel in a single lookup — the motivating caseunsloth/Qwen3.6-35B-A3B-GGUFnow resolves toqwen3.6-35b-a3bwithout any hand-registered alias - Phase 5 runs before the wildcard phase; the blast-radius audit confirmed no
*-bearing alias inmodel-metadata.yamlcontains/, so the ordering change is behavior-neutral for existing routing - Phase numbering in tracing output realigned to match the documented phase chain (previous code emitted
phase = 7for the namespace fallback while comments called it phase 6) - 12 new unit tests covering standard HF form, composition with suffix peel, case-sensitive vendor, registered-alias precedence, unresolvable residual, three-segment form, segment-cap rejection, no-slash input, whitespace rejection, empty segments, re-entry bounding, and alias-phase precedence
- 9 new integration tests in
tests/format_suffix_normalization_test.rsexercising the fullRouterConfig/BackendConfigpublic API through phase 5 - Pipeline doc updated in
docs/en/configuration/advanced.md(and Korean counterpart) with a new "HuggingFace repo-prefix stripping (phase 5)" section covering the composition semantics, security bounds, and out-of-scope list (hyphen prefixes, HF API discovery)
Changed¶
- Replaced the previous phase-6 namespace fallback with the new phase-5 HuggingFace prefix-strip layer. The previous phase was case-sensitive and did not compose with suffix peel; the new phase applies stricter input validation (segment cap, empty-segment rejection, whitespace rejection) but composes with phase 4's case-insensitive peel through the bounded re-entry. Pathological inputs above
MAX_PREFIX_SEGMENTS(3) — such asprovider/deep/nested/model— are now rejected by phase 5 rather than silently matched via recursiversplit_oncefallback (#555) - Aliases currently classified as
vendor-prefixin the #560 audit (e.g.,Qwen/Qwen3.6-35B-A3B,MiniMaxAI/MiniMax-M2.5) are now peel-coverable-adjacent post-#555: phase 2 still wins on the explicit alias, but phase 5 + phase 4 together reach the same metadata. Retroactive removal is deferred to a follow-up audit per #555 design section 7
Fixed¶
POST /anthropic/v1/messagesnow works when the selected backend is configured with aunix://URL (#567)- Native Anthropic backends and OpenAI-compatible backends both work over Unix sockets, for both non-streaming and streaming requests
- Socket paths containing spaces (e.g. macOS
~/Library/Application Support/...) are handled correctly - Auth header selection (
x-api-keyfor Anthropic backends,Authorization: Bearerfor OpenAI-compatible backends) is correct on the Unix socket path anthropic-versionheader is added automatically for Anthropic backends on the Unix socket path, matching the HTTP path behavior
v1.5.2 - 2026-04-21¶
Added¶
- Regression tests locking down the transport-layer passthrough contract for llama.cpp and MLxcel backends (#562)
- New
tests/llamacpp_passthrough_test.rsandtests/mlxcel_passthrough_test.rscovering all four passthrough call sites: direct backendexecute_chat_completion, factory-built backend (BackendFactory -> LlamaCppBackend),proxy/backend.rsHTTP handler, and the streaming handler - New
test_mlxcel_factory_backend_passthrough_nonstandard_fieldsasserts thatBackendFactory -> LlamaCppBackend::execute_chat_completionpreserves non-standard fields byte-for-byte at transport time - Anthropic input test (
tests/anthropic_input_test.rs) extended with explicit passthrough coverage docs/en/architecture/backend-passthrough.mdand its Korean counterpartdocs/ko/architecture/backend-passthrough.mddocumenting the passthrough contract, the four guarded call sites, and the list of router-side transforms that run before transport (global_prompts,transform_payload_for_openaifor o1/o3/gpt-5*,web_searchinjection) (#562, #563)docs/reports/alias-audit-2026-04.mdclassifying every alias inmodel-metadata.yamlinto peel-redundant, peel-redundant-but-kept, and peel-independent categories, with an "aliases vs peel" policy section added todocs/en/configuration/advanced.md(and the Korean counterpart) explaining when to prefer each mechanism (#560)
Changed¶
- Narrowed the passthrough contract from an implied "byte-equivalent" global guarantee to a transport-layer scope — the router may still run
global_promptsinjection, o1/o3/gpt-5* payload transforms, andweb_searchtool injection before transport, but no provider-specific rewriting happens at the transport boundary (#563) - Comment-only clarifications in
src/http/streaming/handler.rs,src/infrastructure/backends/factory/backend_factory.rs,src/infrastructure/backends/llamacpp/backend.rs, andsrc/proxy/backend.rs - Audited
model-metadata.yamlaliases for peel-normalization redundancy: removed aliases that differ from the canonical ID only by suffixes already handled by the layered peel (-4bit,-q4_k_m,-fp8,-gguf,-mlx,-awq, etc.), while preserving aliases that encode canonical flavor variants (-qat,-instruct) or disambiguate parameter counts (#557) - New
tests/alias_audit_helper.rsandtests/format_suffix_normalization_test.rsenforce the peel-vs-alias boundary going forward
CI¶
- Target Ubuntu 26.04 LTS (Resolute) instead of 25.10 (Questing) in the Debian build workflow
- Fall back to
createdAtwhen releasepublishedAtis null indebian/update-changelog.shto prevent changelog regression when the latest release is still in draft
v1.5.1 - 2026-04-20¶
Added¶
- Built-in
web_searchtool for self-hosted LLM backends (#553) - Router-level tool transparently injected into chat completion requests for vLLM, Ollama, llama.cpp, MLxcel, LM Studio, Continuum Router, and Generic backends
- Pluggable
SearchProvidertrait undersrc/services/search/withSerperProviderimplementation; Exa and Brave scaffolded behind the same trait - Configurable
inject_policy(auto/always/never) with per-backend overrides; commercial backends (OpenAI, Azure, Gemini, Anthropic) left untouched so their nativeweb_searchcontinues to flow through unchanged - Bounded non-streaming tool-execution loop parses
web_searchtool calls, executes the provider, appends tool-role results, and re-invokes the backend up tomax_tool_iterationsrounds - New
BackendTypeConfig::is_self_hosted/is_commercialhelpers covered by unit tests enforcing the commercial/self-hosted partition invariant - API keys redacted in Debug output and never logged; hot-reload friendly
WebSearchConfigwith${ENV}substitution - Prometheus counters for tool calls, injections, and iteration-cap hits under
src/metrics/web_search - Layered quantization and format suffix normalization for model metadata lookup (#549)
- New
layered_format_strip()insrc/models/pattern_matching.rsiteratively peels allowlisted quantization/format/flavor tokens from the right side of a model ID, retrying exact-id/alias/date-suffix matches after each peel - Token categories:
BIT_WIDTH,GGUF_QUANT,FP_FORMAT,INT_FORMAT,LIBRARY,IMATRIX,UNSLOTH,CONTAINER,FLAVOR(all case-insensitive) - Parameter-count suffixes preserved:
-Nbitstripped as quantization;-Nb,-aNb,-eNb,-0.6bkept as parameter counts - Canonical base IDs ending in allowlisted flavors (e.g.
gemma-3-12b-qat) win via exact-id match before peel runs - Normalization pipeline wired into
find_matching_config,BackendConfig::get_model_metadata,RouterConfig::get_model_metadata,RouterConfig::get_thinking_pattern_config,resolve_model_tier(routing), andget_model_profile(admin) - Model metadata for GLM 5.1, Qwen 3.6, and MiniMax M2.7 (#548)
- Teams release notification posted to Microsoft Teams via Power Automate webhook after build and Docker jobs
Changed¶
- Migrate documentation toolchain from MkDocs + Material for MkDocs to Zensical — reads
mkdocs.ymlnatively and bundles required extensions
Fixed¶
- Security: Cap layered peel phase with
MAX_MODEL_ID_LEN=256andMAX_PEEL_ITERATIONS=8to eliminate DoS via pathological model IDs (previously O(n²) allocation on inputs like-4bit-4bit-4bit-...) - Security: Enforce 256-char model field length at
/v1/chat/completions,/v1/completions,/v1/embeddings, and/v1/embeddings/sparse(parity with existing/v1/responsescheck) - Consolidate 7-phase metadata matching pipeline into a single implementation (
find_matching_config_slice) with thin adapters at each call site, eliminating drift betweenBackendConfig,Config::get_model_metadata,Config::get_thinking_pattern_config, andfind_matching_config - Replace
cfg.to_ascii_lowercase() == peelwithstr::eq_ignore_ascii_caseon the hot path (~4000 fewer per-request String allocations) - Pin Pygments <2.20 to fix MkDocs build failure (superseded by Zensical migration)
CI¶
- Bump softprops/action-gh-release from 2 to 3 (#544)
- Bump actions/github-script from 8 to 9 (#545)
- Bump actions/upload-pages-artifact from 4 to 5 (#554)
Documentation¶
- Document suffix-order ambiguity (
-qat-4bitvs-4bit-qat) and internal peel phase bounds indocs/en/configuration/advanced.md - Add
pattern_matching.rsto Model Aggregation Service module listing indocs/en/architecture.mdwith cross-reference to suffix normalization section - New
docs/en/web-search.mdfeature documentation;config.yaml.exampleextended withweb_searchsection
v1.5.0 - 2026-04-11¶
Added¶
- Smart routing system with model tier & capability profile registry (#525, #531)
- Rule-based request classifier & smart routing policy engine (#526, #532)
- Load-aware dynamic tier adjustment (#527, #533)
- LLM-based request classifier with hybrid mode (#528, #534)
- Smart routing observability, admin API & documentation (#529, #535)
- Codex-compatible Responses API extensions (#536, #537)
Changed¶
- Upgrade core dependencies — axum 0.8, sha2 0.11, rand 0.10 (#523)
- Add Gemma 4 model family metadata (#538)
Fixed¶
- Complete smart routing integration gaps
- Increase DefaultTransformer PDF size limit from 20MB to 32MB (#542)
CI¶
- Bump actions/deploy-pages from 4 to 5 (#521)
Dependencies¶
- Bump the minor-and-patch dependency group with 4 updates (#539)
Documentation¶
- Add Codex-compatible Responses API gap analysis report
v1.4.5 - 2026-03-27¶
Fixed¶
- Return 400 error when file references are used without file service configured (#519)
Changed¶
- Add GLM-5-Turbo model metadata (#516)
Documentation¶
- Fix Korean anti-AI-slop violations in ko/ documentation
- Fix slop word and transition word in api.md
v1.4.4 - 2026-03-18¶
Fixed¶
- Fix Anthropic thinking failing for high/xhigh reasoning effort —
budget_tokens(32768) exceeded defaultmax_tokens(16384), causing API rejection (#514) - Auto-adjust
max_tokenstobudget_tokens + 4096when thinking is enabled and budget exceeds max
Changed¶
- Add GPT-5.4 model family:
gpt-5.4,gpt-5.4-pro,gpt-5.4-mini,gpt-5.4-nanowith 1M context window (#515) - Update Gemini 3 series: add
gemini-3.1-pro-preview,gemini-3-flash-preview,gemini-3.1-flash-lite-preview; markgemini-3-pro-previewas deprecated - Recognize Gemini 3 Flash and 3.1 Flash-Lite as thinking models for
include_thoughtsauto-injection - Update Claude 4.6 models: context window to 1M (GA), fix Sonnet 4.6 max_output to 64K, correct knowledge cutoffs
- Update config examples and documentation with latest model names across 8 files
v1.4.3 - 2026-03-18¶
Fixed¶
- Fix Gemini thinking models (2.5 Pro, 3 Pro, etc.) not returning
reasoning_contentin streaming responses through the router (#513) - Replaced
transform_payload_for_gemini()withtransform_request_gemini()across all three Gemini streaming paths to ensureinclude_thoughts: trueauto-injection
v1.4.2 - 2026-03-17¶
Changed¶
- Change mid-stream fallback default to enabled for improved streaming reliability (#504)
- Breaking: Mid-stream fallback is now enabled by default; set
mid_stream_fallback.enabled: falseto restore previous behavior
Documentation¶
- Add failover latency tuning guide for optimizing fallback behavior
v1.4.1 - 2026-03-17¶
Added¶
- Mid-stream fallback for streaming inference (#497) — when a backend fails mid-stream during SSE streaming, the router transparently retries with a fallback backend
Changed¶
- Decouple pre-stream fallback from mid-stream fallback (#500) — each can now be independently enabled/disabled
- Bump dependency versions to latest major releases
Fixed¶
- Fix streaming config changes not detected in hot reload system (#503)
- Fix mid-stream connection errors leaking to client during fallback (#502)
- Remove unused config crate dependency
CI¶
v1.4.0 - 2026-03-14¶
Added¶
- Prefix-aware routing: PrefixAwareHash selection strategy with Consistent Hash with Bounded Loads (CHWBL) (#455, #457, #461)
- Response caching: SHA256-based cache key computation with streaming response buffering and post-completion caching (#456, #459, #462)
- Multi-tier CacheStore: in-memory backend (#466), Redis/Valkey backend with connection pooling (#467), and S3-backed tiered L1/L2 cache (#483)
- KV cache index: shared data structure (#470), KV event consumer for vLLM backend streams (#471), prefix overlap scoring integrated into backend selection (#473), configuration/metrics/admin endpoints (#474)
- Tiered KV cache with storage-tier awareness (GPU hot / external warm) (#484)
- Disaggregated prefill/decode orchestration with external KV tensor transfer (#485)
- Anthropic cache_control breakpoint auto-injection (#460)
- Multimodal embedding support for Gemini Embedding 2 (#492)
- Shared cache configuration and operational metrics (#468)
- 30 new models added to model-metadata.yaml (#472)
Changed¶
- Rename VAST-specific identifiers to generic S3/external storage names (#490) — update configuration files if using VAST-specific field names
Fixed¶
- Make RequestExecutor transport-aware for Unix socket paths with spaces (#488)
- Replace relative source tree links with GitHub URLs in docs
CI¶
- Bump docker/setup-qemu-action from 3 to 4 (#428)
- Bump docker/metadata-action from 5 to 6 (#426)
- Bump docker/setup-buildx-action from 3 to 4 (#429)
- Bump docker/build-push-action from 6 to 7 (#430)
- Bump docker/login-action from 3 to 4 (#427)
Documentation¶
- Comprehensive KV cache feature documentation, benchmarks, and config examples (#477)
- VAST Data connection guide and integration examples (#486)
- Sync Korean documentation with English counterparts
- Split monolithic configuration.md into 6 smaller files
v1.3.0 - 2026-03-12¶
Added¶
- Agent Communication Protocol (ACP) support with JSON-RPC 2.0 protocol layer and stdio transport (#414, #420)
- ACP session management with protocol lifecycle, initialize/shutdown handshake (#415, #421)
- ACP-to-LLM inference pipeline with streaming support (#416, #422)
- ACP tool call reporting and permission delegation (#417, #423)
- MCP-over-ACP bridge for MCP server tunneling (#418, #424)
- ACP agent registry with metadata and configuration support (#419, #425)
- ACP integration tests for protocol lifecycle and session management
Fixed¶
- Resolve clippy
field_reassign_with_defaultwarnings in ACP integration tests
CI¶
- Bump actions/upload-artifact from 6 to 7 (#398)
Documentation¶
- ACP architecture documentation with MkDocs integration
- ACP practical usage guide with IDE integration examples
- KV cache integration plan for router-level caching strategies
v1.2.1 - 2026-03-07¶
Added¶
- MLxcel backend type support for MLX-based model serving (#412, #413) — fully API-compatible with llama-server, reusing the same backend implementation for health checks, model discovery, and proxying
v1.2.0 - 2026-03-06¶
Added¶
- Admin Statistics API with comprehensive request-level statistics collection and reporting (#409)
- Endpoints:
GET /admin/stats,GET /admin/stats/models,GET /admin/stats/backends,POST /admin/stats/reset - Time-windowed queries, token usage tracking, latency percentiles (p50, p95, p99)
- Statistics persistence with configurable snapshot path, interval, and staleness checks (#410, #411)
- Atomic writes, restore on startup, final snapshot on graceful shutdown
Documentation¶
- Add admin stats and persistence to configuration guide
- Add post-refactoring benchmark report for v1.1.0 (#407)
v1.1.1 - 2026-03-04¶
Added¶
- Embeddable library crate (Phase 1) — use continuum-router as a Rust dependency (#394)
- Type-safe config builders for programmatic library usage (#400)
- Cargo feature flags for optional library dependencies (#399)
- Persistent storage for runtime API keys (#405)
- New LLM model metadata entries (#403)
Fixed¶
- Fix Gemini-specific transforms incorrectly applied in Anthropic handler (#404)
v1.1.0 - 2026-03-01¶
Added¶
- Embedded WebUI for configuration management and API key administration (#388)
- Windows AF_UNIX socket support via socket2 crate (#390)
- Nano Banana 2 (Gemini Image Generation) support
Fixed¶
- Resolve compilation error in
ClientAddr::is_unixfor tuple variant matching - Resolve Windows AF_UNIX socket accept failure and config validation
- Accept Windows absolute paths in Unix socket config validation (#393)
- Resolve Windows compilation errors in Unix socket tests and transport parsing (#392)
v1.0.0 - 2026-02-19¶
Added¶
- Continuum Router federation — router-to-router chaining as a new backend type (#385)
- LM Studio as a dedicated backend type (#381)
- Anthropic adaptive thinking effort parameter (
output_config.effort) (#384) - Adaptive thinking and auto reasoning effort level across backends (#378)
- Cohere/Jina-compatible rerank and sparse embedding endpoints (#374)
- BGE-M3 and multilingual embedding model support (#373)
- Claude Opus 4.6 model metadata
- Qwen3-Coder-Next, Qwen3-VL-30B/8B model metadata
Changed¶
- Handle SIGTERM for graceful shutdown on Unix systems (#370)
- Reduce per-backend filter and model metadata log verbosity during model refresh (#371, #375)
CI¶
- Replace Ubuntu 24.10 with 25.10 in deb build matrix (#376)
v0.36.1 - 2026-01-30¶
Fixed¶
- Trigger immediate health check after sync_backends during hot reload (#368) — new backends now available within 1-2 seconds instead of up to 30 seconds
- Sync health_check_info and use URL-based updates during hot reload (#369) — new backends properly receive API key authentication
- Accelerate health checks for recently added backends — 1-second check interval for 5 minutes after addition
- Trigger model cache refresh when backends transition to healthy state with 5-second debounce
v0.36.0 - 2026-01-27¶
Added¶
- Native Anthropic Messages API handler with endpoint routing (#355)
- Anthropic to OpenAI request/response transformation (#356, #357)
- Anthropic streaming response format (#358)
- Direct Anthropic to Gemini request/response transformation (#359)
- File_id source type and file resolution for Anthropic input (#360)
- Claude Code compatibility for Anthropic handler (#365)
- Tiered token counting for all backend types
- Parallel file reference resolution for improved performance
- Anthropic-version header format validation
Fixed¶
- Require HTTPS for image and document URLs to prevent SSRF
- Return generic error messages to clients instead of backend details
- Use authenticated user_id from API key for file ownership checks
- Use UUID v4 for secure message/tool ID generation
- Place tool messages before user text in Anthropic-to-OpenAI conversion
- Override stop_reason to tool_use when tool_use blocks are present
- Apply max_completion_tokens conversion for OpenAI-routed Anthropic requests
- Propagate file access denied and not found errors to client
- Call current_config() once per request for consistent behavior
Refactored¶
- Extract common SSE event type and data extraction logic
- Add parse_bytes method to SseParser for proper UTF-8 handling
- Remove unnecessary Arc wrapper in AnthropicFileResolver
- Box FileResolutionResult::Resolved to reduce enum size
v0.35.0 - 2026-01-23¶
Added¶
- Gemini 3 thoughtSignature support in function calling (#354)
- PDF support for OpenAI and Anthropic file transformers (#340)
- Text/plain support for AnthropicFileTransformer (#342)
Fixed¶
- Add PDF support to DefaultTransformer and file resolution (#343)
- Add tool message transformation to non-streaming Anthropic requests (#344)
- Reject non-image files in DefaultTransformer with clear error message (#338)
- Fix AI SDK incompatibility with Responses API streaming format (#335)
v0.34.0 - 2026-01-16¶
Added¶
- Automatic quality parameter conversion between DALL-E and GPT Image models (#330)
Changed¶
- Native Anthropic conversion for Responses API PDF file uploads (#332)
Fixed¶
- Gemini streaming tool_calls compatibility fixes (#333) — missing index field, tool_choice format preservation, unnecessary transformation removal
v0.33.0 - 2026-01-13¶
Added¶
/v1/embeddingsendpoint for embedding API support (#319)- Resolve local file_id references in Responses API requests (#326)
user_dataandevalspurpose values for Files API (#322)
Fixed¶
- Use flat tool format for Responses API function tools (#324)
- Improve Unix socket test stability for parallel execution (#328)
v0.32.0 - 2026-01-09¶
Added¶
- Reasoning effort documentation and improved xhigh fallback logging (#317)
Fixed¶
- Support implicit message type inference in Responses API InputItem (#316)
Refactored¶
- Optimize InputItem deserializer and add invalid role test
v0.31.5 - 2026-01-09¶
Added¶
- Responses API pass-through support for native OpenAI backends (#313) — smart routing based on backend type with direct forwarding to
/v1/responsesendpoint - OpenAI Responses API file input types (#311) — support for
input_text,input_file,input_imagecontent parts with SSRF validation
Fixed¶
- Forward raw backend error responses in pass-through mode
- Address security and performance issues in Responses API pass-through
v0.31.4 - 2026-01-07¶
Fixed¶
- Use current_config() for hot reload support in proxy handlers (#310) — API key and configuration changes via hot reload now properly apply to new requests
v0.31.3 - 2026-01-06¶
Fixed¶
- Add Anthropic transformations to Unix socket transport (#308) — Unix socket transport now applies the same request/response transformations as HTTP transport
- Preserve stream parameter for non-streaming Anthropic requests (#306)
v0.31.2 - 2026-01-05¶
Added¶
- Non-streaming support for Anthropic backend requests
- Tool call and tool result transformation for Anthropic backend — enables multi-turn tool use conversations
v0.31.1 - 2026-01-04¶
Fixed¶
- Non-streaming Anthropic requests failing with wrong authentication header (#301) — now correctly uses
x-api-keyheader instead ofAuthorization: Bearer
v0.31.0 - 2026-01-04¶
Added¶
- Unix socket server binding alongside TCP (#298) — supports
unix:URI scheme, socket_mode configuration, auto-cleanup - Reasoning parameter support for Responses API (#296) with nested format and low/medium/high/xhigh effort levels
- xhigh reasoning effort support for GPT-5.2 thinking models with auto-downgrade for unsupported models
- Configurable health check endpoints per backend type (#293) — custom endpoint, fallback endpoints, method, body, accept_status, and headers
Changed¶
- Comprehensive reasoning parameter normalization across backends (#294)
v0.30.0 - 2026-01-01¶
Added¶
- Wildcard patterns and date suffix handling in model aliases (#286) — automatic date suffix normalization,
*pattern matching (prefix, suffix, infix), zero-config date handling
Fixed¶
- Apply default URL for Anthropic backend when not specified (#288)
- Replace owned_by placeholders with backend-type-specific values (#287)
Documentation¶
- Translate wildcard pattern and date suffix handling documentation to Korean (#289)
v0.29.0 - 2026-01-01¶
Added¶
- Accelerated health checks during backend warmup (#282) — 1s interval on HTTP 503, configurable via
warmup_check_intervalandmax_warmup_duration --model-metadataCLI option for specifying model metadata file path at runtime (#281)
Fixed¶
- Replace OpenAI owned_by placeholder with 'openai' (#280)
- Prevent race condition in Admin API concurrent backend creation (#278)
- Fix missing processing steps in hot reload (#277)
- Cloud backends now show
available: truein/v1/models/{model_id}(#272)
v0.28.0 - 2025-12-31¶
Added¶
- SSE streaming support for tool calls (#258)
- llama.cpp tool calling auto-detection via
/propsendpoint (#263) - Extended
/v1/models/{model_id}endpoint with rich metadata fields (#262) - Tool result message transformation for multi-turn conversations (#265)
- Backend-specific owned_by placeholders for llamacpp, vllm, ollama, http (#267)
Changed¶
- Improved
--helpoutput formatting with title header and project attribution (#269)
Fixed¶
- Sync model metadata cache with ConfigManager (#270)
v0.27.0 - 2025-12-29¶
Added¶
- Complete Unix socket support for model discovery and SSE streaming (#248, #252, #253, #254, #256)
- SSE/streaming for Unix socket backends
- Backend type auto-detection for Unix sockets
- vLLM and llama.cpp model discovery via Unix sockets
- Tool call transformation across all backends (#244, #245, #246) — tool definitions, tool_choice, and tool call responses for Anthropic, Gemini, and llama.cpp
v0.26.0 - 2025-12-27¶
Added¶
- GET
/v1/models/{model}endpoint for single model retrieval with real-time availability status (#236)
v0.25.0 - 2025-12-26¶
Added¶
- CORS (Cross-Origin Resource Sharing) support (#234) — configurable origins, wildcard patterns, custom schemes (e.g.,
tauri://localhost), preflight cache - Unix Domain Socket backend support (#232) —
unix:///path/to/socketscheme, lower latency than localhost TCP
v0.24.0 - 2025-12-26¶
Added¶
- llama.cpp backend support for local LLM inference (#230)
- Allow router to start without any backends configured (#226)
Changed¶
- Enable hot reload for backend additions/removals from config (#229)
v0.23.1 - 2025-12-25¶
CI¶
- Add Windows x86_64 build target to release workflow (#224)
v0.23.0 - 2025-12-23¶
Added¶
- GLM 4.7 model support with thinking capabilities (#222)
- GCP Service Account authentication support for Gemini (#208)
- Distributed tracing with correlation ID propagation (#207) — W3C Trace Context with traceparent header
- Thinking pattern metadata for models with implicit start tags (#218)
- Model metadata for NVIDIA Nemotron 3 Nano, Qwen Image Layered, and Kakao Kanana-2 (#202)
- ASCII diagram to image replacement system for MkDocs (#200)
Fixed¶
- Prevent cache stampede with singleflight, stale-while-revalidate, and background refresh (#220)
- Apply global_prompts changes via hot reload (#219)
- Invalidate model cache when backend config changes (#206)
CI¶
- Skip Rust tests in CI when only non-code files change (#204)
- Bump actions/github-script from 7 to 8 (#210)
- Bump apple-actions/import-codesign-certs from 3 to 6 (#212)
- Bump actions/cache from 4 to 5 (#211)
- Bump actions/checkout from 4 to 6 (#209)
v0.22.0 - 2025-12-19¶
Added¶
- Docker support with pre-built binary images — Debian (~50MB) and Alpine (~10MB) with multi-arch support (#198)
- Container health check CLI (
--health-check) for orchestration (#198) - Docker Compose quick start configuration
- Automated Docker image publishing to ghcr.io in release workflow
- MkDocs documentation website with Material theme (#183)
- Korean documentation translation (i18n) — complete localization of all 20 documentation files (#190)
- Security policy with vulnerability reporting process (#191)
- Dependency security auditing with cargo-deny and Dependabot (#192)
Changed¶
- Integrate orphaned architecture documentation into MkDocs site (#186)
- Rename documentation files to lowercase kebab-case for URL-friendly filenames
Fixed¶
- Fix health check response validation logic bug (operator precedence)
- Fix address parsing fallback silently hiding configuration errors
- Fix IPv6 address formatting in health check
v0.21.0 - 2025-12-19¶
Added¶
- Gemini 3 Flash Preview model support (#168)
- Default authentication mode for API endpoints (#173) —
permissive(default) orblockingmode - Backend error passthrough for 4xx responses (#177) — parse and forward original error messages from OpenAI, Anthropic, and Gemini
Fixed¶
- Handle UTF-8 multi-byte character corruption in streaming responses (#179)
- Strip response_format parameter for GPT Image models (#176)
- Allow auto-discovery for all backends except Anthropic (#172)
- Always return b64_json field for Gemini image generation responses (#181)
v0.20.0 - 2025-12-18¶
Added¶
- Image variations support for Gemini (nano-banana) models (#165)
- Image edit support for Gemini (nano-banana) models (#164)
- Enhanced
/v1/images/generationswith streaming and GPT Image features (#161) - gpt-image-1.5 model support (#159)
/v1/images/variationsendpoint (#155)/v1/images/editsendpoint for image editing and inpainting (#156)- External Markdown file support for system prompts with REST API management (#146)
- Automatic model discovery for backends without explicit model list (#142)
- Solar Open 100B model
Security¶
- API key redaction to prevent credential exposure in logs and error messages (#150)
Changed¶
- Optimized release binary size from 20MB to 6MB (70% reduction) (#144)
Refactored¶
v0.19.0 - 2025-12-13¶
Added¶
- Runtime Configuration Management API (#139)
- Configuration query, modification, save/restore, and backend management APIs
- Sensitive information masking, JSON Schema generation, configuration history with rollback (up to 50 entries)
- Comprehensive Admin REST API reference documentation
- 33 integration tests for configuration API endpoints
Security¶
- Input validation with 1MB content limit and 32-level nesting depth
- Audit logging for sensitive data exports with 30+ sensitive field patterns
v0.18.0 - 2025-12-13¶
Added¶
- Per-API-key rate limiting (#137)
- API key management and configuration system
- Files API authentication and authorization (#131)
- Hot reload for runtime configuration updates (#130)
Fixed¶
- Add ConnectInfo extension for admin/metrics/files endpoints
- Address security vulnerabilities in API key management
Refactored¶
- Extract CLI and app utilities into modular structure (#132)
- Split converter.rs into modular structure (#132)
- Split large source files into modular components
v0.17.0 - 2025-12-12¶
Added¶
- Anthropic backend file content transformation (#126)
- Gemini backend file content transformation (#127)
Fixed¶
- Streaming file uploads to prevent memory exhaustion (#128)
v0.16.0 - 2025-12-12¶
Added¶
- OpenAI-compatible Files API endpoints (#111)
- File resolution middleware for chat completions (#120)
- OpenAI backend file handling strategy (#121, #122)
- Persistent metadata storage for Files API (#125)
- GPT-5.2 model support (#124)
- Circuit breaker pattern for automatic backend failover
- Admin endpoint authentication and audit logging
- Configurable fallback models for unavailable model scenarios with cross-provider support
Fixed¶
- Sanitize fallback error headers and metric labels
- Use index-based lookup for fallback chain traversal
- Reduce lock contention in FallbackService with snapshot pattern
v0.15.0 - 2025-12-05¶
Added¶
- Nano Banana (Gemini Image Generation) API support (#102)
- Split
/v1/modelsendpoint — standard lightweight vs extended metadata response (#101)
Changed¶
- Optimize LRU cache to use read lock for cache lookups (#105)
Fixed¶
- Replace
.expect()panics with proper error propagation in HttpClientFactory (#104)
Refactored¶
- Extract streaming handler logic to dedicated StreamService (#106)
- Eliminate retry logic code duplication in proxy.rs (#103)
v0.14.2 - 2025-12-05¶
Added¶
- Log token usage (input/output tokens) on request completion (#92)
v0.14.1 - 2025-12-05¶
Fixed¶
- Optimize Anthropic backend TTFT with connection pooling and HTTP/2 (#90)
- Optimize Gemini backend TTFT with connection pooling and HTTP/2 (#88)
- Apply base name fallback matching to aliases in model metadata lookup (#84)
v0.14.0 - 2025-12-04¶
Added¶
- Router-wide global system prompt injection (#82)
CI¶
- Replace deprecated actions-rs/toolchain with dtolnay/rust-toolchain
- Add RUSTFLAGS for macOS ARM64 ring build
- Switch to rustls-tls for musl cross-compilation support
v0.13.0 - 2025-12-04¶
Added¶
- OpenAI
/v1/responsesAPI support with session management (#49) - True SSE streaming for
/v1/responsesAPI - Background cleanup task for expired sessions
- Override
/v1/modelsresponse fields via model-metadata.yaml (#75)
Security¶
- SecretString for API key storage across all backends (#76)
- Session access control and input validation for Responses API
Changed¶
- Immediate mode for SseParser for reduced first-response latency
Refactored¶
- String allocation optimizations and error handling standardization
v0.12.0 - 2025-12-04¶
Fixed¶
- Handle exact hash matches in consistent hash binary search (#72)
- Replace panics with Option returns and implement stats aggregation (#71)
- Remove hardcoded auth requirement from
/v1/modelsendpoint
Refactored¶
- Reorganize OpenAI model metadata by family (#74)
- Extract AnthropicStreamTransformer to dedicated module (#73)
- Split backends mod.rs into separate modules (#69)
- Extract embedded tests to separate files (#68)
- Create HttpClientFactory for centralized HTTP client creation (#67)
- Create UrlValidator module with SSRF prevention (#66)
- Extract RequestExecutor to shared common module (#65)
- Extract HeaderBuilder with auth strategies (#64)
- Extract AtomicStatistics to shared common module
v0.11.0 - 2025-12-03¶
Added¶
- Native Anthropic Claude API backend with extended thinking support
- OpenAI to Claude reasoning parameter conversion
- Flat reasoning_effort parameter for Anthropic
- Claude 4, 4.1, 4.5 model metadata
Fixed¶
- Improve health check and model fetching for Anthropic/Gemini backends
- Accept-Encoding fixes for streaming — use
identityheader and disable compression
v0.10.0 - 2025-12-03¶
Added¶
- Native Google Gemini API backend support
- OpenAI Images API support for image generation
- Authenticated health checks for OpenAI and API-key backends
- Built-in OpenAI model metadata for
/v1/modelsresponse - API key authentication for streaming requests
- Configurable image generation timeout
- Response_format validation for image generation API
Fixed¶
- Convert max_tokens to max_completion_tokens for newer OpenAI models
- Correct URL construction for all API endpoints
- Request body size limits to prevent DoS attacks
Security¶
- Remove sensitive data from debug logs
Refactored¶
- Unify request retry logic with RequestType enum
v0.9.0 - 2025-12-02¶
Added¶
- Enhanced rate limiting with token bucket algorithm
- Comprehensive Prometheus metrics and monitoring (#10)
Security¶
- Prevent IP spoofing via X-Forwarded-For manipulation
- Prevent header injection vulnerabilities
- Eliminate race condition in token refill
- Protect API keys with SHA-256 hashing
- Prevent memory exhaustion via unbounded bucket growth
- Comprehensive authentication for metrics endpoint
- Cardinality limits and label sanitization to prevent metric explosion DoS
Fixed¶
- Implement singleton pattern for metrics to prevent memory leaks
- Improve error handling to prevent panic conditions
- Resolve environment variable race condition in config test
- Fix integration test failures in metrics
v0.8.0 - 2025-09-09¶
Added¶
- Model ID alias support for metadata sharing (#27)
Fixed¶
- Return empty list instead of 503 when all backends are unhealthy (#28)
v0.7.1 - 2025-09-08¶
Fixed¶
- Improve config path validation for home directory and executable paths (#26)
v0.7.0 - 2025-09-07¶
Added¶
- Rich metadata support for
/v1/modelsendpoint (#23, #25) - Enhanced configuration management (#9, #22)
- Advanced load balancing strategies (Weighted, Least-Latency, Consistent-Hash) with enhanced error handling (#21)
Fixed¶
- Use streaming timeout configuration from config.yaml instead of hardcoded 25s limit
v0.6.0 - 2025-09-03¶
Fixed¶
- Use timeout configuration from config.yaml instead of hardcoded values (#19)
Documentation¶
- Comprehensive timeout configuration and model documentation updates
v0.5.0 - 2025-09-02¶
Added¶
- Optional retry configuration with sensible defaults
- Comprehensive integration tests and performance optimizations
- Complete service layer implementation
- Middleware architecture and enhanced backend abstraction
Fixed¶
- Handle streaming requests without model field gracefully
- Resolve floating-point precision and timing issues in tests
- Resolve test failures and deadlocks in object pool and SSE parser
- Resolve initial health check race condition
Refactored¶
- Split oversized modules into layered architecture
- Extract complex types into type aliases for better readability
v0.4.0 - 2025-08-25¶
Added¶
- Model-based routing with health monitoring
Fixed¶
- Improve health check integration and SSE parsing
v0.3.0 - 2025-08-25¶
Added¶
- SSE streaming support for real-time chat completions (#5)
- Model aggregation from multiple endpoints (#4)
v0.2.0 - 2025-08-25¶
Added¶
- Multiple backends support with round-robin load balancing (#1)
v0.1.0 - 2025-08-24¶
Added¶
- Initial release with OpenAI-compatible endpoints and proxy functionality