Skip to main content

provider_core/
continuity.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3use router_protocol::CanonicalRequest;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum FailureClass {
10    Authentication,
11    Authorization,
12    SubscriptionExhaustion,
13    RollingRateExhaustion,
14    ModelAccess,
15    InvalidRequest,
16    AdapterInvalidRequest,
17    UnsupportedCapability,
18    ModelNotFoundOrRetired,
19    Overload,
20    Timeout,
21    Network,
22    MalformedStream,
23    Cancelled,
24    #[serde(rename = "upstream_5xx")]
25    Upstream5xx,
26    Unknown,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum ProviderFailureEvidence {
31    Authentication {
32        account_ref: String,
33    },
34    Authorization {
35        account_ref: String,
36    },
37    SubscriptionExhaustion {
38        capacity_domain_ref: String,
39    },
40    RollingRateExhaustion {
41        retry_window_ref: String,
42    },
43    ModelAccess {
44        model_ref: String,
45    },
46    InvalidCanonicalRequest {
47        request_ref: String,
48    },
49    AdapterInvalidRequest {
50        adapter_version: String,
51    },
52    UnsupportedCapability {
53        capability_ref: String,
54    },
55    ModelNotFoundOrRetired {
56        catalog_snapshot_ref: String,
57    },
58    Overload {
59        endpoint_ref: String,
60    },
61    Timeout {
62        visibility_ref: String,
63    },
64    Network {
65        visibility_ref: String,
66    },
67    MalformedStream {
68        visibility_ref: String,
69    },
70    Cancelled {
71        visibility_ref: String,
72    },
73    Upstream5xx {
74        endpoint_ref: String,
75    },
76    Unknown {
77        bounded_fingerprint: String,
78        scope_ref: String,
79    },
80}
81
82pub const fn classify_failure(evidence: &ProviderFailureEvidence) -> FailureClass {
83    match evidence {
84        ProviderFailureEvidence::Authentication { .. } => FailureClass::Authentication,
85        ProviderFailureEvidence::Authorization { .. } => FailureClass::Authorization,
86        ProviderFailureEvidence::SubscriptionExhaustion { .. } => {
87            FailureClass::SubscriptionExhaustion
88        }
89        ProviderFailureEvidence::RollingRateExhaustion { .. } => {
90            FailureClass::RollingRateExhaustion
91        }
92        ProviderFailureEvidence::ModelAccess { .. } => FailureClass::ModelAccess,
93        ProviderFailureEvidence::InvalidCanonicalRequest { .. } => FailureClass::InvalidRequest,
94        ProviderFailureEvidence::AdapterInvalidRequest { .. } => {
95            FailureClass::AdapterInvalidRequest
96        }
97        ProviderFailureEvidence::UnsupportedCapability { .. } => {
98            FailureClass::UnsupportedCapability
99        }
100        ProviderFailureEvidence::ModelNotFoundOrRetired { .. } => {
101            FailureClass::ModelNotFoundOrRetired
102        }
103        ProviderFailureEvidence::Overload { .. } => FailureClass::Overload,
104        ProviderFailureEvidence::Timeout { .. } => FailureClass::Timeout,
105        ProviderFailureEvidence::Network { .. } => FailureClass::Network,
106        ProviderFailureEvidence::MalformedStream { .. } => FailureClass::MalformedStream,
107        ProviderFailureEvidence::Cancelled { .. } => FailureClass::Cancelled,
108        ProviderFailureEvidence::Upstream5xx { .. } => FailureClass::Upstream5xx,
109        ProviderFailureEvidence::Unknown { .. } => FailureClass::Unknown,
110    }
111}
112
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct RawProviderResponse {
116    pub response_ref: String,
117    pub transport: String,
118    pub status_code: Option<u16>,
119    pub provider_code: Option<String>,
120    pub retry_after_seconds: Option<u64>,
121    pub stream_terminal: bool,
122    pub cancelled: bool,
123    pub body_fingerprint: String,
124}
125
126pub fn classify_raw_provider_response(raw: &RawProviderResponse) -> FailureClass {
127    if raw.cancelled || raw.transport == "cancellation" {
128        return FailureClass::Cancelled;
129    }
130    match raw.transport.as_str() {
131        "timeout" => return FailureClass::Timeout,
132        "network" => return FailureClass::Network,
133        "sse" if raw.status_code == Some(200) && !raw.stream_terminal => {
134            return FailureClass::MalformedStream;
135        }
136        "adapter" if raw.status_code == Some(422) => {
137            return FailureClass::UnsupportedCapability;
138        }
139        "adapter" => return FailureClass::AdapterInvalidRequest,
140        _ => {}
141    }
142    match (raw.status_code, raw.provider_code.as_deref()) {
143        (Some(401), _) => FailureClass::Authentication,
144        (Some(403), Some("fixture_model_entitlement")) => FailureClass::ModelAccess,
145        (Some(403), _) => FailureClass::Authorization,
146        (Some(429), Some("fixture_subscription_limit")) => FailureClass::SubscriptionExhaustion,
147        (Some(429), _) => FailureClass::RollingRateExhaustion,
148        (Some(400), _) => FailureClass::InvalidRequest,
149        (Some(404), _) => FailureClass::ModelNotFoundOrRetired,
150        (Some(503), Some("fixture_overloaded")) => FailureClass::Overload,
151        (Some(500..=599), Some("fixture_upstream")) => FailureClass::Upstream5xx,
152        _ => FailureClass::Unknown,
153    }
154}
155
156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157pub enum AdapterDisposition {
158    SupportedFixture,
159    Unsupported,
160    NotApplicable,
161    BlockedPendingLiveEvidence,
162}
163
164#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct AdapterCapabilityDispositions {
166    pub authentication: AdapterDisposition,
167    pub catalog_discovery: AdapterDisposition,
168    pub capability_discovery: AdapterDisposition,
169    pub request_transform: AdapterDisposition,
170    pub stream_transform: AdapterDisposition,
171    pub tools: AdapterDisposition,
172    pub reasoning_mapping: AdapterDisposition,
173    pub usage: AdapterDisposition,
174    pub quota_cost: AdapterDisposition,
175    pub probes: AdapterDisposition,
176    pub idempotency: AdapterDisposition,
177    pub privacy: AdapterDisposition,
178    pub support_status: AdapterDisposition,
179}
180
181#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct ProviderRoute {
184    pub route_ref: String,
185    pub provider_id: String,
186    pub model_id: String,
187    pub endpoint_ref: String,
188    pub fixture_only: bool,
189    pub automatic_live_eligible: bool,
190}
191
192#[derive(Clone, Debug, PartialEq, Eq)]
193pub struct PreparedProviderDispatch {
194    pub request_ref: String,
195    pub route_ref: String,
196    pub adapter_id: String,
197}
198
199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
200pub enum AdapterBoundaryError {
201    LiveRouteProhibited,
202    InvalidRoute,
203}
204
205impl fmt::Display for AdapterBoundaryError {
206    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207        formatter.write_str(match self {
208            Self::LiveRouteProhibited => "provider route is not eligible for the offline adapter",
209            Self::InvalidRoute => "provider route is invalid",
210        })
211    }
212}
213
214impl std::error::Error for AdapterBoundaryError {}
215
216pub trait ProviderAdapter: Send + Sync {
217    fn adapter_id(&self) -> &'static str;
218
219    fn prepare(
220        &self,
221        route: &ProviderRoute,
222        request_ref: &str,
223        request: &CanonicalRequest,
224    ) -> Result<PreparedProviderDispatch, AdapterBoundaryError>;
225
226    fn normalize_failure(&self, evidence: &ProviderFailureEvidence) -> FailureClass {
227        classify_failure(evidence)
228    }
229
230    fn classify_raw_response(&self, raw: &RawProviderResponse) -> FailureClass {
231        classify_raw_provider_response(raw)
232    }
233
234    fn capability_dispositions(&self) -> AdapterCapabilityDispositions;
235
236    fn transform_fixture(&self, _category: &str, canonical_input: &str) -> String {
237        canonical_input.to_owned()
238    }
239}
240
241#[derive(Clone, Copy, Debug, Default)]
242pub struct FixtureContinuityAdapter;
243
244impl ProviderAdapter for FixtureContinuityAdapter {
245    fn adapter_id(&self) -> &'static str {
246        "fixture-continuity"
247    }
248
249    fn prepare(
250        &self,
251        route: &ProviderRoute,
252        request_ref: &str,
253        _request: &CanonicalRequest,
254    ) -> Result<PreparedProviderDispatch, AdapterBoundaryError> {
255        if !fixture_ref(&route.route_ref)
256            || !route.route_ref.starts_with("route.fixture.")
257            || !fixture_ref(&route.provider_id)
258            || !fixture_ref(&route.model_id)
259            || !fixture_ref(&route.endpoint_ref)
260            || !route.endpoint_ref.starts_with("endpoint.fixture.")
261            || !fixture_ref(request_ref)
262            || !request_ref.starts_with("request.")
263        {
264            return Err(AdapterBoundaryError::InvalidRoute);
265        }
266        if !route.fixture_only || route.automatic_live_eligible {
267            return Err(AdapterBoundaryError::LiveRouteProhibited);
268        }
269        Ok(PreparedProviderDispatch {
270            request_ref: request_ref.to_owned(),
271            route_ref: route.route_ref.clone(),
272            adapter_id: self.adapter_id().to_owned(),
273        })
274    }
275
276    fn capability_dispositions(&self) -> AdapterCapabilityDispositions {
277        use AdapterDisposition::{
278            BlockedPendingLiveEvidence as Blocked, SupportedFixture as Supported,
279        };
280        AdapterCapabilityDispositions {
281            authentication: Blocked,
282            catalog_discovery: Supported,
283            capability_discovery: Supported,
284            request_transform: Supported,
285            stream_transform: Supported,
286            tools: Supported,
287            reasoning_mapping: Supported,
288            usage: Supported,
289            quota_cost: Blocked,
290            probes: Supported,
291            idempotency: Supported,
292            privacy: Blocked,
293            support_status: Blocked,
294        }
295    }
296
297    fn transform_fixture(&self, category: &str, canonical_input: &str) -> String {
298        match (category, canonical_input) {
299            ("usage", "usage.request") => "usage.normalized".to_owned(),
300            ("cancellation", "cancel.request") => "cancelled.no_retry".to_owned(),
301            ("errors", "error.matrix") => "typed.failure".to_owned(),
302            ("context", input) if input.starts_with("context.") => "context.accepted".to_owned(),
303            _ => canonical_input.to_owned(),
304        }
305    }
306}
307
308impl FixtureContinuityAdapter {
309    pub fn prepare_reference(
310        &self,
311        route: &ProviderRoute,
312        request_ref: &str,
313    ) -> Result<PreparedProviderDispatch, AdapterBoundaryError> {
314        if !fixture_ref(&route.route_ref)
315            || !route.route_ref.starts_with("route.fixture.")
316            || !fixture_ref(&route.provider_id)
317            || !fixture_ref(&route.model_id)
318            || !fixture_ref(&route.endpoint_ref)
319            || !route.endpoint_ref.starts_with("endpoint.fixture.")
320            || !fixture_ref(request_ref)
321            || !request_ref.starts_with("request.")
322        {
323            return Err(AdapterBoundaryError::InvalidRoute);
324        }
325        if !route.fixture_only || route.automatic_live_eligible {
326            return Err(AdapterBoundaryError::LiveRouteProhibited);
327        }
328        Ok(PreparedProviderDispatch {
329            request_ref: request_ref.to_owned(),
330            route_ref: route.route_ref.clone(),
331            adapter_id: self.adapter_id().to_owned(),
332        })
333    }
334}
335
336#[derive(Clone, Copy, Debug, PartialEq, Eq)]
337pub enum FixtureAdapterKind {
338    OpenAi,
339    Kimi,
340    OllamaCloud,
341    OpenRouter,
342    LocalOllama,
343}
344
345#[derive(Clone, Copy, Debug, PartialEq, Eq)]
346pub struct BoundFixtureContinuityAdapter {
347    kind: FixtureAdapterKind,
348}
349
350impl BoundFixtureContinuityAdapter {
351    pub fn bind(
352        adapter_ref: &str,
353        provider_ref: &str,
354        provider_id: &str,
355        deployment_profile: &str,
356        adapter_version: &str,
357    ) -> Result<Self, AdapterBoundaryError> {
358        let kind = match (
359            adapter_ref,
360            provider_ref,
361            provider_id,
362            deployment_profile,
363            adapter_version,
364        ) {
365            (
366                "adapter.fixture.openai",
367                "provider.fixture.openai",
368                "openai",
369                "hosted_subscription",
370                "fixture-1.0.1",
371            ) => FixtureAdapterKind::OpenAi,
372            (
373                "adapter.fixture.kimi",
374                "provider.fixture.kimi",
375                "kimi",
376                "hosted_subscription",
377                "fixture-1.0.1",
378            ) => FixtureAdapterKind::Kimi,
379            (
380                "adapter.fixture.ollama_cloud",
381                "provider.fixture.ollama_cloud",
382                "ollama_cloud",
383                "hosted_cloud",
384                "fixture-1.0.1",
385            ) => FixtureAdapterKind::OllamaCloud,
386            (
387                "adapter.fixture.openrouter",
388                "provider.fixture.openrouter",
389                "openrouter",
390                "hosted_gateway",
391                "fixture-1.0.1",
392            ) => FixtureAdapterKind::OpenRouter,
393            (
394                "adapter.fixture.local_ollama",
395                "provider.fixture.local_ollama",
396                "ollama_local",
397                "local_compute",
398                "fixture-1.0.1",
399            ) => FixtureAdapterKind::LocalOllama,
400            _ => return Err(AdapterBoundaryError::InvalidRoute),
401        };
402        Ok(Self { kind })
403    }
404
405    pub const fn kind(self) -> FixtureAdapterKind {
406        self.kind
407    }
408
409    pub fn probe_fixture(&self, probe: &str) -> Result<&'static str, AdapterBoundaryError> {
410        if probe != "catalog_or_status" {
411            return Err(AdapterBoundaryError::InvalidRoute);
412        }
413        Ok("catalog_or_status.probed_fixture")
414    }
415
416    pub fn prepare_reference(
417        &self,
418        route: &ProviderRoute,
419        request_ref: &str,
420    ) -> Result<PreparedProviderDispatch, AdapterBoundaryError> {
421        FixtureContinuityAdapter.prepare_reference(route, request_ref)?;
422        if route.provider_id != self.provider_id() {
423            return Err(AdapterBoundaryError::InvalidRoute);
424        }
425        Ok(PreparedProviderDispatch {
426            request_ref: request_ref.to_owned(),
427            route_ref: route.route_ref.clone(),
428            adapter_id: self.adapter_id().to_owned(),
429        })
430    }
431
432    const fn provider_id(self) -> &'static str {
433        match self.kind {
434            FixtureAdapterKind::OpenAi => "openai",
435            FixtureAdapterKind::Kimi => "kimi",
436            FixtureAdapterKind::OllamaCloud => "ollama_cloud",
437            FixtureAdapterKind::OpenRouter => "openrouter",
438            FixtureAdapterKind::LocalOllama => "ollama_local",
439        }
440    }
441}
442
443impl ProviderAdapter for BoundFixtureContinuityAdapter {
444    fn adapter_id(&self) -> &'static str {
445        match self.kind {
446            FixtureAdapterKind::OpenAi => "adapter.fixture.openai",
447            FixtureAdapterKind::Kimi => "adapter.fixture.kimi",
448            FixtureAdapterKind::OllamaCloud => "adapter.fixture.ollama_cloud",
449            FixtureAdapterKind::OpenRouter => "adapter.fixture.openrouter",
450            FixtureAdapterKind::LocalOllama => "adapter.fixture.local_ollama",
451        }
452    }
453
454    fn prepare(
455        &self,
456        route: &ProviderRoute,
457        request_ref: &str,
458        _request: &CanonicalRequest,
459    ) -> Result<PreparedProviderDispatch, AdapterBoundaryError> {
460        self.prepare_reference(route, request_ref)
461    }
462
463    fn capability_dispositions(&self) -> AdapterCapabilityDispositions {
464        use AdapterDisposition::{
465            BlockedPendingLiveEvidence as Blocked, NotApplicable, SupportedFixture as Supported,
466            Unsupported,
467        };
468        let local = self.kind == FixtureAdapterKind::LocalOllama;
469        AdapterCapabilityDispositions {
470            authentication: if local { NotApplicable } else { Blocked },
471            catalog_discovery: Supported,
472            capability_discovery: Supported,
473            request_transform: Supported,
474            stream_transform: Supported,
475            tools: if local { Unsupported } else { Supported },
476            reasoning_mapping: Supported,
477            usage: Supported,
478            quota_cost: if local { NotApplicable } else { Blocked },
479            probes: Supported,
480            idempotency: Supported,
481            privacy: if local { Supported } else { Blocked },
482            support_status: Blocked,
483        }
484    }
485
486    fn transform_fixture(&self, category: &str, canonical_input: &str) -> String {
487        match (category, canonical_input) {
488            ("tools", "tool.required") => "unsupported".to_owned(),
489            _ => FixtureContinuityAdapter.transform_fixture(category, canonical_input),
490        }
491    }
492}
493
494fn fixture_ref(value: &str) -> bool {
495    !value.is_empty()
496        && value.len() <= 128
497        && value
498            .bytes()
499            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
500}