Skip to main content

nexus_core/app/
backends.rs

1//! Every backend the app can be logged into at once. Replaces the old
2//! single active `self.provider` — since `/model` now merges every
3//! configured backend's models into one list, sending a request has to
4//! resolve *which* backend a picked model belongs to, not just use "the"
5//! provider.
6
7use crate::provider::openrouter::OpenRouter;
8use crate::provider::{BackendTag, Model};
9
10#[derive(Clone, Default)]
11pub struct Backends {
12    pub openrouter: Option<OpenRouter>,
13    pub openai: Option<OpenRouter>,
14    pub opencode: Option<OpenRouter>,
15    pub codex: Option<OpenRouter>,
16}
17
18impl Backends {
19    pub const fn any(&self) -> bool {
20        self.openrouter.is_some()
21            || self.openai.is_some()
22            || self.opencode.is_some()
23            || self.codex.is_some()
24    }
25
26    /// Whether a credential-backed provider exists for `tag`.
27    pub const fn configured(&self, tag: BackendTag) -> bool {
28        self.get(tag).is_some()
29    }
30
31    pub const fn get(&self, tag: BackendTag) -> Option<&OpenRouter> {
32        match tag {
33            BackendTag::OpenRouter => self.openrouter.as_ref(),
34            BackendTag::OpenAi => self.openai.as_ref(),
35            BackendTag::OpencodeGo => self.opencode.as_ref(),
36            BackendTag::Codex => self.codex.as_ref(),
37        }
38    }
39
40    pub fn set(&mut self, tag: BackendTag, provider: OpenRouter) {
41        match tag {
42            BackendTag::OpenRouter => self.openrouter = Some(provider),
43            BackendTag::OpenAi => self.openai = Some(provider),
44            BackendTag::OpencodeGo => self.opencode = Some(provider),
45            BackendTag::Codex => self.codex = Some(provider),
46        }
47    }
48
49    /// Which backend tags are currently configured, in a fixed display
50    /// order — used to drive the model picker's backend filter.
51    pub fn configured_tags(&self) -> Vec<BackendTag> {
52        [
53            BackendTag::OpenRouter,
54            BackendTag::OpenAi,
55            BackendTag::OpencodeGo,
56            BackendTag::Codex,
57        ]
58        .into_iter()
59        .filter(|t| self.get(*t).is_some())
60        .collect()
61    }
62
63    /// Split a composite model id (see `composite_id`) into the backend
64    /// that owns it and the raw id to actually send in a request, and
65    /// clone out that backend's provider. `None` if the composite id's
66    /// backend isn't configured (e.g. its login was removed).
67    pub fn resolve(&self, composite_id: &str) -> Option<(OpenRouter, String)> {
68        for tag in [
69            BackendTag::OpenAi,
70            BackendTag::OpencodeGo,
71            BackendTag::Codex,
72        ] {
73            if let Some(raw) = composite_id.strip_prefix(tag.key_prefix()) {
74                return self.get(tag).cloned().map(|p| (p, raw.to_string()));
75            }
76        }
77        // No known prefix matched => a bare id. Prefer OpenRouter for
78        // backwards compatibility with existing stored model choices; if it
79        // isn't configured (tests/single-backend setups), use the first
80        // configured backend so unprefixed defaults still work.
81        self.get(BackendTag::OpenRouter)
82            .or_else(|| self.configured_tags().first().and_then(|t| self.get(*t)))
83            .cloned()
84            .map(|p| (p, composite_id.to_string()))
85    }
86}
87
88/// The key used to persist/compare a model choice (favorites, last-used,
89/// current-model, per-feature model settings): bare id for `OpenRouter`
90/// (unprefixed, so existing users' saved data keeps working untouched),
91/// prefixed for the other three backends since their raw ids can collide.
92pub fn composite_id(m: &Model) -> String {
93    format!("{}{}", m.backend.key_prefix(), m.id)
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn resolve_routes_prefixed_models_to_their_backend_even_with_openrouter_configured() {
102        let mut backends = Backends::default();
103        backends.set(
104            BackendTag::OpenRouter,
105            OpenRouter::openrouter_flavor("or".into()),
106        );
107        backends.set(BackendTag::OpenAi, OpenRouter::openai("oa".into()));
108        backends.set(BackendTag::OpencodeGo, OpenRouter::opencode_go("go".into()));
109        backends.set(BackendTag::Codex, OpenRouter::openai_codex("codex".into()));
110
111        let (provider, raw) = backends.resolve("openai:gpt-4.1-mini").unwrap();
112        assert_eq!(provider.backend_tag(), BackendTag::OpenAi);
113        assert_eq!(raw, "gpt-4.1-mini");
114
115        let (provider, raw) = backends.resolve("opencode:deepseek-v4-flash").unwrap();
116        assert_eq!(provider.backend_tag(), BackendTag::OpencodeGo);
117        assert_eq!(raw, "deepseek-v4-flash");
118
119        let (provider, raw) = backends.resolve("codex:gpt-5.4-mini").unwrap();
120        assert_eq!(provider.backend_tag(), BackendTag::Codex);
121        assert_eq!(raw, "gpt-5.4-mini");
122    }
123
124    #[test]
125    fn resolve_keeps_bare_ids_on_openrouter_for_legacy_settings() {
126        let mut backends = Backends::default();
127        backends.set(
128            BackendTag::OpenRouter,
129            OpenRouter::openrouter_flavor("or".into()),
130        );
131        backends.set(BackendTag::OpenAi, OpenRouter::openai("oa".into()));
132
133        let (provider, raw) = backends.resolve("google/gemini-2.5-flash-lite").unwrap();
134        assert_eq!(provider.backend_tag(), BackendTag::OpenRouter);
135        assert_eq!(raw, "google/gemini-2.5-flash-lite");
136    }
137}