dwara_core/dataplane/
ai_proxy.rs

1//! The AI proxy action (DW-075): the dataplane half of the
2//! provider-adapter pack — extended by DW-076 with routing and
3//! failover.
4//!
5//! Runs when a route's action is `ai`: parse the client's OpenAI
6//! chat-completions body, resolve the model alias through the
7//! generation's [`AiRuntime`](crate::ai::AiRuntime), translate via the
8//! provider's [`ProviderAdapter`],
9//! place the call through the provider's UPSTREAM (pooling, TLS,
10//! timeouts, breaker, health — the standard machinery), and translate
11//! the response back. All failures answer in the OpenAI error shape so
12//! standard client SDKs parse them; the gateway's request id rides
13//! along inside `error.request_id`.
14//!
15//! # Routing and failover (DW-076)
16//!
17//! `AiRuntime::route` turns the alias into an ordered candidate list:
18//! `[primary, failover...]` for a chained alias, or exactly one
19//! deterministically-picked canary version for a split alias (see
20//! `ai::routing`). The action walks the candidates and advances on
21//! RETRYABLE outcomes — a 429, a 5xx, a transport error, or a
22//! dialect-specific translation rejection — and treats everything
23//! else as final. Failover is invisible to the client by
24//! construction: a provider response is read and translated WHOLE
25//! before any byte reaches the client, so a failing candidate emits
26//! nothing client-visible. Same-provider retries are deliberately not
27//! the chain's job — the provider's upstream breaker owns those; the
28//! chain only ever moves to a DIFFERENT provider/model pair. Usage and
29//! request metrics attribute to the provider and canary version that
30//! actually served; the access record follows the serving provider.
31//!
32//! Body bounds: an AI request must be read whole to translate it (the
33//! zero-buffering rule yields here by necessity, opt-in by choosing
34//! the action), and both directions are capped INCREMENTALLY — the
35//! read aborts as soon as the cap is crossed, never after buffering
36//! the whole body — at [`MAX_AI_REQUEST_BYTES`] inbound and
37//! [`MAX_AI_PROVIDER_RESPONSE_BYTES`] from the provider, so a hostile
38//! or runaway peer cannot turn the translation into an unbounded
39//! buffer.
40//!
41//! Streaming (DW-077): `stream: true` requests stream back to the
42//! client as `text/event-stream`, translated frame-by-frame with ZERO
43//! added buffering ([`AiStreamBody`] + [`StreamTranslator`]): each
44//! complete provider frame becomes client frames in the same poll.
45//! Token counts are provider-reported only (locked decision) and
46//! accumulate mid-stream into one terminal usage chunk; the gateway
47//! owns the `data: [DONE]` terminator. The failover chain applies
48//! until the streaming response is returned (the commit point); after
49//! the first forwarded frame a provider abort ends the stream cleanly
50//! with an error chunk — already-forwarded content stands.
51
52use crate::ai::adapter::{adapter_for, ProviderAdapter};
53use crate::ai::openai_compat;
54use crate::ai::stream::StreamTranslator;
55use crate::ai::types::{ChatRequest, Usage};
56use crate::ai::CompiledProvider;
57use crate::config::ai::UnknownModelPolicy;
58use crate::dataplane::proxy::{consumer_type_str, DataPlane, Generation, ProxyBody};
59use bytes::Bytes;
60use http_body_util::{BodyExt as _, Full};
61use hyper::body::Body;
62use hyper::{HeaderMap, Method, Request, Response, StatusCode};
63use std::pin::pin;
64use std::pin::Pin;
65use std::sync::Arc;
66use std::task::{Context, Poll};
67use tracing::Instrument as _;
68
69/// Inbound AI request body cap: 16 MiB, generous against long-context
70/// requests while bounding the translation buffer.
71pub const MAX_AI_REQUEST_BYTES: usize = 16 * 1024 * 1024;
72
73/// Provider response body cap: 32 MiB (non-streaming completions of
74/// extreme length). Provider ERROR bodies are capped tighter at
75/// [`MAX_AI_ERROR_BYTES`] — they are never legitimately large.
76pub const MAX_AI_PROVIDER_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
77
78/// Provider error body cap: 1 MiB.
79pub const MAX_AI_ERROR_BYTES: usize = 1024 * 1024;
80
81/// AI-02: the provider path for each non-chat endpoint. The passthrough
82/// forwards the request body as-is to the provider's upstream at this
83/// path (no adapter translation). The path is OpenAI-compatible (the
84/// most common target); providers with different paths can override
85/// the path per-provider via `ai.providers[].path`.
86fn passthrough_path(endpoint: crate::config::AiEndpoint) -> &'static str {
87    match endpoint {
88        crate::config::AiEndpoint::Embeddings => "/v1/embeddings",
89        crate::config::AiEndpoint::Images => "/v1/images/generations",
90        crate::config::AiEndpoint::Audio => "/v1/audio/speech",
91        crate::config::AiEndpoint::Moderation => "/v1/moderations",
92        crate::config::AiEndpoint::Chat => "/v1/chat/completions",
93        // AI-04 (#194): Batch API uses /v1/batches for create/status.
94        // The result file endpoint (/v1/batches/{id}/output) is handled
95        // separately in serve_ai_batch_results.
96        crate::config::AiEndpoint::Batch => "/v1/batches",
97    }
98}
99
100/// Resolve the outbound request path for a provider. When the provider
101/// has a `path` override configured, it is used verbatim for ALL
102/// endpoints (chat, embeddings, etc.). When unset, the adapter's
103/// per-kind default path is used.
104fn resolve_path<'a>(provider: &'a CompiledProvider, default: &'a str) -> &'a str {
105    provider.path.as_deref().unwrap_or(default)
106}
107
108/// AI-02: serve a non-chat AI endpoint (embeddings, images, audio,
109/// moderation) as a passthrough. The request body is forwarded to the
110/// provider's upstream as-is (no adapter translation), and the response
111/// is returned as-is. Model governance (allowlist check) still applies
112/// using the `model` field from the request body. Budget enforcement
113/// and semantic caching do NOT apply (they are chat-specific).
114#[allow(clippy::too_many_arguments)]
115async fn serve_ai_passthrough(
116    json_body: serde_json::Value,
117    route_name: &str,
118    gen: &Arc<Generation>,
119    dp: &Arc<DataPlane>,
120    rid: &str,
121    rec: &mut crate::observability::AccessRecord,
122    identity: Option<&crate::security::authn::Identity>,
123    endpoint: crate::config::AiEndpoint,
124) -> Response<ProxyBody> {
125    // Extract the model alias from the request body for governance and
126    // routing. The `model` field is present in all OpenAI endpoint
127    // request shapes (embeddings, images, audio, moderation). When
128    // absent, the request is forwarded without governance/routing (a
129    // passthrough to the first provider's upstream).
130    let model_alias = json_body
131        .get("model")
132        .and_then(serde_json::Value::as_str)
133        .unwrap_or("");
134
135    // DW-084: model governance pre-route check (same as chat). The
136    // requested model alias is checked against the consumer's binding
137    // team allowlists BEFORE routing.
138    let gateway = gen.snapshot.gateway();
139    let governance = dp.ai_governance();
140    if !governance.is_empty() {
141        let consumer = identity.map(|id| id.consumer_name.as_str());
142        let route_cfg = gateway.routes.iter().find(|r| r.name == route_name);
143        let route_policies: &[String] = route_cfg.map(|r| r.policies.as_slice()).unwrap_or(&[]);
144        let service_policies: &[String] = route_cfg
145            .map(|r| crate::ai::budget::service_policies_of(gateway, &r.service))
146            .unwrap_or(&[]);
147        let listener_policies = crate::ai::budget::listener_policies_of(gateway, &rec.listener);
148        let consumer_policies: &[String] = consumer
149            .map(|c| crate::ai::budget::consumer_policies_of(gateway, c))
150            .unwrap_or(&[]);
151        let verdict = governance.check(
152            consumer,
153            consumer_policies,
154            route_policies,
155            service_policies,
156            listener_policies,
157            &[],
158            model_alias,
159        );
160        if let crate::ai::governance::GovernanceVerdict::Deny { reason, .. } = &verdict {
161            dp.observability_arc().record_ai_governance_denied(reason);
162            return ai_error_response(
163                StatusCode::FORBIDDEN,
164                &format!("model '{}' is denied by policy: {reason}", model_alias),
165                "invalid_request_error",
166                Some("model_denied_by_policy"),
167                rid,
168            );
169        }
170    }
171
172    // Route the model alias to find the provider (same runtime as
173    // chat). When the alias is empty or not found, return 404.
174    let Some(runtime) = gen.ai() else {
175        return ai_error_response(
176            StatusCode::INTERNAL_SERVER_ERROR,
177            "no ai block configured",
178            "api_error",
179            Some("internal_error"),
180            rid,
181        );
182    };
183    let (candidates, _, _) = runtime
184        .route_with_policy_and_canary_index(model_alias, rid, "")
185        .await;
186    if candidates.is_empty() {
187        return ai_error_response(
188            StatusCode::NOT_FOUND,
189            &format!("the model '{}' does not exist", model_alias),
190            "invalid_request_error",
191            Some("model_not_found"),
192            rid,
193        );
194    }
195
196    // Forward to the first candidate (no failover for passthrough —
197    // the response is returned as-is, so retry logic would need to
198    // buffer the entire response, which defeats the passthrough
199    // design).
200    let target = &candidates[0];
201    let version = target.version.as_deref().unwrap_or("default");
202    let Some(provider) = runtime.provider(&target.provider) else {
203        return ai_error_response(
204            StatusCode::INTERNAL_SERVER_ERROR,
205            "no provider could serve the model",
206            "api_error",
207            Some("provider_unreachable"),
208            rid,
209        );
210    };
211    let Some(handle) = gen.registry().get(&provider.upstream) else {
212        return ai_error_response(
213            StatusCode::INTERNAL_SERVER_ERROR,
214            "no provider could serve the model",
215            "api_error",
216            Some("provider_unreachable"),
217            rid,
218        );
219    };
220    rec.upstream = Some(provider.upstream.clone());
221    rec.attempts = 1;
222
223    // Build the outbound request: the passthrough path + the original
224    // body + the provider's auth headers.
225    let path = resolve_path(provider, passthrough_path(endpoint));
226    let mut outbound = http::Request::builder()
227        .method(http::Method::POST)
228        .uri(path);
229    outbound = outbound.header("content-type", "application/json");
230    let auth_pairs: Vec<(String, String)> = if let Some(pool) = &provider.credential_pool {
231        match pool.pick(rid) {
232            Some(entry) => vec![(entry.header.clone(), entry.value.clone())],
233            None => provider.auth_headers.clone(),
234        }
235    } else {
236        provider.auth_headers.clone()
237    };
238    for (name, value) in &auth_pairs {
239        if let (Ok(n), Ok(v)) = (
240            hyper::header::HeaderName::from_bytes(name.as_bytes()),
241            hyper::header::HeaderValue::from_str(value),
242        ) {
243            outbound = outbound.header(n, v);
244        }
245    }
246    let body_bytes = serde_json::to_vec(&json_body).unwrap_or_default();
247    let outbound = match outbound.body(Full::new(Bytes::from(body_bytes))) {
248        Ok(r) => r,
249        Err(_) => {
250            return ai_error_response(
251                StatusCode::INTERNAL_SERVER_ERROR,
252                "failed to build provider request",
253                "api_error",
254                Some("internal_error"),
255                rid,
256            );
257        }
258    };
259
260    let obs = dp.observability_arc();
261    let started = std::time::Instant::now();
262    let upstream_resp = match handle.send(outbound).await {
263        Ok(r) => r,
264        Err(e) => {
265            obs.record_ai_request(&provider.name, route_name, "transport_error", version);
266            obs.record_ai_request_duration(
267                &provider.name,
268                route_name,
269                started.elapsed().as_secs_f64(),
270            );
271            tracing::warn!(
272                code = "ai_provider_unreachable",
273                request_id = %rid,
274                provider = %provider.name,
275                upstream = %provider.upstream,
276                "ai passthrough call failed: {e}"
277            );
278            return ai_error_response(
279                StatusCode::BAD_GATEWAY,
280                "no provider could serve the model",
281                "api_error",
282                Some("provider_unreachable"),
283                rid,
284            );
285        }
286    };
287
288    let status = upstream_resp.status();
289    let up_headers = upstream_resp.headers().clone();
290    let up_body = upstream_resp.into_body();
291    obs.record_ai_request(
292        &provider.name,
293        route_name,
294        if status.is_success() {
295            "success"
296        } else {
297            "error"
298        },
299        version,
300    );
301    obs.record_ai_request_duration(&provider.name, route_name, started.elapsed().as_secs_f64());
302
303    if !status.is_success() {
304        let err_bytes =
305            match bounded_collect(up_body, MAX_AI_ERROR_BYTES, "provider error", rid).await {
306                Ok(b) => b,
307                Err(resp) => return resp,
308            };
309        let err_json: serde_json::Value = serde_json::from_slice(&err_bytes).unwrap_or(
310            serde_json::json!({"error": {"message": String::from_utf8_lossy(&err_bytes).to_string()}}),
311        );
312        return response_with_json(status, &err_json, &up_headers);
313    }
314
315    // Success: return the response body as-is (no translation).
316    let ok_bytes =
317        match bounded_collect(up_body, MAX_AI_PROVIDER_RESPONSE_BYTES, "response", rid).await {
318            Ok(b) => b,
319            Err(resp) => return resp,
320        };
321    Response::builder()
322        .status(status)
323        .header("content-type", "application/json")
324        .body(ProxyBody::Full(Full::new(ok_bytes)))
325        .expect("passthrough response is valid")
326}
327
328/// AI-05 (#195): chat passthrough mode. Forward the chat request body
329/// as-is to the provider (no adapter translation), but still apply
330/// governance and routing. The model is extracted from the body for
331/// routing. The response is returned as-is.
332#[allow(clippy::too_many_arguments)]
333async fn serve_ai_chat_passthrough(
334    json_body: serde_json::Value,
335    route_name: &str,
336    gen: &Arc<Generation>,
337    dp: &Arc<DataPlane>,
338    rid: &str,
339    rec: &mut crate::observability::AccessRecord,
340    identity: Option<&crate::security::authn::Identity>,
341) -> Response<ProxyBody> {
342    // Extract the model alias from the request body for governance and
343    // routing. The `model` field is present in all chat dialects.
344    let model_alias = json_body
345        .get("model")
346        .and_then(serde_json::Value::as_str)
347        .unwrap_or("");
348
349    // DW-084: model governance pre-route check (same as chat and
350    // non-chat passthrough).
351    let gateway = gen.snapshot.gateway();
352    let governance = dp.ai_governance();
353    if !governance.is_empty() {
354        let consumer = identity.map(|id| id.consumer_name.as_str());
355        let route_cfg = gateway.routes.iter().find(|r| r.name == route_name);
356        let route_policies: &[String] = route_cfg.map(|r| r.policies.as_slice()).unwrap_or(&[]);
357        let service_policies: &[String] = route_cfg
358            .map(|r| crate::ai::budget::service_policies_of(gateway, &r.service))
359            .unwrap_or(&[]);
360        let listener_policies = crate::ai::budget::listener_policies_of(gateway, &rec.listener);
361        let consumer_policies: &[String] = consumer
362            .map(|c| crate::ai::budget::consumer_policies_of(gateway, c))
363            .unwrap_or(&[]);
364        let verdict = governance.check(
365            consumer,
366            consumer_policies,
367            route_policies,
368            service_policies,
369            listener_policies,
370            &[],
371            model_alias,
372        );
373        if let crate::ai::governance::GovernanceVerdict::Deny { reason, .. } = &verdict {
374            dp.observability_arc().record_ai_governance_denied(reason);
375            return ai_error_response(
376                StatusCode::FORBIDDEN,
377                &format!("model '{}' is denied by policy: {reason}", model_alias),
378                "invalid_request_error",
379                Some("model_denied_by_policy"),
380                rid,
381            );
382        }
383    }
384
385    // Route the model alias to find the provider.
386    let Some(runtime) = gen.ai() else {
387        return ai_error_response(
388            StatusCode::INTERNAL_SERVER_ERROR,
389            "no ai block configured",
390            "api_error",
391            Some("internal_error"),
392            rid,
393        );
394    };
395    let (candidates, _, _) = runtime
396        .route_with_policy_and_canary_index(model_alias, rid, "")
397        .await;
398    if candidates.is_empty() {
399        return ai_error_response(
400            StatusCode::NOT_FOUND,
401            &format!("the model '{}' does not exist", model_alias),
402            "invalid_request_error",
403            Some("model_not_found"),
404            rid,
405        );
406    }
407
408    // Forward to the first candidate (no failover for passthrough).
409    let target = &candidates[0];
410    let version = target.version.as_deref().unwrap_or("default");
411    let Some(provider) = runtime.provider(&target.provider) else {
412        return ai_error_response(
413            StatusCode::INTERNAL_SERVER_ERROR,
414            "no provider could serve the model",
415            "api_error",
416            Some("provider_unreachable"),
417            rid,
418        );
419    };
420    let Some(handle) = gen.registry().get(&provider.upstream) else {
421        return ai_error_response(
422            StatusCode::INTERNAL_SERVER_ERROR,
423            "no provider could serve the model",
424            "api_error",
425            Some("provider_unreachable"),
426            rid,
427        );
428    };
429    rec.upstream = Some(provider.upstream.clone());
430    rec.attempts = 1;
431
432    // Build the outbound request: the chat path + the original body +
433    // the provider's auth headers.
434    let path = resolve_path(provider, passthrough_path(crate::config::AiEndpoint::Chat));
435    let mut outbound = http::Request::builder()
436        .method(http::Method::POST)
437        .uri(path);
438    outbound = outbound.header("content-type", "application/json");
439    let auth_pairs: Vec<(String, String)> = if let Some(pool) = &provider.credential_pool {
440        match pool.pick(rid) {
441            Some(entry) => vec![(entry.header.clone(), entry.value.clone())],
442            None => provider.auth_headers.clone(),
443        }
444    } else {
445        provider.auth_headers.clone()
446    };
447    for (name, value) in &auth_pairs {
448        if let (Ok(n), Ok(v)) = (
449            hyper::header::HeaderName::from_bytes(name.as_bytes()),
450            hyper::header::HeaderValue::from_str(value),
451        ) {
452            outbound = outbound.header(n, v);
453        }
454    }
455    let body_bytes = serde_json::to_vec(&json_body).unwrap_or_default();
456    let outbound = match outbound.body(Full::new(Bytes::from(body_bytes))) {
457        Ok(r) => r,
458        Err(_) => {
459            return ai_error_response(
460                StatusCode::INTERNAL_SERVER_ERROR,
461                "failed to build provider request",
462                "api_error",
463                Some("internal_error"),
464                rid,
465            );
466        }
467    };
468
469    let obs = dp.observability_arc();
470    let started = std::time::Instant::now();
471    let upstream_resp = match handle.send(outbound).await {
472        Ok(r) => r,
473        Err(e) => {
474            obs.record_ai_request(&provider.name, route_name, "transport_error", version);
475            obs.record_ai_request_duration(
476                &provider.name,
477                route_name,
478                started.elapsed().as_secs_f64(),
479            );
480            tracing::warn!(
481                code = "ai_provider_unreachable",
482                request_id = %rid,
483                provider = %provider.name,
484                upstream = %provider.upstream,
485                "ai chat passthrough call failed: {e}"
486            );
487            return ai_error_response(
488                StatusCode::BAD_GATEWAY,
489                "no provider could serve the model",
490                "api_error",
491                Some("provider_unreachable"),
492                rid,
493            );
494        }
495    };
496
497    let status = upstream_resp.status();
498    let up_headers = upstream_resp.headers().clone();
499    let up_body = upstream_resp.into_body();
500    obs.record_ai_request(
501        &provider.name,
502        route_name,
503        if status.is_success() {
504            "success"
505        } else {
506            "error"
507        },
508        version,
509    );
510    obs.record_ai_request_duration(&provider.name, route_name, started.elapsed().as_secs_f64());
511
512    if !status.is_success() {
513        let err_bytes =
514            match bounded_collect(up_body, MAX_AI_PROVIDER_RESPONSE_BYTES, "error", rid).await {
515                Ok(b) => b,
516                Err(resp) => return resp,
517            };
518        let err_json: serde_json::Value = serde_json::from_slice(&err_bytes).unwrap_or(
519            serde_json::json!({"error": {"message": String::from_utf8_lossy(&err_bytes).to_string()}}),
520        );
521        return response_with_json(status, &err_json, &up_headers);
522    }
523
524    // Success: return the response body as-is (no translation).
525    let ok_bytes =
526        match bounded_collect(up_body, MAX_AI_PROVIDER_RESPONSE_BYTES, "response", rid).await {
527            Ok(b) => b,
528            Err(resp) => return resp,
529        };
530    Response::builder()
531        .status(status)
532        .header("content-type", "application/json")
533        .body(ProxyBody::Full(Full::new(ok_bytes)))
534        .expect("passthrough response is valid")
535}
536
537/// Serve one `ai` route action (called from `dispatch_action` in the
538/// proxy module). `rec` is the request's access record: the provider's
539/// upstream name is attributed there so analytics and the access log
540/// see which provider served the call.
541///
542/// AI-02: the `endpoint` parameter selects which AI endpoint the route
543/// serves. `Chat` uses the full adapter translation pipeline (DW-075).
544/// The other endpoints (`Embeddings`, `Images`, `Audio`, `Moderation`)
545/// are proxied as a passthrough: the request body is forwarded to the
546/// provider's upstream as-is, and the response is returned as-is.
547/// Model governance (allowlist check) still applies using the `model`
548/// field from the request body.
549#[allow(clippy::too_many_arguments)]
550pub(super) async fn serve_ai<B>(
551    req: Request<B>,
552    route_name: &str,
553    gen: &Arc<Generation>,
554    dp: &Arc<DataPlane>,
555    rid: &str,
556    rec: &mut crate::observability::AccessRecord,
557    identity: Option<&crate::security::authn::Identity>,
558    listener_name: &str,
559    endpoint: crate::config::AiEndpoint,
560    dialect: crate::config::AiIngressDialect,
561) -> Response<ProxyBody>
562where
563    B: Body<Data = Bytes> + Send + 'static,
564    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
565{
566    // AI token budget pre-check (DW-078): BEFORE the request body is
567    // even read — a holder whose window is exhausted never reaches a
568    // provider. The resolution mirrors the rate-limit chain (consumer
569    // > route > service > listener > global, most-specific budget
570    // governs); unbudgeted holders resolve no guard and skip.
571    let budget = {
572        let engine = dp.ai_budgets();
573        if engine.is_empty() {
574            None
575        } else {
576            let consumer = identity.map(|id| id.consumer_name.as_str());
577            let gateway = gen.snapshot.gateway();
578            let route_cfg = gateway.routes.iter().find(|r| r.name == route_name);
579            let route_policies: &[String] = route_cfg.map(|r| r.policies.as_slice()).unwrap_or(&[]);
580            let service_policies: &[String] = route_cfg
581                .map(|r| crate::ai::budget::service_policies_of(gateway, &r.service))
582                .unwrap_or(&[]);
583            let consumer_policies: &[String] = consumer
584                .map(|c| crate::ai::budget::consumer_policies_of(gateway, c))
585                .unwrap_or(&[]);
586            let listener_policies = crate::ai::budget::listener_policies_of(gateway, listener_name);
587            engine.resolve(
588                consumer,
589                consumer_policies,
590                route_policies,
591                service_policies,
592                listener_policies,
593                &gateway.global_policies,
594            )
595        }
596    };
597    // DW-089: collect the deduplicated applicable policy names (the
598    // same resolution order as the rate-limit check) so the AI proxy
599    // path can feed provider outcomes back to the adaptive controller.
600    let applicable_policies: Vec<String> = {
601        let gateway = gen.snapshot.gateway();
602        let consumer = identity.map(|id| id.consumer_name.as_str());
603        let route_cfg = gateway.routes.iter().find(|r| r.name == route_name);
604        let route_policies: &[String] = route_cfg.map(|r| r.policies.as_slice()).unwrap_or(&[]);
605        let service_policies: &[String] = route_cfg
606            .map(|r| crate::ai::budget::service_policies_of(gateway, &r.service))
607            .unwrap_or(&[]);
608        let consumer_policies: &[String] = consumer
609            .map(|c| crate::ai::budget::consumer_policies_of(gateway, c))
610            .unwrap_or(&[]);
611        let listener_policies = crate::ai::budget::listener_policies_of(gateway, listener_name);
612        let mut seen = std::collections::HashSet::new();
613        let mut out = Vec::new();
614        for name in consumer_policies
615            .iter()
616            .chain(route_policies)
617            .chain(service_policies)
618            .chain(listener_policies)
619            .chain(&gateway.global_policies)
620        {
621            if seen.insert(name.clone()) {
622                out.push(name.clone());
623            }
624        }
625        out
626    };
627    if let Some(guard) = &budget {
628        let now_s = std::time::SystemTime::now()
629            .duration_since(std::time::UNIX_EPOCH)
630            .map(|d| d.as_secs())
631            .unwrap_or(0);
632        if let crate::ai::budget::BudgetVerdict::Denied {
633            kind,
634            retry_after_s,
635        } = guard.check(now_s)
636        {
637            rec.rate_limited = true;
638            dp.observability_arc()
639                .record_ai_budget_denied(kind.as_str());
640            tracing::info!(
641                code = "ai_budget_exceeded",
642                request_id = %rid,
643                route = %route_name,
644                kind = kind.as_str(),
645                retry_after_s,
646                "AI token budget exhausted; rejecting before provider contact"
647            );
648            let body = openai_compat::error_body(
649                "the token budget for this window is exhausted; retry later",
650                "rate_limit_error",
651                Some("ai_budget_exceeded"),
652                rid,
653            );
654            return Response::builder()
655                .status(StatusCode::TOO_MANY_REQUESTS)
656                .header("retry-after", retry_after_s.to_string())
657                .header(hyper::header::CONTENT_TYPE, "application/json")
658                .body(ProxyBody::Full(Full::new(Bytes::from(body.to_string()))))
659                .expect("static 429 response is valid");
660        }
661    }
662
663    // The generation's compiled AI table; absent only when validation
664    // was bypassed (defensive: every publish path validates).
665    let Some(runtime) = gen.ai() else {
666        return ai_error_response(
667            // Validation rejects an ai route action without an ai
668            // block, so reaching here is a config-invariant violation,
669            // not a client error — 5xx, not 404.
670            StatusCode::INTERNAL_SERVER_ERROR,
671            "this gateway has no ai block configured",
672            "api_error",
673            Some("ai_not_configured"),
674            rid,
675        );
676    };
677
678    // 1. Read the request body, bounded incrementally.
679    let (_parts, body) = req.into_parts();
680    let bytes = match bounded_collect(body, MAX_AI_REQUEST_BYTES, "request", rid).await {
681        Ok(b) => b,
682        Err(resp) => return resp,
683    };
684
685    // 2. Parse the OpenAI-shaped body into the canonical request.
686    let json_body: serde_json::Value = match serde_json::from_slice(&bytes) {
687        Ok(v) => v,
688        Err(e) => {
689            return ai_error_response(
690                StatusCode::BAD_REQUEST,
691                &format!("request body is not valid JSON: {e}"),
692                "invalid_request_error",
693                Some("invalid_json"),
694                rid,
695            )
696        }
697    };
698
699    // AI-02: non-chat endpoints are proxied as a passthrough. The
700    // request body is forwarded to the provider's upstream as-is (no
701    // adapter translation), and the response is returned as-is. Model
702    // governance (allowlist check) still applies using the `model`
703    // field from the request body. Budget enforcement and semantic
704    // caching do NOT apply (they are chat-specific). This branch runs
705    // BEFORE chat-request parsing so non-chat bodies (which lack
706    // `messages`) do not trigger a 400.
707    if endpoint != crate::config::AiEndpoint::Chat {
708        return serve_ai_passthrough(json_body, route_name, gen, dp, rid, rec, identity, endpoint)
709            .await;
710    }
711
712    // AI-05 (#195): multi-dialect ingress. Parse the request body
713    // using the route's configured dialect. The default is OpenAI
714    // (the historical behavior). Anthropic and Gemini dialects parse
715    // the native request format into the canonical ChatRequest.
716    // Passthrough mode forwards the body as-is (no translation).
717    let mut chat_req: ChatRequest = match dialect {
718        crate::config::AiIngressDialect::Openai => {
719            match openai_compat::parse_chat_request(&json_body) {
720                Ok(r) => r,
721                Err(e) => {
722                    return ai_error_response(
723                        StatusCode::BAD_REQUEST,
724                        &e.to_string(),
725                        openai_compat::error_type_of(&e),
726                        None,
727                        rid,
728                    );
729                }
730            }
731        }
732        crate::config::AiIngressDialect::Anthropic => {
733            match crate::ai::ingress::parse_anthropic_request(&json_body) {
734                Ok(r) => r,
735                Err(e) => {
736                    return ai_error_response(
737                        StatusCode::BAD_REQUEST,
738                        &e,
739                        "invalid_request_error",
740                        None,
741                        rid,
742                    );
743                }
744            }
745        }
746        crate::config::AiIngressDialect::Gemini => {
747            match crate::ai::ingress::parse_gemini_request(&json_body) {
748                Ok(r) => r,
749                Err(e) => {
750                    return ai_error_response(
751                        StatusCode::BAD_REQUEST,
752                        &e,
753                        "invalid_request_error",
754                        None,
755                        rid,
756                    );
757                }
758            }
759        }
760        crate::config::AiIngressDialect::Passthrough => {
761            // AI-05 (#195): chat passthrough mode. Forward the request
762            // body as-is to the provider (no adapter translation), but
763            // still apply governance, metering, and budget enforcement.
764            // The model is extracted from the body for routing.
765            return serve_ai_chat_passthrough(json_body, route_name, gen, dp, rid, rec, identity)
766                .await;
767        }
768    };
769
770    // AI-07: the OTel GenAI semantic-convention span. Every gen_ai.*
771    // field is declared Empty so `Span::record` can fill it as the
772    // values resolve (request-side here, response-side after the
773    // provider answers). The span is instrumented around the provider
774    // call so it appears in the trace tree as a child of the request
775    // span; response-side fields are recorded on the handle (recording
776    // does not require the span to be current).
777    let gen_ai_span = tracing::info_span!(
778        "gen_ai.chat",
779        "gen_ai.system" = tracing::field::Empty,
780        "gen_ai.request.model" = tracing::field::Empty,
781        "gen_ai.response.model" = tracing::field::Empty,
782        "gen_ai.request.max_tokens" = tracing::field::Empty,
783        "gen_ai.request.temperature" = tracing::field::Empty,
784        "gen_ai.request.top_p" = tracing::field::Empty,
785        "gen_ai.usage.prompt_tokens" = tracing::field::Empty,
786        "gen_ai.usage.completion_tokens" = tracing::field::Empty,
787        "gen_ai.usage.total_tokens" = tracing::field::Empty,
788        "gen_ai.response.finish_reasons" = tracing::field::Empty,
789        "gen_ai.response.id" = tracing::field::Empty,
790    );
791    // Request-side attributes are known once the canonical request is
792    // parsed. `gen_ai.system` is recorded when the serving provider is
793    // selected (it depends on the candidate kind).
794    crate::observability::record_gen_ai_request_attrs(
795        &gen_ai_span,
796        "",
797        &chat_req.model,
798        chat_req.max_tokens,
799        chat_req.temperature,
800        chat_req.top_p,
801    );
802    // AI-07: end-to-end AI request latency, measured from here (request
803    // parsed) through the terminal outcome. Recorded alongside each
804    // `record_ai_request` outcome below.
805    let ai_started = std::time::Instant::now();
806
807    // PERF-01: local token estimation pre-check. The first budget
808    // check (above, before the body was read) only rejects when the
809    // window is ALREADY exhausted. This second check estimates the
810    // request's total token cost (prompt estimate + requested
811    // max_tokens) and rejects when `spent + estimated > limit` BEFORE
812    // any provider contact — so a holder near the limit cannot overrun
813    // by a large prompt. The estimate is conservative (overcounts);
814    // the actual spend is still provider-reported after the call.
815    if let Some(guard) = &budget {
816        let prompt_est = crate::ai::token_estimator::estimate_prompt_tokens(&chat_req);
817        let max_out = chat_req.max_tokens.unwrap_or(0);
818        let estimated_total = prompt_est.saturating_add(max_out);
819        if estimated_total > 0 {
820            let now_s = std::time::SystemTime::now()
821                .duration_since(std::time::UNIX_EPOCH)
822                .map(|d| d.as_secs())
823                .unwrap_or(0);
824            if let crate::ai::budget::BudgetVerdict::Denied {
825                kind,
826                retry_after_s,
827            } = guard.check_with_estimate(now_s, estimated_total)
828            {
829                rec.rate_limited = true;
830                dp.observability_arc()
831                    .record_ai_budget_denied(kind.as_str());
832                tracing::info!(
833                    code = "ai_budget_exceeded",
834                    request_id = %rid,
835                    route = %route_name,
836                    kind = kind.as_str(),
837                    retry_after_s,
838                    estimated_tokens = estimated_total,
839                    "AI token budget would be exceeded by estimated \
840                     tokens; rejecting before provider contact (PERF-01)"
841                );
842                let body = openai_compat::error_body(
843                    "the token budget for this window would be exceeded \
844                     by the estimated request size; retry later",
845                    "rate_limit_error",
846                    Some("ai_budget_exceeded"),
847                    rid,
848                );
849                return Response::builder()
850                    .status(StatusCode::TOO_MANY_REQUESTS)
851                    .header("retry-after", retry_after_s.to_string())
852                    .header(hyper::header::CONTENT_TYPE, "application/json")
853                    .body(ProxyBody::Full(Full::new(Bytes::from(body.to_string()))))
854                    .expect("static 429 response is valid");
855            }
856        }
857    }
858
859    // DW-084: model governance pre-route check. The requested model
860    // alias is checked against the consumer's binding team allowlists
861    // BEFORE routing — a disallowed (or typo'd) alias is blocked at
862    // the edge with 403 `model_denied_by_policy` rather than
863    // surfacing as a provider 404. The resolution mirrors the
864    // rate-limit/budget chain (consumer > route > service > listener
865    // > global, deny-wins intersection); a consumer with no binding
866    // allowlist policy is allowed (fail-open).
867    let governance = dp.ai_governance();
868    let governance_verdict = if governance.is_empty() {
869        crate::ai::governance::GovernanceVerdict::Allow
870    } else {
871        let consumer = identity.map(|id| id.consumer_name.as_str());
872        let gateway = gen.snapshot.gateway();
873        let route_cfg = gateway.routes.iter().find(|r| r.name == route_name);
874        let route_policies: &[String] = route_cfg.map(|r| r.policies.as_slice()).unwrap_or(&[]);
875        let service_policies: &[String] = route_cfg
876            .map(|r| crate::ai::budget::service_policies_of(gateway, &r.service))
877            .unwrap_or(&[]);
878        let consumer_policies: &[String] = consumer
879            .map(|c| crate::ai::budget::consumer_policies_of(gateway, c))
880            .unwrap_or(&[]);
881        let listener_policies = crate::ai::budget::listener_policies_of(gateway, listener_name);
882        governance.check(
883            consumer,
884            consumer_policies,
885            route_policies,
886            service_policies,
887            listener_policies,
888            &gateway.global_policies,
889            &chat_req.model,
890        )
891    };
892    let governance_consumer = identity
893        .map(|id| id.consumer_name.clone())
894        .unwrap_or_else(|| "anonymous".to_string());
895    let governance_team = match &governance_verdict {
896        crate::ai::governance::GovernanceVerdict::Deny { policy, .. } => policy.clone(),
897        crate::ai::governance::GovernanceVerdict::Allow => String::new(),
898    };
899    if let crate::ai::governance::GovernanceVerdict::Deny { reason, .. } = &governance_verdict {
900        dp.observability_arc().record_ai_governance_denied(reason);
901        tracing::info!(
902            code = "model_denied_by_policy",
903            request_id = %rid,
904            route = %route_name,
905            consumer = %governance_consumer,
906            model = %chat_req.model,
907            reason = %reason,
908            "AI model governance denied the requested model before routing"
909        );
910        // DW-084: a blocked attempt is ALWAYS audited (the done-when
911        // requirement) when the governance block is present — the
912        // `audit` flag only extends recording to ALLOWED calls.
913        if let Some(analytics) = dp.analytics() {
914            analytics.offer_ai_governance_event(crate::analytics::AiGovernanceEvent {
915                ts_ms: now_ms(),
916                consumer: governance_consumer.clone(),
917                team: governance_team.clone(),
918                model: chat_req.model.clone(),
919                verdict: "deny".to_string(),
920                reason: reason.clone(),
921            });
922        }
923        let body = openai_compat::error_body(
924            &format!(
925                "the model '{}' is not allowed for this consumer by the \
926                 team allowlist policy",
927                chat_req.model
928            ),
929            "invalid_request_error",
930            Some("model_denied_by_policy"),
931            rid,
932        );
933        return Response::builder()
934            .status(StatusCode::FORBIDDEN)
935            .header(hyper::header::CONTENT_TYPE, "application/json")
936            .body(ProxyBody::Full(Full::new(Bytes::from(body.to_string()))))
937            .expect("static 403 response is valid");
938    }
939    // DW-084: when the audit switch is on, record ALLOWED calls too
940    // (shadow review — which team called which model).
941    if governance.audit() {
942        if let Some(analytics) = dp.analytics() {
943            analytics.offer_ai_governance_event(crate::analytics::AiGovernanceEvent {
944                ts_ms: now_ms(),
945                consumer: governance_consumer.clone(),
946                team: governance_team.clone(),
947                model: chat_req.model.clone(),
948                verdict: "allow".to_string(),
949                reason: String::new(),
950            });
951        }
952    }
953
954    // DW-082: prompt guardrails. AFTER the chat request is parsed and
955    // AFTER the governance check, BEFORE model resolution and the
956    // provider call. A `block` action returns a 400
957    // `guardrail_blocked`; a `redact` action scrubs the prompt and
958    // continues; a `log` action records and continues (dry-run). The
959    // policy chain is the same one governance uses (consumer > route
960    // > service > listener > global).
961    let guardrails = dp.ai_guardrails();
962    if !guardrails.is_empty() {
963        let consumer = identity.map(|id| id.consumer_name.as_str());
964        let gateway = gen.snapshot.gateway();
965        let route_cfg = gateway.routes.iter().find(|r| r.name == route_name);
966        let route_policies: &[String] = route_cfg.map(|r| r.policies.as_slice()).unwrap_or(&[]);
967        let service_policies: &[String] = route_cfg
968            .map(|r| crate::ai::budget::service_policies_of(gateway, &r.service))
969            .unwrap_or(&[]);
970        let consumer_policies: &[String] = consumer
971            .map(|c| crate::ai::budget::consumer_policies_of(gateway, c))
972            .unwrap_or(&[]);
973        let listener_policies = crate::ai::budget::listener_policies_of(gateway, listener_name);
974        let guardrail_result = guardrails.check_prompt(
975            consumer_policies,
976            route_policies,
977            service_policies,
978            listener_policies,
979            &gateway.global_policies,
980            &chat_req,
981        );
982        match &guardrail_result {
983            crate::ai::guardrails::GuardrailResult::Block { rule_name, reason } => {
984                dp.observability_arc()
985                    .record_ai_guardrail_blocked(rule_name, "prompt");
986                tracing::info!(
987                    code = "guardrail_blocked",
988                    request_id = %rid,
989                    route = %route_name,
990                    rule = %rule_name,
991                    reason = %reason,
992                    "AI guardrail blocked the prompt before the provider call"
993                );
994                let body = openai_compat::error_body(
995                    &format!("the prompt was blocked by guardrail rule '{rule_name}': {reason}"),
996                    "invalid_request_error",
997                    Some("guardrail_blocked"),
998                    rid,
999                );
1000                return Response::builder()
1001                    .status(StatusCode::BAD_REQUEST)
1002                    .header(hyper::header::CONTENT_TYPE, "application/json")
1003                    .body(ProxyBody::Full(Full::new(Bytes::from(body.to_string()))))
1004                    .expect("static 400 response is valid");
1005            }
1006            crate::ai::guardrails::GuardrailResult::Redact {
1007                rule_name,
1008                redacted_prompt,
1009            } => {
1010                tracing::info!(
1011                    code = "guardrail_redacted",
1012                    request_id = %rid,
1013                    route = %route_name,
1014                    rule = %rule_name,
1015                    "AI guardrail redacted the prompt before the provider call"
1016                );
1017                chat_req = guardrails.apply_redaction(&chat_req, redacted_prompt);
1018            }
1019            crate::ai::guardrails::GuardrailResult::Allow => {}
1020        }
1021    }
1022
1023    // AI-13 (#201): fetch remote image URLs for multimodal requests.
1024    // When the request contains remote image URLs (OpenAI
1025    // `image_url.url` convention) and the target adapter requires
1026    // base64 data (Anthropic, Gemini), the gateway fetches the image,
1027    // converts to base64, and fills `data_b64` + `media_type`. The
1028    // OpenAI adapter continues to pass the URL through unchanged.
1029    // The fetcher is skipped entirely when there are no remote images.
1030    if crate::ai::image_fetch::has_remote_images(&chat_req) {
1031        let cfg = crate::ai::image_fetch::ImageFetchConfig::default();
1032        chat_req = crate::ai::image_fetch::fetch_remote_images(chat_req, &cfg).await;
1033    }
1034
1035    // 3. Streaming (DW-077): the request is passed through with
1036    // usage reporting FORCED on the provider call — the stream
1037    // metrics and the (upcoming) token budgets need provider-reported
1038    // counts even when the client did not ask for the usage chunk,
1039    // and the terminal usage chunk we emit is the documented
1040    // include_usage shape either way.
1041    let stream_requested = chat_req.stream;
1042    if stream_requested {
1043        chat_req.stream_options_include_usage = true;
1044    }
1045
1046    // DW-083: semantic cache lookup. AFTER guardrails (the prompt may
1047    // have been redacted) and AFTER the stream flag is known, BEFORE
1048    // model routing + the provider call. A cache hit returns the
1049    // cached response with no provider call and no token spend.
1050    // PERF-02: streaming requests are now cached too (the stream is
1051    // tee'd on a miss and replayed on a hit). The lookup is async (it
1052    // may call the embedding service) but runs in the already-async
1053    // serve_ai; a miss or error fails open.
1054    let sem_cache = dp.ai_semantic_cache();
1055    let can_cache = sem_cache.is_some();
1056    if can_cache {
1057        if let Some(sem_cache) = &sem_cache {
1058            let prompt_text = chat_req
1059                .messages
1060                .iter()
1061                .map(|m| m.text_content())
1062                .collect::<Vec<_>>()
1063                .join("\n");
1064            if let Some(cached) = sem_cache.lookup(&prompt_text, &chat_req.model).await {
1065                dp.observability_arc()
1066                    .record_ai_semantic_cache_hit(&chat_req.model);
1067                tracing::info!(
1068                    code = "semantic_cache_hit",
1069                    request_id = %rid,
1070                    route = %route_name,
1071                    model = %chat_req.model,
1072                    "AI semantic cache hit; returning cached response with no provider call"
1073                );
1074                return match cached {
1075                    crate::ai::semantic_cache::CachedResponse::Json(v) => {
1076                        response_with_json(StatusCode::OK, &v, &HeaderMap::new())
1077                    }
1078                    crate::ai::semantic_cache::CachedResponse::Frames(frames) => {
1079                        // PERF-02: replay the cached SSE frames as a
1080                        // single concatenated text/event-stream body.
1081                        let body = frames.join("");
1082                        Response::builder()
1083                            .status(StatusCode::OK)
1084                            .header(hyper::header::CONTENT_TYPE, "text/event-stream")
1085                            .header(hyper::header::CACHE_CONTROL, "no-cache")
1086                            .body(ProxyBody::Full(Full::new(Bytes::from(body))))
1087                            .expect("cached SSE replay response is valid")
1088                    }
1089                };
1090            }
1091            dp.observability_arc()
1092                .record_ai_semantic_cache_miss(&chat_req.model);
1093        }
1094    }
1095
1096    // 4. Route (DW-076 / DW-085): the ordered candidate list for this
1097    // alias — [primary, failover...] for a chained alias, the
1098    // deterministic canary pick for a split alias, or the policy
1099    // evaluation result for a policy alias (DW-085: a FallbackChain
1100    // policy may call an external classifier service async). Empty
1101    // means the alias does not exist.
1102    let prompt_text = chat_req
1103        .messages
1104        .iter()
1105        .map(|m| m.text_content())
1106        .collect::<Vec<_>>()
1107        .join("\n");
1108    let (candidates, policy_decision, canary_version_index) = runtime
1109        .route_with_policy_and_canary_index(&chat_req.model, rid, &prompt_text)
1110        .await;
1111    // DW-085: record the routing-policy decision as a metric. The
1112    // `ai` domain cannot import `observability` (the dependency
1113    // direction forbids it), so the decision travels back here and
1114    // the dataplane records it.
1115    if let Some(decision) = policy_decision {
1116        if let Some(crate::ai::CompiledModel::Policy(policy)) = runtime.model(&chat_req.model) {
1117            let name = policy.name();
1118            match decision {
1119                crate::ai::policy::PolicyDecision::Escalate => {
1120                    dp.observability_arc()
1121                        .record_ai_routing_policy_escalation(name);
1122                }
1123                crate::ai::policy::PolicyDecision::Cheap
1124                | crate::ai::policy::PolicyDecision::ClassifierError => {
1125                    dp.observability_arc().record_ai_routing_policy_cheap(name);
1126                }
1127                crate::ai::policy::PolicyDecision::LatencyCost => {
1128                    dp.observability_arc()
1129                        .record_ai_routing_policy_latency_cost_selection(name);
1130                }
1131            }
1132        }
1133    }
1134    // DW-086: experiment alias resolution. When the model is an
1135    // experiment (A/B test) alias, route_with_policy already picked
1136    // the variant and returned its target in `candidates`. Here we
1137    // inject the variant's system message into the request (if any)
1138    // and record the assignment to analytics for attribution.
1139    // AI-14 (#202): server-side prompt template resolution. When the
1140    // client references a prompt template by name, resolve it from
1141    // the experiments config, substitute variables, and prepend the
1142    // system message BEFORE any existing system message.
1143    if let Some(prompt_ref) = &chat_req.prompt {
1144        let experiments = runtime.experiments_config();
1145        let overrides = runtime.prompt_overrides();
1146        let system = if let Some((name, version)) = prompt_ref.split_once('/') {
1147            // Explicit version reference.
1148            experiments
1149                .and_then(|exp| exp.prompts.get(name))
1150                .and_then(|p| p.versions.get(version))
1151                .map(|v| v.system.clone())
1152        } else {
1153            // Active version (with runtime override).
1154            crate::ai::experiments::active_prompt_system(experiments, overrides, prompt_ref)
1155        };
1156        if let Some(mut system) = system {
1157            // Substitute {{var}} placeholders with prompt_variables.
1158            for (key, value) in &chat_req.prompt_variables {
1159                let placeholder = format!("{{{{{key}}}}}");
1160                system = system.replace(&placeholder, value);
1161            }
1162            chat_req.messages.insert(
1163                0,
1164                crate::ai::types::ChatMessage::text(crate::ai::types::ChatRole::System, system),
1165            );
1166        }
1167    }
1168    if let Some(crate::ai::CompiledModel::Experiment(test)) = runtime.model(&chat_req.model) {
1169        let variant = test.pick(rid);
1170        // Record the variant selection as a metric.
1171        dp.observability_arc()
1172            .record_ai_experiment_variant_selection(&test.name, &variant.name);
1173        // Inject the variant's prompt-version system message BEFORE
1174        // any existing system message (the variant's system message
1175        // is the experiment's prompt; the client's system message, if
1176        // any, follows it).
1177        if let Some(sys) = &variant.system_message {
1178            chat_req.messages.insert(
1179                0,
1180                crate::ai::types::ChatMessage::text(
1181                    crate::ai::types::ChatRole::System,
1182                    sys.clone(),
1183                ),
1184            );
1185        }
1186        // Record the assignment to analytics (fire-and-forget).
1187        if let Some(analytics) = dp.analytics() {
1188            analytics.offer_ai_experiment_assignment(crate::analytics::AiExperimentAssignment {
1189                ts_ms: std::time::SystemTime::now()
1190                    .duration_since(std::time::UNIX_EPOCH)
1191                    .map(|d| d.as_millis() as i64)
1192                    .unwrap_or(0),
1193                request_id: rid.to_string(),
1194                experiment: test.name.clone(),
1195                variant: variant.name.clone(),
1196                model: variant.target.provider_model.clone(),
1197                consumer: identity
1198                    .map(|id| id.consumer_name.clone())
1199                    .unwrap_or_else(|| "anonymous".to_string()),
1200            });
1201        }
1202    }
1203
1204    if candidates.is_empty() {
1205        return ai_error_response(
1206            StatusCode::NOT_FOUND,
1207            &format!("the model '{}' does not exist", chat_req.model),
1208            "invalid_request_error",
1209            Some("model_not_found"),
1210            rid,
1211        );
1212    }
1213
1214    // 5. Walk the candidates (DW-076 failover). Retryable outcomes —
1215    // a 429, a 5xx, a transport error, or a per-dialect translation
1216    // rejection — advance to the NEXT candidate; anything else is
1217    // final. Nothing reaches the client until a candidate SUCCEEDS
1218    // (the provider response is read whole before translation), so
1219    // failover is invisible to the client. Same-provider retries are
1220    // not the chain's job: the provider's upstream breaker owns those.
1221    let obs = dp.observability_arc();
1222    let mut attempts: u32 = 0;
1223    let mut last_transport_error = true;
1224    let mut fallback_response: Response<ProxyBody> = ai_error_response(
1225        StatusCode::BAD_GATEWAY,
1226        "no provider could serve the model",
1227        "api_error",
1228        Some("provider_unreachable"),
1229        rid,
1230    );
1231    for target in candidates {
1232        attempts += 1;
1233        let version = target.version.as_deref().unwrap_or("default");
1234        let Some(provider) = runtime.provider(&target.provider) else {
1235            tracing::error!(
1236                code = "ai_provider_missing",
1237                request_id = %rid,
1238                provider = %target.provider,
1239                "routed provider is not in the compiled table (validate-vs-build race)"
1240            );
1241            obs.record_ai_request(&target.provider, route_name, "transport_error", version);
1242            obs.record_ai_request_duration(
1243                &target.provider,
1244                route_name,
1245                ai_started.elapsed().as_secs_f64(),
1246            );
1247            continue;
1248        };
1249        let adapter = adapter_for(provider.kind);
1250        // AI-07: record the serving provider's kind as gen_ai.system
1251        // (re-recorded per candidate so the span reflects the provider
1252        // that actually served; on failover the last candidate wins).
1253        gen_ai_span.record("gen_ai.system", provider.kind.gen_ai_system());
1254
1255        // Translate for THIS provider (the provider model differs per
1256        // candidate). A per-dialect rejection may not exist in the
1257        // next dialect, so it advances rather than failing the request.
1258        let provider_req = match adapter.build_request(&chat_req, &target.provider_model) {
1259            Ok(r) => r,
1260            Err(e) => {
1261                obs.record_ai_request(&provider.name, route_name, "translation_error", version);
1262                obs.record_ai_request_duration(
1263                    &provider.name,
1264                    route_name,
1265                    ai_started.elapsed().as_secs_f64(),
1266                );
1267                tracing::info!(
1268                    code = "ai_provider_translation_rejected",
1269                    request_id = %rid,
1270                    provider = %provider.name,
1271                    "candidate rejected the conversation: {e}"
1272                );
1273                fallback_response = ai_error_response(
1274                    StatusCode::BAD_REQUEST,
1275                    &e.to_string(),
1276                    openai_compat::error_type_of(&e),
1277                    None,
1278                    rid,
1279                );
1280                last_transport_error = false;
1281                continue;
1282            }
1283        };
1284
1285        let Some(handle) = gen.registry().get(&provider.upstream) else {
1286            tracing::error!(
1287                code = "ai_provider_upstream_missing",
1288                request_id = %rid,
1289                provider = %provider.name,
1290                upstream = %provider.upstream,
1291                "ai provider's upstream is not in the registry (validate-vs-build race)"
1292            );
1293            obs.record_ai_request(&provider.name, route_name, "transport_error", version);
1294            obs.record_ai_request_duration(
1295                &provider.name,
1296                route_name,
1297                ai_started.elapsed().as_secs_f64(),
1298            );
1299            continue;
1300        };
1301        let outbound_path = resolve_path(provider, &provider_req.path);
1302        let mut outbound = Request::builder()
1303            .method(Method::POST)
1304            .uri(outbound_path)
1305            .header(hyper::header::CONTENT_TYPE, "application/json")
1306            .header(hyper::header::ACCEPT, "application/json");
1307        for (name, value) in &provider_req.headers {
1308            if let Ok(v) = hyper::header::HeaderValue::from_str(value) {
1309                outbound = outbound.header(name.clone(), v);
1310            }
1311        }
1312        // Provider auth from the compiled table (resolved at compile
1313        // time; adapters never see credentials). Applied last so
1314        // nothing overrides it; unrepresentable values skip loudly.
1315        // DW-080: when the provider has a credential pool, pick one
1316        // credential per request and track its index for quarantine
1317        // on 429. When the pool is exhausted (all keys quarantined),
1318        // fail this attempt with a 429 + Retry-After.
1319        let mut pool_entry_index: Option<usize> = None;
1320        let auth_pairs: Vec<(String, String)> = if let Some(pool) = &provider.credential_pool {
1321            match pool.pick(rid) {
1322                Some(entry) => {
1323                    pool_entry_index = Some(entry.index);
1324                    vec![(entry.header.clone(), entry.value.clone())]
1325                }
1326                None => {
1327                    // Pool exhaustion: all keys quarantined.
1328                    let retry_after = pool
1329                        .earliest_quarantine_expiry()
1330                        .map(|t| {
1331                            t.duration_since(std::time::Instant::now())
1332                                .as_secs()
1333                                .clamp(1, 600)
1334                        })
1335                        .unwrap_or(60);
1336                    tracing::warn!(
1337                        code = "ai_credential_pool_exhausted",
1338                        request_id = %rid,
1339                        provider = %provider.name,
1340                        retry_after_secs = retry_after,
1341                        "ai credential pool exhausted (all keys quarantined)"
1342                    );
1343                    obs.record_ai_request(&provider.name, route_name, "pool_exhausted", version);
1344                    obs.record_ai_request_duration(
1345                        &provider.name,
1346                        route_name,
1347                        ai_started.elapsed().as_secs_f64(),
1348                    );
1349                    let body = openai_compat::error_body(
1350                        "all provider API keys are rate-limited; retry later",
1351                        "pool_exhausted",
1352                        Some("429"),
1353                        rid,
1354                    );
1355                    let mut headers = HeaderMap::new();
1356                    if let Ok(v) = hyper::header::HeaderValue::from_str(&format!("{retry_after}")) {
1357                        headers.insert("retry-after", v);
1358                    }
1359                    fallback_response =
1360                        response_with_json(hyper::StatusCode::TOO_MANY_REQUESTS, &body, &headers);
1361                    continue;
1362                }
1363            }
1364        } else {
1365            provider.auth_headers.clone()
1366        };
1367        for (name, value) in &auth_pairs {
1368            match (
1369                hyper::header::HeaderName::from_bytes(name.as_bytes()),
1370                hyper::header::HeaderValue::from_str(value),
1371            ) {
1372                (Ok(n), Ok(v)) => {
1373                    outbound = outbound.header(n, v);
1374                }
1375                _ => {
1376                    tracing::error!(
1377                        code = "ai_provider_auth_header_invalid",
1378                        provider = %provider.name,
1379                        "provider auth header is not representable; call is sent unauthenticated"
1380                    );
1381                }
1382            }
1383        }
1384        let body_bytes = serde_json::to_vec(&provider_req.body).unwrap_or_default();
1385        let outbound = match outbound.body(Full::new(Bytes::from(body_bytes))) {
1386            Ok(r) => r,
1387            Err(_) => {
1388                obs.record_ai_request(&provider.name, route_name, "transport_error", version);
1389                obs.record_ai_request_duration(
1390                    &provider.name,
1391                    route_name,
1392                    ai_started.elapsed().as_secs_f64(),
1393                );
1394                continue;
1395            }
1396        };
1397        // Attribution follows the SERVING (or last attempted)
1398        // provider across the failover walk (DW-076).
1399        rec.upstream = Some(provider.upstream.clone());
1400        rec.attempts = attempts;
1401        let attempt_started = std::time::Instant::now();
1402        // AI-07: instrument the upstream send with the gen_ai span so
1403        // the provider call appears under the GenAI span in the trace
1404        // tree (the span's duration covers the provider round-trip).
1405        let upstream_resp = match handle.send(outbound).instrument(gen_ai_span.clone()).await {
1406            Ok(r) => r,
1407            Err(e) => {
1408                obs.record_ai_request(&provider.name, route_name, "transport_error", version);
1409                obs.record_ai_request_duration(
1410                    &provider.name,
1411                    route_name,
1412                    ai_started.elapsed().as_secs_f64(),
1413                );
1414                tracing::warn!(
1415                    code = "ai_provider_unreachable",
1416                    request_id = %rid,
1417                    provider = %provider.name,
1418                    upstream = %provider.upstream,
1419                    "ai provider call failed: {e}"
1420                );
1421                last_transport_error = true;
1422                continue;
1423            }
1424        };
1425
1426        let status = upstream_resp.status();
1427        let (up_parts, up_body) = upstream_resp.into_parts();
1428        let retryable = status.as_u16() == 429 || status.is_server_error();
1429        // DW-089: feed the provider outcome to the adaptive controller
1430        // for every applicable policy. The latency is measured from the
1431        // attempt start to header resolution; the Retry-After header is
1432        // parsed from the provider response for the origin-signal
1433        // backoff. The controller is a no-op for policies without an
1434        // adaptive block.
1435        if !applicable_policies.is_empty() {
1436            let latency = attempt_started.elapsed();
1437            let retry_after = parse_ai_retry_after(&up_parts.headers);
1438            let adaptive = Arc::clone(gen.adaptive());
1439            let status_u16 = status.as_u16();
1440            for name in &applicable_policies {
1441                adaptive.record_outcome(name, status_u16, latency, retry_after);
1442            }
1443        }
1444        // DW-091: feed the provider outcome to the auto-canary
1445        // controller when this request was served by a canary-tracked
1446        // AI model alias. The canary side is index 1; the baseline is
1447        // index 0. The latency is measured from the attempt start to
1448        // header resolution (the same point the adaptive controller
1449        // observes). The controller is a no-op when the alias has no
1450        // canary_analysis (canary_version_index is None).
1451        if let Some(idx) = canary_version_index {
1452            if let Some(canary) = gen.canary() {
1453                let latency_ms = attempt_started.elapsed().as_secs_f64() * 1000.0;
1454                canary.record_outcome(&chat_req.model, idx == 1, status.as_u16(), latency_ms);
1455            }
1456        }
1457        if !status.is_success() {
1458            let err_bytes =
1459                match bounded_collect(up_body, MAX_AI_ERROR_BYTES, "provider error", rid).await {
1460                    Ok(b) => b,
1461                    Err(resp) => return resp,
1462                };
1463            let value: serde_json::Value = serde_json::from_slice(&err_bytes).unwrap_or_default();
1464            let parsed = adapter.parse_error(&value);
1465            obs.record_ai_request(&provider.name, route_name, "provider_error", version);
1466            obs.record_ai_request_duration(
1467                &provider.name,
1468                route_name,
1469                ai_started.elapsed().as_secs_f64(),
1470            );
1471            let body = openai_compat::error_body(
1472                &parsed.message,
1473                parsed.error_type.as_deref().unwrap_or("api_error"),
1474                parsed.code.as_deref(),
1475                rid,
1476            );
1477            let resp = response_with_json(status, &body, &up_parts.headers);
1478            if retryable {
1479                // DW-080: quarantine the credential pool entry on 429.
1480                // The pool skips this key for the quarantine window;
1481                // subsequent requests rotate to the next key. The
1482                // provider's Retry-After header (if present) overrides
1483                // the configured quarantine window, capped at 600s.
1484                if status.as_u16() == 429 {
1485                    if let (Some(pool), Some(idx)) = (&provider.credential_pool, pool_entry_index) {
1486                        let retry_after = parse_ai_retry_after(&up_parts.headers);
1487                        pool.quarantine(idx, retry_after.map(|d| d.as_secs()));
1488                        tracing::info!(
1489                            code = "ai_credential_pool_quarantined",
1490                            request_id = %rid,
1491                            provider = %provider.name,
1492                            key_index = idx,
1493                            "credential pool key quarantined after 429"
1494                        );
1495                    }
1496                }
1497                // AI-09 (#198): record per-key health from rate-limit
1498                // headers on every provider response (error or success).
1499                // When remaining is 0, the pool proactively cools the
1500                // key until the reset window, preventing the next 429.
1501                if let (Some(pool), Some(idx)) = (&provider.credential_pool, pool_entry_index) {
1502                    let health = parse_rate_limit_headers(&up_parts.headers, status.as_u16());
1503                    pool.record_health(idx, health);
1504                }
1505                // 429/5xx is transient — try the next candidate; if
1506                // none succeed, the LAST provider's answer is the one
1507                // the client sees (closest to the truth of the outage).
1508                tracing::info!(
1509                    code = "ai_provider_failover",
1510                    request_id = %rid,
1511                    provider = %provider.name,
1512                    status = status.as_u16(),
1513                    "transient provider error; failing over to the next candidate"
1514                );
1515                fallback_response = resp;
1516                last_transport_error = false;
1517                continue;
1518            }
1519            // Other 4xx (bad request, bad key) is deterministic —
1520            // retrying another provider would only re-diagnose it.
1521            return resp;
1522        }
1523        // AI-09 (#198): record per-key health from rate-limit headers
1524        // on successful (non-retryable) provider responses too. This
1525        // captures the remaining quota before a 429 occurs, enabling
1526        // proactive cooldown.
1527        if let (Some(pool), Some(idx)) = (&provider.credential_pool, pool_entry_index) {
1528            let health = parse_rate_limit_headers(&up_parts.headers, status.as_u16());
1529            pool.record_health(idx, health);
1530        }
1531        // Streaming pass-through (DW-077): a 200 SSE response streams
1532        // to the client frame-by-frame with zero added buffering. This
1533        // is the failover COMMIT point — returning the streaming
1534        // response forwards headers (and then chunks) to the client,
1535        // so no later candidate can replace anything from here on.
1536        if stream_requested && is_event_stream(&up_parts.headers) {
1537            // DW-AI-03: unknown-model pricing policy for streaming.
1538            // The check runs BEFORE the stream commits (the failover
1539            // commit point is the streaming response return below) so
1540            // `fail_closed` can reject with 403 without first
1541            // forwarding frames to the client. `alert` logs + meters
1542            // here and proceeds; `allow` is silent. The provider call
1543            // already happened (the 200 SSE body is open), but
1544            // dropping it cancels the upstream request.
1545            let pricing = dp.ai_pricing();
1546            if !pricing.has_pricing(&target.provider_model) {
1547                let policy = gen
1548                    .snapshot
1549                    .gateway()
1550                    .ai
1551                    .as_ref()
1552                    .and_then(|c| c.unknown_model_policy)
1553                    .unwrap_or_default();
1554                match policy {
1555                    UnknownModelPolicy::FailClosed => {
1556                        obs.record_ai_request(
1557                            &provider.name,
1558                            route_name,
1559                            "unknown_model_pricing",
1560                            version,
1561                        );
1562                        obs.record_ai_request_duration(
1563                            &provider.name,
1564                            route_name,
1565                            ai_started.elapsed().as_secs_f64(),
1566                        );
1567                        tracing::warn!(
1568                            code = "ai_unknown_model_pricing",
1569                            request_id = %rid,
1570                            route = %route_name,
1571                            provider = %provider.name,
1572                            model = %target.provider_model,
1573                            "rejecting stream: model has no pricing entry (fail_closed policy)"
1574                        );
1575                        return ai_error_response(
1576                            StatusCode::FORBIDDEN,
1577                            "the model has no pricing entry and the unknown-model policy is fail_closed",
1578                            "invalid_request_error",
1579                            Some("ai_unknown_model_pricing"),
1580                            rid,
1581                        );
1582                    }
1583                    UnknownModelPolicy::Alert => {
1584                        obs.record_ai_unknown_pricing(&target.provider_model);
1585                        tracing::warn!(
1586                            code = "ai_unknown_model_pricing",
1587                            request_id = %rid,
1588                            route = %route_name,
1589                            provider = %provider.name,
1590                            model = %target.provider_model,
1591                            "model has no pricing entry; streaming with cost 0 (alert policy)"
1592                        );
1593                    }
1594                    UnknownModelPolicy::Allow => {}
1595                }
1596            }
1597            obs.record_ai_request(&provider.name, route_name, "success", version);
1598            // AI-07: end-to-end AI request latency (streaming — measured
1599            // to stream establishment, the failover commit point; the
1600            // stream's own duration is dwara_ai_stream_duration_seconds).
1601            obs.record_ai_request_duration(
1602                &provider.name,
1603                route_name,
1604                ai_started.elapsed().as_secs_f64(),
1605            );
1606            tracing::info!(
1607                code = "ai_stream_started",
1608                request_id = %rid,
1609                route = %route_name,
1610                provider = %provider.name,
1611                model = %chat_req.model,
1612                version = %version,
1613                attempts = attempts,
1614                upstream = %provider.upstream,
1615                "ai stream established; forwarding begins"
1616            );
1617            // DW-079: compute the consumer and team strings BEFORE
1618            // moving `budget` into the stream body.
1619            let consumer_name = identity
1620                .map(|id| id.consumer_name.clone())
1621                .unwrap_or_else(|| "anonymous".to_string());
1622            let consumer_type = identity
1623                .map(|id| consumer_type_str(id.consumer_type).to_string())
1624                .unwrap_or_else(|| "user".to_string());
1625            let team = budget
1626                .as_ref()
1627                .map(|g| g.team_key().to_string())
1628                .unwrap_or_default();
1629            // DW-081: build the streaming capture context when logging
1630            // is enabled for this consumer. The prompt is stored
1631            // redacted at stream close; the response is marked as
1632            // streamed (the full content is not reassembled).
1633            let prompt_log_ctx = build_stream_prompt_log_ctx(
1634                dp,
1635                gen,
1636                rid,
1637                route_name,
1638                &provider.name,
1639                &target.provider_model,
1640                version,
1641                identity,
1642                &json_body,
1643            );
1644            let body = super::ai_proxy::AiStreamBody::new(
1645                up_body,
1646                adapter,
1647                format!("chatcmpl-{rid}"),
1648                chat_req.model.clone(),
1649                provider.name.clone(),
1650                target.provider_model.clone(),
1651                version.to_string(),
1652                attempt_started,
1653                Arc::clone(&obs),
1654                rid.to_string(),
1655                budget,
1656                dp.ai_pricing(),
1657                dp.analytics(),
1658                consumer_name,
1659                consumer_type,
1660                team,
1661                prompt_log_ctx,
1662                Arc::clone(&guardrails),
1663                gen_ai_span.clone(),
1664                // PERF-02: streaming semantic cache tee. Pass the
1665                // cache and prompt text so the stream body can collect
1666                // frames and store them at close.
1667                if can_cache {
1668                    sem_cache.as_ref().map(Arc::clone)
1669                } else {
1670                    None
1671                },
1672                chat_req
1673                    .messages
1674                    .iter()
1675                    .map(|m| m.text_content())
1676                    .collect::<Vec<_>>()
1677                    .join("\n"),
1678                chat_req.model.clone(),
1679            );
1680            return Response::builder()
1681                .status(StatusCode::OK)
1682                .header(hyper::header::CONTENT_TYPE, "text/event-stream")
1683                .header(hyper::header::CACHE_CONTROL, "no-cache")
1684                .body(ProxyBody::Ai(Box::new(body)))
1685                .expect("streaming response is valid");
1686        }
1687        let ok_bytes =
1688            match bounded_collect(up_body, MAX_AI_PROVIDER_RESPONSE_BYTES, "response", rid).await {
1689                Ok(b) => b,
1690                Err(resp) => {
1691                    // An over-cap or unreadable SUCCESS body is
1692                    // provider-specific (a runaway completion), exactly
1693                    // like a malformed one below: record it, stash the
1694                    // 502, and let the next candidate answer. (The
1695                    // inbound "request" cap and the cheap "provider
1696                    // error" cap stay FINAL — the first is a client
1697                    // problem, the second is already on an error path.)
1698                    obs.record_ai_request(&provider.name, route_name, "translation_error", version);
1699                    obs.record_ai_request_duration(
1700                        &provider.name,
1701                        route_name,
1702                        ai_started.elapsed().as_secs_f64(),
1703                    );
1704                    tracing::warn!(
1705                        code = "ai_provider_body_over_cap",
1706                        request_id = %rid,
1707                        provider = %provider.name,
1708                        "provider 200 response exceeded the body cap or failed mid-read"
1709                    );
1710                    fallback_response = resp;
1711                    last_transport_error = false;
1712                    continue;
1713                }
1714            };
1715        let value: serde_json::Value = match serde_json::from_slice(&ok_bytes) {
1716            Ok(v) => v,
1717            Err(e) => {
1718                obs.record_ai_request(&provider.name, route_name, "translation_error", version);
1719                obs.record_ai_request_duration(
1720                    &provider.name,
1721                    route_name,
1722                    ai_started.elapsed().as_secs_f64(),
1723                );
1724                tracing::warn!(
1725                    code = "ai_provider_body_invalid",
1726                    request_id = %rid,
1727                    provider = %provider.name,
1728                    "provider 200 response was not valid JSON: {e}"
1729                );
1730                // A malformed 200 body is provider-specific; the next
1731                // candidate may be healthy.
1732                fallback_response = ai_error_response(
1733                    StatusCode::BAD_GATEWAY,
1734                    "the model provider returned a malformed response",
1735                    "api_error",
1736                    Some("provider_malformed_response"),
1737                    rid,
1738                );
1739                last_transport_error = false;
1740                continue;
1741            }
1742        };
1743        let chat_resp = match adapter.parse_response(&value) {
1744            Ok(r) => r,
1745            Err(e) => {
1746                obs.record_ai_request(&provider.name, route_name, "translation_error", version);
1747                obs.record_ai_request_duration(
1748                    &provider.name,
1749                    route_name,
1750                    ai_started.elapsed().as_secs_f64(),
1751                );
1752                tracing::warn!(
1753                    code = "ai_provider_body_untranslatable",
1754                    request_id = %rid,
1755                    provider = %provider.name,
1756                    "provider 200 response could not be translated: {e}"
1757                );
1758                fallback_response = ai_error_response(
1759                    StatusCode::BAD_GATEWAY,
1760                    "the model provider returned a response the gateway could not translate",
1761                    "api_error",
1762                    Some("provider_untranslatable_response"),
1763                    rid,
1764                );
1765                last_transport_error = false;
1766                continue;
1767            }
1768        };
1769        // AI-07: record the response-side gen_ai.* attributes on the
1770        // span (provider-reported only; None leaves the field empty).
1771        let finish_reasons: Vec<&str> = chat_resp
1772            .choices
1773            .iter()
1774            .map(|c| c.finish_reason.as_gen_ai())
1775            .collect();
1776        crate::observability::record_gen_ai_response_attrs(
1777            &gen_ai_span,
1778            chat_resp.model.as_deref(),
1779            chat_resp.usage.and_then(|u| u.prompt_tokens),
1780            chat_resp.usage.and_then(|u| u.completion_tokens),
1781            chat_resp.usage.and_then(|u| u.total_tokens),
1782            &finish_reasons,
1783            chat_resp.id.as_deref(),
1784        );
1785        if let Some(usage) = chat_resp.usage {
1786            // Usage attributes to the SERVING provider and canary
1787            // version (DW-076; the input DW-079 cost metering reads).
1788            obs.record_ai_tokens(
1789                &provider.name,
1790                usage.prompt_tokens.unwrap_or(0),
1791                usage.completion_tokens.unwrap_or(0),
1792                version,
1793            );
1794            // AI-07: per-request token distribution histogram.
1795            obs.record_ai_tokens_per_request(
1796                &provider.name,
1797                &target.provider_model,
1798                usage.prompt_tokens.unwrap_or(0),
1799                usage.completion_tokens.unwrap_or(0),
1800            );
1801            // DW-079: price the call through the dataplane's compiled
1802            // pricing table (swapped on reload, so a pricing change
1803            // applies to the next request with no restart). Unknown
1804            // model -> None (the unknown-model policy is applied
1805            // below, DW-AI-03).
1806            let pricing = dp.ai_pricing();
1807            let cost_opt = pricing.cost_micros(&target.provider_model, usage, false);
1808            let cost = cost_opt.unwrap_or(0);
1809            if cost > 0 {
1810                obs.record_ai_cost(&provider.name, &target.provider_model, cost);
1811            }
1812            // Budget spend (DW-078): the provider-reported usage is
1813            // recorded against the requesting holder's windows AFTER
1814            // the call — check-then-spend; the crossing (if any) is
1815            // already priced into the next pre-check.
1816            if let Some(guard) = &budget {
1817                guard.spend(now_epoch_s(), usage, cost);
1818            }
1819            // DW-079: record the spend dimensions into the analytics
1820            // store for billing reconciliation. The team field is the
1821            // policy-scoped budget's key (the policy name) when a
1822            // team budget binds, else empty.
1823            if let Some(analytics) = dp.analytics() {
1824                let consumer_name = identity
1825                    .map(|id| id.consumer_name.as_str())
1826                    .unwrap_or("anonymous");
1827                let team = budget
1828                    .as_ref()
1829                    .map(|g| g.team_key().to_string())
1830                    .unwrap_or_default();
1831                let prompt = usage.prompt_tokens.unwrap_or(0);
1832                let completion = usage.completion_tokens.unwrap_or(0);
1833                let total = usage
1834                    .total_tokens
1835                    .unwrap_or_else(|| prompt.saturating_add(completion));
1836                analytics.offer_ai_spend(crate::analytics::AiSpendRecord {
1837                    ts_ms: now_ms(),
1838                    consumer: consumer_name.to_string(),
1839                    consumer_type: identity
1840                        .map(|i| consumer_type_str(i.consumer_type))
1841                        .unwrap_or("user")
1842                        .to_string(),
1843                    team,
1844                    provider: provider.name.clone(),
1845                    model: target.provider_model.clone(),
1846                    version: version.to_string(),
1847                    prompt_tokens: prompt,
1848                    completion_tokens: completion,
1849                    total_tokens: total,
1850                    cost_micros: cost,
1851                });
1852            }
1853        }
1854        // DW-AI-03: unknown-model pricing policy. After cost
1855        // computation, if the serving provider_model has no pricing
1856        // entry, apply the configured policy. `fail_closed` rejects
1857        // the response with 403 `ai_unknown_model_pricing` (the
1858        // provider call already happened, but the unpriced model
1859        // cannot serve traffic silently); `alert` logs a WARN and
1860        // increments the `dwara_ai_unknown_pricing_total{model}`
1861        // metric; `allow` (the default) proceeds with cost 0 as
1862        // before.
1863        let pricing = dp.ai_pricing();
1864        if !pricing.has_pricing(&target.provider_model) {
1865            let policy = gen
1866                .snapshot
1867                .gateway()
1868                .ai
1869                .as_ref()
1870                .and_then(|c| c.unknown_model_policy)
1871                .unwrap_or_default();
1872            match policy {
1873                UnknownModelPolicy::FailClosed => {
1874                    obs.record_ai_request(
1875                        &provider.name,
1876                        route_name,
1877                        "unknown_model_pricing",
1878                        version,
1879                    );
1880                    tracing::warn!(
1881                        code = "ai_unknown_model_pricing",
1882                        request_id = %rid,
1883                        route = %route_name,
1884                        provider = %provider.name,
1885                        model = %target.provider_model,
1886                        "rejecting response: model has no pricing entry (fail_closed policy)"
1887                    );
1888                    return ai_error_response(
1889                        StatusCode::FORBIDDEN,
1890                        "the model has no pricing entry and the unknown-model policy is fail_closed",
1891                        "invalid_request_error",
1892                        Some("ai_unknown_model_pricing"),
1893                        rid,
1894                    );
1895                }
1896                UnknownModelPolicy::Alert => {
1897                    obs.record_ai_unknown_pricing(&target.provider_model);
1898                    tracing::warn!(
1899                        code = "ai_unknown_model_pricing",
1900                        request_id = %rid,
1901                        route = %route_name,
1902                        provider = %provider.name,
1903                        model = %target.provider_model,
1904                        "model has no pricing entry; proceeding with cost 0 (alert policy)"
1905                    );
1906                }
1907                UnknownModelPolicy::Allow => {}
1908            }
1909        }
1910        obs.record_ai_request(&provider.name, route_name, "success", version);
1911        // AI-07: end-to-end AI request latency (non-streaming success).
1912        obs.record_ai_request_duration(
1913            &provider.name,
1914            route_name,
1915            ai_started.elapsed().as_secs_f64(),
1916        );
1917        // DW-082: response guardrails. AFTER the response is parsed
1918        // and BEFORE it is returned to the client. A `block` action
1919        // returns a 400 (`response_schema_violation` for schema kind,
1920        // `guardrail_blocked` otherwise); a `log` action records and
1921        // continues. Non-streaming only (streaming responses cannot
1922        // be schema-validated on partial content; banned-content
1923        // checks run per-chunk in the stream body).
1924        if !guardrails.is_empty() {
1925            let consumer = identity.map(|id| id.consumer_name.as_str());
1926            let gateway = gen.snapshot.gateway();
1927            let route_cfg = gateway.routes.iter().find(|r| r.name == route_name);
1928            let route_policies: &[String] = route_cfg.map(|r| r.policies.as_slice()).unwrap_or(&[]);
1929            let service_policies: &[String] = route_cfg
1930                .map(|r| crate::ai::budget::service_policies_of(gateway, &r.service))
1931                .unwrap_or(&[]);
1932            let consumer_policies: &[String] = consumer
1933                .map(|c| crate::ai::budget::consumer_policies_of(gateway, c))
1934                .unwrap_or(&[]);
1935            let listener_policies = crate::ai::budget::listener_policies_of(gateway, listener_name);
1936            let guardrail_result = guardrails.check_response(
1937                consumer_policies,
1938                route_policies,
1939                service_policies,
1940                listener_policies,
1941                &gateway.global_policies,
1942                &chat_resp,
1943            );
1944            if let crate::ai::guardrails::GuardrailResult::Block { rule_name, reason } =
1945                &guardrail_result
1946            {
1947                let kind_label = if reason == "response_schema_violation" {
1948                    "schema"
1949                } else {
1950                    "response"
1951                };
1952                dp.observability_arc()
1953                    .record_ai_guardrail_blocked(rule_name, kind_label);
1954                tracing::info!(
1955                    code = "guardrail_blocked",
1956                    request_id = %rid,
1957                    route = %route_name,
1958                    rule = %rule_name,
1959                    reason = %reason,
1960                    "AI guardrail blocked the response before returning to the client"
1961                );
1962                let error_code = if reason == "response_schema_violation" {
1963                    "response_schema_violation"
1964                } else {
1965                    "guardrail_blocked"
1966                };
1967                let body = openai_compat::error_body(
1968                    &format!("the response was blocked by guardrail rule '{rule_name}': {reason}"),
1969                    "invalid_request_error",
1970                    Some(error_code),
1971                    rid,
1972                );
1973                return Response::builder()
1974                    .status(StatusCode::BAD_REQUEST)
1975                    .header(hyper::header::CONTENT_TYPE, "application/json")
1976                    .body(ProxyBody::Full(Full::new(Bytes::from(body.to_string()))))
1977                    .expect("static 400 response is valid");
1978            }
1979        }
1980        let body = openai_compat::response_to_openai(&chat_resp, &chat_req.model, rid);
1981        // DW-081: prompt/response logging capture hook (non-streaming).
1982        // After the response is parsed and BEFORE it is sent, check
1983        // whether logging is enabled (global + per-consumer override),
1984        // apply sampling, redact, and offer to analytics. The capture
1985        // is fire-and-forget (never blocks the response path).
1986        capture_prompt_log(
1987            dp,
1988            gen,
1989            rid,
1990            route_name,
1991            &provider.name,
1992            &target.provider_model,
1993            version,
1994            identity,
1995            &json_body,
1996            &body,
1997            false,
1998        );
1999        tracing::info!(
2000            code = "ai_request_served",
2001            request_id = %rid,
2002            route = %route_name,
2003            provider = %provider.name,
2004            model = %chat_req.model,
2005            version = %version,
2006            attempts = attempts,
2007            upstream = %provider.upstream,
2008            "ai chat completion translated and served"
2009        );
2010        // DW-083: store the response in the semantic cache
2011        // (non-streaming only). Fire-and-forget — the embedding call
2012        // and HNSW insert happen in a spawned task so they never
2013        // block the response path. The prompt text is the joined
2014        // message text (the same shape the lookup used); the body is
2015        // the OpenAI-shaped response JSON.
2016        if can_cache {
2017            if let Some(sem_cache) = &sem_cache {
2018                let sem_cache = Arc::clone(sem_cache);
2019                let prompt_text = chat_req
2020                    .messages
2021                    .iter()
2022                    .map(|m| m.text_content())
2023                    .collect::<Vec<_>>()
2024                    .join("\n");
2025                let body_clone = body.clone();
2026                let model = chat_req.model.clone();
2027                tokio::spawn(async move {
2028                    sem_cache.store(&prompt_text, &body_clone, &model).await;
2029                });
2030            }
2031        }
2032        return response_with_json(StatusCode::OK, &body, &up_parts.headers);
2033    }
2034
2035    // 6. Every candidate failed. The last stashed provider answer is
2036    // the most truthful response; a pure-transport exhaustion keeps
2037    // the generic 502.
2038    rec.attempts = attempts.max(1);
2039    if attempts > 1 {
2040        tracing::warn!(
2041            code = "ai_failover_exhausted",
2042            request_id = %rid,
2043            model = %chat_req.model,
2044            attempts = attempts,
2045            transport_only = last_transport_error,
2046            "all providers failed for the model"
2047        );
2048    }
2049    fallback_response
2050}
2051
2052/// DW-081: prompt/response logging capture hook. Checks whether
2053/// logging is enabled (global + per-consumer override), applies
2054/// sampling, redacts the prompt and response JSON, and offers the
2055/// record to the analytics store. Fire-and-forget — never blocks.
2056#[allow(clippy::too_many_arguments)]
2057fn capture_prompt_log(
2058    dp: &Arc<DataPlane>,
2059    gen: &Arc<Generation>,
2060    rid: &str,
2061    route_name: &str,
2062    provider: &str,
2063    model: &str,
2064    version: &str,
2065    identity: Option<&crate::security::authn::Identity>,
2066    prompt_json: &serde_json::Value,
2067    response_json: &serde_json::Value,
2068    stream: bool,
2069) {
2070    let Some(logging) = dp.ai_logging() else {
2071        return;
2072    };
2073    // Resolve the per-consumer override from the config.
2074    let consumer_name = identity
2075        .map(|id| id.consumer_name.as_str())
2076        .unwrap_or("anonymous");
2077    let consumer_override = gen
2078        .snapshot
2079        .gateway()
2080        .consumers
2081        .iter()
2082        .find(|c| c.name == consumer_name)
2083        .and_then(|c| c.ai_logging);
2084    if !logging.capture_for(consumer_override) {
2085        return;
2086    }
2087    // Sampling: deterministic per request id.
2088    if !logging.should_sample(rid) {
2089        return;
2090    }
2091    // Redact both the prompt and the response.
2092    let redactor = logging.redactor();
2093    let redacted_prompt = redactor.redact_json(prompt_json);
2094    let redacted_response = redactor.redact_json(response_json);
2095    // Offer to analytics (fire-and-forget).
2096    if let Some(analytics) = dp.analytics() {
2097        analytics.offer_ai_prompt_log(crate::analytics::AiPromptLogRecord {
2098            ts_ms: now_ms(),
2099            request_id: rid.to_string(),
2100            consumer: consumer_name.to_string(),
2101            route: route_name.to_string(),
2102            provider: provider.to_string(),
2103            model: model.to_string(),
2104            version: version.to_string(),
2105            prompt_json: redacted_prompt.to_string(),
2106            response_json: redacted_response.to_string(),
2107            stream,
2108        });
2109    }
2110}
2111
2112/// DW-081: build the streaming capture context. Returns None when
2113/// logging is off for this consumer or the sampling decision is
2114/// negative — the stream body then skips the capture at close. The
2115/// prompt JSON is carried to stream close where it is redacted and
2116/// offered (the response is marked as streamed).
2117#[allow(clippy::too_many_arguments)]
2118fn build_stream_prompt_log_ctx(
2119    dp: &Arc<DataPlane>,
2120    gen: &Arc<Generation>,
2121    rid: &str,
2122    route_name: &str,
2123    provider: &str,
2124    model: &str,
2125    version: &str,
2126    identity: Option<&crate::security::authn::Identity>,
2127    prompt_json: &serde_json::Value,
2128) -> Option<PromptLogCtx> {
2129    let logging = dp.ai_logging()?;
2130    let consumer_name = identity
2131        .map(|id| id.consumer_name.as_str())
2132        .unwrap_or("anonymous");
2133    let consumer_override = gen
2134        .snapshot
2135        .gateway()
2136        .consumers
2137        .iter()
2138        .find(|c| c.name == consumer_name)
2139        .and_then(|c| c.ai_logging);
2140    if !logging.capture_for(consumer_override) {
2141        return None;
2142    }
2143    if !logging.should_sample(rid) {
2144        return None;
2145    }
2146    // Redact the prompt at build time (the stream body does not carry
2147    // the redactor).
2148    let redactor = logging.redactor();
2149    let redacted_prompt = redactor.redact_json(prompt_json);
2150    Some(PromptLogCtx {
2151        route: route_name.to_string(),
2152        provider: provider.to_string(),
2153        model: model.to_string(),
2154        version: version.to_string(),
2155        consumer: consumer_name.to_string(),
2156        prompt_json: redacted_prompt.to_string(),
2157        recorded: false,
2158    })
2159}
2160
2161/// Collect a body up to `cap` bytes, aborting the read the moment the
2162/// cap is crossed (the producer is dropped mid-stream — an over-cap
2163/// body is never buffered whole). Over-cap answers 413 inbound / 502
2164/// provider-side, in the AI error shape.
2165async fn bounded_collect<B>(
2166    body: B,
2167    cap: usize,
2168    what: &str,
2169    rid: &str,
2170) -> Result<Bytes, Response<ProxyBody>>
2171where
2172    B: Body<Data = Bytes> + Send + 'static,
2173    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
2174{
2175    let mut buf: Vec<u8> = Vec::new();
2176    let mut body = pin!(body);
2177    while let Some(frame) = body.as_mut().frame().await {
2178        let frame = frame.map_err(|e| {
2179            let e = e.into();
2180            ai_error_response(
2181                StatusCode::BAD_REQUEST,
2182                &format!("the {what} body could not be read: {e}"),
2183                if what == "request" {
2184                    "invalid_request_error"
2185                } else {
2186                    "api_error"
2187                },
2188                None,
2189                rid,
2190            )
2191        })?;
2192        if let Some(data) = frame.data_ref() {
2193            if buf.len() + data.len() > cap {
2194                return Err(ai_error_response(
2195                    if what == "request" {
2196                        StatusCode::PAYLOAD_TOO_LARGE
2197                    } else {
2198                        StatusCode::BAD_GATEWAY
2199                    },
2200                    &format!("the {what} body exceeds the gateway's {cap}-byte limit"),
2201                    if what == "request" {
2202                        "invalid_request_error"
2203                    } else {
2204                        "api_error"
2205                    },
2206                    Some("body_too_large"),
2207                    rid,
2208                ));
2209            }
2210            buf.extend_from_slice(data);
2211        }
2212    }
2213    Ok(Bytes::from(buf))
2214}
2215
2216/// Build an AI-route error response (OpenAI error shape).
2217fn ai_error_response(
2218    status: StatusCode,
2219    message: &str,
2220    error_type: &str,
2221    code: Option<&str>,
2222    rid: &str,
2223) -> Response<ProxyBody> {
2224    let body = openai_compat::error_body(message, error_type, code, rid);
2225    response_with_json(status, &body, &HeaderMap::new())
2226}
2227
2228/// Current Unix seconds (budget window indexing).
2229fn now_epoch_s() -> u64 {
2230    std::time::SystemTime::now()
2231        .duration_since(std::time::UNIX_EPOCH)
2232        .map(|d| d.as_secs())
2233        .unwrap_or(0)
2234}
2235
2236/// Current Unix milliseconds (DW-079 spend record timestamping).
2237fn now_ms() -> i64 {
2238    std::time::SystemTime::now()
2239        .duration_since(std::time::UNIX_EPOCH)
2240        .map(|d| d.as_millis() as i64)
2241        .unwrap_or(0)
2242}
2243
2244/// Whether provider response headers declare an SSE body.
2245fn is_event_stream(headers: &HeaderMap) -> bool {
2246    headers
2247        .get(hyper::header::CONTENT_TYPE)
2248        .and_then(|v| v.to_str().ok())
2249        .map(|v| v.starts_with("text/event-stream"))
2250        .unwrap_or(false)
2251}
2252
2253/// Parse the `Retry-After` header from an AI provider response (DW-089)
2254/// into a duration from now. Supports both the integer-seconds and
2255/// HTTP-date forms (see `config::versioning::parse_retry_after`).
2256/// Returns `None` when the header is absent or unparseable.
2257fn parse_ai_retry_after(headers: &HeaderMap) -> Option<std::time::Duration> {
2258    let value = headers.get(hyper::header::RETRY_AFTER)?;
2259    let s = value.to_str().ok()?;
2260    let now_unix_seconds = std::time::SystemTime::now()
2261        .duration_since(std::time::UNIX_EPOCH)
2262        .map(|d| d.as_secs() as i64)
2263        .unwrap_or(0);
2264    crate::config::versioning::parse_retry_after(s, now_unix_seconds)
2265}
2266
2267/// AI-09 (#198): Parse provider rate-limit headers into per-key health.
2268/// Covers the common header names used by OpenAI, Anthropic, and
2269/// Gemini. Returns a `KeyHealth` with whatever fields were present.
2270fn parse_rate_limit_headers(headers: &HeaderMap, status: u16) -> crate::ai::credentials::KeyHealth {
2271    use crate::ai::credentials::KeyHealth;
2272    use std::time::{Duration, Instant};
2273
2274    let mut health = KeyHealth {
2275        last_status: Some(status),
2276        updated_at: Some(Instant::now()),
2277        ..Default::default()
2278    };
2279
2280    // OpenAI: x-ratelimit-remaining-requests, x-ratelimit-remaining-tokens
2281    // Anthropic: anthropic-ratelimit-requests-remaining, anthropic-ratelimit-tokens-remaining
2282    // Gemini: x-ratelimit-remaining (generic)
2283    // We try all known header names and take the first that parses.
2284    let remaining_request_headers = [
2285        "x-ratelimit-remaining-requests",
2286        "anthropic-ratelimit-requests-remaining",
2287        "x-ratelimit-remaining",
2288    ];
2289    let remaining_token_headers = [
2290        "x-ratelimit-remaining-tokens",
2291        "anthropic-ratelimit-tokens-remaining",
2292    ];
2293    let reset_headers = [
2294        "x-ratelimit-reset-requests",
2295        "anthropic-ratelimit-requests-reset",
2296        "x-ratelimit-reset",
2297    ];
2298
2299    for name in &remaining_request_headers {
2300        if let Some(v) = headers.get(*name).and_then(|h| h.to_str().ok()) {
2301            if let Ok(n) = v.trim().parse::<u64>() {
2302                health.remaining_requests = Some(n);
2303                break;
2304            }
2305            // Some providers send a duration like "1s" or "6m0s"
2306            if let Some(n) = parse_duration_seconds(v) {
2307                health.remaining_requests = Some(n);
2308                break;
2309            }
2310        }
2311    }
2312
2313    for name in &remaining_token_headers {
2314        if let Some(v) = headers.get(*name).and_then(|h| h.to_str().ok()) {
2315            if let Ok(n) = v.trim().parse::<u64>() {
2316                health.remaining_tokens = Some(n);
2317                break;
2318            }
2319        }
2320    }
2321
2322    for name in &reset_headers {
2323        if let Some(v) = headers.get(*name).and_then(|h| h.to_str().ok()) {
2324            if let Some(secs) = parse_duration_seconds(v) {
2325                health.reset_at = Some(Instant::now() + Duration::from_secs(secs));
2326                break;
2327            }
2328            // Try as integer seconds
2329            if let Ok(secs) = v.trim().parse::<u64>() {
2330                health.reset_at = Some(Instant::now() + Duration::from_secs(secs));
2331                break;
2332            }
2333        }
2334    }
2335
2336    health
2337}
2338
2339/// Parse a duration string like "1s", "6m0s", "1h2m3s" into seconds.
2340fn parse_duration_seconds(s: &str) -> Option<u64> {
2341    let s = s.trim();
2342    if s.is_empty() {
2343        return None;
2344    }
2345    // Try plain integer first
2346    if let Ok(n) = s.parse::<u64>() {
2347        return Some(n);
2348    }
2349    // Parse h/m/s suffixes
2350    let mut total: u64 = 0;
2351    let mut num = String::new();
2352    for ch in s.chars() {
2353        if ch.is_ascii_digit() {
2354            num.push(ch);
2355        } else {
2356            let n: u64 = num.parse().ok()?;
2357            num.clear();
2358            match ch {
2359                'h' => total += n * 3600,
2360                'm' => total += n * 60,
2361                's' => total += n,
2362                _ => return None,
2363            }
2364        }
2365    }
2366    // Trailing number without suffix = seconds
2367    if !num.is_empty() {
2368        let n: u64 = num.parse().ok()?;
2369        total += n;
2370    }
2371    Some(total)
2372}
2373
2374/// Build a JSON response. The provider's `x-request-id` (when sent) is
2375/// carried through for correlation; every other provider header is
2376/// dropped — the gateway authored this response.
2377fn response_with_json(
2378    status: StatusCode,
2379    body: &serde_json::Value,
2380    provider_headers: &HeaderMap,
2381) -> Response<ProxyBody> {
2382    let mut builder = Response::builder()
2383        .status(status)
2384        .header(hyper::header::CONTENT_TYPE, "application/json");
2385    if let Some(v) = provider_headers.get("x-request-id") {
2386        builder = builder.header("x-request-id", v);
2387    }
2388    builder
2389        .body(ProxyBody::Full(Full::new(Bytes::from(body.to_string()))))
2390        .expect("static JSON response is valid")
2391}
2392
2393// ---------------------------------------------------------------------------
2394// The streaming body (DW-077)
2395// ---------------------------------------------------------------------------
2396
2397/// Per-stream accounting shared by the body and its metrics hooks.
2398struct StreamGauges {
2399    provider: String,
2400    version: String,
2401    chunks: u64,
2402    first_token_recorded: bool,
2403    started: std::time::Instant,
2404    /// Set once terminal metrics have been recorded (clean end OR
2405    /// drop): the counters must fire exactly once per stream.
2406    closed: bool,
2407}
2408
2409/// The zero-buffer AI streaming body (DW-077): wraps the provider's
2410/// upstream body, translates each COMPLETE provider SSE frame into
2411/// OpenAI-shaped client frames as the bytes arrive, and forwards them
2412/// in the same poll — nothing waits for the stream to finish. Usage
2413/// events accumulate (provider-reported only) and are emitted as one
2414/// terminal chunk; the gateway owns the `data: [DONE]` terminator. A
2415/// provider body error after forwarding becomes a terminal error
2416/// chunk (already-forwarded content stands), never a reset.
2417///
2418/// Infallible by construction: every failure mode is expressed as
2419/// terminal frames, so the client stream always ends cleanly.
2420pub struct AiStreamBody {
2421    /// The provider's body — until the budget cutoff DROPS it (the
2422    /// upstream cancel then propagates immediately, regardless of how
2423    /// the client behaves; see `cut_off`).
2424    inner: Option<crate::dataplane::upstream::UpstreamBody>,
2425    translator: StreamTranslator,
2426    adapter: &'static dyn ProviderAdapter,
2427    /// Synthesized terminal frames waiting to be forwarded.
2428    tail: std::collections::VecDeque<String>,
2429    gauges: StreamGauges,
2430    obs: std::sync::Arc<crate::observability::Observability>,
2431    /// The requesting holder's budget (DW-078): checked as usage
2432    /// events accumulate — a crossing cuts the stream off.
2433    budget: Option<crate::ai::budget::BudgetGuard>,
2434    /// The serving provider's model id (the DW-079 pricing key).
2435    provider_model: String,
2436    /// The request id (the cutoff event's correlation handle).
2437    rid: String,
2438    /// Budget cutoff fired: the provider body was dropped AT the
2439    /// cutoff so the upstream request is cancelled immediately — a
2440    /// stalled client that never drains the tail must not keep the
2441    /// provider generating billed tokens. Only the synthesized tail
2442    /// remains to forward.
2443    cut_off: bool,
2444    /// Budget spend watermarks (DW-078): the accumulated totals
2445    /// already spent. The provider's usage report GROWS as the stream
2446    /// runs (input tokens at message_start, output at message_delta),
2447    /// so every spend is the DELTA above the watermark — each
2448    /// reported token is counted exactly once no matter how many
2449    /// chunks the report arrived in.
2450    budget_spent_tokens: u64,
2451    budget_spent_cost_micros: u64,
2452    /// DW-079: the dataplane's compiled pricing table (for pricing
2453    /// the stream's terminal usage).
2454    pricing: std::sync::Arc<crate::ai::cost::PricingTable>,
2455    /// DW-079: the analytics store (for recording the spend record at
2456    /// stream close). None when analytics is off.
2457    analytics: Option<std::sync::Arc<crate::analytics::EmbeddedAnalytics>>,
2458    /// DW-079: the consumer name for spend attribution.
2459    consumer: String,
2460    /// DW-113: the consumer type for spend attribution (`user` or
2461    /// `agent`).
2462    consumer_type: String,
2463    /// DW-079: the team key for spend attribution (policy name when
2464    /// a team budget binds, else empty).
2465    team: String,
2466    /// DW-079: whether the spend record has been recorded (exactly
2467    /// once per stream, at close).
2468    spend_recorded: bool,
2469    /// DW-081: prompt/response logging context for streaming capture.
2470    /// The prompt JSON (the original OpenAI-shaped request body) is
2471    /// stored redacted at stream close; the response is marked as
2472    /// streamed (the full content is not reassembled — buffering the
2473    /// entire stream would contradict the zero-buffer design).
2474    prompt_log_ctx: Option<PromptLogCtx>,
2475    /// DW-082: the compiled guardrail engine for streaming banned-
2476    /// content checks. Each forwarded chunk's text is checked against
2477    /// banned-content rules; a match cuts the stream off (similar to
2478    /// the budget cutoff). Schema enforcement does not apply to
2479    /// streaming (partial content cannot be validated).
2480    guardrails: std::sync::Arc<crate::ai::guardrails::GuardrailEngine>,
2481    /// DW-082: whether a banned-content cutoff has fired (the tail
2482    /// is the stream's remainder).
2483    guardrail_cut_off: bool,
2484    /// AI-07: the OTel GenAI semantic-convention span. Response-side
2485    /// `gen_ai.*` attributes (usage, finish reasons, response id/model)
2486    /// are recorded on this handle at stream close (the values arrive
2487    /// across the stream and only the terminal usage is complete).
2488    gen_ai_span: tracing::Span,
2489    /// PERF-02: the semantic cache for streaming tee. When present,
2490    /// forwarded SSE frames are collected into `stream_tee_frames`
2491    /// (bounded by `stream_tee_cap_bytes`); at stream close, the
2492    /// collected frames are stored in the cache. None when the cache
2493    /// is disabled or not configured.
2494    stream_tee_cache: Option<std::sync::Arc<crate::ai::semantic_cache::SemanticCacheEngine>>,
2495    /// PERF-02: the collected SSE frames for the streaming tee. Each
2496    /// forwarded `data: ...\n\n` frame is pushed here; at stream close
2497    /// the collected frames are stored in the cache.
2498    stream_tee_frames: Vec<String>,
2499    /// PERF-02: the byte cap for the streaming tee buffer. When the
2500    /// collected frames exceed this cap, the tee is abandoned (the
2501    /// stream is still forwarded to the client, but NOT cached — a
2502    /// single long stream must not consume unbounded memory).
2503    stream_tee_cap_bytes: usize,
2504    /// PERF-02: whether the tee has been abandoned (the cap was
2505    /// exceeded). When true, no store happens at stream close.
2506    stream_tee_abandoned: bool,
2507    /// PERF-02: the prompt text for the streaming store (the joined
2508    /// message text, the same shape the lookup used).
2509    stream_tee_prompt: String,
2510    /// PERF-02: the model alias for the streaming store.
2511    stream_tee_model: String,
2512}
2513
2514/// DW-081: the streaming capture context carried by AiStreamBody.
2515pub(super) struct PromptLogCtx {
2516    route: String,
2517    provider: String,
2518    model: String,
2519    version: String,
2520    consumer: String,
2521    /// The REDACTED prompt as a JSON string (redacted at build time
2522    /// so the stream body does not need to carry the redactor).
2523    prompt_json: String,
2524    /// Whether the prompt log has been recorded (exactly once per
2525    /// stream, at close).
2526    recorded: bool,
2527}
2528
2529impl AiStreamBody {
2530    /// Build the body for a provider stream whose headers resolved
2531    /// successfully. `started` is the request's send instant (first-
2532    /// token latency measures from there).
2533    #[allow(clippy::too_many_arguments)]
2534    pub(super) fn new(
2535        inner: crate::dataplane::upstream::UpstreamBody,
2536        adapter: &'static dyn ProviderAdapter,
2537        response_id: String,
2538        model_alias: String,
2539        provider: String,
2540        provider_model: String,
2541        version: String,
2542        started: std::time::Instant,
2543        obs: std::sync::Arc<crate::observability::Observability>,
2544        rid: String,
2545        budget: Option<crate::ai::budget::BudgetGuard>,
2546        pricing: std::sync::Arc<crate::ai::cost::PricingTable>,
2547        analytics: Option<std::sync::Arc<crate::analytics::EmbeddedAnalytics>>,
2548        consumer: String,
2549        consumer_type: String,
2550        team: String,
2551        prompt_log_ctx: Option<PromptLogCtx>,
2552        guardrails: std::sync::Arc<crate::ai::guardrails::GuardrailEngine>,
2553        gen_ai_span: tracing::Span,
2554        stream_tee_cache: Option<std::sync::Arc<crate::ai::semantic_cache::SemanticCacheEngine>>,
2555        stream_tee_prompt: String,
2556        stream_tee_model: String,
2557    ) -> Self {
2558        AiStreamBody {
2559            inner: Some(inner),
2560            translator: StreamTranslator::new(response_id, model_alias, openai_compat::unix_now()),
2561            adapter,
2562            tail: std::collections::VecDeque::new(),
2563            gauges: StreamGauges {
2564                provider,
2565                version,
2566                chunks: 0,
2567                first_token_recorded: false,
2568                started,
2569                closed: false,
2570            },
2571            obs,
2572            budget,
2573            provider_model,
2574            rid,
2575            cut_off: false,
2576            budget_spent_tokens: 0,
2577            budget_spent_cost_micros: 0,
2578            pricing,
2579            analytics,
2580            consumer,
2581            consumer_type,
2582            team,
2583            spend_recorded: false,
2584            prompt_log_ctx,
2585            guardrails,
2586            guardrail_cut_off: false,
2587            gen_ai_span,
2588            stream_tee_cap_bytes: 1024 * 1024,
2589            stream_tee_frames: Vec::new(),
2590            stream_tee_abandoned: false,
2591            stream_tee_cache,
2592            stream_tee_prompt,
2593            stream_tee_model,
2594        }
2595    }
2596
2597    /// Spend the provider-reported usage GROWTH since the last spend
2598    /// (DW-078). The translator's usage is an ACCUMULATOR that grows
2599    /// as the provider reports (input tokens early, output late), so
2600    /// only the delta above the watermark is NEW spend — each
2601    /// reported token is counted exactly once regardless of how many
2602    /// chunks its report spanned. Returns whether the token window
2603    /// crossed its limit on THIS spend (the mid-stream cutoff signal).
2604    fn budget_spend_delta(&mut self) -> bool {
2605        let Some(guard) = &self.budget else {
2606            return false;
2607        };
2608        let usage = self.translator.usage();
2609        let total = usage.total_tokens.unwrap_or_else(|| {
2610            usage
2611                .prompt_tokens
2612                .unwrap_or(0)
2613                .saturating_add(usage.completion_tokens.unwrap_or(0))
2614        });
2615        let tokens = total.saturating_sub(self.budget_spent_tokens);
2616        let cost_total = self
2617            .pricing
2618            .cost_micros(&self.provider_model, usage, false)
2619            .unwrap_or(0);
2620        let cost = cost_total.saturating_sub(self.budget_spent_cost_micros);
2621        if tokens == 0 && cost == 0 {
2622            return false;
2623        }
2624        // Watermarks advance to the accumulated totals (max, so a
2625        // provider that revises a count downward never re-spends the
2626        // difference later).
2627        self.budget_spent_tokens = total.max(self.budget_spent_tokens);
2628        self.budget_spent_cost_micros = cost_total.max(self.budget_spent_cost_micros);
2629        guard.spend(
2630            now_epoch_s(),
2631            Usage {
2632                total_tokens: Some(tokens),
2633                ..Usage::default()
2634            },
2635            cost,
2636        )
2637    }
2638
2639    /// Mid-stream cutoff check (DW-078). Called after each translated
2640    /// batch: the batch's NEW usage is spent against the holder's
2641    /// window, and a crossing (only detectable when the dialect
2642    /// reports usage mid-stream — Anthropic's message_start input
2643    /// tokens) cuts the stream.
2644    fn budget_tick(&mut self) {
2645        if self.budget.is_none() {
2646            return;
2647        }
2648        let crossed = self.budget_spend_delta();
2649        if crossed && !self.cut_off && !self.translator.is_ended() {
2650            self.cut_off = true;
2651            // Drop the provider body NOW: an upstream request is
2652            // cancelled the moment its body is dropped, however slowly
2653            // (or never) the client drains the cutoff tail below — the
2654            // cancel must not wait on client behavior. The tail still
2655            // reaches the client: the cutoff event, then [DONE].
2656            self.inner = None;
2657            self.obs.record_ai_budget_denied("tokens");
2658            tracing::warn!(
2659                code = "ai_budget_exceeded_midstream",
2660                request_id = %self.rid,
2661                "token budget crossed mid-stream; cutting off and cancelling the provider stream"
2662            );
2663            self.tail
2664                .push_back(crate::ai::budget::BudgetGuard::cutoff_frame(&self.rid));
2665            self.tail.push_back("data: [DONE]\n\n".to_string());
2666        }
2667    }
2668
2669    /// DW-082: mid-stream banned-content check. Called after each
2670    /// translated batch: the batch's forwarded text is checked against
2671    /// banned-content rules; a match cuts the stream off (similar to
2672    /// the budget cutoff). Schema enforcement does not apply to
2673    /// streaming (partial content cannot be validated).
2674    fn guardrail_tick(&mut self, batch_text: &str) {
2675        if self.guardrails.is_empty() || self.guardrail_cut_off {
2676            return;
2677        }
2678        // AI-08 (#197): PII redaction on streaming chunks. The
2679        // redacted text replaces the batch text in the translated
2680        // frames. This runs BEFORE the banned-content check so the
2681        // banned patterns see the redacted text.
2682        let batch_text = self.guardrails.redact_stream_chunk(batch_text);
2683        if let Some(rule_name) = self.guardrails.check_stream_chunk(&batch_text) {
2684            self.guardrail_cut_off = true;
2685            // Drop the provider body NOW (eager upstream cancel).
2686            self.inner = None;
2687            self.obs.record_ai_guardrail_blocked(&rule_name, "banned");
2688            tracing::warn!(
2689                code = "guardrail_blocked_midstream",
2690                request_id = %self.rid,
2691                rule = %rule_name,
2692                "banned-content guardrail matched mid-stream; cutting off and cancelling the provider stream"
2693            );
2694            // Synthesize an error chunk and the terminator.
2695            let error_payload = serde_json::json!({
2696                "error": {
2697                    "code": "guardrail_blocked",
2698                    "message": "the response was blocked by a banned-content guardrail",
2699                    "request_id": self.rid,
2700                }
2701            });
2702            self.tail.push_back(format!("data: {}\n\n", error_payload));
2703            self.tail.push_back("data: [DONE]\n\n".to_string());
2704        }
2705    }
2706
2707    /// Record per-chunk accounting for a translated batch.
2708    fn note_chunks(&mut self, count: usize, first: bool) {
2709        for _ in 0..count {
2710            self.obs.record_ai_stream_chunk(&self.gauges.provider);
2711        }
2712        self.gauges.chunks += count as u64;
2713        if first && !self.gauges.first_token_recorded {
2714            self.gauges.first_token_recorded = true;
2715            self.obs.record_ai_first_token(
2716                &self.gauges.provider,
2717                self.gauges.started.elapsed().as_secs_f64(),
2718            );
2719        }
2720    }
2721
2722    /// PERF-02: collect forwarded SSE frames into the tee buffer for
2723    /// streaming cache storage at close. Each frame is a `data: ...\n\n`
2724    /// string. The buffer is bounded by `stream_tee_cap_bytes`: when
2725    /// the total collected bytes exceed the cap, the tee is abandoned
2726    /// (no store at close — the stream is still forwarded to the
2727    /// client unmodified).
2728    fn tee_collect(&mut self, frames: &[String]) {
2729        if self.stream_tee_cache.is_none() || self.stream_tee_abandoned {
2730            return;
2731        }
2732        let mut total: usize = self.stream_tee_frames.iter().map(|f| f.len()).sum();
2733        for f in frames {
2734            total += f.len();
2735            if total > self.stream_tee_cap_bytes {
2736                // Cap exceeded: abandon the tee. The stream is still
2737                // forwarded to the client; it just is not cached.
2738                self.stream_tee_abandoned = true;
2739                self.stream_tee_frames.clear();
2740                tracing::info!(
2741                    code = "semantic_cache_stream_tee_abandoned",
2742                    cap_bytes = self.stream_tee_cap_bytes,
2743                    "streaming semantic cache tee abandoned (cap exceeded); stream not cached"
2744                );
2745                return;
2746            }
2747            self.stream_tee_frames.push(f.clone());
2748        }
2749    }
2750
2751    /// PERF-02: store the collected streaming frames in the semantic
2752    /// cache. Called at stream close when the tee was not abandoned
2753    /// and the cache is present. Fire-and-forget (spawned): the
2754    /// embedding call and HNSW insert happen after the response is
2755    /// fully sent.
2756    fn tee_store(&self) {
2757        if self.stream_tee_abandoned || self.stream_tee_frames.is_empty() {
2758            return;
2759        }
2760        let Some(cache) = &self.stream_tee_cache else {
2761            return;
2762        };
2763        let cache = Arc::clone(cache);
2764        let prompt = self.stream_tee_prompt.clone();
2765        let model = self.stream_tee_model.clone();
2766        let frames = self.stream_tee_frames.clone();
2767        tokio::spawn(async move {
2768            cache.store_streaming(&prompt, frames, &model).await;
2769        });
2770    }
2771
2772    /// Terminal metrics: exactly once per stream. Records duration and
2773    /// the accumulated provider-reported usage (the DW-079 input; the
2774    /// version label carries the canary attribution, DW-076).
2775    fn close(&mut self) {
2776        if self.gauges.closed {
2777            return;
2778        }
2779        self.gauges.closed = true;
2780        self.obs.record_ai_stream_end(
2781            &self.gauges.provider,
2782            self.gauges.started.elapsed().as_secs_f64(),
2783        );
2784        let usage = self.translator.usage();
2785        // Terminal budget spend (DW-078): the not-yet-spent delta. A
2786        // dialect that reports usage only in its final events spends
2787        // here (its mid-stream ticks saw nothing) — enforced by the
2788        // next pre-check; mid-stream cutoff was impossible for it.
2789        // After a cutoff the stream's spend is already recorded.
2790        if !self.cut_off {
2791            self.budget_spend_delta();
2792        }
2793        if usage.prompt_tokens.is_some() || usage.completion_tokens.is_some() {
2794            self.obs.record_ai_tokens(
2795                &self.gauges.provider,
2796                usage.prompt_tokens.unwrap_or(0),
2797                usage.completion_tokens.unwrap_or(0),
2798                &self.gauges.version,
2799            );
2800            // AI-07: per-request token distribution histogram.
2801            self.obs.record_ai_tokens_per_request(
2802                &self.gauges.provider,
2803                &self.provider_model,
2804                usage.prompt_tokens.unwrap_or(0),
2805                usage.completion_tokens.unwrap_or(0),
2806            );
2807        }
2808        // AI-07: record the response-side gen_ai.* attributes on the
2809        // span at stream close. The response model is the serving
2810        // provider model (streaming providers do not echo a model in a
2811        // single frame); the response id is the gateway-synthesized
2812        // stream id; finish reasons are not available mid-stream
2813        // without reassembling choices (left empty — "where available").
2814        crate::observability::record_gen_ai_response_attrs(
2815            &self.gen_ai_span,
2816            Some(&self.provider_model),
2817            usage.prompt_tokens,
2818            usage.completion_tokens,
2819            usage.total_tokens,
2820            &[],
2821            Some(self.translator.id()),
2822        );
2823        // DW-079: record the spend dimensions into the analytics
2824        // store (exactly once per stream). The terminal usage is the
2825        // accumulated provider-reported total; cost is priced through
2826        // the dataplane's pricing table.
2827        if !self.spend_recorded {
2828            self.spend_recorded = true;
2829            let prompt = usage.prompt_tokens.unwrap_or(0);
2830            let completion = usage.completion_tokens.unwrap_or(0);
2831            let total = usage
2832                .total_tokens
2833                .unwrap_or_else(|| prompt.saturating_add(completion));
2834            let cost = self
2835                .pricing
2836                .cost_micros(&self.provider_model, usage, false)
2837                .unwrap_or(0);
2838            if cost > 0 {
2839                self.obs
2840                    .record_ai_cost(&self.gauges.provider, &self.provider_model, cost);
2841            }
2842            if let Some(analytics) = &self.analytics {
2843                analytics.offer_ai_spend(crate::analytics::AiSpendRecord {
2844                    ts_ms: now_ms(),
2845                    consumer: self.consumer.clone(),
2846                    consumer_type: self.consumer_type.clone(),
2847                    team: self.team.clone(),
2848                    provider: self.gauges.provider.clone(),
2849                    model: self.provider_model.clone(),
2850                    version: self.gauges.version.clone(),
2851                    prompt_tokens: prompt,
2852                    completion_tokens: completion,
2853                    total_tokens: total,
2854                    cost_micros: cost,
2855                });
2856            }
2857        }
2858        // DW-081: streaming prompt/response capture. The prompt is
2859        // stored redacted (redacted at build time); the response is
2860        // marked as streamed (the full content is not reassembled —
2861        // buffering the entire stream would contradict the zero-buffer
2862        // design). The context was built at stream start only when
2863        // logging is enabled for this consumer and the sampling
2864        // decision was positive.
2865        if let Some(ctx) = &mut self.prompt_log_ctx {
2866            if !ctx.recorded {
2867                ctx.recorded = true;
2868                if let Some(analytics) = &self.analytics {
2869                    analytics.offer_ai_prompt_log(crate::analytics::AiPromptLogRecord {
2870                        ts_ms: now_ms(),
2871                        request_id: self.rid.clone(),
2872                        consumer: ctx.consumer.clone(),
2873                        route: ctx.route.clone(),
2874                        provider: ctx.provider.clone(),
2875                        model: ctx.model.clone(),
2876                        version: ctx.version.clone(),
2877                        prompt_json: ctx.prompt_json.clone(),
2878                        response_json: serde_json::json!({"streamed": true}).to_string(),
2879                        stream: true,
2880                    });
2881                }
2882            }
2883        }
2884        // PERF-02: store the collected streaming frames in the
2885        // semantic cache (fire-and-forget). No-op when the tee was
2886        // abandoned or the cache is absent.
2887        self.tee_store();
2888    }
2889}
2890
2891impl hyper::body::Body for AiStreamBody {
2892    type Data = Bytes;
2893    type Error = super::proxy::ProxyBodyError;
2894
2895    fn poll_frame(
2896        self: Pin<&mut Self>,
2897        cx: &mut Context<'_>,
2898    ) -> Poll<Option<Result<hyper::body::Frame<Bytes>, super::proxy::ProxyBodyError>>> {
2899        let this = self.get_mut();
2900        loop {
2901            // Drain synthesized terminal frames first: one data frame
2902            // per poll (flushes reach the client per frame).
2903            if let Some(text) = this.tail.pop_front() {
2904                if this.tail.is_empty() && this.translator.is_ended() {
2905                    // The last terminal frame: close after it is
2906                    // handed over. The NEXT poll returns None.
2907                    this.tee_collect(std::slice::from_ref(&text));
2908                    this.close();
2909                }
2910                return Poll::Ready(Some(Ok(hyper::body::Frame::data(Bytes::from(text)))));
2911            }
2912            if this.translator.is_ended() {
2913                return Poll::Ready(None);
2914            }
2915            // The provider body is gone once the budget cutoff dropped
2916            // it (eager upstream cancel): the synthesized tail above is
2917            // the stream's entire remainder.
2918            let Some(inner) = this.inner.as_mut() else {
2919                return Poll::Ready(None);
2920            };
2921            match Pin::new(inner).poll_frame(cx) {
2922                Poll::Ready(Some(Ok(frame))) => {
2923                    let Some(data) = frame.data_ref().cloned() else {
2924                        continue; // trailers / non-data frames pass by
2925                    };
2926                    let (frames, chunks, first) = this.translator.feed(data.as_ref(), this.adapter);
2927                    this.note_chunks(chunks, first);
2928                    this.budget_tick();
2929                    if this.translator.is_ended() {
2930                        this.tail.extend(this.translator.finish());
2931                    }
2932                    if frames.is_empty() {
2933                        // Bytes only continued a partial frame; keep
2934                        // polling (or drain the tail next loop).
2935                        continue;
2936                    }
2937                    let mut joined = String::new();
2938                    for f in &frames {
2939                        joined.push_str(f);
2940                    }
2941                    // DW-082: banned-content guardrail check on the
2942                    // forwarded batch text. A match cuts the stream
2943                    // off (the tail is synthesized above); the
2944                    // already-joined batch is still forwarded (the
2945                    // match is in THIS batch — the cutoff prevents
2946                    // FURTHER content, not retroactive suppression).
2947                    this.guardrail_tick(&joined);
2948                    if this.guardrail_cut_off {
2949                        // The guardrail fired: forward this batch
2950                        // (already-joined) then the tail (error +
2951                        // [DONE]) drains on subsequent polls. The
2952                        // provider body was dropped by guardrail_tick.
2953                        this.tee_collect(&frames);
2954                        return Poll::Ready(Some(Ok(hyper::body::Frame::data(Bytes::from(
2955                            joined,
2956                        )))));
2957                    }
2958                    this.tee_collect(&frames);
2959                    return Poll::Ready(Some(Ok(hyper::body::Frame::data(Bytes::from(joined)))));
2960                }
2961                Poll::Ready(Some(Err(e))) => {
2962                    // Mid-stream provider abort: already-forwarded
2963                    // content stands; the client stream ends cleanly
2964                    // with an error chunk and the terminator.
2965                    tracing::warn!(
2966                        code = "ai_stream_aborted",
2967                        provider = %this.gauges.provider,
2968                        "provider stream aborted mid-flight: {e}"
2969                    );
2970                    this.tail.extend(
2971                        this.translator
2972                            .abort_tail("the model provider closed the stream"),
2973                    );
2974                    continue;
2975                }
2976                Poll::Ready(None) => {
2977                    // Clean end: flush any unterminated frame, then
2978                    // the terminal usage chunk and the terminator.
2979                    let (frames, chunks, first) = this.translator.flush_partial(this.adapter);
2980                    this.note_chunks(chunks, first);
2981                    let mut joined = String::new();
2982                    for f in frames {
2983                        joined.push_str(&f);
2984                    }
2985                    this.tail.extend(this.translator.finish());
2986                    if joined.is_empty() {
2987                        continue;
2988                    }
2989                    return Poll::Ready(Some(Ok(hyper::body::Frame::data(Bytes::from(joined)))));
2990                }
2991                Poll::Pending => return Poll::Pending,
2992            }
2993        }
2994    }
2995
2996    fn is_end_stream(&self) -> bool {
2997        false
2998    }
2999
3000    fn size_hint(&self) -> hyper::body::SizeHint {
3001        hyper::body::SizeHint::default()
3002    }
3003}
3004
3005impl Drop for AiStreamBody {
3006    fn drop(&mut self) {
3007        // A client that hangs up mid-stream still owes its terminal
3008        // metrics (duration histogram; usage if reported).
3009        self.close();
3010    }
3011}