1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! Single source of model facts for CodeWhale (#3071, #3073).
//!
//! Historically, "what is this model's context window / max output / does it
//! reason?" was answered by several hard-coded sites:
//!
//! * [`crate::models::context_window_for_model`] /
//! [`crate::models::known_context_window_for_model`] for context windows,
//! * [`crate::models::max_output_tokens_for_model`] for output caps,
//! * [`crate::models::model_supports_reasoning`] for the reasoning flag,
//! * the `DEFAULT_*` model-id constants in `crates/config/src/lib.rs` for the
//! canonical model each provider ships by default.
//!
//! This module is the **foundation** for collapsing those into one place: a
//! [`ModelMetadata`] registry keyed by model id, plus a single [`lookup`]
//! entry point. It is intentionally *additive* — the existing call sites are
//! left untouched in this pass and will be migrated to consume the registry in
//! a later change (so behaviour is unchanged today).
//!
//! ## Seeding discipline (no drift)
//!
//! The registry does not re-declare context-window / max-output / reasoning
//! numbers. Instead it **seeds** each entry by calling the existing
//! `crate::models` functions, so the registry can never silently disagree with
//! `models.rs`. The canonical model ids come from the same provider defaults
//! the config crate ships (see [`SEED_MODEL_IDS`]). The
//! [`tests::registry_context_window_matches_models_rs`] drift guard then
//! re-asserts the equivalence for a sample so that if a future change replaces
//! a seed with a hard-coded literal, CI catches the drift immediately.
//!
//! NOTE: the public surface here is intentionally not yet consumed by
//! production call sites (consumers are wired in a later pass), so
//! `dead_code` is allowed at the module level until then.
#![allow(dead_code)]
use std::collections::BTreeMap;
use std::sync::OnceLock;
use crate::models::{
context_window_for_model, max_output_tokens_for_model, model_supports_reasoning,
};
/// Coarse provider grouping for a model entry.
///
/// This is deliberately a small, stable enum rather than a re-export of
/// `config::ApiProvider`: the registry's job is to answer "what kind of model
/// is this", and many models (Kimi, GLM, Qwen, …) are reachable through
/// several concrete providers. Routing decisions still live in
/// `config::ApiProvider` / `model_routing`; this is only a hint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelProvider {
/// DeepSeek-family models (first-class; preserve full support).
DeepSeek,
/// Anthropic Claude models.
Anthropic,
/// OpenAI public API models (gpt-5.5 family).
OpenAi,
/// OpenAI Codex route models (gpt-5*-codex).
OpenAiCodex,
/// Moonshot / Kimi models.
Moonshot,
/// Z.ai GLM models.
Zai,
/// MiniMax models.
Minimax,
/// Alibaba Qwen models.
Qwen,
/// Arcee Trinity models.
Arcee,
/// Xiaomi MiMo models.
XiaomiMimo,
/// Anything not otherwise classified (still gets real metadata via the
/// `models.rs` heuristics where possible).
Other,
}
/// Default concrete provider that can serve a registry provider family.
#[must_use]
pub fn serving_provider(provider: ModelProvider) -> crate::config::ApiProvider {
match provider {
ModelProvider::DeepSeek => crate::config::ApiProvider::Deepseek,
ModelProvider::Anthropic => crate::config::ApiProvider::Anthropic,
ModelProvider::OpenAi => crate::config::ApiProvider::Openai,
ModelProvider::OpenAiCodex => crate::config::ApiProvider::OpenaiCodex,
ModelProvider::Moonshot => crate::config::ApiProvider::Moonshot,
ModelProvider::Zai => crate::config::ApiProvider::Zai,
ModelProvider::Minimax => crate::config::ApiProvider::Minimax,
ModelProvider::Qwen => crate::config::ApiProvider::Openrouter,
ModelProvider::Arcee => crate::config::ApiProvider::Arcee,
ModelProvider::XiaomiMimo => crate::config::ApiProvider::XiaomiMimo,
ModelProvider::Other => crate::config::ApiProvider::Openrouter,
}
}
/// One row of model facts, looked up in [`lookup`].
///
/// All numeric fields are seeded from `crate::models` so they stay in lockstep
/// with the legacy lookups (see module docs).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelMetadata {
/// Canonical model id as sent to the provider (e.g. `"deepseek-v4-pro"`).
pub id: &'static str,
/// Coarse provider grouping.
pub provider: ModelProvider,
/// Approximate context window in tokens, if known.
pub context_window: Option<u32>,
/// Approximate maximum output tokens, if known.
pub max_output: Option<u32>,
/// Whether the model emits reasoning / thinking content that must be kept
/// out of answer prose.
pub supports_reasoning: bool,
}
impl ModelMetadata {
/// Build a metadata row for `id` by seeding every fact from the existing
/// `crate::models` lookups. This is the only constructor, which is what
/// keeps the registry from drifting away from `models.rs`.
fn seed(id: &'static str, provider: ModelProvider) -> Self {
Self {
id,
provider,
context_window: context_window_for_model(id),
max_output: max_output_tokens_for_model(id),
supports_reasoning: model_supports_reasoning(id),
}
}
}
/// Canonical `(model id, provider)` seeds for the registry.
///
/// These mirror the provider defaults shipped by `crates/config/src/lib.rs`
/// (the `DEFAULT_*_MODEL` constants) plus the explicitly-enumerated models in
/// [`crate::models::known_context_window_for_model`]. Keep this list curated:
/// it is the set of models we make first-class promises about. Unknown ids are
/// still answered by [`lookup`] via the `models.rs` heuristics, they just are
/// not pre-seeded here.
const SEED_MODEL_IDS: &[(&str, ModelProvider)] = &[
// --- DeepSeek (first-class; config DEFAULT_DEEPSEEK_MODEL / NIM / OpenAI
// / Atlascloud / Novita / Fireworks / Siliconflow / SGLang / vLLM /
// Huggingface / Together / Volcengine / WanjieArk / Ollama defaults) ---
("deepseek-v4-pro", ModelProvider::DeepSeek),
("deepseek-v4-flash", ModelProvider::DeepSeek),
("deepseek-ai/deepseek-v4-pro", ModelProvider::DeepSeek),
("deepseek-ai/deepseek-v4-flash", ModelProvider::DeepSeek),
("deepseek/deepseek-v4-pro", ModelProvider::DeepSeek),
("deepseek/deepseek-v4-flash", ModelProvider::DeepSeek),
("deepseek-reasoner", ModelProvider::DeepSeek),
("deepseek-coder:1.3b", ModelProvider::DeepSeek),
// --- Anthropic (config DEFAULT_ANTHROPIC_MODEL + models.rs rows) ---
("claude-opus-4-8", ModelProvider::Anthropic),
("claude-sonnet-4-6", ModelProvider::Anthropic),
("claude-haiku-4-5", ModelProvider::Anthropic),
// --- OpenAI public API + Codex (config DEFAULT_OPENAI_CODEX_MODEL) ---
("gpt-5.5", ModelProvider::OpenAi),
("gpt-5.5-pro", ModelProvider::OpenAi),
("gpt-5-codex", ModelProvider::OpenAiCodex),
("gpt-5.3-codex", ModelProvider::OpenAiCodex),
// --- Moonshot / Kimi (config DEFAULT_MOONSHOT_MODEL / KIMI_CODE) ---
("kimi-k2.7-code", ModelProvider::Moonshot),
("kimi-k2.6", ModelProvider::Moonshot),
("kimi-for-coding", ModelProvider::Moonshot),
("moonshotai/kimi-k2.7-code", ModelProvider::Moonshot),
("moonshotai/kimi-k2.6", ModelProvider::Moonshot),
// --- Z.ai GLM (config DEFAULT_ZAI_MODEL) ---
("z-ai/glm-5.1", ModelProvider::Zai),
("z-ai/glm-5.2", ModelProvider::Zai),
("glm-5.1", ModelProvider::Zai),
("glm-5.2", ModelProvider::Zai),
// --- MiniMax (config DEFAULT_MINIMAX_MODEL) ---
("minimax/minimax-m3", ModelProvider::Minimax),
("minimax-m3", ModelProvider::Minimax),
("minimax/minimax-2.7", ModelProvider::Minimax),
("minimax-m2.7", ModelProvider::Minimax),
// --- Qwen (OpenRouter routing defaults) ---
("qwen/qwen3.6-flash", ModelProvider::Qwen),
("qwen/qwen3.6-plus", ModelProvider::Qwen),
("qwen/qwen3.6-35b-a3b", ModelProvider::Qwen),
// --- Arcee Trinity (config DEFAULT_ARCEE_MODEL) ---
("trinity-large-thinking", ModelProvider::Arcee),
("arcee-ai/trinity-large-thinking", ModelProvider::Arcee),
("trinity-mini", ModelProvider::Arcee),
// --- Xiaomi MiMo (config DEFAULT_XIAOMI_MIMO_MODEL) ---
("mimo-v2.5-pro", ModelProvider::XiaomiMimo),
("mimo-v2.5", ModelProvider::XiaomiMimo),
];
fn registry() -> &'static BTreeMap<&'static str, ModelMetadata> {
static REGISTRY: OnceLock<BTreeMap<&'static str, ModelMetadata>> = OnceLock::new();
REGISTRY.get_or_init(|| {
SEED_MODEL_IDS
.iter()
.map(|&(id, provider)| (id, ModelMetadata::seed(id, provider)))
.collect()
})
}
/// Look up model facts by id.
///
/// Returns a pre-seeded [`ModelMetadata`] when `model` is one of the canonical
/// [`SEED_MODEL_IDS`] (case-insensitive). For any other id, this falls back to
/// the same `crate::models` heuristics (explicit `_Nk` suffix, DeepSeek/Claude
/// family rules, etc.) and reports the provider as [`ModelProvider::Other`], so
/// callers always get a usable answer rather than `None` for a real model.
///
/// Returns `None` only when the id is unrecognised by every existing source
/// (no seed match and `models.rs` yields no context window).
#[must_use]
pub fn lookup(model: &str) -> Option<ModelMetadata> {
if let Some(meta) = registry().get(model) {
return Some(meta.clone());
}
// Case-insensitive seed match (model ids are compared lowercased by the
// legacy `models.rs` helpers, so honour that here too).
let lowered = model.to_lowercase();
if lowered != model
&& let Some(meta) = registry().get(lowered.as_str())
{
return Some(meta.clone());
}
// Not pre-seeded: defer to the existing heuristics. If they recognise the
// model at all (any known context window), surface a synthetic row so the
// single lookup entry point still works for the long tail of ids.
let context_window = context_window_for_model(model);
let max_output = max_output_tokens_for_model(model);
let supports_reasoning = model_supports_reasoning(model);
if context_window.is_none() && max_output.is_none() && !supports_reasoning {
return None;
}
Some(ModelMetadata {
// The id is not 'static here; we cannot store it, so this synthetic row
// reports an empty id. Pre-seeded rows (the common case) carry the real
// id. This keeps the public type `'static`-clean without leaking.
id: "",
provider: ModelProvider::Other,
context_window,
max_output,
supports_reasoning,
})
}
/// All pre-seeded model ids, for callers that want to enumerate the canonical
/// catalog (e.g. a future provider-aware model picker, #3075).
#[must_use]
pub fn seeded_model_ids() -> Vec<&'static str> {
registry().keys().copied().collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// DRIFT GUARD (#3071, #3073).
///
/// The registry must agree with `crate::models` for the context window of
/// every model it claims to know. Today they agree because the registry is
/// *seeded* from `models.rs`; this test exists so that if a future change
/// replaces a seed with a hard-coded literal that drifts from `models.rs`,
/// CI fails here instead of shipping two disagreeing sources of truth.
#[test]
fn registry_context_window_matches_models_rs() {
// A representative sample spanning every provider grouping and every
// distinct window bucket the legacy table produces.
let sample = [
("deepseek-v4-pro", Some(1_000_000)),
("deepseek-v4-flash", Some(1_000_000)),
("deepseek-coder:1.3b", Some(128_000)),
("claude-opus-4-8", Some(1_000_000)),
("claude-sonnet-4-6", Some(1_000_000)),
("claude-haiku-4-5", Some(200_000)),
("gpt-5.5", Some(1_050_000)),
("gpt-5-codex", Some(400_000)),
("kimi-k2.7-code", Some(262_144)),
("kimi-k2.6", Some(262_144)),
("z-ai/glm-5.1", Some(202_752)),
("z-ai/glm-5.2", Some(1_000_000)),
("minimax/minimax-m3", Some(1_000_000)),
("minimax-m2.7", Some(204_800)),
("qwen/qwen3.6-flash", Some(1_000_000)),
("qwen/qwen3.6-35b-a3b", Some(262_144)),
("trinity-large-thinking", Some(262_144)),
("trinity-mini", Some(128_000)),
("mimo-v2.5-pro", Some(1_000_000)),
];
for (model, expected) in sample {
let meta = lookup(model)
.unwrap_or_else(|| panic!("seeded model {model} should be in the registry"));
// 1. Registry value equals the documented expectation.
assert_eq!(
meta.context_window, expected,
"registry context window for {model} drifted from expected"
);
// 2. Registry value equals the LIVE models.rs value (the real guard:
// catches any future hard-coded literal that drifts).
assert_eq!(
meta.context_window,
context_window_for_model(model),
"registry context window for {model} drifted from models.rs"
);
}
}
#[test]
fn registry_max_output_and_reasoning_match_models_rs() {
for &(id, _) in SEED_MODEL_IDS {
let meta = lookup(id).unwrap_or_else(|| panic!("{id} should be seeded"));
assert_eq!(
meta.max_output,
max_output_tokens_for_model(id),
"registry max_output for {id} drifted from models.rs"
);
assert_eq!(
meta.supports_reasoning,
model_supports_reasoning(id),
"registry supports_reasoning for {id} drifted from models.rs"
);
}
}
#[test]
fn serving_provider_maps_each_family() {
use crate::config::ApiProvider;
assert_eq!(
serving_provider(ModelProvider::DeepSeek),
ApiProvider::Deepseek
);
assert_eq!(
serving_provider(ModelProvider::Anthropic),
ApiProvider::Anthropic
);
assert_eq!(serving_provider(ModelProvider::OpenAi), ApiProvider::Openai);
assert_eq!(
serving_provider(ModelProvider::OpenAiCodex),
ApiProvider::OpenaiCodex
);
assert_eq!(
serving_provider(ModelProvider::Moonshot),
ApiProvider::Moonshot
);
assert_eq!(serving_provider(ModelProvider::Zai), ApiProvider::Zai);
assert_eq!(
serving_provider(ModelProvider::Minimax),
ApiProvider::Minimax
);
assert_eq!(
serving_provider(ModelProvider::Qwen),
ApiProvider::Openrouter
);
assert_eq!(serving_provider(ModelProvider::Arcee), ApiProvider::Arcee);
assert_eq!(
serving_provider(ModelProvider::XiaomiMimo),
ApiProvider::XiaomiMimo
);
assert_eq!(
serving_provider(ModelProvider::Other),
ApiProvider::Openrouter
);
}
#[test]
fn deepseek_models_are_classified_as_deepseek() {
// Branding / first-class DeepSeek support guard: the default DeepSeek
// models must be present and classified as DeepSeek.
for id in [
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-ai/deepseek-v4-pro",
] {
let meta = lookup(id).expect("DeepSeek default should be seeded");
assert_eq!(meta.provider, ModelProvider::DeepSeek);
assert_eq!(meta.context_window, Some(1_000_000));
}
}
#[test]
fn lookup_is_case_insensitive_for_seeded_ids() {
let lower = lookup("deepseek-v4-pro").expect("seeded");
let upper = lookup("DeepSeek-V4-Pro").expect("case-insensitive seed match");
assert_eq!(upper.id, "deepseek-v4-pro");
assert_eq!(upper.context_window, lower.context_window);
assert_eq!(upper.provider, ModelProvider::DeepSeek);
}
#[test]
fn lookup_falls_back_to_models_rs_for_unseeded_known_ids() {
// `deepseek-v3.2-256k-preview` is not in SEED_MODEL_IDS but models.rs
// recognises it via the explicit `_Nk` hint. The single lookup entry
// point must still answer it rather than returning None.
let meta = lookup("deepseek-v3.2-256k-preview").expect("known via models.rs heuristics");
assert_eq!(meta.context_window, Some(256_000));
assert_eq!(
meta.context_window,
context_window_for_model("deepseek-v3.2-256k-preview")
);
assert_eq!(meta.provider, ModelProvider::Other);
}
#[test]
fn lookup_returns_none_for_completely_unknown_model() {
assert!(lookup("totally-made-up-model-xyz").is_none());
}
#[test]
fn seeded_model_ids_are_non_empty_and_unique() {
let ids = seeded_model_ids();
assert!(!ids.is_empty());
let mut sorted = ids.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), ids.len(), "seed ids must be unique");
}
}