use super::model_config::{self, ProviderConfig};
use crate::{
cli::args::VALID_THINKING_LEVELS,
core::{
auth, model_refresh,
provider::{AllowedFallbackModel, ModelConfig, ModelType},
settings_manager::{self, Settings},
virtual_models::{self, VirtualModelDefinition},
},
embedded,
error::{Error, Result},
utils::image::InputLimits,
};
use serde_json::{Value, json};
use std::{
collections::{HashMap, HashSet},
sync::{Arc, OnceLock},
};
pub const SUPPORTED_PROVIDERS: &[&str] = &[
"deepseek",
"opencode",
"opencode-go",
"anthropic",
"ant-ling",
"azure",
"baseten",
"cerebras",
"fireworks",
"github-copilot",
"google",
"groq",
"huggingface",
"kimi-coding",
"meta",
"minimax",
"minimax-cn",
"moonshotai",
"moonshotai-cn",
"nvidia",
"openai",
"openrouter",
"qwen-token-plan",
"qwen-token-plan-cn",
"qwen-token-plan-individual",
"together",
"typesafe",
"vercel-ai-gateway",
"xai",
"xiaomi",
"xiaomi-token-plan-ams",
"xiaomi-token-plan-cn",
"xiaomi-token-plan-sgp",
"zai",
"zai-coding-cn",
];
fn baseline_catalog() -> &'static HashMap<String, Vec<Value>> {
static CATALOG: OnceLock<HashMap<String, Vec<Value>>> = OnceLock::new();
CATALOG.get_or_init(|| {
serde_json::from_str(embedded!("models/models.all.json")).unwrap_or_default()
})
}
fn baseline_models(provider: &str) -> Vec<Value> {
baseline_catalog()
.get(provider)
.cloned()
.unwrap_or_default()
}
fn models_store() -> Option<Value> {
model_refresh::read_models_store()
}
fn catalog_key(model: &Value) -> Option<(ModelType, &str)> {
let id = model.get("id").and_then(|v| v.as_str())?;
let kind = ModelType::from_catalog(model.get("type"))?;
Some((kind, id))
}
pub fn provider_models(provider: &str) -> Vec<Value> {
let mut merged = baseline_models(provider);
let mut index: HashMap<(ModelType, String), usize> = merged
.iter()
.enumerate()
.filter_map(|(i, m)| catalog_key(m).map(|(kind, id)| ((kind, id.to_string()), i)))
.collect();
if let Some(dynamic) = models_store()
.as_ref()
.and_then(|v| v.get(provider))
.and_then(|p| p.get("models"))
.and_then(|m| m.as_array())
{
for model in dynamic {
let Some(key) = catalog_key(model) else {
continue;
};
let key = (key.0, key.1.to_string());
match index.get(&key) {
Some(&idx) => merged[idx] = model.clone(),
None => {
index.insert(key, merged.len());
merged.push(model.clone());
}
}
}
}
if let Some(cfg) = model_config::provider_config(provider) {
apply_models_json(&mut merged, &cfg);
}
merged.retain(|m| ModelType::from_catalog(m.get("type")).is_some());
merged
}
fn merge_compat(base: Option<&Value>, override_: Option<&Value>) -> Option<Value> {
match (base, override_) {
(None, None) => None,
(Some(b), None) => Some(b.clone()),
(None, Some(o)) => Some(o.clone()),
(Some(b), Some(o)) => {
const NESTED: &[&str] = &[
"openRouterRouting",
"vercelGatewayRouting",
"chatTemplateKwargs",
"chatTemplateArgs",
];
let mut merged = b.as_object().cloned().unwrap_or_default();
let Some(oo) = o.as_object() else {
return Some(Value::Object(merged));
};
for (k, v) in oo {
if NESTED.contains(&k.as_str())
&& (merged
.get(k.as_str())
.map(|x| x.is_object())
.unwrap_or(false)
|| v.is_object())
{
let mut nested = merged
.get(k.as_str())
.and_then(|x| x.as_object())
.cloned()
.unwrap_or_default();
if let Some(ov) = v.as_object() {
for (nk, nv) in ov {
nested.insert(nk.clone(), nv.clone());
}
}
merged.insert(k.clone(), Value::Object(nested));
} else {
merged.insert(k.clone(), v.clone());
}
}
Some(Value::Object(merged))
}
}
}
fn apply_models_json(merged: &mut Vec<Value>, cfg: &ProviderConfig) {
for m in merged.iter_mut() {
let is_chat = ModelType::from_catalog(m.get("type")) == Some(ModelType::Chat);
if let Some(obj) = m.as_object_mut()
&& let Some(bu) = &cfg.base_url
{
obj.insert("baseUrl".to_string(), Value::String(bu.clone()));
}
if !is_chat {
continue;
}
let compat = merge_compat(m.get("compat"), cfg.compat.as_ref());
if let Some(compat) = compat
&& let Some(obj) = m.as_object_mut()
{
obj.insert("compat".to_string(), compat);
}
}
for definition in &cfg.models {
let Some(id) = definition.get("id").and_then(|v| v.as_str()) else {
continue;
};
let kind = ModelType::from_catalog(definition.get("type")).unwrap_or_default();
let existing = merged
.iter()
.position(|m| catalog_key(m) == Some((kind, id)));
let defaults = find_model_defaults(
merged,
id,
definition.get("api").and_then(|v| v.as_str()),
kind,
)
.cloned();
let mut model = definition.clone();
if model.get("api").is_none() {
if let Some(api) = &cfg.api {
model
.as_object_mut()
.unwrap()
.insert("api".to_string(), json!(api));
} else if let Some(api) = defaults
.as_ref()
.and_then(|d| d.get("api"))
.and_then(|v| v.as_str())
{
model
.as_object_mut()
.unwrap()
.insert("api".to_string(), json!(api));
}
}
if model.get("baseUrl").is_none() {
let bu = cfg.base_url.clone().or_else(|| {
defaults
.as_ref()
.and_then(|d| d.get("baseUrl"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
if let Some(bu) = bu {
model
.as_object_mut()
.unwrap()
.insert("baseUrl".to_string(), json!(bu));
}
}
if let Some(compat) = merge_compat(cfg.compat.as_ref(), model.get("compat")) {
model
.as_object_mut()
.unwrap()
.insert("compat".to_string(), compat);
}
if let Some(idx) = existing {
merged[idx] = model;
} else {
merged.push(model);
}
}
for (model_id, ov) in &cfg.model_overrides {
if let Some(idx) = merged.iter().position(|m| {
ModelType::from_catalog(m.get("type")) == Some(ModelType::Chat)
&& m.get("id").and_then(|v| v.as_str()) == Some(model_id)
}) {
apply_model_override(&mut merged[idx], ov);
}
}
}
fn find_model_defaults<'a>(
models: &'a [Value],
id: &str,
api: Option<&str>,
kind: ModelType,
) -> Option<&'a Value> {
let same_type = || {
models
.iter()
.filter(move |m| ModelType::from_catalog(m.get("type")).unwrap_or_default() == kind)
};
if let Some(m) = same_type().find(|m| m.get("id").and_then(|v| v.as_str()) == Some(id)) {
return Some(m);
}
if let Some(api) = api
&& let Some(m) = same_type().find(|m| m.get("api").and_then(|v| v.as_str()) == Some(api))
{
return Some(m);
}
if kind == ModelType::Chat
&& let Some(m) = same_type()
.find(|m| m.get("api").and_then(|v| v.as_str()) == Some("openai-completions"))
{
return Some(m);
}
same_type().next()
}
fn apply_model_override(model: &mut Value, ov: &Value) {
let Some(obj) = model.as_object_mut() else {
return;
};
for key in ["name", "reasoning", "input", "contextWindow", "maxTokens"] {
if let Some(v) = ov.get(key) {
obj.insert(key.to_string(), v.clone());
}
}
for key in ["thinkingLevelMap", "samplingParams"] {
if let (Some(base), Some(over)) = (obj.get(key), ov.get(key)) {
if let (Some(b), Some(o)) = (base.as_object(), over.as_object()) {
let mut merged = b.clone();
for (k, v) in o {
merged.insert(k.clone(), v.clone());
}
obj.insert(key.to_string(), Value::Object(merged));
}
} else if let Some(v) = ov.get(key) {
obj.insert(key.to_string(), v.clone());
}
}
if let Some(over) = ov.get("samplingParamsByThinkingLevel") {
let mut merged = obj
.get("samplingParamsByThinkingLevel")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
if let Some(o) = over.as_object() {
for (level, params) in o {
let base_level = merged.get(level).and_then(|v| v.as_object()).cloned();
match (base_level, params.as_object()) {
(Some(b), Some(p)) => {
let mut lv = b;
for (k, v) in p {
lv.insert(k.clone(), v.clone());
}
merged.insert(level.clone(), Value::Object(lv));
}
_ => {
merged.insert(level.clone(), params.clone());
}
}
}
}
obj.insert(
"samplingParamsByThinkingLevel".to_string(),
Value::Object(merged),
);
}
if let Some(over) = ov.get("cost") {
let mut cost = obj.get("cost").cloned().unwrap_or_else(|| json!({}));
if let (Some(c), Some(o)) = (cost.as_object_mut(), over.as_object()) {
for (k, v) in o {
c.insert(k.clone(), v.clone());
}
}
obj.insert("cost".to_string(), cost);
}
if let Some(over) = ov.get("inputLimits") {
let mut merged = obj
.get("inputLimits")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
if let Some(o) = over.as_object() {
for (k, v) in o {
if k == "images" {
let mut images = merged
.get("images")
.and_then(|x| x.as_object())
.cloned()
.unwrap_or_default();
if let Some(oi) = v.as_object() {
for (ik, iv) in oi {
if ik == "resize" && iv.is_object() {
let mut resize = images
.get("resize")
.and_then(|x| x.as_object())
.cloned()
.unwrap_or_default();
if let Some(or) = iv.as_object() {
for (rk, rv) in or {
resize.insert(rk.clone(), rv.clone());
}
}
images.insert(ik.clone(), Value::Object(resize));
} else {
images.insert(ik.clone(), iv.clone());
}
}
}
merged.insert(k.clone(), Value::Object(images));
} else {
merged.insert(k.clone(), v.clone());
}
}
}
obj.insert("inputLimits".to_string(), Value::Object(merged));
}
if let Some(compat) = merge_compat(obj.get("compat"), ov.get("compat")) {
obj.insert("compat".to_string(), compat);
}
}
pub fn provider_headers(provider: &str) -> Vec<(String, String)> {
let mut merged: HashMap<String, String> = HashMap::new();
for m in provider_models(provider) {
if let Some(headers) = m.get("headers").and_then(|v| v.as_object()) {
for (k, v) in headers {
if let Some(vs) = v.as_str() {
merged.insert(k.clone(), vs.to_string());
}
}
}
}
let mut out: Vec<(String, String)> = merged.into_iter().collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
#[derive(Debug, Clone)]
pub struct ModelEntry {
pub id: String,
pub name: String,
pub api: String,
pub model_type: ModelType,
pub base_url: String,
pub provider: String,
pub input: Vec<String>,
pub output: Vec<String>,
pub reasoning: bool,
pub context_window: u32,
pub max_tokens: u32,
pub max_tokens_field: String,
pub sampling_params: Option<Value>,
pub sampling_params_by_thinking_level: Option<Value>,
pub provider_routing: Option<Value>,
pub supports_usage_in_streaming: bool,
pub supports_store: bool,
pub supports_finish_reason: bool,
pub requires_assistant_after_tool_result: bool,
pub supports_strict_mode: bool,
pub cost: Option<Value>,
pub supports_developer_role: bool,
pub requires_reasoning_content_on_assistant_messages: bool,
pub thinking_format: String,
pub supports_reasoning_effort: bool,
pub thinking_level_map: Option<HashMap<String, Option<String>>>,
pub auth_header: bool,
pub input_limits: InputLimits,
pub allowed_fallback_models: Vec<AllowedFallbackModel>,
pub supports_openai_grammar_tools: bool,
pub send_session_affinity_headers: Option<bool>,
pub session_affinity_format: Option<String>,
}
fn parse_allowed_fallback_models(v: Option<&Value>) -> Vec<AllowedFallbackModel> {
let Some(items) = v.and_then(|v| v.as_array()) else {
return Vec::new();
};
items
.iter()
.filter_map(|item| {
let model = item.get("model")?.as_str()?.to_string();
Some(AllowedFallbackModel {
provider: item
.get("provider")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
model,
cost: item.get("cost").cloned(),
})
})
.collect()
}
fn detect_compat(provider: &str, base_url: &str) -> (&'static str, bool) {
let base = base_url.to_ascii_lowercase();
let is_zai = provider == "zai"
|| provider == "zai-coding-cn"
|| base.contains("api.z.ai")
|| base.contains("open.bigmodel.cn");
let is_together = provider == "together"
|| base.contains("api.together.ai")
|| base.contains("api.together.xyz");
let is_moonshot =
provider == "moonshotai" || provider == "moonshotai-cn" || base.contains("api.moonshot.");
let is_openrouter = provider == "openrouter" || base.contains("openrouter.ai");
let is_cloudflare_gateway =
provider == "cloudflare-ai-gateway" || base.contains("gateway.ai.cloudflare.com");
let is_nvidia = provider == "nvidia" || base.contains("integrate.api.nvidia.com");
let is_ant_ling = provider == "ant-ling" || base.contains("api.ant-ling.com");
let is_deepseek = provider == "deepseek" || base.contains("deepseek.com");
let is_grok = provider == "xai" || base.contains("api.x.ai");
let format = if is_deepseek {
"deepseek"
} else if is_zai {
"zai"
} else if is_together {
"together"
} else if is_ant_ling {
"ant-ling"
} else if is_openrouter {
"openrouter"
} else {
"openai"
};
let supports_effort = !is_grok
&& !is_zai
&& !is_moonshot
&& !is_together
&& !is_cloudflare_gateway
&& !is_nvidia
&& !is_ant_ling;
(format, supports_effort)
}
pub fn find_model(provider: &str, model_id: &str) -> Result<ModelEntry> {
find_model_impl(provider, model_id, None)
}
fn find_virtual_definition(provider: &str, model_id: &str) -> Option<Arc<VirtualModelDefinition>> {
let defs = virtual_models::active_definitions();
let exact = defs.iter().find(|d| {
d.provider == provider && (d.id == model_id || (!d.name.is_empty() && d.name == model_id))
});
exact.cloned().or_else(|| {
defs.into_iter().find(|d| {
d.provider == provider
&& (model_id.starts_with(&d.id)
|| (!d.name.is_empty() && model_id.starts_with(&d.name)))
})
})
}
fn virtual_model_entry(definition: &VirtualModelDefinition) -> ModelEntry {
let levels = definition.resolved_thinking_levels();
let mut thinking_level_map: HashMap<String, Option<String>> = HashMap::new();
for level in VALID_THINKING_LEVELS {
thinking_level_map.insert(
level.to_string(),
levels.iter().any(|l| l == level).then(|| level.to_string()),
);
}
ModelEntry {
id: definition.id.clone(),
name: if definition.name.is_empty() {
definition.id.clone()
} else {
definition.name.clone()
},
api: definition.api.clone(),
model_type: definition.model_type,
base_url: definition.base_url.clone(),
provider: definition.provider.clone(),
input: definition.resolved_input(),
output: definition.output.clone(),
reasoning: definition.model_type == ModelType::Chat && levels.iter().any(|l| l != "off"),
context_window: definition.context_window,
max_tokens: definition.max_tokens,
max_tokens_field: "max_tokens".to_string(),
sampling_params: None,
sampling_params_by_thinking_level: None,
provider_routing: None,
supports_usage_in_streaming: false,
supports_store: false,
supports_finish_reason: true,
requires_assistant_after_tool_result: false,
supports_strict_mode: false,
cost: definition.cost.clone(),
supports_developer_role: false,
requires_reasoning_content_on_assistant_messages: false,
thinking_format: String::new(),
supports_reasoning_effort: false,
thinking_level_map: Some(thinking_level_map),
auth_header: false,
input_limits: InputLimits::default(),
allowed_fallback_models: Vec::new(),
supports_openai_grammar_tools: false,
send_session_affinity_headers: None,
session_affinity_format: None,
}
}
pub fn find_model_of_type(provider: &str, model_id: &str, kind: ModelType) -> Result<ModelEntry> {
find_model_impl(provider, model_id, Some(kind))
}
fn find_model_impl(provider: &str, model_id: &str, kind: Option<ModelType>) -> Result<ModelEntry> {
if let Some(definition) = find_virtual_definition(provider, model_id)
&& kind.is_none_or(|k| k == definition.model_type)
{
return Ok(virtual_model_entry(&definition));
}
let provider_models = provider_models(provider);
if provider_models.is_empty() {
return Err(Error::msg(format!(
"no models available for provider {}: run /login to refresh the model catalog",
provider
)));
}
for (pass_exact, m) in provider_models
.iter()
.map(|m| (true, m))
.chain(provider_models.iter().map(|m| (false, m)))
{
if let Some(kind) = kind
&& ModelType::from_catalog(m.get("type")) != Some(kind)
{
continue;
}
if ModelType::from_catalog(m.get("type")) == Some(ModelType::Classifier)
&& classifier_hidden_by_chatgpt_sign_in(provider)
{
continue;
}
let id = m.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = m.get("name").and_then(|v| v.as_str()).unwrap_or("");
let matched = if pass_exact {
id == model_id || (!name.is_empty() && name == model_id)
} else {
model_id.starts_with(id) || (!name.is_empty() && model_id.starts_with(name))
};
if matched {
let compat = m.get("compat").cloned().unwrap_or(Value::Null);
let auth_header = model_config::provider_config(provider)
.map(|c| c.auth_header)
.unwrap_or(false);
let base_url = m
.get("baseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let (detected_format, detected_supports_effort) = detect_compat(provider, &base_url);
return Ok(ModelEntry {
id: id.to_string(),
name: m
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(id)
.to_string(),
api: {
m.get("api")
.and_then(|v| v.as_str())
.unwrap_or("openai-completions")
}
.to_string(),
model_type: ModelType::from_catalog(m.get("type")).unwrap_or_default(),
base_url,
provider: provider.to_string(),
input: m
.get("input")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
output: m
.get("output")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default(),
reasoning: m
.get("reasoning")
.and_then(|v| v.as_bool())
.unwrap_or(false),
context_window: m .get("contextWindow")
.and_then(|v| v.as_u64())
.unwrap_or(128_000) as u32,
max_tokens: m
.get("maxTokens")
.and_then(|v| v.as_u64())
.unwrap_or(16_384) as u32,
max_tokens_field: compat
.get("maxTokensField")
.and_then(|v| v.as_str())
.unwrap_or("max_completion_tokens")
.to_string(),
sampling_params: m.get("samplingParams").cloned(),
sampling_params_by_thinking_level: m.get("samplingParamsByThinkingLevel").cloned(),
cost: m.get("cost").cloned(),
supports_developer_role: compat
.get("supportsDeveloperRole")
.and_then(|v| v.as_bool())
.unwrap_or(false),
requires_reasoning_content_on_assistant_messages: compat
.get("requiresReasoningContentOnAssistantMessages")
.and_then(|v| v.as_bool())
.unwrap_or(false),
thinking_format: compat
.get("thinkingFormat")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| detected_format.to_string()),
supports_reasoning_effort: compat
.get("supportsReasoningEffort")
.and_then(|v| v.as_bool())
.unwrap_or(detected_supports_effort),
provider_routing: compat
.get("openRouterRouting")
.or_else(|| compat.get("vercelGatewayRouting"))
.cloned(),
supports_usage_in_streaming: compat
.get("supportsUsageInStreaming")
.and_then(|v| v.as_bool())
.unwrap_or(true),
supports_store: compat
.get("supportsStore")
.and_then(|v| v.as_bool())
.unwrap_or(true),
supports_finish_reason: compat
.get("supportsFinishReason")
.and_then(|v| v.as_bool())
.unwrap_or(true),
requires_assistant_after_tool_result: compat
.get("requiresAssistantAfterToolResult")
.and_then(|v| v.as_bool())
.unwrap_or(false),
supports_strict_mode: compat
.get("supportsStrictMode")
.and_then(|v| v.as_bool())
.unwrap_or(false),
supports_openai_grammar_tools: compat
.get("supportsOpenAIGrammarTools")
.and_then(|v| v.as_bool())
.unwrap_or(false),
send_session_affinity_headers: compat
.get("sendSessionAffinityHeaders")
.and_then(|v| v.as_bool()),
session_affinity_format: compat
.get("sessionAffinityFormat")
.and_then(|v| v.as_str())
.map(|v| v.to_string()),
thinking_level_map: m.get("thinkingLevelMap").and_then(|v| v.as_object()).map(
|obj| {
obj.iter()
.map(|(k, v)| (k.clone(), v.as_str().map(|s| s.to_string())))
.collect()
},
),
auth_header,
input_limits: InputLimits::from_catalog(m.get("inputLimits")),
allowed_fallback_models: parse_allowed_fallback_models(
compat.get("allowedFallbackModels"),
),
});
}
}
Err(Error::msg(match kind {
Some(kind) => format!(
"model {} not found (provider: {}, type: {})",
model_id,
provider,
kind.as_str()
),
None => format!("model {} not found (provider: {})", model_id, provider),
}))
}
pub fn model_config_from_entry(
entry: &ModelEntry,
api_key_override: Option<String>,
session_id: Option<String>,
) -> ModelConfig {
let api_key = auth::resolve_api_key(&entry.provider, api_key_override).unwrap_or_default();
let temperature = settings_manager::read_settings_temperature().or_else(|| {
entry
.sampling_params
.as_ref()
.and_then(|sp| sp.get("temperature"))
.and_then(|v| v.as_f64())
});
ModelConfig {
model_type: entry.model_type,
provider: entry.provider.clone(),
model_id: entry.id.clone(),
base_url: entry.base_url.clone(),
sampling_params: entry.sampling_params.clone(),
sampling_params_by_thinking_level: entry.sampling_params_by_thinking_level.clone(),
provider_routing: entry.provider_routing.clone(),
supports_usage_in_streaming: entry.supports_usage_in_streaming,
supports_store: entry.supports_store,
supports_finish_reason: entry.supports_finish_reason,
requires_assistant_after_tool_result: entry.requires_assistant_after_tool_result,
supports_strict_mode: entry.supports_strict_mode,
api_key,
api: entry.api.clone(),
input: entry.input.clone(),
output: entry.output.clone(),
reasoning: entry.reasoning,
max_tokens: if entry.max_tokens > 0 {
Some(entry.max_tokens)
} else {
None
},
temperature,
context_window: entry.context_window,
thinking_format: entry.thinking_format.clone(),
supports_reasoning_effort: entry.supports_reasoning_effort,
thinking_level_map: entry.thinking_level_map.clone(),
auth_header: entry.auth_header,
image_resize: entry.input_limits.images.resize,
allowed_fallback_models: entry.allowed_fallback_models.clone(),
supports_developer_role: entry.supports_developer_role,
requires_reasoning_content_on_assistant_messages: entry
.requires_reasoning_content_on_assistant_messages,
max_tokens_field: entry.max_tokens_field.clone(),
cost: entry.cost.clone(),
session_id,
send_session_affinity_headers: entry.send_session_affinity_headers,
session_affinity_format: entry.session_affinity_format.clone(),
max_retry_delay_ms: None,
thinking_budgets: None,
}
}
pub fn supports_openai_grammar_tools(provider: &str, model_id: &str) -> bool {
find_model(provider, model_id)
.map(|entry| entry.supports_openai_grammar_tools)
.unwrap_or(false)
}
pub fn configured_model(
provider: &str,
model_id: &str,
api_key_override: Option<String>,
session_id: Option<String>,
) -> Result<ModelConfig> {
let entry = find_model(provider, model_id)?;
Ok(model_config_from_entry(
&entry,
api_key_override,
session_id,
))
}
pub fn configured_model_of_type(
provider: &str,
model_id: &str,
kind: ModelType,
api_key_override: Option<String>,
session_id: Option<String>,
) -> Result<ModelConfig> {
let entry = find_model_of_type(provider, model_id, kind)?;
Ok(model_config_from_entry(
&entry,
api_key_override,
session_id,
))
}
pub fn list_models(provider: &str) -> Vec<(String, String)> {
list_models_of_type(provider, ModelType::Chat)
}
pub fn classifier_hidden_by_chatgpt_sign_in(provider: &str) -> bool {
provider == "openai" && auth::read_oauth_credential(provider).is_some()
}
pub fn list_models_of_type(provider: &str, kind: ModelType) -> Vec<(String, String)> {
if kind == ModelType::Classifier && classifier_hidden_by_chatgpt_sign_in(provider) {
return Vec::new();
}
let items = provider_models(provider);
let available: Option<HashSet<String>> = {
let cred = auth::read_oauth_credential(provider);
cred.and_then(|c| c.available_model_ids)
.map(|ids| ids.into_iter().collect())
};
let mut out: Vec<(String, String)> = items
.iter()
.filter(|m| ModelType::from_catalog(m.get("type")) == Some(kind))
.filter(|m| {
let id = m.get("id").and_then(|v| v.as_str()).unwrap_or("");
match &available {
Some(set) if provider == "github-copilot" => set.contains(id),
_ => true,
}
})
.filter_map(|m| {
let id = m.get("id").and_then(|v| v.as_str())?;
let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id);
Some((id.to_string(), name.to_string()))
})
.collect();
let virtuals = virtual_models::list_for_provider_of_type(provider, kind);
if !virtuals.is_empty() {
let hidden: HashSet<&str> = virtuals.iter().map(|(id, _)| id.as_str()).collect();
out.retain(|(id, _)| !hidden.contains(id.as_str()));
out.extend(virtuals);
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogModel {
pub provider: String,
pub id: String,
pub name: String,
pub kind: ModelType,
}
pub fn catalog_providers() -> Vec<String> {
let mut out: Vec<String> = SUPPORTED_PROVIDERS.iter().map(|p| p.to_string()).collect();
for (id, _) in model_config::read_models_config() {
if !out.contains(&id) {
out.push(id);
}
}
out
}
pub fn list_all_models() -> Vec<CatalogModel> {
catalog_providers()
.into_iter()
.flat_map(|provider| collect_all_of_type(&provider, None))
.collect()
}
pub fn list_all_models_of_type(kind: ModelType) -> Vec<CatalogModel> {
catalog_providers()
.into_iter()
.flat_map(|provider| collect_all_of_type(&provider, Some(kind)))
.collect()
}
pub fn list_all_available_models() -> Vec<CatalogModel> {
auth::list_configured_providers()
.into_iter()
.flat_map(|provider| collect_all_of_type(&provider, None))
.collect()
}
pub fn list_all_available_models_of_type(kind: ModelType) -> Vec<CatalogModel> {
auth::list_configured_providers()
.into_iter()
.flat_map(|provider| collect_all_of_type(&provider, Some(kind)))
.collect()
}
fn collect_all_of_type(provider: &str, kind: Option<ModelType>) -> Vec<CatalogModel> {
let kinds: Vec<ModelType> = match kind {
Some(kind) => vec![kind],
None => vec![ModelType::Chat, ModelType::Image, ModelType::Classifier],
};
kinds
.iter()
.flat_map(|kind| {
list_models_of_type(provider, *kind)
.into_iter()
.map(|(id, name)| CatalogModel {
provider: provider.to_string(),
id,
name,
kind: *kind,
})
})
.collect()
}
fn providers_offering_model(model_name: &str) -> Vec<String> {
let lower = model_name.to_lowercase();
SUPPORTED_PROVIDERS
.iter()
.filter(|p| {
list_models(p)
.into_iter()
.any(|(id, name)| id.to_lowercase() == lower || name.to_lowercase() == lower)
})
.map(|p| p.to_string())
.collect()
}
fn pick_provider_for_bare_model(
model_name: &str,
candidates: &[String],
configured: &[String],
) -> Result<Option<String>> {
if candidates.is_empty() {
return Ok(None);
}
let credentialed: Vec<&String> = candidates
.iter()
.filter(|p| configured.iter().any(|c| c == *p))
.collect();
let pool: Vec<&String> = if credentialed.is_empty() {
candidates.iter().collect()
} else {
credentialed
};
match pool.len() {
1 => Ok(Some(pool[0].clone())),
_ => Err(Error::msg(format!(
"Model \"{model_name}\" is offered by multiple providers ({}); pass --provider or use \"<provider>/{model_name}\".",
pool.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(", ")
))),
}
}
pub fn resolve_provider_model(
provider_flag: Option<&str>,
model_flag: Option<&str>,
settings: &Settings,
) -> Result<(String, String, Option<String>)> {
let env_provider = std::env::var("PRUX_PROVIDER").ok();
let env_model = std::env::var("PRUX_MODEL").ok();
let provider_flag = provider_flag
.map(|s| s.to_string())
.or(env_provider)
.or(settings.default_provider.clone());
let model_flag = model_flag
.map(|s| s.to_string())
.or(env_model)
.or(settings.default_model.clone());
let (m_provider, m_model, m_thinking) = model_flag
.as_deref()
.map(parse_model_arg)
.unwrap_or_default();
match (provider_flag, m_provider) {
(Some(_p), Some(mp)) => Ok((mp, m_model, m_thinking)),
(Some(p), None) => {
if m_model.is_empty() {
match default_model_for(&p) {
Some((pp, mm)) => Ok((pp, mm, m_thinking)),
None => Err(Error::msg(format!("unsupported provider: {}", p))),
}
} else {
Ok((p, m_model, m_thinking))
}
}
(None, Some(mp)) => Ok((mp, m_model, m_thinking)),
(None, None) => {
if !m_model.is_empty() {
let candidates = providers_offering_model(&m_model);
let configured = auth::list_configured_providers();
if let Some(provider) =
pick_provider_for_bare_model(&m_model, &candidates, &configured)?
{
return Ok((provider, m_model, m_thinking));
}
}
match default_model_for(SUPPORTED_PROVIDERS[0]) {
Some((p, m)) => Ok((p, m, None)),
None => Err(Error::msg(
"no provider and model configured. set PRUX_PROVIDER/PRUX_MODEL or use --provider/--model.",
)),
}
}
}
}
pub fn provider_explicitly_configured(
provider_flag: Option<&str>,
model_flag: Option<&str>,
settings: &Settings,
) -> bool {
let env_provider = std::env::var("PRUX_PROVIDER").ok();
let env_model = std::env::var("PRUX_MODEL").ok();
if provider_flag.is_some() || env_provider.is_some() || settings.default_provider.is_some() {
return true;
}
let model_flag = model_flag
.map(|s| s.to_string())
.or(env_model)
.or(settings.default_model.clone());
model_flag
.as_deref()
.map(parse_model_arg)
.and_then(|(p, _, _)| p)
.is_some()
}
pub fn is_known_provider(provider: &str) -> bool {
!provider_models(provider).is_empty()
}
fn default_model_id(provider: &str) -> Option<&'static str> {
Some(match provider {
"ant-ling" => "Ring-2.6-1T",
"anthropic" => "claude-opus-4-8",
"azure" => "gpt-5.4",
"baseten" => "zai-org/GLM-5.2",
"cerebras" => "gpt-oss-120b",
"deepseek" => "deepseek-v4-pro",
"fireworks" => "accounts/fireworks/models/kimi-k3",
"github-copilot" => "gpt-5.4",
"google" => "gemini-3.1-pro-preview",
"groq" => "openai/gpt-oss-120b",
"huggingface" => "moonshotai/Kimi-K2.6",
"kimi-coding" => "kimi-for-coding",
"meta" => "muse-spark-1.3",
"minimax" => "MiniMax-M2.7",
"minimax-cn" => "MiniMax-M2.7",
"moonshotai" => "kimi-k2.6",
"moonshotai-cn" => "kimi-k2.6",
"nvidia" => "nvidia/nemotron-3-ultra-550b-a55b",
"openai" => "gpt-5.5",
"opencode" => "kimi-k2.6",
"opencode-go" => "kimi-k3",
"openrouter" => "moonshotai/kimi-k2.6",
"qwen-token-plan" => "qwen3.7-max",
"qwen-token-plan-cn" => "qwen3.7-max",
"qwen-token-plan-individual" => "qwen3.8-max",
"together" => "moonshotai/Kimi-K3",
"vercel-ai-gateway" => "zai/glm-5.1",
"xai" => "grok-4.7",
"xiaomi" => "mimo-v2.5-pro",
"xiaomi-token-plan-ams" => "mimo-v2.5-pro",
"xiaomi-token-plan-cn" => "mimo-v2.5-pro",
"xiaomi-token-plan-sgp" => "mimo-v2.5-pro",
"zai" => "glm-5.3",
"zai-coding-cn" => "glm-5.3",
_ => return None,
})
}
pub fn default_model_for(provider: &str) -> Option<(String, String)> {
let models = provider_models(provider);
if let Some(preferred) = default_model_id(provider) {
let available: Vec<&str> = models
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()))
.collect();
if available.contains(&preferred) {
return Some((provider.to_string(), preferred.to_string()));
}
}
models
.iter()
.find(|m| ModelType::from_catalog(m.get("type")) == Some(ModelType::Chat))
.and_then(|m| {
m.get("id")
.and_then(|v| v.as_str())
.map(|s| (provider.to_string(), s.to_string()))
})
.or_else(|| {
virtual_models::list_for_provider(provider)
.into_iter()
.next()
.map(|(id, _)| (provider.to_string(), id))
})
}
pub fn parse_model_arg(arg: &str) -> (Option<String>, String, Option<String>) {
let split = |m: &str| -> (String, Option<String>) {
if let Some((model, suffix)) = m.split_once(':')
&& VALID_THINKING_LEVELS.contains(&suffix)
{
return (model.to_string(), Some(suffix.to_string()));
}
(m.to_string(), None)
};
if let Some((p, m)) = arg.split_once('/') {
let (model, thinking) = split(m);
(Some(p.to_string()), model, thinking)
} else {
let (model, thinking) = split(arg);
(None, model, thinking)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::settings_manager::agent_dir;
use serde_json::json;
#[test]
fn bare_model_provider_pick_prefers_credentials_and_reports_ambiguity() {
let both = || vec!["openrouter".to_string(), "opencode".to_string()];
let err = pick_provider_for_bare_model("gpt-6", &both(), &[])
.unwrap_err()
.to_string();
assert!(err.contains("multiple providers"), "{err}");
assert!(err.contains("openrouter, opencode"), "{err}");
assert_eq!(
pick_provider_for_bare_model("gpt-6", &both(), &["opencode".to_string()]).unwrap(),
Some("opencode".to_string())
);
let err = pick_provider_for_bare_model("gpt-6", &both(), &both())
.unwrap_err()
.to_string();
assert!(err.contains("multiple providers"), "{err}");
assert_eq!(
pick_provider_for_bare_model("nope", &[], &[]).unwrap(),
None
);
}
#[test]
fn catalog_has_models_offered_by_multiple_providers() {
let _ad = crate::test_support::AgentDirGuard::temp();
let providers = providers_offering_model("claude-sonnet-4-5");
assert!(providers.len() > 1, "{providers:?}");
assert!(providers.iter().any(|p| p == "anthropic"), "{providers:?}");
}
#[test]
fn detect_compat_matches_pi_detection() {
assert_eq!(
detect_compat("opencode-go", "https://opencode.ai/zen/go/v1"),
("openai", true)
);
assert_eq!(
detect_compat("deepseek", "https://api.deepseek.com"),
("deepseek", true)
);
assert_eq!(
detect_compat("zai", "https://api.z.ai/api/paas/v4"),
("zai", false)
);
assert_eq!(
detect_compat("together", "https://api.together.xyz/v1"),
("together", false)
);
assert_eq!(
detect_compat("ant-ling", "https://api.ant-ling.com/v1"),
("ant-ling", false)
);
assert_eq!(
detect_compat("openrouter", "https://openrouter.ai/api/v1"),
("openrouter", true)
);
assert_eq!(
detect_compat("moonshotai", "https://api.moonshot.cn/v1"),
("openai", false)
);
assert_eq!(
detect_compat("nvidia", "https://integrate.api.nvidia.com/v1"),
("openai", false)
);
assert_eq!(
detect_compat("xai", "https://api.x.ai/v1"),
("openai", false)
);
assert_eq!(
detect_compat(
"cloudflare-ai-gateway",
"https://gateway.ai.cloudflare.com/v1/a/b"
),
("openai", false)
);
}
#[test]
fn find_model_detects_thinking_capabilities_for_uncatalogued_format() {
let _ad = crate::test_support::AgentDirGuard::temp();
let hy3 = find_model("opencode-go", "hy3").unwrap();
assert_eq!(hy3.thinking_format, "openai");
assert!(hy3.supports_reasoning_effort);
assert_eq!(
hy3.thinking_level_map
.as_ref()
.and_then(|m| m.get("off"))
.and_then(|v| v.as_deref()),
Some("none")
);
let mimo = find_model("opencode-go", "mimo-v2.5").unwrap();
assert_eq!(mimo.thinking_format, "openai");
assert!(mimo.supports_reasoning_effort);
assert!(mimo.thinking_level_map.is_none());
let kimi = find_model("moonshotai", "kimi-k2.6").unwrap();
assert_eq!(kimi.thinking_format, "deepseek");
assert!(!kimi.supports_reasoning_effort);
let copilot = find_model("github-copilot", "kimi-k3").unwrap();
assert!(!copilot.supports_reasoning_effort);
}
#[test]
fn openai_classifier_listing_follows_chatgpt_sign_in() {
let _ad = crate::test_support::AgentDirGuard::temp();
let visible = list_models_of_type("openai", ModelType::Classifier);
assert!(
visible.iter().any(|(id, _)| id == "gpt-6-luna"),
"无凭据时 gpt-6-luna 应可见: {visible:?}"
);
assert!(find_model_of_type("openai", "gpt-6-luna", ModelType::Classifier).is_ok());
let cred = crate::core::oauth::OAuthCredential {
access: "x".into(),
refresh: "r".into(),
expires: u64::MAX,
enterprise_url: None,
available_model_ids: None,
client_id: None,
scopes: None,
};
crate::core::auth::write_oauth_credential("openai", &cred).unwrap();
assert!(
list_models_of_type("openai", ModelType::Classifier).is_empty(),
"ChatGPT 登录态下不应列出 openai 的分类器"
);
assert!(
find_model_of_type("openai", "gpt-6-luna", ModelType::Classifier).is_err(),
"ChatGPT 登录态下 openai 的分类器也不应可解析"
);
assert!(!list_models_of_type("openai", ModelType::Chat).is_empty());
}
#[test]
fn list_models_filters_copilot_by_available_ids() {
let _ad = crate::test_support::AgentDirGuard::temp();
let all = list_models("github-copilot");
assert!(
!all.is_empty(),
"github-copilot 应有基线模型(pi.dev 或内嵌)"
);
let cred = crate::core::oauth::OAuthCredential {
access: "x".into(),
refresh: "r".into(),
expires: u64::MAX,
enterprise_url: None,
available_model_ids: Some(vec!["never-exists-model".to_string()]),
client_id: None,
scopes: None,
};
crate::core::auth::write_oauth_credential("github-copilot", &cred).unwrap();
let filtered = list_models("github-copilot");
assert!(
filtered.is_empty(),
"目录模型不在 availableModelIds 中应被过滤"
);
let first_id = all[0].0.clone();
let cred2 = crate::core::oauth::OAuthCredential {
access: "x".into(),
refresh: "r".into(),
expires: u64::MAX,
enterprise_url: None,
available_model_ids: Some(vec![first_id.clone()]),
client_id: None,
scopes: None,
};
crate::core::auth::write_oauth_credential("github-copilot", &cred2).unwrap();
let filtered2 = list_models("github-copilot");
assert_eq!(filtered2.len(), 1);
assert_eq!(filtered2[0].0, first_id);
crate::core::auth::remove_auth("github-copilot").ok();
}
fn test_dir() {
}
fn write_store(v: serde_json::Value) {
let path = agent_dir().join("models-store.json");
std::fs::write(&path, serde_json::to_string(&v).unwrap()).unwrap();
}
fn write_models_json(v: serde_json::Value) {
let path = agent_dir().join("models.json");
std::fs::write(&path, serde_json::to_string(&v).unwrap()).unwrap();
}
#[test]
fn meta_provider_baseline_models() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let base = baseline_models("meta");
assert!(!base.is_empty());
let ids: Vec<&str> = base
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()))
.collect();
assert!(ids.contains(&"muse-spark-1.3"));
assert!(ids.contains(&"muse-spark-1.2"));
assert!(ids.contains(&"muse-spark-1.1"));
assert!(ids.contains(&"muse-spark-1.3-contributor"));
assert!(ids.contains(&"muse-spark-1.2-contributor"));
let e = find_model("meta", "muse-spark-1.3").unwrap();
assert_eq!(e.provider, "meta");
assert_eq!(e.api, "openai-responses");
assert_eq!(e.base_url, "https://api.meta.ai/v1");
assert!(e.reasoning);
assert_eq!(e.context_window, 1_048_576);
assert_eq!(e.max_tokens, 131_072);
assert_eq!(
e.thinking_level_map
.as_ref()
.and_then(|m| m.get("max"))
.and_then(|v| v.as_deref()),
Some("max"),
"muse-spark-1.3 支持 max effort"
);
assert_eq!(
e.thinking_level_map
.as_ref()
.and_then(|m| m.get("off"))
.and_then(|v| v.as_deref()),
None,
"Muse 不支持 off"
);
assert_eq!(
default_model_for("meta").unwrap(),
("meta".to_string(), "muse-spark-1.3".to_string())
);
let e2 = find_model("meta", "muse-spark-1.1").unwrap();
assert_eq!(
e2.thinking_level_map
.as_ref()
.and_then(|m| m.get("max"))
.and_then(|v| v.as_deref()),
None,
"muse-spark-1.1 无 max effort"
);
}
#[test]
fn opus_5_5_offers_low_through_max() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let expected = vec!["low", "medium", "high", "xhigh", "max"];
for (provider, id) in [
("github-copilot", "claude-opus-5.5"),
("anthropic", "claude-opus-5-5"),
("anthropic", "claude-sonnet-5-5"),
] {
let m = find_model(provider, id).unwrap();
let cfg = model_config_from_entry(&m, None, None);
assert_eq!(cfg.supported_thinking_levels(), expected, "{provider}/{id}");
}
}
#[test]
fn cross_provider_model_collections_cover_types_and_providers() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let all = list_all_models();
assert!(
all.iter()
.any(|m| m.provider == "anthropic" && m.kind == ModelType::Chat),
"全量视图应含内置 provider 的 chat 模型"
);
assert!(
all.iter().any(|m| m.kind == ModelType::Image),
"全量视图应含图片模型"
);
assert!(
all.iter().any(|m| m.kind == ModelType::Classifier),
"全量视图应含分类器模型"
);
let images = list_all_models_of_type(ModelType::Image);
assert!(!images.is_empty());
assert!(images.iter().all(|m| m.kind == ModelType::Image));
assert_eq!(
images.len(),
all.iter().filter(|m| m.kind == ModelType::Image).count(),
"指定类型视图应与全量视图的类型子集逐条对应"
);
let chats = list_all_models_of_type(ModelType::Chat);
let providers: HashSet<&str> = chats.iter().map(|m| m.provider.as_str()).collect();
assert!(
providers.len() > 3,
"chat 视图应跨多个 provider: {providers:?}"
);
let all: Vec<(String, String, ModelType)> = all
.into_iter()
.map(|m| (m.provider, m.id, m.kind))
.collect();
let available = list_all_available_models();
for m in &available {
assert!(
all.contains(&(m.provider.clone(), m.id.clone(), m.kind)),
"available 条目不在全量视图里: {m:?}"
);
}
assert_eq!(
list_all_available_models_of_type(ModelType::Classifier).len(),
available
.iter()
.filter(|m| m.kind == ModelType::Classifier)
.count()
);
}
#[test]
fn model_lists_are_filtered_by_catalog_type() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let ids =
|v: Vec<(String, String)>| -> Vec<String> { v.into_iter().map(|(id, _)| id).collect() };
let chat = ids(list_models("openrouter"));
assert!(!chat.is_empty());
assert!(
!chat.iter().any(|id| id == "black-forest-labs/flux.2-flex"),
"图片模型不应出现在 chat 列表"
);
assert!(
!chat.iter().any(|id| id == "~typesafe/jev-latest"),
"分类器不应出现在 chat 列表"
);
let images = ids(list_models_of_type("openrouter", ModelType::Image));
assert!(
images
.iter()
.any(|id| id == "black-forest-labs/flux.2-flex")
);
assert!(!images.iter().any(|id| id == "moonshotai/kimi-k2.6"));
let classifiers = ids(list_models_of_type("openrouter", ModelType::Classifier));
assert!(classifiers.iter().any(|id| id == "~typesafe/jev-latest"));
assert_eq!(
find_model("openrouter", "black-forest-labs/flux.2-flex")
.unwrap()
.model_type,
ModelType::Image
);
let img = model_config_from_entry(
&find_model("openrouter", "black-forest-labs/flux.2-flex").unwrap(),
None,
None,
);
assert_eq!(img.model_type, ModelType::Image);
assert_eq!(
find_model("openrouter", &chat[0]).unwrap().model_type,
ModelType::Chat
);
}
#[test]
fn find_model_of_type_selects_the_entry_of_that_type() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let id = "google/gemini-3-pro-image";
let chat = find_model_of_type("openrouter", id, ModelType::Chat).unwrap();
let image = find_model_of_type("openrouter", id, ModelType::Image).unwrap();
assert_eq!(chat.api, "openai-completions");
assert_eq!(image.api, "openrouter-images");
assert!(image.output.contains(&"image".to_string()));
assert!(chat.output.is_empty());
let err = find_model_of_type("openrouter", id, ModelType::Classifier)
.unwrap_err()
.to_string();
assert!(err.contains("type: classifier"), "{err}");
assert_eq!(
find_model("openrouter", "black-forest-labs/flux.2-flex")
.unwrap()
.output,
vec!["image".to_string()]
);
}
#[test]
fn missing_catalog_type_defaults_to_chat() {
assert_eq!(ModelType::from_catalog(None), Some(ModelType::Chat));
assert_eq!(
ModelType::from_catalog(Some(&json!(""))),
Some(ModelType::Chat)
);
assert_eq!(
ModelType::from_catalog(Some(&json!(7))),
Some(ModelType::Chat)
);
assert_eq!(ModelType::from_catalog(Some(&json!("unknown-kind"))), None);
assert_eq!(
ModelType::from_catalog(Some(&json!("chat"))),
Some(ModelType::Chat)
);
assert_eq!(
ModelType::from_catalog(Some(&json!(" IMAGE "))),
Some(ModelType::Image)
);
}
#[test]
fn unknown_catalog_type_entries_are_dropped() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let path = agent_dir().join("models-store.json");
let _ = std::fs::remove_file(&path);
std::fs::write(
&path,
json!({
"deepseek": {
"models": [
{ "id": "ds-chat", "type": "chat" },
{ "id": "ds-embed", "type": "embedding" },
{ "id": "ds-plain" }
]
}
})
.to_string(),
)
.unwrap();
let ids: Vec<String> = list_models("deepseek")
.into_iter()
.map(|(id, _)| id)
.collect();
assert!(ids.contains(&"ds-chat".to_string()), "{ids:?}");
assert!(ids.contains(&"ds-plain".to_string()), "{ids:?}");
assert!(!ids.contains(&"ds-embed".to_string()), "{ids:?}");
assert!(find_model("deepseek", "ds-embed").is_err());
let _ = std::fs::remove_file(&path);
}
#[test]
fn model_type_strum_conversions() {
for (kind, name) in [
(ModelType::Chat, "chat"),
(ModelType::Image, "image"),
(ModelType::Classifier, "classifier"),
] {
assert_eq!(kind.as_str(), name);
assert_eq!(ModelType::parse(name), Some(kind));
assert_eq!(ModelType::parse(&name.to_uppercase()), Some(kind));
assert_eq!(ModelType::parse(&format!(" {name} ")), Some(kind));
}
assert_eq!(ModelType::parse("unknown-kind"), None);
assert_eq!(ModelType::parse(""), None);
}
#[test]
fn default_model_is_always_chat() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
for p in SUPPORTED_PROVIDERS {
let Some((provider, id)) = default_model_for(p) else {
continue;
};
let entry = find_model(&provider, &id).unwrap();
assert_eq!(entry.model_type, ModelType::Chat, "{provider}/{id}");
}
}
#[test]
fn baseline_models_available_without_store() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
assert_eq!(baseline_models("deepseek").len(), 2); assert!(baseline_models("opencode").len() > 50);
assert!(baseline_models("opencode-go").len() >= 18);
assert!(baseline_models("openai").len() >= 30);
assert!(baseline_models("google").len() >= 20);
assert!(baseline_models("huggingface").len() >= 50);
assert!(baseline_models("openrouter").len() >= 300);
assert!(baseline_models("unknown").is_empty());
for p in SUPPORTED_PROVIDERS {
assert!(!baseline_models(p).is_empty(), "{} 缺少静态基线", p);
}
assert_eq!(provider_models("deepseek").len(), 2);
let e = find_model("deepseek", "deepseek-flash").unwrap();
assert_eq!(e.id, "deepseek-flash");
assert_eq!(e.provider, "deepseek");
assert!(e.reasoning);
assert!(e.thinking_level_map.is_some());
}
#[test]
fn model_entry_carries_catalog_image_limits() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let google = find_model("google", "gemini-3.1-pro-preview").unwrap();
assert_eq!(
google.input_limits.images.resize,
crate::utils::image::ImageResizeLimits {
max_width: 2000,
max_height: 2000,
max_bytes: 4_718_592,
jpeg_quality: 80,
}
);
assert_eq!(
google.input_limits.images.resize,
crate::utils::image::ImageResizeLimits::default()
);
let deepseek = find_model("deepseek", "deepseek-flash").unwrap();
assert_eq!(
deepseek.input_limits.images.resize,
crate::utils::image::ImageResizeLimits::default()
);
}
#[test]
fn model_entry_carries_catalog_request_limits() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let haiku = find_model("anthropic", "claude-haiku-4-5").unwrap();
assert_eq!(haiku.input_limits.max_request_bytes, Some(33_554_432));
assert_eq!(haiku.input_limits.images.max_per_request, Some(100));
assert_eq!(haiku.input_limits.images.max_per_message, None);
let opus = find_model("anthropic", "claude-opus-5").unwrap();
assert_eq!(opus.input_limits.images.max_per_request, Some(600));
let gpt = find_model("openai", "gpt-4-turbo").unwrap();
assert_eq!(gpt.input_limits.max_request_bytes, Some(536_870_912));
assert_eq!(gpt.input_limits.images.max_per_request, Some(1500));
let gemini = find_model("google", "gemini-3.1-pro-preview").unwrap();
assert_eq!(gemini.input_limits.max_request_bytes, Some(20_971_520));
assert_eq!(gemini.input_limits.images.max_per_request, Some(3600));
let vision = find_model("fireworks", "accounts/fireworks/routers/kimi-latest").unwrap();
assert_eq!(vision.input_limits.images.resize.max_width, 2000);
assert_eq!(vision.input_limits.max_request_bytes, None);
assert_eq!(vision.input_limits.images.max_per_request, None);
}
#[test]
fn dynamic_catalog_merge_upserts_by_key_without_scanning() {
let _ad = crate::test_support::AgentDirGuard::temp();
let baseline = baseline_models("deepseek");
let first = baseline.first().expect("基线目录非空");
let first_id = first.get("id").unwrap().as_str().unwrap().to_string();
let mut models: Vec<Value> = vec![json!({
"id": first_id,
"name": "overridden",
"type": "chat"
})];
for i in 0..499 {
models.push(json!({
"id": format!("dyn-{i}"),
"name": format!("Dyn {i}"),
"type": "chat"
}));
}
models.push(json!({ "id": "dyn-bogus", "name": "x", "type": "bogus" }));
crate::core::model_refresh::write_stored_entry("deepseek", json!({ "models": models }))
.unwrap();
let merged = provider_models("deepseek");
assert_eq!(
merged.len(),
baseline.len() + 499,
"覆盖不增条,新键追加,未知 type 丢弃"
);
let overridden = merged
.iter()
.find(|m| m.get("id").and_then(|v| v.as_str()) == Some(first_id.as_str()))
.unwrap();
assert_eq!(overridden.get("name").unwrap().as_str(), Some("overridden"));
assert!(
merged
.iter()
.any(|m| m.get("id").and_then(|v| v.as_str()) == Some("dyn-498"))
);
assert!(
!merged
.iter()
.any(|m| m.get("id").and_then(|v| v.as_str()) == Some("dyn-bogus")),
"未知 type 的条目整条丢弃"
);
crate::core::model_refresh::remove_stored_entry("deepseek").ok();
}
#[test]
fn model_overrides_deep_merge_input_limits() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models.json"));
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
write_models_json(serde_json::json!({
"providers": {
"anthropic": {
"modelOverrides": {
"claude-haiku-4-5": {
"inputLimits": {
"maxRequestBytes": 123,
"images": {
"resize": { "maxWidth": 1568, "jpegQuality": 75 },
"maxPerRequest": 7
}
}
}
}
}
}
}));
let e = find_model("anthropic", "claude-haiku-4-5").unwrap();
assert_eq!(e.input_limits.max_request_bytes, Some(123));
assert_eq!(e.input_limits.images.max_per_request, Some(7));
let resize = e.input_limits.images.resize;
assert_eq!(resize.max_width, 1568, "覆盖值生效");
assert_eq!(resize.jpeg_quality, 75, "覆盖值生效");
assert_eq!(resize.max_height, 2000, "未覆盖字段保留目录值");
assert_eq!(resize.max_bytes, 4_718_592, "未覆盖字段保留目录值");
let _ = std::fs::remove_file(agent_dir().join("models.json"));
}
#[test]
fn model_entry_carries_catalog_allowed_fallback_models() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let fable = find_model("anthropic", "claude-fable-5").unwrap();
let ids: Vec<&str> = fable
.allowed_fallback_models
.iter()
.map(|f| f.model.as_str())
.collect();
assert_eq!(ids, vec!["claude-opus-4-8", "claude-opus-5"]);
assert!(
fable
.allowed_fallback_models
.iter()
.all(|f| f.provider == "anthropic")
);
let cost = fable.allowed_fallback_models[0].cost.as_ref().unwrap();
assert_eq!(cost.get("input").and_then(|v| v.as_f64()), Some(5.0));
assert_eq!(cost.get("output").and_then(|v| v.as_f64()), Some(25.0));
let cfg = model_config_from_entry(&fable, None, None);
assert_eq!(cfg.allowed_fallback_models, fable.allowed_fallback_models);
let sonnet = find_model("anthropic", "claude-sonnet-4-5").unwrap();
assert!(sonnet.allowed_fallback_models.is_empty());
}
#[test]
fn openai_catalog_keeps_retired_codex_models() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
assert!(
provider_models("openai")
.iter()
.any(|m| m.get("id").and_then(|v| v.as_str()) == Some("gpt-5.4")),
"openai 目录应保留 gpt-5.4"
);
assert!(baseline_models("openai-codex").is_empty());
assert!(default_model_id("openai-codex").is_none());
assert!(!SUPPORTED_PROVIDERS.contains(&"openai-codex"));
}
#[test]
fn resolve_provider_model_falls_back_to_default() {
let _ek = crate::test_support::env_key_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
unsafe { std::env::remove_var("PRUX_PROVIDER") };
unsafe { std::env::remove_var("PRUX_MODEL") };
let s = crate::core::settings_manager::Settings {
default_model: None,
default_provider: None,
default_thinking_level: None,
default_project_trust: None,
theme: None,
external_editor: None,
skills: Vec::new(),
themes: Vec::new(),
enabled_models: Vec::new(),
};
let (p, m, t) = resolve_provider_model(None, None, &s).unwrap();
assert_eq!(p, "deepseek");
assert_eq!(m, "deepseek-v4-pro");
assert!(t.is_none());
let (p, m, _) = resolve_provider_model(Some("opencode"), None, &s).unwrap();
assert_eq!(p, "opencode");
assert_eq!(m, "kimi-k2.6");
let (p, m, _) = resolve_provider_model(Some("openai"), None, &s).unwrap();
assert_eq!(p, "openai");
assert_eq!(m, "gpt-5.5");
let (p, m, _) = resolve_provider_model(None, Some("deepseek/deepseek-v4-pro"), &s).unwrap();
assert_eq!(p, "deepseek");
assert_eq!(m, "deepseek-v4-pro");
let mut s2 = s.clone();
s2.default_provider = Some("opencode-go".to_string());
s2.default_model = Some("minimax-m3".to_string());
let (p, m, _) = resolve_provider_model(None, None, &s2).unwrap();
assert_eq!(p, "opencode-go");
assert_eq!(m, "minimax-m3");
}
#[test]
fn provider_explicitly_configured_detects_sources() {
let _ek = crate::test_support::env_key_lock();
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let s = crate::core::settings_manager::Settings {
default_model: None,
default_provider: None,
default_thinking_level: None,
default_project_trust: None,
theme: None,
external_editor: None,
skills: Vec::new(),
themes: Vec::new(),
enabled_models: Vec::new(),
};
unsafe { std::env::remove_var("PRUX_PROVIDER") };
unsafe { std::env::remove_var("PRUX_MODEL") };
assert!(!provider_explicitly_configured(None, None, &s));
assert!(provider_explicitly_configured(Some("deepseek"), None, &s));
unsafe { std::env::set_var("PRUX_PROVIDER", "openai") };
assert!(provider_explicitly_configured(None, None, &s));
unsafe { std::env::remove_var("PRUX_PROVIDER") };
let mut s2 = s.clone();
s2.default_provider = Some("opencode".to_string());
assert!(provider_explicitly_configured(None, None, &s2));
assert!(provider_explicitly_configured(
None,
Some("deepseek/deepseek-v4-pro"),
&s
));
assert!(!provider_explicitly_configured(
None,
Some("deepseek-v4-pro"),
&s
));
}
#[test]
fn dynamic_cache_overrides_baseline() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let mut dynamic = baseline_models("opencode-go");
dynamic[0]["name"] = json!("Renamed By Remote");
dynamic.push(json!({"id": "remote-only-model", "name": "Remote Only"}));
write_store(serde_json::json!({
"opencode-go": {
"models": dynamic,
"checkedAt": 1,
"lastModified": 0,
"etag": "w/\"x\"",
}
}));
let merged = provider_models("opencode-go");
assert!(merged.iter().any(|m| m["name"] == "Renamed By Remote"));
assert!(merged.iter().any(|m| m["id"] == "remote-only-model"));
assert_eq!(provider_models("deepseek").len(), 2);
let listed = list_models("opencode-go");
assert!(listed.iter().any(|(id, _)| id == "remote-only-model"));
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
}
#[test]
fn default_model_follows_pi_table() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
assert_eq!(
default_model_for("deepseek").unwrap(),
("deepseek".to_string(), "deepseek-v4-pro".to_string())
);
assert_eq!(
default_model_for("openai").unwrap(),
("openai".to_string(), "gpt-5.5".to_string())
);
assert_eq!(
default_model_for("google").unwrap(),
("google".to_string(), "gemini-3.1-pro-preview".to_string())
);
assert_eq!(
default_model_for("fireworks").unwrap(),
(
"fireworks".to_string(),
"accounts/fireworks/models/kimi-k3".to_string()
)
);
assert_eq!(
default_model_for("opencode-go").unwrap(),
("opencode-go".to_string(), "kimi-k3".to_string())
);
assert_eq!(
default_model_for("together").unwrap(),
("together".to_string(), "moonshotai/Kimi-K3".to_string())
);
assert!(default_model_id("openai-codex").is_none());
assert_eq!(
default_model_for("nvidia").unwrap(),
(
"nvidia".to_string(),
"nvidia/nemotron-3-ultra-550b-a55b".to_string()
)
);
let v = default_model_for("zai").unwrap();
assert_eq!(v.0, "zai");
assert_ne!(v.1, "glm-5.1", "zai 基线无 glm-5.1,应回退");
assert!(baseline_models("zai").iter().any(|m| m["id"] == v.1));
assert!(default_model_for("unknown").is_none());
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
}
#[test]
fn models_json_adds_custom_provider() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models.json"));
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
write_models_json(serde_json::json!({
"providers": {
"my-ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"compat": { "supportsDeveloperRole": false },
"models": [
{ "id": "llama3.1:8b" },
{
"id": "qwen2.5-coder:7b",
"name": "Qwen Coder",
"reasoning": true,
"contextWindow": 128000,
"maxTokens": 32000,
"thinkingLevelMap": { "off": null, "high": "high" }
}
]
}
}
}));
let models = provider_models("my-ollama");
assert_eq!(models.len(), 2);
assert!(is_known_provider("my-ollama"));
let e = find_model("my-ollama", "llama3.1:8b").unwrap();
assert_eq!(e.provider, "my-ollama");
assert_eq!(e.id, "llama3.1:8b");
assert_eq!(e.base_url, "http://localhost:11434/v1");
assert_eq!(e.api, "openai-completions");
assert!(!e.supports_developer_role, "provider compat 应合并进模型");
assert_eq!(e.context_window, 128_000, "缺省字段用默认值");
assert_eq!(e.max_tokens, 16_384, "缺省 maxTokens 用默认值");
assert!(!e.auth_header, "未配置 authHeader 默认 false");
let e2 = find_model("my-ollama", "qwen2.5-coder:7b").unwrap();
assert_eq!(e2.name, "Qwen Coder");
assert!(e2.reasoning);
assert_eq!(e2.context_window, 128000);
assert_eq!(e2.max_tokens, 32000);
assert!(e2.thinking_level_map.is_some());
let listed = list_models("my-ollama");
assert!(listed.iter().any(|(id, _)| id == "llama3.1:8b"));
let (p, m) = default_model_for("my-ollama").unwrap();
assert_eq!(p, "my-ollama");
assert_eq!(m, "llama3.1:8b");
let _ = std::fs::remove_file(agent_dir().join("models.json"));
}
#[test]
fn models_json_overrides_builtin_provider() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models.json"));
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let baseline_len = baseline_models("deepseek").len();
write_models_json(serde_json::json!({
"providers": {
"deepseek": {
"baseUrl": "https://my-proxy.example.com",
"compat": { "supportsDeveloperRole": false },
"models": [{ "id": "my-custom-model", "contextWindow": 64000 }],
"modelOverrides": {
"deepseek-flash": { "name": "Flash (Proxied)" }
}
}
}
}));
let models = provider_models("deepseek");
assert_eq!(models.len(), baseline_len + 1);
let e = find_model("deepseek", "deepseek-flash").unwrap();
assert_eq!(e.base_url, "https://my-proxy.example.com");
assert_eq!(e.name, "Flash (Proxied)");
assert!(
!e.supports_developer_role,
"provider compat 应覆盖内置 compat"
);
let e2 = find_model("deepseek", "my-custom-model").unwrap();
assert_eq!(e2.base_url, "https://my-proxy.example.com");
assert_eq!(e2.context_window, 64000);
assert!(
e2.reasoning || !e2.reasoning,
"自定义模型默认非推理或按字段"
);
assert!(find_model("deepseek", "deepseek-v4-pro").is_ok());
let _ = std::fs::remove_file(agent_dir().join("models.json"));
}
#[test]
fn models_json_upsert_replaces_same_id() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models.json"));
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
write_models_json(serde_json::json!({
"providers": {
"deepseek": {
"baseUrl": "https://x.example.com",
"models": [{
"id": "deepseek-flash",
"name": "Replaced By Config",
"reasoning": false
}]
}
}
}));
let e = find_model("deepseek", "deepseek-flash").unwrap();
assert_eq!(e.name, "Replaced By Config");
assert!(!e.reasoning, "配置条目整体替换同 id 内置模型");
assert_eq!(e.base_url, "https://x.example.com");
let _ = std::fs::remove_file(agent_dir().join("models.json"));
}
#[test]
fn virtual_models_join_chat_catalog_and_hide_same_id_physical() {
use crate::core::virtual_models::{
ModelRoute, ModelRouteRequest, STANDALONE_OWNER, VIRTUAL_MODEL_API,
VirtualModelDefinition, VirtualModelRouter,
};
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
write_models_json(serde_json::json!({
"providers": {
"prux-vm-test": {
"apiKey": "sk-lit",
"api": "openai-completions",
"models": [{"id": "auto", "name": "Physical Auto"}]
}
}
}));
struct R;
impl VirtualModelRouter for R {
fn route<'a>(
&'a self,
_r: ModelRouteRequest<'a>,
) -> futures_util::future::BoxFuture<'a, Result<ModelRoute>> {
Box::pin(async {
Ok(ModelRoute {
provider: "prux-vm-test".into(),
model_id: "auto".into(),
thinking_level: None,
state: None,
})
})
}
}
crate::core::virtual_models::register(
VirtualModelDefinition::new("prux-vm-test", "auto", "Auto", std::sync::Arc::new(R)),
STANDALONE_OWNER,
);
let listed = list_models("prux-vm-test");
assert_eq!(listed.iter().filter(|(id, _)| id == "auto").count(), 1);
assert_eq!(
find_model("prux-vm-test", "auto").unwrap().api,
VIRTUAL_MODEL_API
);
assert_eq!(
default_model_for("prux-vm-test"),
Some(("prux-vm-test".to_string(), "auto".to_string()))
);
crate::core::virtual_models::register(
VirtualModelDefinition::new("prux-vm-only", "auto", "Auto", std::sync::Arc::new(R)),
STANDALONE_OWNER,
);
assert_eq!(
list_models("prux-vm-only"),
vec![("auto".to_string(), "Auto".to_string())]
);
assert_eq!(
default_model_for("prux-vm-only"),
Some(("prux-vm-only".to_string(), "auto".to_string()))
);
crate::core::virtual_models::unregister("prux-vm-test", "auto");
crate::core::virtual_models::unregister("prux-vm-only", "auto");
let _ = std::fs::remove_file(agent_dir().join("models.json"));
}
#[test]
fn typesafe_provider_ships_the_jev_classifier() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
assert!(SUPPORTED_PROVIDERS.contains(&"typesafe"));
let entry = find_model_of_type("typesafe", "jev-latest", ModelType::Classifier).unwrap();
assert_eq!(entry.api, "typesafe-system-one");
assert_eq!(entry.base_url, "https://api.typesafe.ai/v1/");
assert_eq!(entry.model_type, ModelType::Classifier);
assert!(list_models("typesafe").is_empty());
assert!(default_model_for("typesafe").is_none());
assert_eq!(
list_models_of_type("typesafe", ModelType::Classifier)
.into_iter()
.map(|(id, _)| id)
.collect::<Vec<_>>(),
vec!["jev-latest".to_string()]
);
}
#[test]
fn store_and_user_models_do_not_clobber_other_types() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let _ = std::fs::remove_file(agent_dir().join("models.json"));
let id = "google/gemini-3-pro-image";
let chat = find_model_of_type("openrouter", id, ModelType::Chat).unwrap();
write_store(json!({
"openrouter": { "models": [{ "type": "image", "id": id, "api": "openrouter-images" }] }
}));
assert_eq!(
find_model_of_type("openrouter", id, ModelType::Chat)
.unwrap()
.api,
chat.api
);
assert_eq!(
find_model_of_type("openrouter", id, ModelType::Image)
.unwrap()
.api,
"openrouter-images"
);
write_models_json(json!({
"providers": { "openrouter": { "models": [{ "id": id, "name": "Mine" }] } }
}));
let mine = find_model_of_type("openrouter", id, ModelType::Chat).unwrap();
assert_eq!(mine.name, "Mine");
assert_eq!(mine.api, chat.api, "缺 api 时按同类型条目继承");
assert_eq!(
find_model_of_type("openrouter", id, ModelType::Image)
.unwrap()
.api,
"openrouter-images"
);
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let _ = std::fs::remove_file(agent_dir().join("models.json"));
}
#[test]
fn configured_model_of_type_picks_the_entry_of_that_type() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let _ = std::fs::remove_file(agent_dir().join("models.json"));
let id = "google/gemini-3-pro-image";
let chat = configured_model("openrouter", id, None, None).unwrap();
assert_eq!(chat.model_type, ModelType::Chat);
write_store(json!({
"openrouter": { "models": [{
"type": "classifier", "id": id, "api": "typesafe-system-one"
}] }
}));
let classifier =
configured_model_of_type("openrouter", id, ModelType::Classifier, None, None).unwrap();
assert_eq!(classifier.model_type, ModelType::Classifier);
assert_eq!(classifier.api, "typesafe-system-one");
assert_eq!(classifier.model_id, id);
assert_eq!(
configured_model("openrouter", id, None, None)
.unwrap()
.model_type,
ModelType::Chat
);
let err = configured_model_of_type("typesafe", "jev-latest", ModelType::Image, None, None)
.unwrap_err()
.to_string();
assert!(err.contains("not found"), "{err}");
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
let _ = std::fs::remove_file(agent_dir().join("models.json"));
}
#[test]
fn sampling_params_by_thinking_level_parses_and_merges_per_level() {
let _ad = crate::test_support::AgentDirGuard::temp();
test_dir();
let _ = std::fs::remove_file(agent_dir().join("models-store.json"));
write_models_json(json!({
"providers": {
"sampling-demo": {
"api": "openai-completions",
"models": [{
"id": "qwen-thinking",
"reasoning": true,
"samplingParams": { "temperature": 1.0, "top_p": 0.95 },
"samplingParamsByThinkingLevel": {
"low": { "temperature": 0.6, "top_p": 0.95 },
"high": { "top_k": 64 }
}
}],
"modelOverrides": {
"qwen-thinking": {
"samplingParamsByThinkingLevel": { "high": { "top_k": 20 } }
}
}
}
}
}));
let entry = find_model("sampling-demo", "qwen-thinking").unwrap();
let by_level = entry.sampling_params_by_thinking_level.as_ref().unwrap();
assert_eq!(
by_level["low"],
json!({ "temperature": 0.6, "top_p": 0.95 })
);
assert_eq!(by_level["high"], json!({ "top_k": 20 }));
assert_eq!(entry.sampling_params.as_ref().unwrap()["temperature"], 1.0);
let cfg = configured_model("sampling-demo", "qwen-thinking", None, None).unwrap();
assert_eq!(
cfg.sampling_params_for_level(Some("high")).unwrap()["top_k"],
20
);
assert!(cfg.sampling_params_for_level(Some("medium")).is_none());
let _ = std::fs::remove_file(agent_dir().join("models.json"));
}
#[test]
fn sampling_params_for_level_uses_clamped_level() {
let entry = ModelEntry {
id: "m".into(),
name: "m".into(),
api: "openai-completions".into(),
model_type: ModelType::Chat,
base_url: "https://example.com/v1".into(),
provider: "demo".into(),
input: vec!["text".into()],
output: Vec::new(),
reasoning: true,
context_window: 1000,
max_tokens: 100,
max_tokens_field: "max_tokens".into(),
sampling_params: None,
sampling_params_by_thinking_level: Some(json!({
"high": { "temperature": 0.8 },
"off": { "temperature": 0.7 }
})),
provider_routing: None,
supports_usage_in_streaming: true,
supports_store: true,
supports_finish_reason: true,
requires_assistant_after_tool_result: false,
supports_strict_mode: false,
cost: None,
supports_developer_role: false,
requires_reasoning_content_on_assistant_messages: false,
thinking_format: "openai".into(),
supports_reasoning_effort: true,
thinking_level_map: Some(
[("low", None), ("medium", None), ("high", Some("high"))]
.into_iter()
.map(|(k, v)| (k.to_string(), v.map(|s| s.to_string())))
.collect(),
),
auth_header: false,
input_limits: Default::default(),
allowed_fallback_models: Vec::new(),
supports_openai_grammar_tools: false,
send_session_affinity_headers: None,
session_affinity_format: None,
};
let cfg = model_config_from_entry(&entry, None, None);
assert_eq!(
cfg.sampling_params_for_level(Some("low")).unwrap()["temperature"],
0.8
);
assert_eq!(
cfg.sampling_params_for_level(None).unwrap()["temperature"],
0.7
);
assert_eq!(
cfg.sampling_params_for_level(Some("off")).unwrap()["temperature"],
0.7
);
}
}