feat(inference): multi-provider LLM upstreams (Anthropic, Ollama) + pluggable guardrail pipeline (OpenAI Moderation) - #488
Conversation
…ipeline Roadmap slice 1 of 'multi-cloud LLM providers + native guardrails': Anthropic + Ollama providers and an OpenAI Moderation guardrail stage, policy-driven via InferencePolicy. Provider credentials stay on the router sidecar (secret mount / env) — the agent process never sees them. Controller: - InferencePolicy spec.provider (typed InferenceProvider enum with kebab-case wire tags matching the existing ModelRef.provider strings) and spec.guardrails[] (openai-moderation, applyTo input|output|both). - Compile step emits 'provider' + 'guardrails' in the compiled profile; CEL + reconciler keep both mutually exclusive with bundleRef. - Reconciler forwards ANTHROPIC_API_KEY/ANTHROPIC_ENDPOINT/ OLLAMA_ENDPOINT/OPENAI_MODERATION_* to router sidecars when set; helm CRD template regenerated. Router: - New provider module: fail-closed resolution (unimplemented bedrock -> 501, missing endpoint/credential -> 503; never a silent Azure fallback). Provider-aware UpstreamConfig, URL shapes and auth schemes (Anthropic x-api-key + anthropic-version; Ollama unauthenticated OpenAI-compat under /v1/). - Anthropic Messages native pass-through (streaming + tool use) on /anthropic/v1/messages; Ollama chat completions buffered + SSE. - New guardrails module: Guardrail trait + OpenAI Moderation backend; input pre-flight, buffered output, and hold-and-release SSE scanning (no model text delivered before a scan covers it). Fail-closed on misconfigured stages and backend outages; kars_guardrail_scans_total metric. Tests: 857 controller + 997 router unit tests green; new wiremock integration suite (fake Anthropic/Ollama/moderation upstreams).
There was a problem hiding this comment.
Pull request overview
This PR extends Kars’ inference plane to support policy-driven multi-provider routing (Anthropic, Ollama; Bedrock explicitly unimplemented) and introduces a pluggable guardrail pipeline (first backend: OpenAI Moderation) enforced for buffered and SSE streaming responses.
Changes:
- Adds
InferencePolicy.spec.provider(typed enum with kebab-case wire tags) andspec.guardrails[](ordered stages) with CRD/CEL validation, compilation into the router-consumed profile JSON, and updated docs/changelog. - Implements router-side provider resolution + per-provider proxy behavior (URL shapes + auth schemes), keeping credentials router-side only.
- Implements guardrail pipeline enforcement for input/output, including hold-and-release scanning for SSE streams, plus metrics for scan outcomes.
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| inference-router/tests/proxy_fake_upstream.rs | Updates tests to populate new UpstreamConfig fields (provider, api_key). |
| inference-router/tests/policy_status_endpoint.rs | Extends test router Config literals with new provider/guardrail fields. |
| inference-router/tests/multi_provider_guardrails.rs | Adds end-to-end wiremock tests for Anthropic/Ollama forwarding and moderation guardrail behavior. |
| inference-router/tests/failover_walk.rs | Updates failover tests for expanded UpstreamConfig. |
| inference-router/tests/egress_blocked_endpoint.rs | Extends test Config literals with new provider/guardrail fields. |
| inference-router/tests/agt_governance_integration.rs | Extends test Config literals with new provider/guardrail fields. |
| inference-router/src/routes/mod.rs | Adds apply_provider_resolution to retarget upstream per policy/provider precedence. |
| inference-router/src/routes/chat_completions.rs | Enforces provider selection + guardrails for chat completions, including streaming and recovery paths. |
| inference-router/src/routes/anthropic_messages.rs | Applies provider resolution + guardrails to the Anthropic Messages route (pass-through + translated paths). |
| inference-router/src/proxy.rs | Makes proxy forwarding provider-aware (auth header scheme + URL shaping) and adds UpstreamConfig::azure constructor. |
| inference-router/src/provider.rs | Introduces provider tag parsing/resolution with fail-closed semantics and config-backed targets. |
| inference-router/src/metrics.rs | Adds kars_guardrail_scans_total{provider,direction,outcome} metric. |
| inference-router/src/lib.rs | Exports new guardrails and provider modules. |
| inference-router/src/inference_policy_loader.rs | Loads compiled policy provider + guardrails fields into snapshots for per-request enforcement. |
| inference-router/src/guardrails.rs | Implements guardrail pipeline (OpenAI Moderation) and SSE hold-and-release stream guarding. |
| inference-router/src/failover.rs | Updates test helpers for expanded UpstreamConfig. |
| inference-router/src/config.rs | Adds router config surface for Anthropic/Ollama endpoints and Moderation backend config + secret mount loading. |
| docs/architecture.md | Updates architecture notes to include Anthropic/Ollama as wired providers. |
| docs/api/crd-reference.md | Documents spec.provider and spec.guardrails[] and clarifies precedence/semantics. |
| deploy/helm/kars/templates/crd-inferencepolicy.yaml | Regenerates CRD schema to include provider/guardrails fields + validations. |
| controller/src/reconciler/mod.rs | Forwards router-only env vars for provider endpoints/keys and moderation backend to sidecars (skipping empty). |
| controller/src/inference_policy.rs | Adds typed InferenceProvider + guardrail stage types to the CRD model. |
| controller/src/inference_policy_reconciler.rs | Enforces bundleRef mutual exclusion with provider/guardrails and keeps bundle canonical format unchanged. |
| controller/src/inference_policy_compile.rs | Emits compiled profile JSON keys provider and guardrails (null when absent) with wire-tag pin tests. |
| controller/src/crd_validations.rs | Adds CEL validations for bundleRef exclusivity and guardrail stage count bounds (1–8). |
| controller/src/config_hash.rs | Adds provider/guardrail endpoint env vars (non-secret) to config hash inputs. |
| CHANGELOG.md | Documents the multi-provider + guardrails slice and associated behavior/constraints. |
@microsoft-github-policy-service agree |
Address Copilot review on Azure#488: - Responses-API recovery SSE branch now emits the outcome-accurate frame (content_policy_violation vs guardrail_unavailable/ guardrail_misconfigured) instead of a hard-coded violation frame — scan_openai_output_guardrails returns the block as data and each transport picks its wire shape. - SseGuardState trims retained scan context to MAX_SCAN_CHARS after every clean scan; output scans only ever submit the trailing MAX_SCAN_CHARS anyway, so per-connection memory stays bounded on long streams without weakening scanned-before-delivery. Regression test added.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated 4 comments.
Suppressed comments (5)
inference-router/src/guardrails.rs:731
- If an SSE
data:payload is not valid JSON (or doesn't match the expected dialect),unscannedstays at 0 and the code may treat the chunk as safe to release. That breaks the “no model text reaches the client before a scan has covered it” contract and makes the guard bypassable by non-JSON/variant frames. A safe fallback is to treat unparseabledata:payloads as text-bearing and include them in the scan context (or fail closed).
if let Ok(event) = serde_json::from_str::<serde_json::Value>(payload)
&& let Some(text) = delta_text_from_event(self.dialect, &event)
{
self.unscanned += text.chars().count();
self.accumulated.push_str(&text);
}
inference-router/src/guardrails.rs:745
- In
on_chunk, whenunscanned == 0the code immediately releases all held bytes. This is unsafe whenline_carrycontains a partialdata:line split across chunks: the first chunk can contain unscanned model output bytes but no newline yet, so it gets released before the JSON payload is parsed and scanned. Only release immediately when there is no partial line carry buffered.
// Fast path: window not full. Chunks carrying no delta
// text at all (keepalives, role/annotation frames) are
// safe to release immediately when nothing text-bearing
// is being held alongside them.
if self.unscanned == 0 {
return SseGuardStep::Release(std::mem::take(&mut self.held));
}
inference-router/src/routes/anthropic_messages.rs:287
- PR description claims
x-kars-decision*headers are attached on every block. The Anthropic route returnsdeny_response(...)for provider-resolution and guardrail failures, but this helper currently doesn’t inject the decision headers (unlike the/v1/chat/completionspath). That’s observable API drift for clients relying on those headers for auditing/telemetry.
// Multi-provider slice: retarget at the policy-selected provider.
// Fails closed — see `routes::apply_provider_resolution`.
if let Err(e) = crate::routes::apply_provider_resolution(&state, &mut upstream, &policy) {
tracing::warn!(
target: "inference.audit",
sandbox = %sandbox_name,
inference_policy_digest = %policy.digest,
decision = "deny",
gate = "provider_resolution",
error = %e,
"InferencePolicy provider could not be resolved (anthropic route)"
);
let status = match e {
ProviderError::Unimplemented { .. } => StatusCode::NOT_IMPLEMENTED,
_ => StatusCode::SERVICE_UNAVAILABLE,
};
return deny_response(status, &e.to_string(), "api_error");
}
docs/api/crd-reference.md:495
- Docs currently state
spec.bundleRefis an alternative to inlineprovider/guardrails, but the controller-side reconciler explicitly notes the signed-bundle canonical format does not carryprovider/guardrailsyet (they’re forced toNonewhen a bundle is used). As written, this suggests operators can move these fields into bundles when they currently cannot.
| `spec.bundleRef` | Signed OCI artifact alternative to inline `tokenBudget` / `contentSafety` / `modelPreference` / `provider` / `guardrails` / `displayName`. `appliesTo` always comes from the CR. |
deploy/helm/kars/templates/crd-inferencepolicy.yaml:198
- The CRD schema text says guardrail stages are materialized “at policy load time”, but the router builds the
GuardrailPipelineper request from the policy snapshot (build_guardrail_pipelineinroutes/chat_completions.rs). This description looks stale/misleading for operators troubleshooting runtime failures (which happen at request time).
A single stage of the router-side guardrail pipeline. The router
materialises each stage into a scanner (network client + policy)
at policy load time; a stage whose backend is not configured on
the router (e.g. missing moderation API key) fails the *request*
Second Copilot review round on Azure#488: - SSE guard accepts 'data:' with or without whitespace, so spaceless events can't slip through the stream scan unrecognised. - New guardrails::scan_text_or_raw: when a declared input/output guardrail is active and the body fails to parse as JSON, scan the raw (lossy-UTF-8) bytes instead of skipping — applied to the chat-completions input scan, buffered output scan, and the Anthropic pass-through buffered output scan.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (6)
inference-router/src/proxy.rs:527
- Same as the buffered forward path: this wraps provider-specific credential resolution, not just token acquisition. The current message is misleading for Anthropic/Ollama.
let credential = credential_for_upstream(&auth, copilot.as_deref(), &upstream)
.await
.context("Failed to acquire auth token")?;
let headers = build_upstream_headers(&request_headers, &auth, &credential, &upstream.endpoint)?;
inference-router/src/routes/chat_completions.rs:387
- The explicit 501 returned when a policy selects the Anthropic provider doesn’t attach the canonical
x-kars-decision*headers, unlike other provider/guardrail blocks in this handler. This makes denial telemetry inconsistent for downstream tooling that relies on these headers.
if upstream.provider == ProviderKind::Anthropic {
return errors::openai(
StatusCode::NOT_IMPLEMENTED,
"InferencePolicy selects provider 'anthropic', which serves the Anthropic \
Messages API — send Anthropic-shaped requests to /anthropic/v1/messages \
(chat-completions translation for Anthropic is not implemented)",
"provider_unimplemented",
)
.into_response();
inference-router/src/proxy.rs:300
- This error context is now used for all provider types (including Anthropic API keys and unauthenticated Ollama), so the message “auth token” is misleading. Renaming it to “upstream credential” makes logs/errors accurate across providers.
This issue also appears on line 524 of the same file.
let credential = credential_for_upstream(auth, copilot, upstream)
.await
.context("Failed to acquire auth token")?;
controller/src/crd_validations.rs:283
- The validation message claims “the bundle carries those content fields”, but this repo currently explicitly drops
provider/guardrailswhenbundleRefis used (seemerge_bundle_with_selectorin this same PR). The message should not imply the bundle containsprovider/guardrailsyet.
ValidationRule {
rule: "!has(self.bundleRef) || (!has(self.tokenBudget) && !has(self.contentSafety) && !has(self.modelPreference) && !has(self.provider) && !has(self.guardrails) && !has(self.displayName))".into(),
message: Some("spec.bundleRef is mutually exclusive with spec.tokenBudget, spec.contentSafety, spec.modelPreference, spec.provider, spec.guardrails, and spec.displayName; the bundle carries those content fields".into()),
reason: Some("FieldValueInvalid".into()),
..ValidationRule::default()
deploy/helm/kars/templates/crd-inferencepolicy.yaml:331
- This CRD CEL validation message also claims “the bundle carries those content fields”, but bundle-sourced policies currently do not carry
provider/guardrailsyet (they are forced to defaults). The message should avoid implying otherwise.
- message: spec.bundleRef is mutually exclusive with spec.tokenBudget, spec.contentSafety, spec.modelPreference, spec.provider, spec.guardrails, and spec.displayName; the bundle carries those content fields
reason: FieldValueInvalid
rule: '!has(self.bundleRef) || (!has(self.tokenBudget) && !has(self.contentSafety) && !has(self.modelPreference) && !has(self.provider) && !has(self.guardrails) && !has(self.displayName))'
docs/api/crd-reference.md:495
- The docs currently describe
bundleRefas an alternative to inlineprovider/guardrails, which implies those fields can come from the signed bundle. In this PR they’re explicitly inline-only and mutually exclusive withbundleRef, and bundle-sourced policies drop them to defaults. Updating this line would prevent operator confusion.
| `spec.bundleRef` | Signed OCI artifact alternative to inline `tokenBudget` / `contentSafety` / `modelPreference` / `provider` / `guardrails` / `displayName`. `appliesTo` always comes from the CR. |
…esponses Found in live testing against api.anthropic.com: when the upstream connection negotiates HTTP/1.1, Anthropic responds with transfer-encoding: chunked. The pass-through handler copied all upstream headers onto the rebuilt axum response, and hyper refuses to serialize a response carrying a stale framing header — the client got an empty reply despite a 200 from upstream. The pre-existing Copilot pass-through never hit this (h2 end-to-end), and wiremock tests don't (simple headers). Both relay loops (buffered + streaming) now skip the RFC 9110 connection-specific headers; hyper re-frames the body itself. Verified live: non-streaming + SSE Messages against api.anthropic.com, chat completions (buffered + SSE) against local Ollama, policy hot-reload provider swap, guardrail fail-closed without a key (503), and input-block + mid-stream output cut against a local moderation stub.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (3)
inference-router/src/proxy.rs:526
- Same as above: this path resolves provider-specific credentials (including unauthenticated Ollama), so “auth token” is no longer an accurate description of what failed.
let credential = credential_for_upstream(&auth, copilot.as_deref(), &upstream)
.await
.context("Failed to acquire auth token")?;
inference-router/src/guardrails.rs:725
SseGuardState::ingest_textdecodes arbitrary upstream bytes viaString::from_utf8_lossy(chunk)and appends intoline_carry. If a multi-byte UTF-8 sequence is split across chunk boundaries,from_utf8_lossycan replace it with U+FFFD, so the guardrail scans a different text than what the client ultimately receives. This can weaken the “no unscanned text is released” guarantee for non-ASCII output.
Consider buffering as raw bytes and only decoding complete lines (split on \n) with std::str::from_utf8, failing closed (cut the stream with a guardrail error frame) on invalid UTF-8 / unparseable data: payloads.
fn ingest_text(&mut self, chunk: &[u8]) {
self.line_carry.push_str(&String::from_utf8_lossy(chunk));
// Keep the trailing partial line (no '\n' yet) in the carry.
inference-router/src/proxy.rs:299
- The error context string is now misleading:
credential_for_upstreamcan return an Anthropic API key orNonefor Ollama, not just an auth token. Using “auth token” in the context makes failures harder to interpret when debugging non-Azure providers.
This issue also appears on line 524 of the same file.
let credential = credential_for_upstream(auth, copilot, upstream)
.await
.context("Failed to acquire auth token")?;
…ning Independent review of Azure#488 found four blockers; all fixed with tests and verified live against real Anthropic/Ollama + a moderation stub. B1 — SSE hold-and-release leaked unscanned model text on a chunk boundary that fell inside a data: line: the partial line's bytes were already in `held` but its delta text was uncounted, and the unscanned==0 fast-release shipped them. Now never release while a partial line is buffered; on_end flushes a trailing unterminated line before the final scan. Regression test splits an event mid-content; live test with a 40-char threshold delivered only the error frame. B2 — buffered Responses-API recovery path (400 'unsupported' → /responses) returned the completion with no output scan. Added the enforce_openai_output_guardrails call, matching the other two recovery paths. B3 — routing was retroactively driven by the pre-existing modelPreference.primary.provider tag, so an unchanged CR could 503 or silently cross clouds on upgrade. spec.provider is now the sole routing selector; modelPreference.provider stays informational (drives deployment failover only). Verified: modelPreference provider=anthropic with no spec.provider stays on the Azure upstream. B4 — the sibling inference routes (/v1/completions, /v1/responses, /v1/embeddings, image generation) didn't consult provider/guardrail policy, so an agent could bypass both by not using chat/completions. They now fail closed: 501 on a non-Azure spec.provider, 403 when guardrails are declared. Pure classifier unit-tested; live-verified 501 on /v1/embeddings and /v1/responses under an ollama policy. M1 — 16k scan cap truncated instead of windowing, letting content hide past the cap; GUARDRAIL_STREAM_SCAN_CHARS was unclamped. Now scan_windows() covers all text in successive MAX_SCAN_CHARS windows and the stream threshold is clamped to the cap. Docs/CHANGELOG corrected: routing precedence, sibling-route scope, windowed scanning, and the tool-arg / thinking-delta output-scan gaps (roadmap). 1958 tests green.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
controller/src/inference_policy.rs:166
- The doc comment claims
modelPreference.primary.provider“takes precedence” overspec.provider, but the router implementation and docs statespec.provideris the only routing selector andmodelPreference.*.providerstays informational. This comment is misleading and suggests behavior that does not exist.
/// process. `modelPreference.primary.provider`, when it names a
/// recognised tag, takes precedence over this field so a fallback
/// chain can pin its own route. Mutually exclusive with
Review comment on Azure#488: SseGuardState::ingest_line ignored data: payloads that don't parse as JSON, so unscanned stayed 0 and the fast-release path shipped those bytes without a scan — an upstream drift / malformed frame could bypass the scanned-before-delivery contract. Non-JSON data payloads are now added to the scan buffer as raw text. Valid-JSON structural frames (ping / role-only / stop) still release without a scan round-trip; the non-content extraction surface (tool-call args, thinking deltas) remains the documented follow-up. Two regression tests added.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (3)
controller/src/inference_policy.rs:167
- The doc comment says
modelPreference.primary.providertakes precedence overspec.provider, but the router code explicitly ignoresmodelPreference.*.providerfor routing (seeinference-router/src/routes/mod.rswhereapply_model_preference_overrideignoresprimary.providerand provider routing is driven bypolicy.provider). This comment is misleading for CRD/API consumers and contradicts the fail-closed routing semantics described elsewhere in the PR.
/// process. `modelPreference.primary.provider`, when it names a
/// recognised tag, takes precedence over this field so a fallback
/// chain can pin its own route. Mutually exclusive with
inference-router/src/proxy.rs:118
- The comment implies an inbound SDK-provided auth value is copied through, but
build_upstream_headersexplicitly strips inboundx-api-key(and other creds) before injecting provider auth. Clarifying that onlyanthropic-versionis preserved (whilex-api-keyis always replaced) makes the security behavior unambiguous.
// Anthropic's Messages API authenticates with `x-api-key` and
// requires an `anthropic-version` header. The inbound SDK
// value (when present) was already copied through above —
// only the default is filled in here.
controller/src/reconciler/mod.rs:1964
- The router supports
OPENAI_MODERATION_MODELandGUARDRAIL_STREAM_SCAN_CHARSenv overrides (seeinference-router/src/config.rsandinference-router/src/guardrails.rs), but the controller only forwards moderation key/endpoint into the router sidecar env. In controller-managed sandboxes this makes those overrides effectively unusable, despite being documented/tunable in the router layer.
// Multi-provider inference + guardrail backends. Router-only —
// never on the agent container. Empty values are skipped so
// Azure-only clusters keep an identical env surface (and
// config-hash) to previous releases.
for (name, value) in [
("ANTHROPIC_API_KEY", &ctx.anthropic_api_key),
("ANTHROPIC_ENDPOINT", &ctx.anthropic_endpoint),
("OLLAMA_ENDPOINT", &ctx.ollama_endpoint),
("OPENAI_MODERATION_API_KEY", &ctx.openai_moderation_api_key),
(
"OPENAI_MODERATION_ENDPOINT",
&ctx.openai_moderation_endpoint,
),
] {
Condense the narrative doc/inline comments added across this slice (guardrails, provider, chat_completions, anthropic_messages, config, loader, and the controller CRD types) down to the constraints the code can't show. Also corrects the stale spec.provider field doc — it's the sole routing selector now, modelPreference.provider is informational. Helm CRD regenerated for the trimmed schema descriptions; behaviour unchanged, 1960 tests green.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (3)
inference-router/src/guardrails.rs:414
scan_windowsis documented as splitting by characters, but the early-return check usestext.len()(bytes). For non-ASCII text this can enter the slow path unnecessarily and makes the implementation diverge from the stated contract. Use a char-count check for consistency with the rest of the function.
fn scan_windows(text: &str) -> Vec<&str> {
if text.len() <= MAX_SCAN_CHARS {
return vec![text];
}
inference-router/src/routes/chat_completions.rs:249
- The error
codeused forRouteGap::NonAzureProviderisprovider_unimplemented, but the message indicates the provider is implemented and only this route lacks enforcement. Using a distinct code (e.g.provider_route_unsupported) avoids conflating “provider not implemented by this router build” (bedrock) with “implemented elsewhere but unsupported on this endpoint”.
RouteGap::NonAzureProvider => (
StatusCode::NOT_IMPLEMENTED,
"provider_unimplemented",
format!(
inference-router/src/config.rs:108
- The doc comment for
openai_moderation_api_keysays it “falls back toOPENAI_API_KEY, then theopenai-moderation-api-keysecret mount”, but the implementation checks theopenai-moderation-api-keymount (viasecret_from_env_or_mount) before falling back toOPENAI_API_KEY. Updating the comment avoids confusing operators about precedence.
/// OpenAI Moderation key — `OPENAI_MODERATION_API_KEY` env (falls
/// back to `OPENAI_API_KEY`, then the `openai-moderation-api-key`
/// secret mount). `None` ⇒ `openai-moderation` stages fail closed.
|
Thanks a lot for this John Seong (@sandole) - this looks really great - I am going to review it properly this week! :) |
Summary
This is a first slice of the multi-cloud provider + guardrails roadmap theme. It lets an
InferencePolicypick the inference provider and declare a guardrail pipeline, and teaches the router to actually honor both:I started with Anthropic and Ollama because they're the two cheapest to do properly: Anthropic gets native Messages pass-through on
/anthropic/v1/messages(streaming, tool use and multi-modal all survive, which the existing translation route couldn't offer), and Ollama is OpenAI-compatible so chat completions just work, buffered and SSE, with token metering intact.The part I'd most like eyes on is the streaming guardrail. Scanning a stream after the fact is theater, so the SSE guard holds chunks back and only releases a window once a scan has covered it (default 1000 chars per window,
GUARDRAIL_STREAM_SCAN_CHARSto tune). Flagged streams get cut with a structured error frame. The trade-off is chunkier delivery when a pipeline is active; policies without guardrails don't pay anything.Everything is fail-closed on purpose. A policy that names a provider the router can't serve gets a 501 (
bedrock) or 503 (missing key/endpoint) instead of quietly falling back to Azure, and a declared guardrail stage that can't run blocks the request rather than becoming an open gate. Provider keys (ANTHROPIC_API_KEY,OPENAI_MODERATION_API_KEY) only ever exist on the router sidecar - the reconciler forwards them from controller env the same way the dev creds already travel, and the router strips/replaces anyx-api-keyan agent tries to smuggle through (there's an integration test for exactly that).Helm CRD template regenerated via the drift-test dumper. Endpoints (not secrets) joined
CONFIG_HASH_INPUTS. Both Copilot review comments are addressed in4b48b87c(outcome-accurate SSE error frames; bounded stream scan context).Related Issues
No existing issue tracks this - the provider-expansion note in
docs/architecture.mdasks for a feature request per provider, so I'm happy to file one (auth model, Foundry-feature preservation, the works) and link it here if that's the flow you want. Follow-up slices (Bedrock, Vertex, vLLM; Bedrock Guardrails, Model Armor) would extend the same two seams:provider::ProviderKindand theguardrails::Guardrailtrait.Type of Change
Checklist
cargo check --workspace --tests)collapsible_ifinroutes/mod.rsalso fires onmainwith this toolchain)docs/api/crd-reference.md,docs/architecture.md,CHANGELOG.md)crd-inferencepolicy.yamlregenerated)Testing
Automated (all offline):
inference-router/tests/multi_provider_guardrails.rs): fake Ollama (URL shape, no credentials sent), fake Anthropic (router key replaces agent-supplied key,anthropic-versioninjected), fake moderation endpoint (flag/pass/500→fail-closed), and missing-key pipeline construction failure.Live, running the router binary locally:
api.anthropic.com, non-streaming and SSE, plus the 501 onchat/completionsunder an Anthropic policy.guardrail_misconfigured), input blocked pre-flight (403), and a mid-stream output cut - clean prompt, model emits the flagged word, zero bytes of model text reach the client, just the structured error frame +[DONE].kars_guardrail_scans_totalcounted every scan by direction/outcome. The moderation verdict itself came from a local stub (no OpenAI key on this machine); everything around it was real.Enforcement edge cases the tests + live run cover explicitly:
data:line, and scans non-JSONdata:payloads as raw text rather than fast-releasing an unrecognised frame. A 40-char-threshold live run delivered only the error frame on a cut, zero content deltas.spec.provideris the sole routing selector; the pre-existingmodelPreference.primary.providertag stays informational, so an unchanged CR that only set a model preference keeps its Azure upstream (verified live)./v1/completions,/v1/responses,/v1/embeddings, image generation) don't implement provider routing or guardrails, so they fail closed (501 on a non-Azure provider, 403 when guardrails are declared) rather than silently bypass the policy and live-verified 501 on/v1/embeddingsand/v1/responses. Text over 16k chars is scanned in successive windows, not truncated.