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}