dwara_core/ai/mod.rs
1//! The AI provider-adapter pack (DW-075): one client dialect in,
2//! three provider dialects out.
3//!
4//! Clients send OpenAI chat-completions shaped requests to a route
5//! whose action is `ai`; the gateway resolves the request's `model`
6//! through the [`AiRuntime`] alias table, translates the request to
7//! the serving provider's wire format via that provider's
8//! [`ProviderAdapter`], places the call
9//! through the provider's named UPSTREAM (the same pooling, TLS,
10//! timeout, breaker, and health machinery every other upstream gets),
11//! and translates the response back to the OpenAI shape. The provider
12//! model identifier never reaches the client; the client's alias never
13//! reaches the provider.
14//!
15//! # Layout
16//!
17//! - [`types`] — the canonical chat vocabulary every adapter shares
18//! - [`adapter`] — the [`ProviderAdapter`]
19//! trait, the pure-translation seam DW-076 (routing/failover)
20//! composes on, and the domain error type
21//! - [`adapters`] — the OpenAI, Anthropic, and Gemini dialect impls
22//! - [`openai_compat`] — the client-facing facade (parse/serialize)
23//! - [`sse`] — hand-rolled SSE framing (in-house by the locked M4
24//! dependency decision)
25//! - [`guardrails`] — the DW-082 guardrail engine (prompt-injection,
26//! PII, banned-content, output schema enforcement)
27//! - [`semantic_cache`] — the DW-083 semantic cache
28//! (embedding-similarity cache for AI prompts; feature-gated behind
29//! the `semantic_cache` cargo feature)
30//! - [`policy`] — the DW-085 routing-policy engine (within-request
31//! escalation via an external classifier, and latency-vs-cost
32//! static selection)
33//! - [`a2a`] — the DW-114 A2A (agent-to-agent) protocol scaffold
34//! (adapter, Agent Card parser, stubbed task lifecycle; feature-
35//! gated behind the `a2a` cargo feature)
36//!
37//! # Dependency direction
38//!
39//! `ai` depends on `config` only (the `ai:` block's schema types). The
40//! transport lives in `dataplane` (`dataplane::ai_proxy`), which may
41//! depend on everything; DW-076's failover wraps the call, DW-077
42//! wires the streaming path, DW-078/079 meter it — none of them need
43//! to touch adapter internals, which is the point of the trait.
44
45pub mod a2a;
46pub mod adapter;
47pub mod adapters;
48pub mod batch;
49pub mod budget;
50pub mod cost;
51pub mod credentials;
52pub mod experiments;
53pub mod governance;
54pub mod guardrails;
55pub mod image_fetch;
56pub mod ingress;
57pub mod logging;
58pub mod mcp;
59pub mod openai_compat;
60pub mod policy;
61pub mod redaction;
62pub mod routing;
63pub mod semantic_cache;
64pub mod sse;
65pub mod stream;
66pub mod token_estimator;
67pub mod types;
68
69use crate::config::ai::{AiConfig, AiProviderKind};
70use crate::config::Gateway;
71use std::collections::BTreeMap;
72use std::sync::Arc;
73
74pub use adapter::{adapter_for, AiError, ProviderAdapter, ProviderErrorBody, ProviderRequest};
75
76/// One compiled provider: everything a request needs after the model
77/// alias resolves, with the auth value already RESOLVED (DW-045
78/// compile-time contract — validation resolved it once at publish;
79/// re-resolution here keeps the runtime table self-sufficient and a
80/// reload picks up rotated env/file values).
81#[derive(Debug, Clone)]
82pub struct CompiledProvider {
83 pub name: String,
84 pub kind: AiProviderKind,
85 /// Name of the upstream that carries the transport.
86 pub upstream: String,
87 /// Full request path override. When set, the transport uses this
88 /// path verbatim instead of the adapter's built-in path. None means
89 /// use the adapter's per-kind default path.
90 pub path: Option<String>,
91 /// Resolved auth header pairs (name, value). SECRET: never logged,
92 /// never echoed; the redaction walk in `config` covers config
93 /// echoes and this value lives only here and on the wire.
94 /// Used when the provider has singular `auth` (no pool).
95 pub auth_headers: Vec<(String, String)>,
96 /// DW-080 (Ent): credential pool for this provider. When set, the
97 /// pool is used INSTEAD of `auth_headers` — the proxy picks one
98 /// credential per request and rotates on 429. None when the
99 /// provider has singular `auth` or no auth.
100 pub credential_pool: Option<Arc<credentials::CredentialPool>>,
101}
102
103/// One routable provider/model pair (DW-076): a failover-chain member
104/// or a canary version. `version` is the canary attribution label
105/// (None for plain chain targets).
106#[derive(Debug, Clone, PartialEq)]
107pub struct RouteTarget {
108 /// The provider NAME (lookup key into the provider pool).
109 pub provider: String,
110 /// The provider's own model identifier.
111 pub provider_model: String,
112 /// The canary version name this target serves, if any.
113 pub version: Option<String>,
114}
115
116/// The compiled alias entry (DW-076): either a failover chain
117/// (primary first), a canary split, a routing policy (DW-085), or an
118/// A/B test experiment (DW-086). A policy alias composes over OTHER
119/// aliases' routing plans, so it has no provider/model pair of its
120/// own — validation enforces that `routing_policy` is mutually
121/// exclusive with `failover` and `canary`. An experiment alias
122/// composes over OTHER aliases' routing plans too — validation
123/// enforces that `ab_test` is mutually exclusive with `failover`,
124/// `canary`, and `routing_policy`.
125#[derive(Debug, Clone)]
126pub enum CompiledModel {
127 /// `[primary, alternates...]` in config order.
128 Chain(Vec<RouteTarget>),
129 /// Weighted versions; the pick is deterministic per request id.
130 Canary(Vec<(u32, RouteTarget)>),
131 /// A routing policy (DW-085): within-request escalation or
132 /// latency-vs-cost selection. The policy is evaluated per
133 /// request (async — a FallbackChain policy may call an external
134 /// classifier service).
135 Policy(Arc<policy::CompiledRoutingPolicy>),
136 /// An A/B test experiment (DW-086): two or more variants, each
137 /// naming a model alias (and an optional prompt version). The
138 /// experiment is evaluated per request (a deterministic weighted
139 /// pick by request id selects a variant). Arc'd because the
140 /// compiled test carries resolved targets and prompt messages.
141 Experiment(Arc<experiments::CompiledAbTest>),
142}
143
144/// The per-generation compiled AI table (DW-075): the provider pool
145/// with resolved credentials plus the model alias map. Built at
146/// dataplane refresh from the published config; immutable once
147/// built.
148#[derive(Debug, Clone, Default)]
149pub struct AiRuntime {
150 providers: BTreeMap<String, CompiledProvider>,
151 models: BTreeMap<String, CompiledModel>,
152 /// The compiled MCP gateway (DW-087), built from the `ai.mcp`
153 /// config block. None when the config has no `ai.mcp` block.
154 mcp: Option<Arc<mcp::CompiledMcp>>,
155 /// The compiled A2A surface (DW-114), built from the `ai.a2a`
156 /// config block. None when the block is absent or inert (feature
157 /// off / disabled).
158 a2a: Option<Arc<a2a::CompiledA2a>>,
159 /// AI-14 (#202): The experiments config (prompt versioning, A/B
160 /// tests, evals). Stored for server-side prompt template
161 /// resolution at request time.
162 experiments_config: Option<Arc<crate::config::ai::AiExperiments>>,
163 /// AI-14 (#202): Runtime prompt overrides (prompt_name -> version).
164 /// Populated from the state store at compile time.
165 prompt_overrides: Vec<(String, String)>,
166}
167
168impl AiRuntime {
169 /// Compile from the `ai:` config block. `None` when the block is
170 /// absent (no AI surface). Secret references that fail to resolve
171 /// here (a validate-vs-build race with the environment, or a file
172 /// deleted since publish) fail the PROVIDER closed: the provider
173 /// compiles without its auth header and every call to it will
174 /// surface the provider's own 401 — the loud, attributable
175 /// failure — while an error log records why.
176 pub fn compile(cfg: Option<&AiConfig>, gateway: &Gateway) -> Option<AiRuntime> {
177 let cfg = cfg?;
178 let mut providers = BTreeMap::new();
179 for p in &cfg.providers {
180 let mut auth_headers = Vec::new();
181 let mut credential_pool = None;
182 if let Some(pool_cfg) = &p.credential_pool {
183 // DW-080: compile the credential pool. Each entry is
184 // resolved via the same path as singular auth. Entries
185 // that fail to resolve are skipped (with an error log);
186 // the pool is still built with the remaining entries.
187 // If ALL entries fail to resolve, the pool is None and
188 // the provider compiles without auth (calls will 401).
189 let mut entries = Vec::new();
190 for (i, cred) in pool_cfg.credentials.iter().enumerate() {
191 match crate::config::credentials::resolve_configured_secret(&cred.value) {
192 Ok(value) => entries.push(credentials::PoolEntry {
193 header: cred.header.clone(),
194 value,
195 index: i,
196 }),
197 Err(message) => tracing::error!(
198 code = "ai_credential_pool_entry_unresolved",
199 provider = %p.name,
200 index = i,
201 "ai credential pool entry could not be resolved \
202 at compile time; this key will be skipped: {message}"
203 ),
204 }
205 }
206 if !entries.is_empty() {
207 credential_pool = Some(Arc::new(credentials::CredentialPool::new(
208 entries, pool_cfg,
209 )));
210 }
211 } else if let Some(auth) = &p.auth {
212 match crate::config::credentials::resolve_configured_secret(&auth.value) {
213 Ok(value) => auth_headers.push((auth.header.clone(), value)),
214 Err(message) => tracing::error!(
215 code = "ai_provider_auth_unresolved",
216 provider = %p.name,
217 "ai provider auth could not be resolved at compile \
218 time; calls to this provider will be sent \
219 UNAUTHENTICATED and fail at the provider: {message}"
220 ),
221 }
222 }
223 providers.insert(
224 p.name.clone(),
225 CompiledProvider {
226 name: p.name.clone(),
227 kind: p.kind,
228 upstream: p.upstream.clone(),
229 path: p.path.clone(),
230 auth_headers,
231 credential_pool,
232 },
233 );
234 }
235 // DW-085/DW-086: three-pass compile. Policy aliases and
236 // experiment aliases compose over plain chain/canary aliases
237 // (they resolve named aliases to their primary RouteTarget),
238 // so the plain aliases must be compiled first. A policy alias
239 // cannot reference another policy alias (nested policies are
240 // not allowed); an experiment alias cannot reference another
241 // experiment alias (nested experiments are not allowed). The
242 // first pass skips policy and experiment aliases, the second
243 // pass compiles policy aliases against the first-pass map,
244 // and the third pass compiles experiment aliases against the
245 // first-pass map (experiment variants reference plain
246 // aliases only, not policy aliases).
247 let mut first_pass: BTreeMap<String, CompiledModel> = BTreeMap::new();
248 for (alias, m) in &cfg.models {
249 if m.routing_policy.is_some() || m.ab_test.is_some() {
250 continue;
251 }
252 first_pass.insert(alias.clone(), compile_model(m));
253 }
254 // Second pass: policy aliases.
255 let mut models = first_pass.clone();
256 for (alias, m) in &cfg.models {
257 if let Some(policy_name) = &m.routing_policy {
258 if let Some(policy_cfg) = cfg.routing_policies.get(policy_name) {
259 if let Some(compiled) =
260 policy::CompiledRoutingPolicy::compile(policy_name, policy_cfg, &first_pass)
261 {
262 models.insert(alias.clone(), CompiledModel::Policy(Arc::new(compiled)));
263 }
264 }
265 }
266 }
267 // Third pass: experiment aliases.
268 for (alias, m) in &cfg.models {
269 if let Some(test_name) = &m.ab_test {
270 if let Some(experiments_cfg) = &cfg.experiments {
271 if let Some(test_cfg) = experiments_cfg.ab_tests.get(test_name) {
272 if let Some(compiled) = experiments::CompiledAbTest::compile(
273 test_name,
274 test_cfg,
275 &first_pass,
276 Some(experiments_cfg),
277 ) {
278 models.insert(
279 alias.clone(),
280 CompiledModel::Experiment(Arc::new(compiled)),
281 );
282 }
283 }
284 }
285 }
286 }
287 // DW-087: compile the MCP gateway (tools, sessions, path).
288 let mcp = cfg
289 .mcp
290 .as_ref()
291 .and_then(|mcp_cfg| mcp::CompiledMcp::compile(mcp_cfg, gateway))
292 .map(Arc::new);
293 // DW-114: compile the A2A surface (agents, sessions). When the
294 // `a2a` feature is on and the block is enabled, each configured
295 // agent is also inserted into the provider pool as a provider
296 // of kind `a2a` so model aliases can route to it by name. When
297 // the feature is off or the block is inert, `compile_a2a`
298 // returns None and no A2A providers are wired.
299 let a2a = a2a::compile_a2a(gateway).map(Arc::new);
300 if let Some(compiled_a2a) = &a2a {
301 for agent in compiled_a2a.agents.values() {
302 providers.insert(
303 agent.name.clone(),
304 CompiledProvider {
305 name: agent.name.clone(),
306 kind: AiProviderKind::A2a,
307 upstream: agent.upstream.clone(),
308 path: None,
309 auth_headers: Vec::new(),
310 credential_pool: None,
311 },
312 );
313 }
314 }
315 Some(AiRuntime {
316 providers,
317 models,
318 mcp,
319 a2a,
320 experiments_config: cfg.experiments.clone().map(Arc::new),
321 prompt_overrides: Vec::new(),
322 })
323 }
324
325 /// The compiled MCP gateway (DW-087); None when no `ai.mcp` block.
326 pub fn mcp(&self) -> Option<&Arc<mcp::CompiledMcp>> {
327 self.mcp.as_ref()
328 }
329
330 /// The compiled A2A surface (DW-114); None when no `ai.a2a` block
331 /// or the block is inert (feature off / disabled).
332 pub fn a2a(&self) -> Option<&Arc<a2a::CompiledA2a>> {
333 self.a2a.as_ref()
334 }
335
336 /// AI-14 (#202): The experiments config (prompt versioning, A/B
337 /// tests, evals). None when no `ai.experiments` block.
338 pub fn experiments_config(&self) -> Option<&crate::config::ai::AiExperiments> {
339 self.experiments_config.as_deref()
340 }
341
342 /// AI-14 (#202): Runtime prompt overrides (prompt_name -> version).
343 pub fn prompt_overrides(&self) -> &[(String, String)] {
344 &self.prompt_overrides
345 }
346
347 /// Resolve a model alias to its provider and provider model id
348 /// (the DW-075 single-target view; failover lists and canary
349 /// splits resolve to the same primary).
350 pub fn resolve<'a>(&'a self, alias: &str) -> Option<(&'a CompiledProvider, &'a str)> {
351 let target = self.primary_target(alias)?;
352 let provider = self.providers.get(&target.provider)?;
353 Some((provider, target.provider_model.as_str()))
354 }
355
356 /// The compiled model entry for an alias (routing introspection
357 /// and tests).
358 pub fn model(&self, alias: &str) -> Option<&CompiledModel> {
359 self.models.get(alias)
360 }
361
362 /// Route one request (DW-076): the ordered candidate list to try.
363 /// A canary alias yields exactly ONE candidate — the deterministic
364 /// weighted pick for `pick_key` (the request id); a failover alias
365 /// yields `[primary, alternates...]`. An empty result means the
366 /// alias does not exist. Policy aliases (DW-085) and experiment
367 /// aliases (DW-086) return an empty list here — they need the
368 /// async [`route_with_policy`](Self::route_with_policy) path (the
369 /// classifier call is async for policies; the experiment pick is
370 /// sync but returns an [`experiments::ExperimentDecision`] the dataplane
371 /// records). The sync `route` stays for tests and the non-policy
372 /// dataplane path.
373 pub fn route<'a>(&'a self, alias: &str, pick_key: &str) -> Vec<&'a RouteTarget> {
374 match self.models.get(alias) {
375 Some(CompiledModel::Chain(chain)) => chain.iter().collect(),
376 Some(CompiledModel::Canary(versions)) => {
377 vec![routing::weighted_pick(versions, pick_key)]
378 }
379 Some(CompiledModel::Policy(_)) => Vec::new(),
380 Some(CompiledModel::Experiment(_)) => Vec::new(),
381 None => Vec::new(),
382 }
383 }
384
385 /// DW-091: like [`route`](Self::route) but for canary aliases the
386 /// single picked candidate carries its INDEX (0 = baseline, 1 =
387 /// canary for a 2-version canary split). The auto-canary
388 /// controller needs the index to record the outcome on the
389 /// correct side. Returns `(candidates, canary_index)` where
390 /// `canary_index` is `Some(idx)` only for canary aliases.
391 pub fn route_with_canary_index<'a>(
392 &'a self,
393 alias: &str,
394 pick_key: &str,
395 ) -> (Vec<&'a RouteTarget>, Option<usize>) {
396 match self.models.get(alias) {
397 Some(CompiledModel::Chain(chain)) => (chain.iter().collect(), None),
398 Some(CompiledModel::Canary(versions)) => {
399 let (target, idx) = routing::weighted_pick_with_index(versions, pick_key);
400 (vec![target], Some(idx))
401 }
402 Some(CompiledModel::Policy(_)) => (Vec::new(), None),
403 Some(CompiledModel::Experiment(_)) => (Vec::new(), None),
404 None => (Vec::new(), None),
405 }
406 }
407
408 /// Route one request with policy/experiment evaluation (DW-085 /
409 /// DW-086). Like [`route`](Self::route) but handles Policy
410 /// variants by calling the async
411 /// [`evaluate`](policy::CompiledRoutingPolicy::evaluate) method,
412 /// and Experiment variants by doing the deterministic weighted
413 /// pick. Returns owned [`RouteTarget`]s plus an optional
414 /// [`PolicyDecision`](policy::PolicyDecision) the dataplane
415 /// records as a metric (None for non-policy models). For
416 /// experiment models, the [`experiments::ExperimentDecision`] is returned
417 /// separately via [`route_experiment`](Self::route_experiment).
418 /// For non-policy/non-experiment models, delegates to the sync
419 /// routing logic. An empty result means the alias does not exist.
420 pub async fn route_with_policy(
421 &self,
422 alias: &str,
423 pick_key: &str,
424 prompt_text: &str,
425 ) -> (Vec<RouteTarget>, Option<policy::PolicyDecision>) {
426 match self.models.get(alias) {
427 Some(CompiledModel::Chain(chain)) => (chain.to_vec(), None),
428 Some(CompiledModel::Canary(versions)) => (
429 vec![routing::weighted_pick(versions, pick_key).clone()],
430 None,
431 ),
432 Some(CompiledModel::Policy(policy)) => {
433 let (targets, decision) = policy.evaluate(prompt_text).await;
434 (targets, Some(decision))
435 }
436 Some(CompiledModel::Experiment(test)) => {
437 let variant = test.pick(pick_key);
438 (vec![variant.target.clone()], None)
439 }
440 None => (Vec::new(), None),
441 }
442 }
443
444 /// DW-091: like [`route_with_policy`](Self::route_with_policy) but
445 /// also returns the canary index (0 = baseline, 1 = canary for a
446 /// 2-version canary split) when the alias is a canary alias. The
447 /// auto-canary controller needs the index to record the outcome
448 /// on the correct side. Returns `(candidates, policy_decision,
449 /// canary_index)`.
450 pub async fn route_with_policy_and_canary_index(
451 &self,
452 alias: &str,
453 pick_key: &str,
454 prompt_text: &str,
455 ) -> (
456 Vec<RouteTarget>,
457 Option<policy::PolicyDecision>,
458 Option<usize>,
459 ) {
460 match self.models.get(alias) {
461 Some(CompiledModel::Chain(chain)) => (chain.to_vec(), None, None),
462 Some(CompiledModel::Canary(versions)) => {
463 let (target, idx) = routing::weighted_pick_with_index(versions, pick_key);
464 (vec![target.clone()], None, Some(idx))
465 }
466 Some(CompiledModel::Policy(policy)) => {
467 let (targets, decision) = policy.evaluate(prompt_text).await;
468 (targets, Some(decision), None)
469 }
470 Some(CompiledModel::Experiment(test)) => {
471 let variant = test.pick(pick_key);
472 (vec![variant.target.clone()], None, None)
473 }
474 None => (Vec::new(), None, None),
475 }
476 }
477
478 /// Route one request for an experiment alias (DW-086): the
479 /// deterministic weighted pick selects a variant, returning the
480 /// [`RouteTarget`] and the [`experiments::ExperimentDecision`] for analytics
481 /// attribution. Returns None when the alias is not an experiment
482 /// alias or does not exist.
483 pub fn route_experiment(
484 &self,
485 alias: &str,
486 pick_key: &str,
487 ) -> Option<(RouteTarget, experiments::ExperimentDecision)> {
488 match self.models.get(alias)? {
489 CompiledModel::Experiment(test) => {
490 let variant = test.pick(pick_key);
491 Some((
492 variant.target.clone(),
493 experiments::ExperimentDecision {
494 experiment: test.name.clone(),
495 variant: variant.name.clone(),
496 model: variant.target.provider_model.clone(),
497 },
498 ))
499 }
500 _ => None,
501 }
502 }
503
504 /// The compiled experiment for an alias (introspection/tests).
505 pub fn experiment(&self, alias: &str) -> Option<&Arc<experiments::CompiledAbTest>> {
506 match self.models.get(alias)? {
507 CompiledModel::Experiment(test) => Some(test),
508 _ => None,
509 }
510 }
511
512 /// The compiled provider by name.
513 pub fn provider(&self, name: &str) -> Option<&CompiledProvider> {
514 self.providers.get(name)
515 }
516
517 /// DW-080: the names of all compiled providers (for admin API
518 /// credential pool status enumeration).
519 pub fn provider_names(&self) -> Vec<String> {
520 self.providers.keys().cloned().collect()
521 }
522
523 /// The primary target of an alias (first chain entry; the first
524 /// canary version for split aliases — used by the DW-075
525 /// single-target `resolve`).
526 fn primary_target(&self, alias: &str) -> Option<&RouteTarget> {
527 match self.models.get(alias)? {
528 CompiledModel::Chain(chain) => chain.first(),
529 CompiledModel::Canary(versions) => versions.first().map(|(_, t)| t),
530 // A policy alias has no single primary target — it is
531 // evaluated per request.
532 CompiledModel::Policy(_) => None,
533 // An experiment alias has no single primary target — it
534 // is evaluated per request (the variant pick).
535 CompiledModel::Experiment(_) => None,
536 }
537 }
538
539 /// Number of compiled providers (config introspection/tests).
540 pub fn provider_count(&self) -> usize {
541 self.providers.len()
542 }
543
544 /// Number of model aliases (config introspection/tests).
545 pub fn model_count(&self) -> usize {
546 self.models.len()
547 }
548
549 /// DW-091: rebuild the canary split for `alias` with new weights
550 /// and return a new runtime with the replacement. The providers
551 /// and other models are shared (Arc bump / clone). `new_weights`
552 /// is the new weight per canary version in config order (baseline
553 /// first, canary second for a 2-version canary). The total weight
554 /// MUST stay constant (the caller enforces this). Returns `None`
555 /// when the alias is not a canary alias or the weight count does
556 /// not match the version count.
557 pub fn with_rebuilt_canary(&self, alias: &str, new_weights: &[u32]) -> Option<AiRuntime> {
558 let model = self.models.get(alias)?;
559 let CompiledModel::Canary(versions) = model else {
560 return None;
561 };
562 if new_weights.len() != versions.len() {
563 return None;
564 }
565 let new_versions: Vec<(u32, RouteTarget)> = versions
566 .iter()
567 .zip(new_weights.iter())
568 .map(|((_, target), w)| (*w, target.clone()))
569 .collect();
570 let mut new_models = self.models.clone();
571 new_models.insert(alias.to_string(), CompiledModel::Canary(new_versions));
572 Some(AiRuntime {
573 providers: self.providers.clone(),
574 models: new_models,
575 mcp: self.mcp.clone(),
576 a2a: self.a2a.clone(),
577 experiments_config: self.experiments_config.clone(),
578 prompt_overrides: self.prompt_overrides.clone(),
579 })
580 }
581}
582
583/// Compile one alias config entry into its routing plan (DW-076).
584/// A canary list wins when present; otherwise the chain is
585/// `[primary] + failover`.
586fn compile_model(m: &crate::config::ai::AiModel) -> CompiledModel {
587 if !m.canary.is_empty() {
588 return CompiledModel::Canary(
589 m.canary
590 .iter()
591 .map(|v| {
592 (
593 v.weight,
594 RouteTarget {
595 provider: v.provider.clone(),
596 provider_model: v.provider_model.clone(),
597 version: Some(v.version.clone()),
598 },
599 )
600 })
601 .collect(),
602 );
603 }
604 let mut chain = vec![RouteTarget {
605 provider: m.provider.clone(),
606 provider_model: m.provider_model.clone(),
607 version: None,
608 }];
609 chain.extend(m.failover.iter().map(|f| RouteTarget {
610 provider: f.provider.clone(),
611 provider_model: f.provider_model.clone(),
612 version: None,
613 }));
614 CompiledModel::Chain(chain)
615}