1use 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
69pub const MAX_AI_REQUEST_BYTES: usize = 16 * 1024 * 1024;
72
73pub const MAX_AI_PROVIDER_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
77
78pub const MAX_AI_ERROR_BYTES: usize = 1024 * 1024;
80
81fn 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 crate::config::AiEndpoint::Batch => "/v1/batches",
97 }
98}
99
100fn resolve_path<'a>(provider: &'a CompiledProvider, default: &'a str) -> &'a str {
105 provider.path.as_deref().unwrap_or(default)
106}
107
108#[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 let model_alias = json_body
131 .get("model")
132 .and_then(serde_json::Value::as_str)
133 .unwrap_or("");
134
135 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 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 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 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 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#[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 let model_alias = json_body
345 .get("model")
346 .and_then(serde_json::Value::as_str)
347 .unwrap_or("");
348
349 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 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 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 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 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#[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 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 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 let Some(runtime) = gen.ai() else {
666 return ai_error_response(
667 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 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 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 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 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 return serve_ai_chat_passthrough(json_body, route_name, gen, dp, rid, rec, identity)
766 .await;
767 }
768 };
769
770 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 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 let ai_started = std::time::Instant::now();
806
807 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 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 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 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 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 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 let stream_requested = chat_req.stream;
1042 if stream_requested {
1043 chat_req.stream_options_include_usage = true;
1044 }
1045
1046 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 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 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 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 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 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 crate::ai::experiments::active_prompt_system(experiments, overrides, prompt_ref)
1155 };
1156 if let Some(mut system) = system {
1157 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 dp.observability_arc()
1172 .record_ai_experiment_variant_selection(&test.name, &variant.name);
1173 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 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 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 gen_ai_span.record("gen_ai.system", provider.kind.gen_ai_system());
1254
1255 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 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 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 rec.upstream = Some(provider.upstream.clone());
1400 rec.attempts = attempts;
1401 let attempt_started = std::time::Instant::now();
1402 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 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 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 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 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 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 return resp;
1522 }
1523 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 if stream_requested && is_event_stream(&up_parts.headers) {
1537 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 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 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 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 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 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 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 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 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 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 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 if let Some(guard) = &budget {
1817 guard.spend(now_epoch_s(), usage, cost);
1818 }
1819 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 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 obs.record_ai_request_duration(
1913 &provider.name,
1914 route_name,
1915 ai_started.elapsed().as_secs_f64(),
1916 );
1917 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 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 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 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#[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 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 if !logging.should_sample(rid) {
2089 return;
2090 }
2091 let redactor = logging.redactor();
2093 let redacted_prompt = redactor.redact_json(prompt_json);
2094 let redacted_response = redactor.redact_json(response_json);
2095 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#[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 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
2161async 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
2216fn 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
2228fn 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
2236fn 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
2244fn 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
2253fn 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
2267fn 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 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 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 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
2339fn parse_duration_seconds(s: &str) -> Option<u64> {
2341 let s = s.trim();
2342 if s.is_empty() {
2343 return None;
2344 }
2345 if let Ok(n) = s.parse::<u64>() {
2347 return Some(n);
2348 }
2349 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 if !num.is_empty() {
2368 let n: u64 = num.parse().ok()?;
2369 total += n;
2370 }
2371 Some(total)
2372}
2373
2374fn 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
2393struct StreamGauges {
2399 provider: String,
2400 version: String,
2401 chunks: u64,
2402 first_token_recorded: bool,
2403 started: std::time::Instant,
2404 closed: bool,
2407}
2408
2409pub struct AiStreamBody {
2421 inner: Option<crate::dataplane::upstream::UpstreamBody>,
2425 translator: StreamTranslator,
2426 adapter: &'static dyn ProviderAdapter,
2427 tail: std::collections::VecDeque<String>,
2429 gauges: StreamGauges,
2430 obs: std::sync::Arc<crate::observability::Observability>,
2431 budget: Option<crate::ai::budget::BudgetGuard>,
2434 provider_model: String,
2436 rid: String,
2438 cut_off: bool,
2444 budget_spent_tokens: u64,
2451 budget_spent_cost_micros: u64,
2452 pricing: std::sync::Arc<crate::ai::cost::PricingTable>,
2455 analytics: Option<std::sync::Arc<crate::analytics::EmbeddedAnalytics>>,
2458 consumer: String,
2460 consumer_type: String,
2463 team: String,
2466 spend_recorded: bool,
2469 prompt_log_ctx: Option<PromptLogCtx>,
2475 guardrails: std::sync::Arc<crate::ai::guardrails::GuardrailEngine>,
2481 guardrail_cut_off: bool,
2484 gen_ai_span: tracing::Span,
2489 stream_tee_cache: Option<std::sync::Arc<crate::ai::semantic_cache::SemanticCacheEngine>>,
2495 stream_tee_frames: Vec<String>,
2499 stream_tee_cap_bytes: usize,
2504 stream_tee_abandoned: bool,
2507 stream_tee_prompt: String,
2510 stream_tee_model: String,
2512}
2513
2514pub(super) struct PromptLogCtx {
2516 route: String,
2517 provider: String,
2518 model: String,
2519 version: String,
2520 consumer: String,
2521 prompt_json: String,
2524 recorded: bool,
2527}
2528
2529impl AiStreamBody {
2530 #[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 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 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 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 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 fn guardrail_tick(&mut self, batch_text: &str) {
2675 if self.guardrails.is_empty() || self.guardrail_cut_off {
2676 return;
2677 }
2678 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(text) = this.tail.pop_front() {
2904 if this.tail.is_empty() && this.translator.is_ended() {
2905 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 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; };
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 continue;
2936 }
2937 let mut joined = String::new();
2938 for f in &frames {
2939 joined.push_str(f);
2940 }
2941 this.guardrail_tick(&joined);
2948 if this.guardrail_cut_off {
2949 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 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 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 self.close();
3010 }
3011}