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