Skip to main content

claude_codex/
registry.rs

1use crate::{
2    anthropic::{json_error, schema::MessagesRequest},
3    config::AliasProvider,
4    provider::{CliHandlers, Provider, RequestContext},
5};
6use anyhow::{Result, anyhow};
7use async_trait::async_trait;
8use axum::{http::StatusCode, response::Response};
9use std::collections::{BTreeMap, HashSet};
10use std::sync::Arc;
11
12pub const ANTHROPIC_STYLE_ALIASES: &[&str] = &[
13    "haiku",
14    "claude-haiku-4-5",
15    "claude-haiku-4-5-20251001",
16    "sonnet",
17    "claude-sonnet-4-6",
18    "claude-sonnet-5",
19    "opus",
20    "claude-opus-4-7",
21    "claude-opus-4-8",
22    "claude-opus-5",
23    "fable",
24    "claude-fable-5",
25];
26
27pub const CURSOR_PREFIXES: &[&str] = &["cursor:", "cursor-plan:", "cursor-ask:"];
28
29const CURSOR_LEGACY_MODELS: &[&str] = &[
30    "cursor",
31    "cursor-agent",
32    "cursor-composer",
33    "cursor-composer-fast",
34    "cursor-plan",
35    "cursor-ask",
36    "composer-2.5",
37    "composer-2.5-fast",
38];
39
40pub(crate) const CODEX_MODELS: &[&str] = &[
41    "gpt-5.2",
42    "gpt-5.3-codex",
43    "gpt-5.3-codex-spark",
44    "gpt-5.4",
45    "gpt-5.4-mini",
46    "gpt-5.5",
47    "gpt-5.6-luna",
48    "gpt-5.6-sol",
49    "gpt-5.6-terra",
50];
51
52pub(crate) const KIMI_MODELS: &[&str] = &["kimi-for-coding", "kimi-k2.6", "kimi-k3", "k2.6", "k3"];
53pub(crate) const GROK_MODELS: &[&str] = &["grok-composer-2.5-fast", "grok-4.5"];
54
55pub struct Registry {
56    alias_provider: AliasProvider,
57    models: BTreeMap<String, Vec<String>>,
58    handlers: BTreeMap<String, Arc<dyn Provider>>,
59}
60
61impl Registry {
62    pub fn new(alias_provider: AliasProvider) -> Self {
63        let mut models: BTreeMap<String, Vec<String>> = BTreeMap::new();
64        models.insert(
65            "anthropic".into(),
66            ANTHROPIC_STYLE_ALIASES
67                .iter()
68                .map(|alias| (*alias).to_string())
69                .collect(),
70        );
71        models.insert("codex".into(), expand_codex_models());
72        models.insert(
73            "kimi".into(),
74            KIMI_MODELS.iter().map(|m| (*m).to_string()).collect(),
75        );
76        models.insert("cursor".into(), build_cursor_models());
77        models.insert(
78            "grok".into(),
79            GROK_MODELS
80                .iter()
81                .map(|model| (*model).to_string())
82                .collect(),
83        );
84        let mut handlers = BTreeMap::new();
85        for (name, entries) in &models {
86            let handler: Arc<dyn Provider> = match name.as_str() {
87                "anthropic" => Arc::new(crate::providers::anthropic::AnthropicProvider::new()),
88                "codex" => Arc::new(crate::providers::codex::CodexProvider::new()),
89                "kimi" => Arc::new(crate::providers::kimi::KimiProvider::new()),
90                "cursor" => Arc::new(crate::providers::cursor::CursorProvider::new()),
91                "grok" => Arc::new(crate::providers::grok::GrokProvider::new()),
92                _ => Arc::new(PlaceholderProvider::new(name, entries.clone())),
93            };
94            handlers.insert(name.clone(), handler);
95        }
96
97        Self {
98            alias_provider,
99            models,
100            handlers,
101        }
102    }
103
104    pub fn with_default_alias() -> Self {
105        Self::new(crate::config::alias_provider())
106    }
107
108    pub fn from_providers(
109        alias_provider: AliasProvider,
110        providers: impl IntoIterator<Item = Arc<dyn Provider>>,
111    ) -> Self {
112        let mut models = BTreeMap::new();
113        let mut handlers = BTreeMap::new();
114        for provider in providers {
115            let name = provider.name().to_string();
116            models.insert(name.clone(), provider.supported_models());
117            handlers.insert(name, provider);
118        }
119        Self {
120            alias_provider,
121            models,
122            handlers,
123        }
124    }
125
126    pub fn list_provider_names(&self) -> Vec<String> {
127        let mut names: Vec<String> = self.handlers.keys().cloned().collect();
128        names.sort_unstable();
129        names
130    }
131
132    pub fn provider(&self, name: &str) -> Option<Arc<dyn Provider>> {
133        self.handlers.get(name).cloned()
134    }
135
136    pub fn supported_models_for(&self, provider: &str) -> Vec<String> {
137        let mut models = self.models.get(provider).cloned().unwrap_or_default();
138        if provider == self.alias_provider.as_str() {
139            for alias in ANTHROPIC_STYLE_ALIASES {
140                if !models.iter().any(|value| value == alias) {
141                    models.push((*alias).to_string());
142                }
143            }
144        }
145        models.sort_unstable();
146        models
147    }
148
149    pub fn all_supported_models(&self) -> Vec<(String, String)> {
150        let mut out = Vec::new();
151        for provider in self.handlers.keys() {
152            for model in self.supported_models_for(provider) {
153                out.push((model, provider.clone()));
154            }
155        }
156        out
157    }
158
159    pub fn grouped_models(&self) -> BTreeMap<String, Vec<String>> {
160        let mut out = BTreeMap::new();
161        for provider in self.handlers.keys() {
162            out.insert(provider.clone(), self.supported_models_for(provider));
163        }
164        out
165    }
166
167    pub fn provider_for_model(
168        &self,
169        raw_model: &str,
170        session_affinity: Option<&AliasProvider>,
171    ) -> Option<Arc<dyn Provider>> {
172        let normalized = normalize_incoming_model(raw_model);
173        // Claude-shaped models always resolve to the configured alias target (the
174        // Anthropic passthrough by default). Session affinity is deliberately NOT
175        // consulted here: a codex request earlier in the same session must never drag
176        // the opus/haiku slots off the Anthropic backend. This is what lets the opus
177        // slot stay on Max while the sonnet slot runs on codex within one session.
178        let _ = session_affinity;
179        if is_anthropic_alias(&normalized) || normalized.starts_with("claude-") {
180            return self.handlers.get(self.alias_provider.as_str()).cloned();
181        }
182        if is_cursor_model(&normalized) {
183            return self.handlers.get("cursor").cloned();
184        }
185
186        // Exact model-name match reaches a specific backend regardless of the alias
187        // target: this is how `ANTHROPIC_DEFAULT_SONNET_MODEL=gpt-5.6-terra` sends the
188        // sonnet slot to codex even while aliases default to the Anthropic passthrough.
189        for (name, models) in &self.models {
190            if name == "anthropic" {
191                continue;
192            }
193            if models.iter().any(|candidate| candidate == &normalized) {
194                return self.handlers.get(name).cloned();
195            }
196        }
197
198        None
199    }
200
201    pub fn unknown_model_message(&self) -> String {
202        let mut parts = Vec::new();
203        for (provider, models) in self.grouped_models() {
204            let mut models = models;
205            models.sort_unstable();
206            parts.push(format!("{}: {}", provider, models.join(", ")));
207        }
208        format!("Supported: {}.", parts.join("; "))
209    }
210}
211
212pub fn normalize_incoming_model(model: &str) -> String {
213    let suffix = "[1m]";
214    if model.len() >= suffix.len() && model.to_ascii_lowercase().ends_with(suffix) {
215        return model[..model.len() - suffix.len()].to_string();
216    }
217    model.to_string()
218}
219
220pub fn is_anthropic_alias(model: &str) -> bool {
221    ANTHROPIC_STYLE_ALIASES.contains(&model)
222}
223
224pub fn is_cursor_model(model: &str) -> bool {
225    if CURSOR_LEGACY_MODELS.contains(&model) {
226        return true;
227    }
228
229    CURSOR_PREFIXES
230        .iter()
231        .any(|prefix| model.starts_with(prefix))
232}
233
234struct PlaceholderProvider {
235    name: &'static str,
236    models: Vec<String>,
237}
238
239impl PlaceholderProvider {
240    fn new(name: &str, models: Vec<String>) -> Self {
241        let name = match name {
242            "codex" => "codex",
243            "kimi" => "kimi",
244            "cursor" => "cursor",
245            "grok" => "grok",
246            _ => "codex",
247        };
248        Self { name, models }
249    }
250}
251
252#[async_trait]
253impl Provider for PlaceholderProvider {
254    fn name(&self) -> &'static str {
255        self.name
256    }
257
258    fn supported_models(&self) -> Vec<String> {
259        self.models.clone()
260    }
261
262    fn cli(&self) -> &'static dyn CliHandlers {
263        match self.name {
264            "codex" => &CODEX_CLI,
265            "kimi" => &KIMI_CLI,
266            "cursor" => &CURSOR_CLI,
267            "grok" => &GROK_CLI,
268            _ => &CODEX_CLI,
269        }
270    }
271
272    async fn handle_messages(&self, _body: MessagesRequest, ctx: RequestContext) -> Response {
273        placeholder_provider_response("messages", &ctx.provider)
274    }
275
276    async fn handle_count_tokens(&self, _body: MessagesRequest, ctx: RequestContext) -> Response {
277        placeholder_provider_response("count_tokens", &ctx.provider)
278    }
279}
280
281fn placeholder_provider_response(route: &str, provider: &str) -> Response {
282    let _ = route;
283    json_error(
284        StatusCode::NOT_IMPLEMENTED,
285        "unsupported_provider_error",
286        format!("provider '{}' is not yet implemented", provider),
287    )
288}
289
290#[derive(Clone, Copy)]
291struct PlaceholderCli {
292    provider: &'static str,
293}
294
295impl CliHandlers for PlaceholderCli {
296    fn login(&self) -> Result<()> {
297        Err(anyhow!("{}: browser login not supported", self.provider))
298    }
299
300    fn device(&self) -> Result<()> {
301        Err(anyhow!("{}: device login not supported", self.provider))
302    }
303
304    fn status(&self) -> Result<()> {
305        use serde_json::Value;
306        let path = crate::paths::provider_auth_file(self.provider);
307        let legacy = crate::paths::provider_legacy_auth_file(self.provider);
308        if crate::auth::load_auth_file_with_legacy::<Value>(&path, &legacy).is_some() {
309            Ok(())
310        } else {
311            Err(anyhow!("Not authenticated"))
312        }
313    }
314
315    fn logout(&self) -> Result<()> {
316        let path = crate::paths::provider_auth_file(self.provider);
317        let legacy = crate::paths::provider_legacy_auth_file(self.provider);
318        let _ = crate::auth::delete_auth_file(&path, &legacy);
319        Ok(())
320    }
321}
322
323const CODEX_CLI: PlaceholderCli = PlaceholderCli { provider: "codex" };
324const KIMI_CLI: PlaceholderCli = PlaceholderCli { provider: "kimi" };
325const CURSOR_CLI: PlaceholderCli = PlaceholderCli { provider: "cursor" };
326const GROK_CLI: PlaceholderCli = PlaceholderCli { provider: "grok" };
327fn expand_codex_models() -> Vec<String> {
328    let mut set = HashSet::new();
329    let mut out = Vec::new();
330    for model in CODEX_MODELS {
331        if set.insert((*model).to_string()) {
332            out.push((*model).to_string());
333        }
334        let fast = format!("{model}-fast");
335        if set.insert(fast.clone()) {
336            out.push(fast);
337        }
338    }
339    out.sort_unstable();
340    out
341}
342
343fn build_cursor_models() -> Vec<String> {
344    let mut out: Vec<String> = CURSOR_LEGACY_MODELS
345        .iter()
346        .map(|s| (*s).to_string())
347        .collect();
348    out.sort_unstable();
349    out
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn normalize_model_trims_hint() {
358        assert_eq!(normalize_incoming_model("gpt-5.4-fast[1m]"), "gpt-5.4-fast");
359        assert_eq!(normalize_incoming_model("gpt-5.4-fast"), "gpt-5.4-fast");
360    }
361
362    #[test]
363    fn alias_routes_to_configured_provider() {
364        let registry = Registry::new(AliasProvider::Kimi);
365        let p = registry.provider_for_model("haiku", None);
366        assert!(p.is_some());
367        assert_eq!(p.expect("provider").name(), "kimi");
368    }
369
370    #[test]
371    fn opus_4_8_routes_to_configured_provider() {
372        let registry = Registry::new(AliasProvider::Codex);
373        let p = registry.provider_for_model("claude-opus-4-8", None);
374        assert!(p.is_some());
375        assert_eq!(p.expect("provider").name(), "codex");
376    }
377
378    #[test]
379    fn claude_5_aliases_route_to_configured_provider() {
380        let registry = Registry::new(AliasProvider::Codex);
381        for model in [
382            "claude-sonnet-5",
383            "claude-opus-5",
384            "fable",
385            "claude-fable-5",
386        ] {
387            let p = registry.provider_for_model(model, None);
388            assert!(p.is_some(), "{model} should route to a provider");
389            assert_eq!(p.expect("provider").name(), "codex");
390        }
391    }
392
393    #[test]
394    fn claude_models_route_to_anthropic_passthrough_by_default() {
395        let registry = Registry::new(AliasProvider::Anthropic);
396        for model in [
397            "opus",
398            "claude-opus-4-8",
399            "sonnet",
400            "haiku",
401            "claude-3-5-haiku-20241022",
402        ] {
403            let p = registry.provider_for_model(model, None);
404            assert!(p.is_some(), "{model} should route");
405            assert_eq!(p.expect("provider").name(), "anthropic", "{model}");
406        }
407    }
408
409    #[test]
410    fn explicit_codex_model_routes_to_codex_while_default_is_anthropic() {
411        let registry = Registry::new(AliasProvider::Anthropic);
412        let p = registry.provider_for_model("gpt-5.6-terra", None);
413        assert_eq!(p.expect("provider").name(), "codex");
414    }
415
416    #[test]
417    fn session_affinity_cannot_hijack_claude_slot() {
418        // Even if a prior codex request set Codex affinity, claude aliases stay on anthropic.
419        let registry = Registry::new(AliasProvider::Anthropic);
420        let p = registry.provider_for_model("claude-opus-4-8", Some(&AliasProvider::Codex));
421        assert_eq!(p.expect("provider").name(), "anthropic");
422    }
423
424    #[test]
425    fn cursor_prefix_routes() {
426        let registry = Registry::new(AliasProvider::Codex);
427        assert_eq!(
428            registry
429                .provider_for_model("cursor:gpt-5.5", None)
430                .unwrap()
431                .name(),
432            "cursor"
433        );
434        assert_eq!(
435            registry
436                .provider_for_model("cursor-plan:gpt-5.5", None)
437                .unwrap()
438                .name(),
439            "cursor"
440        );
441        assert_eq!(
442            registry
443                .provider_for_model("cursor-ask:gpt-5.5", None)
444                .unwrap()
445                .name(),
446            "cursor"
447        );
448    }
449}