Skip to main content

bitrouter_core/routers/
routing_table.rs

1use std::fmt;
2use std::future::Future;
3use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6
7use crate::errors::Result;
8use crate::routers::content::RouteContext;
9
10// ── API protocol ──────────────────────────────────────────────────
11
12/// The API protocol / wire format that an endpoint uses.
13///
14/// Model protocols determine how LLM requests are serialized (OpenAI chat
15/// completions, Anthropic messages, Google generative AI). Tool protocols
16/// determine how tool discovery and invocation work (MCP, REST).
17///
18/// A provider may default to one protocol but individual endpoints can
19/// override it.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ApiProtocol {
23    // Model protocols
24    Openai,
25    Anthropic,
26    Google,
27    // Tool protocols
28    Mcp,
29    Rest,
30    // Agent protocols
31    Acp,
32}
33
34impl fmt::Display for ApiProtocol {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(match self {
37            Self::Openai => "openai",
38            Self::Anthropic => "anthropic",
39            Self::Google => "google",
40            Self::Mcp => "mcp",
41            Self::Rest => "rest",
42            Self::Acp => "acp",
43        })
44    }
45}
46
47// ── Routing ──────────────────────────────────────────────────────
48
49/// The resolved target for a routed request (model or tool).
50pub struct RoutingTarget {
51    /// The provider name to route to.
52    pub provider_name: String,
53    /// Upstream service identifier: model ID for language models, tool ID for tools.
54    pub service_id: String,
55    /// The resolved API protocol for this endpoint.
56    pub api_protocol: ApiProtocol,
57}
58
59/// A single entry in the route listing, describing a configured route.
60#[derive(Debug, Clone)]
61pub struct RouteEntry {
62    /// The virtual service name (e.g. "default", "gpt-4o", "create_issue").
63    pub name: String,
64    /// The provider name this route resolves to.
65    pub provider: String,
66    /// The API protocol the provider uses.
67    pub protocol: ApiProtocol,
68}
69
70/// Input token pricing per million tokens.
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
72pub struct InputTokenPricing {
73    /// Cost per million non-cached input tokens.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub no_cache: Option<f64>,
76    /// Cost per million cache-read input tokens.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub cache_read: Option<f64>,
79    /// Cost per million cache-write input tokens.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub cache_write: Option<f64>,
82}
83
84impl InputTokenPricing {
85    fn is_empty(&self) -> bool {
86        self.no_cache.is_none() && self.cache_read.is_none() && self.cache_write.is_none()
87    }
88}
89
90/// Output token pricing per million tokens.
91#[derive(Debug, Clone, Default, Serialize, Deserialize)]
92pub struct OutputTokenPricing {
93    /// Cost per million text output tokens.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub text: Option<f64>,
96    /// Cost per million reasoning output tokens.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub reasoning: Option<f64>,
99}
100
101impl OutputTokenPricing {
102    fn is_empty(&self) -> bool {
103        self.text.is_none() && self.reasoning.is_none()
104    }
105}
106
107/// Token pricing per million tokens for a model.
108#[derive(Debug, Clone, Default, Serialize, Deserialize)]
109pub struct ModelPricing {
110    #[serde(default, skip_serializing_if = "InputTokenPricing::is_empty")]
111    pub input_tokens: InputTokenPricing,
112    #[serde(default, skip_serializing_if = "OutputTokenPricing::is_empty")]
113    pub output_tokens: OutputTokenPricing,
114}
115
116impl ModelPricing {
117    /// Returns `true` when no pricing data is set.
118    pub fn is_empty(&self) -> bool {
119        self.input_tokens.is_empty() && self.output_tokens.is_empty()
120    }
121}
122
123/// A routing table that maps incoming names to routing targets.
124///
125/// Used for both model routing and tool routing with separate instances.
126pub trait RoutingTable: Send + Sync {
127    /// Routes an incoming name to a routing target.
128    ///
129    /// `context` carries optional message metadata for content-aware routing.
130    /// Non-API callers should pass [`RouteContext::default()`].
131    fn route(
132        &self,
133        incoming_name: &str,
134        context: &RouteContext,
135    ) -> impl Future<Output = Result<RoutingTarget>> + Send;
136
137    /// Lists all configured routes.
138    fn list_routes(&self) -> Vec<RouteEntry> {
139        Vec::new()
140    }
141}
142
143impl<T: RoutingTable> RoutingTable for Arc<T> {
144    async fn route(&self, incoming_name: &str, context: &RouteContext) -> Result<RoutingTarget> {
145        (**self).route(incoming_name, context).await
146    }
147
148    fn list_routes(&self) -> Vec<RouteEntry> {
149        (**self).list_routes()
150    }
151}
152
153/// Strips ANSI escape sequences (CSI codes) from a string.
154///
155/// Model names and service IDs should never contain terminal formatting.
156/// This function removes any `ESC[…X` sequences (where `X` is an ASCII
157/// letter in `0x40..=0x7E`) to prevent ANSI leak from environment
158/// variables, config values, or client payloads.
159///
160/// Malformed sequences (no final letter) are stripped up to end-of-string.
161/// Non-ANSI bracket characters (e.g. `model[v2]`) are preserved.
162pub fn strip_ansi_escapes(input: &str) -> String {
163    let bytes = input.as_bytes();
164    let len = bytes.len();
165    let mut out = String::with_capacity(len);
166    let mut i = 0;
167
168    while i < len {
169        if bytes[i] == 0x1b && i + 1 < len && bytes[i + 1] == b'[' {
170            // Skip ESC + '[' and consume parameter bytes until the final byte
171            // (an ASCII letter in 0x40..=0x7E) or end of string.
172            i += 2;
173            while i < len && !(0x40..=0x7E).contains(&bytes[i]) {
174                i += 1;
175            }
176            if i < len {
177                i += 1; // skip the final letter
178            }
179        } else {
180            // The input is a valid UTF-8 `&str`, so indexing into a char
181            // boundary always yields a valid character.
182            let ch = input[i..].chars().next().unwrap_or_default();
183            out.push(ch);
184            i += ch.len_utf8();
185        }
186    }
187
188    out
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn strip_ansi_removes_bold() {
197        assert_eq!(
198            strip_ansi_escapes("claude-opus-4-6\x1b[1m"),
199            "claude-opus-4-6"
200        );
201    }
202
203    #[test]
204    fn strip_ansi_removes_bold_prefix() {
205        assert_eq!(
206            strip_ansi_escapes("\x1b[1mclaude-opus-4-6\x1b[0m"),
207            "claude-opus-4-6"
208        );
209    }
210
211    #[test]
212    fn strip_ansi_noop_clean_string() {
213        assert_eq!(strip_ansi_escapes("gpt-4o"), "gpt-4o");
214    }
215
216    #[test]
217    fn strip_ansi_removes_color_codes() {
218        assert_eq!(
219            strip_ansi_escapes("\x1b[32mmodel-name\x1b[0m"),
220            "model-name"
221        );
222    }
223
224    #[test]
225    fn strip_ansi_handles_empty_string() {
226        assert_eq!(strip_ansi_escapes(""), "");
227    }
228
229    #[test]
230    fn strip_ansi_preserves_brackets_without_esc() {
231        // Literal brackets (no ESC prefix) should be preserved.
232        assert_eq!(strip_ansi_escapes("model[v2]"), "model[v2]");
233    }
234}