1use 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}